Compare commits

..

27 Commits

Author SHA1 Message Date
51eb907078 Mise en place de la version 1.0.2-bêta
Some checks failed
Build & Push Images / build (core) (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-07-07 00:12:40 +02:00
9f0359d8c0 Dernière fournée de corrections Sonar
Mise en place de MegaLinter ; web-lint pour la partie code quality sur la CI, Trivy pour la partie CVE
2026-07-07 00:11:52 +02:00
587e0fe097 passage 1.0.1-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m58s
Build & Push Images / build (core) (push) Successful in 3m39s
Build & Push Images / build-switcher (push) Successful in 20s
2026-07-06 15:25:48 +02:00
5c3c58265f Première fournée correction dette technique.
Refactoring du code pour améliorer la lisibilité de ce dernier coté core
Correction sur l'export / import : les images n'étaient pas bien liées à la campagne lors de l'export / import et provoquait un bug d'affichage des images
lorsqu'on voulait ouvrir une campagne importée.
2026-07-06 14:39:55 +02:00
f72161e41e Correction sur l'export PDF : problème d'échappement de caractères qui faisait péter un test unitaire
Some checks failed
Build & Push Images / build (core) (push) Successful in 3m45s
Build & Push Images / build (web) (push) Successful in 1m53s
E2E Tests / e2e (push) Failing after 9m2s
Build & Push Images / build-switcher (push) Successful in 42s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m18s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 18s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / build (brain) (push) Successful in 1m13s
2026-07-04 11:42:18 +02:00
4d05aa5118 Passage à la version 1.0.0
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 22s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 2m54s
Build & Push Images / build (brain) (push) Successful in 1m21s
Build & Push Images / build (core) (push) Successful in 3m37s
Build & Push Images / build (web) (push) Successful in 2m1s
E2E Tests / e2e (push) Failing after 9m37s
Build & Push Images / build-switcher (push) Successful in 42s
2026-07-04 11:21:46 +02:00
beafbc2fa9 Livret PDF v2, graphe des liens côté campagne, et refactor de l'import
Livret PDF (export campagne) :
- refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs
  en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat,
  ligne de contexte des scènes, en-tête courant, tables zébrées
- reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub
  fusionnées dans leur chapitre, partie « Quêtes » sans doublon
- contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces
  explorables, partie « Tables aléatoires »
- polices DejaVu embarquées (couverture Unicode, licence incluse) et harness
  visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres)

Graphe des liens :
- déplacé du Lore vers la campagne (les PNJ sont des entités de campagne) ;
  endpoint /api/npcs/lore/{id} supprimé
- layout retravaillé (anti-chevauchement des libellés, éventail des feuilles
  autour des hubs), scènes et quêtes ajoutées, libellés sur 2 lignes,
  filtres de légende mémorisés, focus au survol
- positions drag & drop persistées sur la campagne (migration V23,
  PUT /api/campaigns/{id}/graph-positions, bouton « Disposition auto »)

Divers :
- ImportService découpé en classes dédiées (parser d'archive, remapper d'ids,
  inserters campagne/lore/état de jeu, conversion legacy des quêtes)
- fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans
- garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs),
  budget SCSS anyComponentStyle relevé à 12 ko
2026-07-04 11:15:16 +02:00
b2c3800bf8 Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m53s
Build & Push Images / build (core) (push) Successful in 3m40s
Build & Push Images / build-switcher (push) Successful in 16s
mode séance, battlemaps multiples et export Foundry ciblé

- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
  ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
  Fusion quête/conteneur dans la sidebar, progression par partie (statuts
  disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
  automatiquement (arc technique « Quêtes libres », V21 : levée de la
  contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
  scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
  pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
  brouillons de scènes en propose→applique (brain : narrative-fields,
  scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
  épinglée (V19), récap « précédemment » (brain : session-recap), onglet
  « Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
  liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
  1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
  étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
  légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
  tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
  (respect du périmètre + une Scene Foundry par variante de carte,
  rétro-compatible anciens bundles).
2026-07-03 15:29:23 +02:00
268f4721ce Ajout de fichiers de logs et relèvement de la limite à 2Go pour l'import d'une sauvegarde
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m26s
Build & Push Images / build-switcher (push) Successful in 18s
2026-06-27 10:10:50 +02:00
11a33b1cf0 Pages de lore en blocs (grille 2D libre) + bloc image recadrable, et import dd2vtt sur les scènes
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m23s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m24s
Build & Push Images / build-switcher (push) Successful in 20s
Refonte du système de templates de lore en assemblage de blocs (façon Lore
d'Amsel), plus le support des cartes DungeonDraft (.dd2vtt) sur les scènes.

Templates de lore — éditeur de blocs en grille :
- Builder partagé `block-grid-builder` : palette de types de blocs à glisser sur
  une grille 12 colonnes, placement LIBRE (x/y) et redimensionnement en largeur
  ET hauteur par poignées, renommage en place. Remplace la liste + flèches de
  template-create/template-edit (dédupliqués).
- Rendu : page-view (aperçu) honore largeur ET hauteur (grille à lignes fixes) ;
  page-edit garde les colonnes (côte-à-côte) avec des hauteurs naturelles pour
  l'édition. Repli empilé pour les templates sans mise en page.
- Modèle : `TemplateField` gagne `id` (clé stable) et `pos {x,y,w,h}`. Les valeurs
  de page sont ancrées sur l'`id` (repli sur le nom) → renommer un bloc ne perd
  plus son contenu. Sérialisation JSON additive, aucune migration ; correction au
  passage de la non-relecture de `foundryPath` dans le converter.

Bloc image :
- Nouveau composant `image-block` (remplace la galerie pour les pages de lore) :
  plusieurs images plein cadre (carrousel), déplacement (recadrage) et zoom
  persistés PAR image et PAR page. Rendu `object-fit: contain` (image entière,
  bordures noires) avec zoom 0,4×–4×.
- Stockage : `Page.imageFraming` (fieldKey → imageId → {x,y,scale}), colonne
  `image_framing` (migration V8), DTO/mapper + passthrough export/import.

Scènes — source de carte au choix :
- Sélecteur Dungeon Alchemist (image/vidéo + .json, inchangé) / DungeonDraft
  (.dd2vtt). Le .dd2vtt embarque l'image en base64 : extraction à l'upload
  (→ média) + sidecar allégé (→ données) pour que l'export Foundry et le module
  fonctionnent sans changement. Mode déduit à la relecture de l'extension.
  Front-only, sans migration.

i18n fr/en, tests (converters back + helper front) et specs e2e mis à jour.
2026-06-26 17:38:27 +02:00
f670735100 Export/import par campagne + espace de jeu (v2), fix du nombre de joueurs, et ergonomie de jeu
Export / import de contenu (format v2)
- Export SÉLECTIF : on peut exporter une seule campagne (sa clôture : arcs →
  chapitres → scènes, PNJ, ennemis, catalogues, tables, système de jeu lié) au
  lieu de tout exporter. Options par export : inclure le lore, l'espace de jeu,
  les images. L'export global devient « Sauvegarde complète ».
- ESPACE DE JEU désormais exporté/importé : parties (Playthrough), séances,
  journal, flags, progression de quêtes, et feuilles de perso (Character.playthroughId
  préservé au lieu d'être annulé). Remapping complet des références à l'import.
- Format v2 rétro-compatible : un export v1 (sans section jeu) s'importe toujours
  (champs absents → null → traités comme vides).
- Correction d'un bug latent : collectReferencedStorageKeys traitait les références
  d'images comme des clés de stockage alors que ce sont des IDs → les binaires
  d'images n'étaient en réalité pas embarqués dans le zip. Résolution ID → clé.
- Images des Rooms d'une scène (illustration + plan) désormais collectées (elles
  étaient oubliées des deux collecteurs).
- Endpoint : GET /api/admin/data/export?campaignId=&includeLore=&includePlay=&includeImages=
  ; écran « Sauvegarde des données » avec choix campagne + cases à cocher.
- Tests : round-trip étendu au jeu + binaire image réel, export sélectif par
  campagne, import d'archive v1, et DataTransferController.

Correction : nombre de joueurs non persisté
- playerCount était saisi à la création mais jeté par le backend (absent du
  domaine/entité/DTO/mapper) → affichage « 0 joueurs ». Ajouté sur toute la stack
  + migration V7 (défaut 4 pour les campagnes existantes) + intégré à l'export.

Ergonomie de l'espace de jeu et des listes
- Description de campagne repliable (clamp 3 lignes + « Voir plus / moins »).
- Dossiers repliables (chevron + compteur) dans les listes PNJ et Ennemis, sur la
  page campagne ET les pages dédiées.
- Renommage en place des parties (« Mes parties ») via un crayon.
- Panneau de session : onglets sur 2 rangs (« Scènes » n'est plus rogné) ; scènes
  triées par ordre manuel ; arcs repliables (repliés par défaut) ; PJ/PNJ repliables
  et PNJ regroupés par dossier.
2026-06-26 14:38:39 +02:00
c1e3a20e5a Ajout de tests unitaires pour améliorer la couverture globale des tests 2026-06-26 09:40:01 +02:00
a9242ba1e1 Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m50s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 18s
Corrections fonctionnelles
- order initialisé à la création des pages et dossiers de lore (nextOrderFor) :
  un nouvel élément se place désormais en dernier de sa fratrie au lieu de 0
- storePortrait : type MIME canonique dérivé de l'extension du fichier
  (un data URL "image/jpg" n'est plus rejeté silencieusement)
- takeUntilDestroyed ajouté sur les abonnements paramMap de arc-view,
  folder-view et campaign-detail (fuites mémoire)

Factorisation (suppression de duplication)
- shared/folder-grouping.util.ts : groupByFolder + byOrder + byFolderName
  mutualisés entre npc-list, enemy-list, campaign-detail et la sidebar
  (folderChildren). Le tri des dossiers est désormais cohérent entre la
  sidebar et les vues cartes (insensible casse/accents partout)
- DataSyncService.onChange()/persist() remplacent le câblage
  changed$ + reorder recopié dans 5 vues
- campaign-detail : loadCampaignBundle()/applyCampaignBundle() éliminent
  le forkJoin dupliqué entre ngOnInit et reload
- ReorderSupport (domain/shared) : squelette générique remplaçant les
  8 boucles de réordonnancement copiées dans les services
- FoundryExportService : GameSystem résolu une seule fois dans buildBundle
- module Foundry : walkScalars mutualise la récursion de flattenStats et
  flattenStructure (comportements préservés) ; esc() de l'importer aligné
  sur foundry.utils.escapeHTML

Suppression de code mort
- characterService/characters retirés de loadCampaignTreeData et de ses
  16 appelants (arguments, injections, imports et littéraux CampaignTreeData)
- CSS orphelin : .tree-row.cdk-drop-list-receiving et .btn-back
- BottomPanel.initiallyOpen (jamais lu) retiré de l'interface et de l'appelant
- commentaire trompeur du PDF corrigé

Tests
- mise à jour de 3 tests obsolètes de campaign-tree.helper.spec qui
  vérifiaient encore l'ancien comportement (tri alphabétique, non-classés
  en enfants directs) → alignés sur le modèle actuel (tri par order +
  pseudo-dossier "Sans dossier")
2026-06-26 09:10:20 +02:00
3896c5b4cb Ajout de la possibilité d'importer les images des monstres issus de foundry
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m52s
Build & Push Images / build (core) (push) Successful in 3m34s
Build & Push Images / build-switcher (push) Successful in 18s
2026-06-25 17:28:09 +02:00
b999aa54ad Possibilité d'importer un template de monstre depuis le systeme qu'on utilise sur Foundry pour forcer le template. Lors de l'export, le monstre est automatiquement adapté comme ça.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m25s
Build & Push Images / build (web) (push) Successful in 1m54s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 17s
à l'import des monstres depuis foundry : on garde la structure originelle utilisée sur foundry également
2026-06-25 16:50:27 +02:00
9bd66613b6 Ajout de drag'n drop là ou c'est possible ; ajout de l'import des ennemis depuis foundryVTT
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m33s
Build & Push Images / build-switcher (push) Successful in 17s
2026-06-25 15:21:25 +02:00
18f5a260b0 passage v1.0.0-beta 2026-06-25 12:18:19 +02:00
c0e1da78c8 Mise en place de l'export foundry ; modification des Arc, chapitres et scènes..... => Suppression de la section maps pour arc, chapitres ; et dans scène on importe les maps format foundry (image / vidéo + json)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m35s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m37s
Build & Push Images / build-switcher (push) Successful in 19s
2026-06-25 12:16:27 +02:00
5bc038acd8 Suppression de la sélection du langage au démarrage de l'appli : maintenant c'est le système qui décide de la langue.
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 33s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m30s
Build & Push Images / build (brain) (push) Successful in 1m22s
E2E Tests / e2e (push) Failing after 5m47s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 48s
Build & Push Images / build (web) (push) Successful in 1m55s
Changement relicat Loremind -> DM Loremind
Passage en 0.18.0 stable publique
2026-06-22 16:59:55 +02:00
f347dcd357 Passage à la 0.17.3 et ajout d'un splash screen pour le démarrage de l'application
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 18s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 28s
Build & Push Images / build (brain) (push) Successful in 1m11s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m9s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 44s
Build & Push Images / build (core) (push) Successful in 3m2s
2026-06-22 11:34:55 +02:00
46fea8d53c On arrête de mettre appimagetool dans la release - actif après la release 0.17.2
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 22s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 25s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 1m55s
2026-06-22 11:15:41 +02:00
e05b26563f passage v0.17.2 release officielle
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 28s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m22s
Build & Push Images / build (brain) (push) Successful in 1m20s
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (web) (push) Successful in 1m52s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build-switcher (push) Successful in 43s
2026-06-22 10:37:00 +02:00
dc66199177 Passage v0.17.2-beta suite au changement de l'installeur
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m25s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build-switcher (push) Successful in 19s
2026-06-22 09:51:26 +02:00
b133d0f16d Correction dans l'installeur : ajout d'une case pour pouvoir lancer le programme automatiquement à la fin de l'installation (sous windows) afin d'éviter que l'utilisateur ne soit perturbé 2026-06-22 09:49:33 +02:00
024d37dea6 Correction des worflow pour linux
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m44s
Build & Push Images / build (core) (push) Successful in 3m12s
Build & Push Images / build-switcher (push) Successful in 16s
2026-06-20 11:16:46 +02:00
997aadf5b5 Mise en place de la distribution en .appimage pour les distro linux
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m49s
Build & Push Images / build (core) (push) Successful in 3m22s
Build & Push Images / build-switcher (push) Successful in 19s
2026-06-20 11:04:15 +02:00
560c07d5c3 Changement du logo pour l'application et changement du nom en "DM Loremind" pour le tittre de l'application.
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m17s
Build & Push Images / build (brain) (push) Successful in 1m13s
E2E Tests / e2e (push) Failing after 5m22s
Build & Push Images / build (core) (push) Successful in 3m25s
Build & Push Images / build-switcher (push) Successful in 51s
Build & Push Images / build (web) (push) Successful in 1m57s
2026-06-19 10:00:13 +02:00
721 changed files with 35741 additions and 5184 deletions

View File

@@ -0,0 +1,77 @@
name: Qualité & Sécurité
# Analyse statique (MegaLinter, config racine .mega-linter.yml) + CVE des
# dépendances (Trivy). Workflow SÉPARÉ de ci.yml : un rouge qualité ne bloque
# pas la chaîne tests → release pendant la phase de rodage. Une fois la base
# assainie, on pourra l'ajouter aux checks requis de la branch protection.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
megalinter:
name: MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Historique complet : requis par gitleaks (scan de l'historique)
# et par le mode "fichiers modifiés seulement" en PR.
fetch-depth: 0
# Flavor "cupcake" : image allégée couvrant les langages courants
# (Java/Python/TS inclus). Si un linter activé manquait à la flavor,
# MegaLinter échoue en l'indiquant → remplacer par oxsecurity/megalinter@v9
# (image complète, plus lourde).
- name: MegaLinter
uses: oxsecurity/megalinter/flavors/cupcake@v9
env:
# push sur main → tout le dépôt ; PR → seulement les fichiers modifiés.
VALIDATE_ALL_CODEBASE: ${{ github.event_name == 'push' }}
- name: Publier les rapports
if: always()
uses: actions/upload-artifact@v4
with:
name: megalinter-reports
path: megalinter-reports/
web-lint:
name: Web (ESLint · angular-eslint + sonarjs)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: npm ci
working-directory: web
run: npm ci --no-audit --no-fund
# Lint via la toolchain du projet (et non via MegaLinter : ESLint a
# besoin des plugins de web/node_modules et du contexte Angular).
# Config + règles : web/eslint.config.js (sonarjs = règles "à la Sonar").
- name: ng lint
working-directory: web
run: npm run lint
trivy:
name: Trivy (CVE dépendances Maven / pip / npm)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Installer Trivy
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Scanne les manifestes du dépôt (pom.xml, requirements*.txt,
# package-lock.json) contre les bases CVE. --ignore-unfixed : on ne
# bloque que sur les vulnérabilités qui ONT un correctif publié.
- name: Scan des dépendances (HIGH/CRITICAL bloquants)
run: |
trivy fs . \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--skip-dirs node_modules \
--skip-dirs docusaurus \
--exit-code 1

View File

@@ -1,12 +1,12 @@
name: Desktop installers name: Desktop installers
# Produit les installeurs de BUREAU (.msi Windows pour l'instant) et les publie # Produit les installeurs de BUREAU (.msi Windows + AppImage Linux) et les publie
# en tant qu'assets d'une GitHub Release, sur tag `v*`. # en tant qu'assets d'une GitHub Release, sur tag `v*`.
# #
# Complementaire au pipeline Gitea Actions (.gitea/workflows/release.yml) qui, # Complementaire au pipeline Gitea Actions (.gitea/workflows/release.yml) qui,
# lui, build et pousse les IMAGES Docker. Ici on est sur GitHub car jpackage et # lui, build et pousse les IMAGES Docker. Ici on est sur GitHub car jpackage ne
# PyInstaller ne savent PAS cross-compiler : le .msi DOIT etre construit sur un # sait PAS cross-compiler : le .msi DOIT etre construit sur un runner Windows et
# runner Windows, et GitHub en fournit gratuitement (windows-latest). # l'AppImage sur un runner Linux — GitHub fournit les deux gratuitement.
# #
# Prerequis : le depot Gitea doit etre mirrore vers GitHub (push mirror, tags # Prerequis : le depot Gitea doit etre mirrore vers GitHub (push mirror, tags
# inclus) pour que le tag declenche ce workflow. # inclus) pour que le tag declenche ce workflow.
@@ -191,6 +191,72 @@ jobs:
path: core/target/dist-out/*.msi path: core/target/dist-out/*.msi
retention-days: 90 retention-days: 90
# TODO (plus tard) : job `linux` sur ubuntu-latest produisant un AppImage # LINUX : AppImage (1 fichier, toutes distros) attache a la MEME release.
# (jpackage --type app-image + appimagetool) + PyInstaller Linux du Brain, # Tourne sur ubuntu-latest (jpackage ne cross-compile pas -> build natif Linux).
# attache a la MEME release. Reutilise la meme matrice / les memes etapes. # Brain empaquete via python-build-standalone (pas de Python embeddable Linux
# officiel ; pas de PyInstaller -> pas de faux positif AV). Memes regles de
# publication que windows : stable -> release publique ; beta -> artefact prive.
linux:
needs: tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref }}
# Apporte jpackage (empaquetage natif) dans le PATH.
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
# Pas de setup-python : le Brain utilise SON python-build-standalone
# (telecharge par le script, avec son propre pip). Le host n'en a pas besoin.
# Version (numerique X.Y.Z) + tag + isbeta, comme le job windows.
- name: Derive version
id: ver
run: |
if [ '${{ github.event_name }}' = 'workflow_dispatch' ]; then
raw='${{ inputs.version }}'
else
raw='${{ github.ref_name }}'
fi
raw="${raw#v}" # 0.15.0 ou 0.15.0-beta
num="${raw%%-*}" # 0.15.0
case "$raw" in *-beta*) isbeta=true ;; *) isbeta=false ;; esac
echo "version=$num" >> "$GITHUB_OUTPUT"
echo "tag=v$raw" >> "$GITHUB_OUTPUT"
echo "isbeta=$isbeta" >> "$GITHUB_OUTPUT"
- name: Build Linux AppImage
env:
# Authentifie l'appel API GitHub (resolution python-build-standalone) :
# evite le rate limit 60/h anonyme des runners partages.
GITHUB_TOKEN: ${{ github.token }}
run: bash ./installers/desktop/build-linux.sh --version ${{ steps.ver.outputs.version }}
# STABLE uniquement : attache l'AppImage a la release publique (meme tag que
# le .msi -> les deux installeurs sur la meme release).
- name: Publish AppImage to GitHub Release (stable)
if: ${{ steps.ver.outputs.isbeta == 'false' }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
files: core/target/dist-out/DM_Loremind*.AppImage
fail_on_unmatched_files: true
# BETA uniquement : artefact PRIVE (pas de release publique).
- name: Upload AppImage as private artifact (beta)
if: ${{ steps.ver.outputs.isbeta == 'true' }}
uses: actions/upload-artifact@v4
with:
name: loremind-beta-${{ steps.ver.outputs.version }}-appimage
path: core/target/dist-out/DM_Loremind*.AppImage
retention-days: 90

1
.gitignore vendored
View File

@@ -128,3 +128,4 @@ installers/desktop/README.md
web/coverage/ web/coverage/
brain/htmlcov/ brain/htmlcov/
brain/.coverage brain/.coverage
foundry-module/

37
.mega-linter.yml Normal file
View File

@@ -0,0 +1,37 @@
# Configuration MegaLinter — https://megalinter.io/latest/config-file/
#
# Périmètre volontairement resserré (liste ENABLE_LINTERS exclusive) : on
# active les linters au fil de l'eau plutôt que de subir les ~100 par défaut.
# Le scan CVE des dépendances n'est PAS ici : il est porté par le job Trivy
# (cf. .gitea/workflows/quality.yml).
APPLY_FIXES: none
DEFAULT_BRANCH: main
SHOW_ELAPSED_TIME: true
PRINT_ALPACA: false
# Liste EXCLUSIVE : tout linter absent d'ici est désactivé.
ENABLE_LINTERS:
# --- core (Java) : bugs + code smells "à la Sonar" sur les sources.
# (Sonar for IDE reste pertinent en local pour l'analyse de flux fine.)
- JAVA_PMD
# --- brain (Python) ---
- PYTHON_RUFF # remplace flake8/pylint/isort, très rapide
- PYTHON_BANDIT # sécurité du code Python
# - PYTHON_MYPY # à activer quand le brain aura des annotations de types
# --- repo entier ---
- REPOSITORY_GITLEAKS # secrets committés (tokens, mots de passe…)
- DOCKERFILE_HADOLINT # bonnes pratiques Dockerfile
# --- web (TypeScript) : PAS via MegaLinter. Le lint tourne avec la
# toolchain du projet (job "web-lint" de quality.yml → ng lint,
# config web/eslint.config.js avec angular-eslint + sonarjs).
# Jamais d'analyse des artefacts de build, dépendances et sites docs.
FILTER_REGEX_EXCLUDE: '(^|/)(node_modules|target|dist|coverage|\.angular|\.mvn|docusaurus)/'
# Rapports déposés là où le workflow les publie en artefact CI.
REPORT_OUTPUT_FOLDER: megalinter-reports
# Phase de rodage : passer temporairement à true pour rendre le job
# informatif (rapport sans échec CI) le temps de purger l'existant.
DISABLE_ERRORS: false

View File

@@ -1,4 +1,4 @@
# LoreMind # DM Loremind
[English](README.md) · **Français** [English](README.md) · **Français**
@@ -10,33 +10,45 @@
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation) [![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ) [![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ)
## Découvrir LoreMind en vidéo ## Découvrir DM Loremind en vidéo
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8) [![Présentation DM Loremind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png) ![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## Ce que ça fait ## Ce que ça fait
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu. DM Loremind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
### Lore ### Lore
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches. Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
### Game System ### Système de JDR
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système. Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
### Campaign ### Campagne
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu. Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du système de JDR retenu.
### Assistant IA ### Assistant IA
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table. Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir. L'IA s'exécute **en local via [Ollama](https://ollama.com/)** — vos données ne quittent jamais votre machine — ou dans le **cloud** avec votre propre clé API via [1min.ai](https://1min.ai/), [Mistral](https://mistral.ai/), [Google AI Studio (Gemini)](https://aistudio.google.com/) ou [OpenRouter](https://openrouter.ai/).
## Démarrage rapide
**Bureau (le plus simple)** — récupérez le dernier installeur sur la [page Releases](https://github.com/IGMLcreation/LoreMind/releases) : Windows `.msi` ou Linux `.AppImage`, puis lancez-le. Vos données restent en local.
**Auto-hébergement Docker** — sous Linux :
```bash
curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
```
Sous Windows, suivez le [guide d'installation](https://loremind-docs.igmlcreation.fr/).
## Documentation ## Documentation
@@ -49,20 +61,20 @@ Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.
Quelques limites à connaître : Quelques limites à connaître :
- 10 utilisateurs maximum simultanés (instances isolées) - 10 utilisateurs maximum simultanés (instances isolées)
- Session limitée à 20 minutes avant réinitialisation - Session limitée à 20 minutes avant réinitialisation
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur) - Fonctions IA non incluses dans la démo (elles nécessitent un fournisseur d'IA — Ollama en local ou une clé API cloud — configuré côté serveur)
## Soutenir le projet ## Soutenir le projet
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien : DM Loremind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs - **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs - **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
## Licence ## Licence
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**. DM Loremind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique : En pratique :
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer. - Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence. - Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application. - Les univers (Lore) et campagnes que vous créez avec DM Loremind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -1,4 +1,4 @@
# LoreMind # DM Loremind
**English** · [Français](README.fr.md) **English** · [Français](README.fr.md)
@@ -10,15 +10,15 @@
[![Patreon](https://img.shields.io/badge/Patreon-support-red)](https://www.patreon.com/c/IGMLCreation) [![Patreon](https://img.shields.io/badge/Patreon-support-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2)](https://discord.gg/cPpFzCjEzQ) [![Discord](https://img.shields.io/badge/Discord-join-5865F2)](https://discord.gg/cPpFzCjEzQ)
## See LoreMind in action ## See DM Loremind in action
[![LoreMind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8) [![DM Loremind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
![Dashboard](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png) ![Dashboard](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## What it does ## What it does
LoreMind brings together what a game master usually scatters across several tools. The app is built around three core modules, augmented by an AI assistant that draws on all of your content. DM Loremind brings together what a game master usually scatters across several tools. The app is built around three core modules, augmented by an AI assistant that draws on all of your content.
### Lore ### Lore
@@ -36,7 +36,19 @@ Structure your campaigns as Arcs → Chapters → Scenes, with a clear split bet
A context-aware assistant that pulls from your Lore, rules and campaigns to answer your questions, suggest consistent content, or improvise around an unexpected situation at the table. A context-aware assistant that pulls from your Lore, rules and campaigns to answer your questions, suggest consistent content, or improvise around an unexpected situation at the table.
The AI runs **locally via [Ollama](https://ollama.com/)** or via **[1min.ai](https://1min.ai/)**. More engines will be supported in the future. The AI runs **locally via [Ollama](https://ollama.com/)** — your data stays on your machine — or in the **cloud** with your own API key via [1min.ai](https://1min.ai/), [Mistral](https://mistral.ai/), [Google AI Studio (Gemini)](https://aistudio.google.com/) or [OpenRouter](https://openrouter.ai/).
## Quick start
**Desktop (easiest)** — grab the latest installer from the [Releases page](https://github.com/IGMLcreation/LoreMind/releases): Windows `.msi` or Linux `.AppImage`, then run it. Your data stays local.
**Self-host with Docker** — on Linux:
```bash
curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
```
On Windows, follow the [installation guide](https://loremind-docs.igmlcreation.fr/en/).
## Documentation ## Documentation
@@ -49,20 +61,20 @@ A demo instance is available at **[loremind-demo.igmlcreation.fr](https://loremi
A few limitations to be aware of: A few limitations to be aware of:
- 10 concurrent users maximum (isolated instances) - 10 concurrent users maximum (isolated instances)
- Sessions limited to 20 minutes before reset - Sessions limited to 20 minutes before reset
- The AI part is not included in the demo (requires Ollama or 1min.ai server-side) - The AI features are not included in the demo (they require an AI provider — local Ollama or a cloud API key — configured server-side)
## Support the project ## Support the project
LoreMind is **and will remain free when self-hosted**. Development moves faster with your support: DM Loremind is **and will remain free when self-hosted**. Development moves faster with your support:
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs - **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback - **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback
## License ## License
LoreMind is distributed under the **[GNU AGPL v3](LICENSE)** license. DM Loremind is distributed under the **[GNU AGPL v3](LICENSE)** license.
In practice: In practice:
- You can use it for free, host it, modify it, and redistribute it. - You can use it for free, host it, modify it, and redistribute it.
- If you modify the code and expose the modified app over a network (even as a private SaaS), you must make your changes public under the same license. - If you modify the code and expose the modified app over a network (even as a private SaaS), you must make your changes public under the same license.
- The worlds (Lore) and campaigns you create with LoreMind **belong entirely to you** — the license only covers the application's code. - The worlds (Lore) and campaigns you create with DM Loremind **belong entirely to you** — the license only covers the application's code.

View File

@@ -6,7 +6,12 @@ from pydantic import BaseModel, Field
from app.api.deps import get_generate_page_use_case, get_llm_provider from app.api.deps import get_generate_page_use_case, get_llm_provider
from app.application.generate_page import GeneratePageUseCase from app.application.generate_page import GeneratePageUseCase
from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry
from app.application.prompts import conversation_title as title_prompts from app.application.prompts import conversation_title as title_prompts
from app.application.prompts import narrative_fields as narrative_fields_prompts
from app.application.prompts import scene_drafts as scene_drafts_prompts
from app.application.prompts import session_recap as session_recap_prompts
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.core.language import get_user_language from app.core.language import get_user_language
from app.domain.models import PageGenerationContext from app.domain.models import PageGenerationContext
@@ -131,3 +136,157 @@ async def summarize_conversation_title(
if not title: if not title:
title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"]) title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"])
return SummarizeTitleResponseDTO(title=title) return SummarizeTitleResponseDTO(title=title)
# --- Étoffer une entité narrative (Pilier A : co-MJ propose → l'humain valide) ----------
class NarrativeFieldSpecDTO(BaseModel):
"""Un champ autorisé : clé technique + libellé lisible (fourni par le Core)."""
key: str
label: str = Field(default="")
class NarrativeFieldsRequestDTO(BaseModel):
"""Contexte envoyé par le Core pour proposer des valeurs de champs (arc/chapitre/scène)."""
entity_type: str = Field(default="")
context: str = Field(default="")
instruction: str = Field(default="")
# Whitelist (clé + libellé) fournie par le Core, source de vérité.
fields: list[NarrativeFieldSpecDTO] = Field(default_factory=list)
class NarrativeFieldsResponseDTO(BaseModel):
"""Retour : une valeur proposée par clé (uniquement des clés autorisées, non vides)."""
fields: dict[str, str]
@router.post("/generate/narrative-fields", response_model=NarrativeFieldsResponseDTO)
async def generate_narrative_fields(
body: NarrativeFieldsRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> NarrativeFieldsResponseDTO:
"""Propose des valeurs pour ÉTOFFER une entité narrative (patch champ par champ, non appliqué).
Whitelist stricte : on ne retient que les clés autorisées, non vides. Un objet vide
est une réponse VALIDE (le modèle n'a rien de pertinent à proposer — l'entité est
peut-être déjà complète) ; seule une sortie non-JSON est une erreur.
"""
allowed = {f.key for f in body.fields if f.key}
prompt = narrative_fields_prompts.narrative_fields_prompt(
body.entity_type, body.context, body.instruction,
[{"key": f.key, "label": f.label} for f in body.fields], language)
try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de champs exploitables.")
out: dict[str, str] = {}
raw_fields = parsed.get("fields")
if isinstance(raw_fields, dict):
for key, value in raw_fields.items():
if key not in allowed:
continue
if not isinstance(value, (str, int, float)):
continue
text = str(value).strip()
if text:
out[str(key)] = text
return NarrativeFieldsResponseDTO(fields=out)
# --- Peupler un chapitre en scènes (Pilier A : capacité « create ») ----------
class SceneDraftsRequestDTO(BaseModel):
"""Contexte envoyé par le Core pour ébaucher des scènes d'un chapitre."""
context: str = Field(default="")
instruction: str = Field(default="")
count: int = Field(default=4)
class SceneDraftDTO(BaseModel):
name: str
description: str = Field(default="")
playerNarration: str = Field(default="")
class SceneDraftsResponseDTO(BaseModel):
scenes: list[SceneDraftDTO]
@router.post("/generate/scene-drafts", response_model=SceneDraftsResponseDTO)
async def generate_scene_drafts(
body: SceneDraftsRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> SceneDraftsResponseDTO:
"""Propose des ébauches de scènes pour un chapitre (non créées). Un titre par scène
est obligatoire ; on borne le nombre. Seule une sortie non-JSON est une erreur."""
n = max(1, min(8, body.count))
prompt = scene_drafts_prompts.scene_drafts_prompt(body.context, body.instruction, n, language)
try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.8)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de scènes exploitables.")
scenes: list[SceneDraftDTO] = []
for s in (parsed.get("scenes") or [])[:n]:
if not isinstance(s, dict):
continue
name = str(s.get("name") or "").strip()
if not name:
continue
scenes.append(SceneDraftDTO(
name=name[:200],
description=str(s.get("description") or "").strip(),
playerNarration=str(s.get("playerNarration") or "").strip(),
))
return SceneDraftsResponseDTO(scenes=scenes)
# --- Récap « précédemment… » d'une séance (mode cockpit) ---------------------
class SessionRecapRequestDTO(BaseModel):
"""Journal chronologique de la séance précédente + méta courte."""
transcript: str
context: str = Field(default="")
class SessionRecapResponseDTO(BaseModel):
recap: str
@router.post("/generate/session-recap", response_model=SessionRecapResponseDTO)
async def generate_session_recap(
body: SessionRecapRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> SessionRecapResponseDTO:
"""Rédige le récap « Précédemment… » à lire aux joueurs (texte libre, pas de JSON)."""
if not body.transcript.strip():
raise HTTPException(status_code=422, detail="Journal vide : rien à résumer.")
prompt = session_recap_prompts.session_recap_prompt(body.transcript, body.context, language)
try:
raw = await generate_with_retry(llm, prompt, temperature=0.7)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
recap = raw.strip()
if not recap:
raise HTTPException(status_code=502, detail="Le modèle n'a renvoyé aucun récit.")
return SessionRecapResponseDTO(recap=recap)

View File

@@ -0,0 +1,50 @@
"""Prompt d'étoffage des champs d'une entité narrative (arc / chapitre / scène) — Pilier A.
Générique : le Core (Java) est la SOURCE DE VÉRITÉ des champs (clé + libellé) et les
passe en entrée ; ce module ne fait que formuler le prompt. Le modèle ne renvoie que les
clés fournies et OMET celles pour lesquelles il n'a rien de pertinent (pas de remplissage forcé).
"""
from app.core.language import language_name
# Étiquette lisible du type d'entité, pour la formulation du prompt.
ENTITY_LABEL: dict[str, str] = {
"arc": "cet arc narratif",
"chapter": "ce chapitre",
"scene": "cette scène",
}
def narrative_fields_prompt(entity_type: str, entity_context: str, instruction: str,
fields: list[dict], language: str) -> str:
"""Construit le prompt d'étoffage. `fields` = [{key, label}] (whitelist du Core)."""
label = ENTITY_LABEL.get(entity_type or "", "cette entité narrative")
lines = []
for f in fields or []:
key = str(f.get("key") or "").strip()
if not key:
continue
flabel = str(f.get("label") or key).strip()
lines.append(f'- "{key}" : {flabel}')
fields_list = "\n".join(lines)
instruction_block = (
f"\nConsigne particulière du MJ : {instruction.strip()}\n"
if instruction and instruction.strip() else ""
)
return (
f"Tu es un co-Maître de Jeu. On te donne l'état ACTUEL d'{label} de jeu de rôle. "
"Propose des valeurs pour l'ÉTOFFER, cohérentes avec ce qui existe déjà.\n\n"
f"{entity_context.strip()}\n"
f"{instruction_block}\n"
"Champs que tu peux remplir (n'utilise QUE ces clés) :\n"
f"{fields_list}\n\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format exact : {"fields": {"cle": "valeur proposée", ...}}\n'
"- N'inclus QUE des clés de la liste ci-dessus. N'invente AUCUNE autre clé.\n"
"- Si un champ est déjà bien rempli ou si tu n'as rien de pertinent, OMETS-le "
"(ne le renvoie pas) plutôt que de le remplir de force.\n"
"- Reste cohérent avec le contexte : n'invente pas d'élément qui contredit "
"l'entité ou la campagne.\n"
f"- Rédige les valeurs en {language_name(language)}.\n"
"Renvoie maintenant le JSON."
)

View File

@@ -0,0 +1,30 @@
"""Prompt d'ébauche de scènes pour un chapitre (Pilier A — capacité « create »).
Le co-MJ propose plusieurs scènes cohérentes pour PEUPLER un chapitre vide (ou en manque).
JSON structuré, une liste de scènes ; l'humain révise et ne crée que celles qu'il retient.
"""
from app.core.language import language_name
def scene_drafts_prompt(context: str, instruction: str, count: int, language: str) -> str:
instruction_block = (
f"\nConsigne particulière du MJ : {instruction.strip()}\n"
if instruction and instruction.strip() else ""
)
return (
f"Tu es un co-Maître de Jeu. Propose {count} SCÈNES de jeu de rôle pour PEUPLER ce "
"chapitre, cohérentes entre elles et avec le contexte.\n\n"
f"{context.strip()}\n"
f"{instruction_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format exact : {"scenes": [{"name": "...", "description": "...", "playerNarration": "..."}]}\n'
f"- Propose AU PLUS {count} scènes, distinctes et complémentaires (une progression du chapitre).\n"
"- 'name' : titre court et évocateur (OBLIGATOIRE).\n"
"- 'description' : un résumé bref (une phrase).\n"
"- 'playerNarration' : 2-3 phrases de mise en scène lues aux joueurs.\n"
"- Ne DUPLIQUE pas les scènes déjà présentes ; reste cohérent avec le chapitre et la campagne "
"(n'invente pas d'élément qui les contredit).\n"
f"- Rédige en {language_name(language)}.\n"
"Renvoie maintenant le JSON."
)

View File

@@ -0,0 +1,25 @@
"""Prompt du récap « précédemment dans… » (mode séance).
Le Core envoie le journal chronologique de la séance PRÉCÉDENTE ; le modèle rédige un
récapitulatif court, à lire aux joueurs à l'ouverture de la séance suivante. Texte libre
(pas de JSON) : c'est de la narration.
"""
from app.core.language import language_name
def session_recap_prompt(transcript: str, context: str, language: str) -> str:
context_block = f"\n{context.strip()}\n" if context and context.strip() else ""
return (
"Tu es le Maître du Jeu. Voici le journal de la SÉANCE PRÉCÉDENTE de ta table "
"(entrées chronologiques : notes, évènements, jets de dés, actions des joueurs).\n"
f"{context_block}\n"
"Journal :\n"
f"{transcript.strip()}\n\n"
"Rédige un récapitulatif « Précédemment… » à LIRE AUX JOUEURS pour ouvrir la "
"nouvelle séance :\n"
"- 4 à 8 phrases, ton narratif et vivant, au passé.\n"
"- Uniquement ce qui s'est réellement passé dans le journal — n'invente RIEN, "
"ne révèle aucun secret du MJ.\n"
"- Termine sur la situation où les joueurs se sont arrêtés (le « cliffhanger »).\n"
f"- Rédige en {language_name(language)}. Pas de préambule ni de méta : juste le récit."
)

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.16.3", version="1.0.2-beta",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -23,7 +23,11 @@ sys.path.insert(0, _HERE)
# AVANT que l'app n'importe le pdf_extractor (qui détecte la version au chargement). # AVANT que l'app n'importe le pdf_extractor (qui détecte la version au chargement).
# tessdata (fra+eng) est embarqué dans tesseract/tessdata. Sans ce bloc, l'OCR # tessdata (fra+eng) est embarqué dans tesseract/tessdata. Sans ce bloc, l'OCR
# reste désactivé en dégradation gracieuse (PDF born-digital OK, scans signalés). # reste désactivé en dégradation gracieuse (PDF born-digital OK, scans signalés).
_TESS = os.path.join(_HERE, "tesseract", "tesseract.exe") # Binaire selon l'OS : tesseract.exe (Windows embeddable) ou tesseract (Linux/Mac).
# Si aucun Tesseract n'est bundlé (cas Linux/AppImage par défaut), le bloc est
# sauté et pytesseract retombe sur le tesseract SYSTÈME du PATH (ex. apt install
# tesseract-ocr) — sinon OCR désactivé en dégradation gracieuse.
_TESS = os.path.join(_HERE, "tesseract", "tesseract.exe" if os.name == "nt" else "tesseract")
if os.path.exists(_TESS): if os.path.exists(_TESS):
os.environ.setdefault("TESSDATA_PREFIX", os.path.join(_HERE, "tesseract")) os.environ.setdefault("TESSDATA_PREFIX", os.path.join(_HERE, "tesseract"))
try: try:

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.16.3</version> <version>1.0.2-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -148,6 +148,23 @@
<artifactId>tink</artifactId> <artifactId>tink</artifactId>
<version>1.21.0</version> <version>1.21.0</version>
</dependency> </dependency>
<!-- openhtmltopdf — rendu XHTML+CSS -> PDF, 100 % JVM (pas de binaire natif :
compatible distribution bureau locale via jpackage). Sert l'export PDF
d'une campagne (livret). -->
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
<!-- TwelveMonkeys imageio-webp — décodeur WebP pur Java pour ImageIO. Sans lui,
ImageIO (et donc openhtmltopdf/PDFBox) ne sait pas lire le WebP : les
portraits/illustrations WebP seraient absents du PDF. -->
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-webp</artifactId>
<version>3.12.0</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -1,9 +1,13 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -23,13 +27,16 @@ public class ArcService {
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final QuestRepository questRepository;
public ArcService(ArcRepository arcRepository, public ArcService(ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository) { SceneRepository sceneRepository,
QuestRepository questRepository) {
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.questRepository = questRepository;
} }
/** Compte des entités qui seront supprimées en cascade avec l'arc. */ /** Compte des entités qui seront supprimées en cascade avec l'arc. */
@@ -86,6 +93,37 @@ public class ArcService {
return arcRepository.save(arc); return arcRepository.save(arc);
} }
/**
* Patch CIBLÉ champ-par-champ d'un arc (Pilier A — co-création). Applique UNIQUEMENT
* les {@link FieldProposal} reçus ; les autres champs restent INTACTS (contraste voulu
* avec {@link #updateArc} qui écrase tout via BeanUtils).
*/
@Transactional
public Arc patchArc(String id, List<FieldProposal> fields) {
Arc arc = arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé avec l'ID: " + id));
if (fields != null) {
for (FieldProposal f : fields) {
if (f == null || f.key() == null) continue;
applyField(arc, f.key(), f.proposedValue());
}
}
return arcRepository.save(arc);
}
/** Whitelist STRICTE des champs étoffables d'un arc ; clé inconnue ignorée. */
private void applyField(Arc arc, String key, String value) {
switch (key) {
case "description" -> arc.setDescription(value);
case "themes" -> arc.setThemes(value);
case "stakes" -> arc.setStakes(value);
case "rewards" -> arc.setRewards(value);
case "resolution" -> arc.setResolution(value);
case "gmNotes" -> arc.setGmNotes(value);
default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ }
}
}
/** /**
* Calcule l'impact d'une suppression en cascade : chapitres + scènes * Calcule l'impact d'une suppression en cascade : chapitres + scènes
* qui disparaîtront avec l'arc. * qui disparaîtront avec l'arc.
@@ -111,10 +149,28 @@ public class ArcService {
} }
chapterRepository.deleteById(chapter.getId()); chapterRepository.deleteById(chapter.getId());
} }
// Détache les quêtes rattachées (arc HUB) : elles deviennent TRANSVERSES plutôt
// que fantômes (arcId pointant un arc disparu). Weak ref, pas de FK cascade.
for (Quest quest : questRepository.findByArcId(id)) {
quest.setArcId(null);
questRepository.save(quest);
}
arcRepository.deleteById(id); arcRepository.deleteById(id);
} }
public boolean arcExists(String id) { public boolean arcExists(String id) {
return arcRepository.existsById(id); return arcRepository.existsById(id);
} }
/**
* Réordonne les arcs d'une campagne : {@code order} = position dans la liste fournie.
* Les ids inconnus sont ignorés. Transactionnel.
*/
@Transactional
public void reorderArcs(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
arcRepository::findById,
Arc::setOrder,
arcRepository::save);
}
} }

View File

@@ -41,6 +41,16 @@ public class CampaignBriefBuilder {
sb.append("# Campagne : ").append(cc.campaignName()).append("\n"); sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
appendStructure(sb, cc);
appendNpcs(sb, cc);
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendStructure(StringBuilder sb, CampaignStructuralContext cc) {
sb.append("\n## Structure (arcs → chapitres → scènes)\n"); sb.append("\n## Structure (arcs → chapitres → scènes)\n");
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ") sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
.append("un arc LINEAR contient des chapitres en séquence._\n"); .append("un arc LINEAR contient des chapitres en séquence._\n");
@@ -48,34 +58,38 @@ public class CampaignBriefBuilder {
sb.append("_(aucun arc pour le moment)_\n"); sb.append("_(aucun arc pour le moment)_\n");
} }
for (ArcSummary arc : cc.arcs()) { for (ArcSummary arc : cc.arcs()) {
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name()); appendArc(sb, arc);
if (notBlank(arc.description())) sb.append("").append(arc.description()); }
}
private void appendArc(StringBuilder sb, ArcSummary arc) {
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n");
for (ChapterSummary ch : arc.chapters()) {
appendChapter(sb, arc.hub(), ch);
}
}
private void appendChapter(StringBuilder sb, boolean hub, ChapterSummary ch) {
sb.append(hub ? "- Quête : " : "- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n");
for (SceneSummary sc : ch.scenes()) {
sb.append(" - Scène : ").append(sc.name());
if (notBlank(sc.description())) sb.append("").append(sc.description());
sb.append("\n"); sb.append("\n");
for (ChapterSummary ch : arc.chapters()) {
sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n");
for (SceneSummary sc : ch.scenes()) {
sb.append(" - Scène : ").append(sc.name());
if (notBlank(sc.description())) sb.append("").append(sc.description());
sb.append("\n");
}
}
} }
}
if (!cc.npcs().isEmpty()) { private void appendNpcs(StringBuilder sb, CampaignStructuralContext cc) {
sb.append("\n## PNJ existants\n"); if (cc.npcs().isEmpty()) return;
for (NpcSummary n : cc.npcs()) { sb.append("\n## PNJ existants\n");
sb.append("- ").append(n.name()); for (NpcSummary n : cc.npcs()) {
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet()); sb.append("- ").append(n.name());
sb.append("\n"); if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
} sb.append("\n");
} }
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
} }
private void appendLore(StringBuilder sb, LoreStructuralContext lore) { private void appendLore(StringBuilder sb, LoreStructuralContext lore) {

View File

@@ -0,0 +1,40 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Component;
/**
* Formateur de contexte campagne pour les prompts IA.
* Centralise la construction du bloc "nom + description + système de jeu".
*/
@Component
public class CampaignContextFormatter {
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
public CampaignContextFormatter(CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
}
/** Contexte compact : nom de campagne + description + système de jeu. */
public String format(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
}
}

View File

@@ -1,17 +1,17 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.CampaignImportProgress; import com.loremind.domain.campaigncontext.generation.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.NpcProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.NpcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Room; import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter; import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -83,66 +83,95 @@ public class CampaignImportService {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId); throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
} }
int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0; int arcsCreated = 0;
int chaptersCreated = 0;
int scenesCreated = 0;
// Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés // Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés
// via leur existingId dans l'arbre fusionné venu de la revue). // via leur existingId dans l'arbre fusionné venu de la revue).
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId); int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
for (ArcProposal arcP : proposal.arcs()) { for (ArcProposal arcP : proposal.arcs()) {
if (isBlank(arcP.name())) continue; if (isBlank(arcP.name())) continue;
String arcId; ArcOutcome outcome = applyArc(campaignId, arcP, arcOrder);
if (!isBlank(arcP.existingId())) { arcOrder = outcome.nextOrder();
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache arcsCreated += outcome.arcsCreated();
} else { chaptersCreated += outcome.chaptersCreated();
arcOrder++; scenesCreated += outcome.scenesCreated();
Arc arc = arcService.createArc(Arc.builder()
.name(arcP.name().trim())
.description(nullIfBlank(arcP.description()))
.campaignId(campaignId)
.order(arcOrder)
.type(parseArcType(arcP.type()))
.build());
arcId = arc.getId();
arcsCreated++;
}
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
for (ChapterProposal chapP : safe(arcP.chapters())) {
if (isBlank(chapP.name())) continue;
String chapId;
if (!isBlank(chapP.existingId())) {
chapId = chapP.existingId();
} else {
chapterOrder++;
Chapter chapter = chapterService.createChapter(
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
chapId = chapter.getId();
chaptersCreated++;
}
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
for (SceneProposal sceneP : safe(chapP.scenes())) {
if (isBlank(sceneP.name())) continue;
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
sceneOrder++;
sceneService.createScene(Scene.builder()
.name(sceneP.name().trim())
.description(nullIfBlank(sceneP.description()))
.playerNarration(nullIfBlank(sceneP.playerNarration()))
.gmSecretNotes(nullIfBlank(sceneP.gmNotes()))
.chapterId(chapId)
.order(sceneOrder)
.rooms(toRooms(sceneP.rooms()))
.build());
scenesCreated++;
}
}
} }
int npcsCreated = createNpcs(campaignId, proposal.npcs()); int npcsCreated = createNpcs(campaignId, proposal.npcs());
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated); return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
} }
/** Ordre d'arc suivant + compteurs créés (arc et, en cascade, ses chapitres/scènes). */
private record ArcOutcome(int nextOrder, int arcsCreated, int chaptersCreated, int scenesCreated) {}
private ArcOutcome applyArc(String campaignId, ArcProposal arcP, int arcOrder) {
String arcId;
int arcsCreated = 0;
if (!isBlank(arcP.existingId())) {
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
} else {
arcOrder++;
Arc arc = arcService.createArc(Arc.builder()
.name(arcP.name().trim())
.description(nullIfBlank(arcP.description()))
.campaignId(campaignId)
.order(arcOrder)
.type(parseArcType(arcP.type()))
.build());
arcId = arc.getId();
arcsCreated = 1;
}
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
int chaptersCreated = 0;
int scenesCreated = 0;
for (ChapterProposal chapP : safe(arcP.chapters())) {
if (isBlank(chapP.name())) continue;
ChapterOutcome outcome = applyChapter(arcId, chapP, chapterOrder);
chapterOrder = outcome.nextOrder();
chaptersCreated += outcome.chaptersCreated();
scenesCreated += outcome.scenesCreated();
}
return new ArcOutcome(arcOrder, arcsCreated, chaptersCreated, scenesCreated);
}
/** Ordre de chapitre suivant + compteurs créés (chapitre et, en cascade, ses scènes). */
private record ChapterOutcome(int nextOrder, int chaptersCreated, int scenesCreated) {}
private ChapterOutcome applyChapter(String arcId, ChapterProposal chapP, int chapterOrder) {
String chapId;
int chaptersCreated = 0;
if (!isBlank(chapP.existingId())) {
chapId = chapP.existingId();
} else {
chapterOrder++;
Chapter chapter = chapterService.createChapter(
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
chapId = chapter.getId();
chaptersCreated = 1;
}
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
int scenesCreated = 0;
for (SceneProposal sceneP : safe(chapP.scenes())) {
if (isBlank(sceneP.name()) || !isBlank(sceneP.existingId())) continue; // vide ou déjà présente
sceneOrder++;
sceneService.createScene(Scene.builder()
.name(sceneP.name().trim())
.description(nullIfBlank(sceneP.description()))
.playerNarration(nullIfBlank(sceneP.playerNarration()))
.gmSecretNotes(nullIfBlank(sceneP.gmNotes()))
.chapterId(chapId)
.order(sceneOrder)
.rooms(toRooms(sceneP.rooms()))
.build());
scenesCreated++;
}
return new ChapterOutcome(chapterOrder, chaptersCreated, scenesCreated);
}
/** /**
* Crée les PNJ proposés (description → values["Description"], même convention * Crée les PNJ proposés (description → values["Description"], même convention
* que les cartes d'action des ateliers). Les PNJ portant un nom déjà présent * que les cartes d'action des ateliers). Les PNJ portant un nom déjà présent
@@ -182,7 +211,8 @@ public class CampaignImportService {
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */ /** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
private static ArcType parseArcType(String type) { private static ArcType parseArcType(String type) {
return "HUB".equalsIgnoreCase(type == null ? "" : type.trim()) ? ArcType.HUB : ArcType.LINEAR; String normalized = type == null ? "" : type.trim();
return "HUB".equalsIgnoreCase(normalized) ? ArcType.HUB : ArcType.LINEAR;
} }
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */ /** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */

View File

@@ -0,0 +1,23 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
import java.util.List;
import java.util.Map;
/**
* Bilan de préparation d'une campagne (read-model, Pilier B « guidage »).
*
* @param campaignId campagne évaluée
* @param overallStatus statut agrégé (DRAFT si ≥1 gap bloquant, PLAYABLE si ≥1
* recommandé sans bloquant, POLISHED sinon)
* @param counts nombre de gaps par sévérité ({@code "BLOCKING"|"RECOMMENDED"|"OPTIONAL"})
* @param gaps liste des manques, triés par gravité décroissante
*/
public record CampaignReadinessAssessment(
String campaignId,
ReadinessStatus overallStatus,
Map<String, Integer> counts,
List<ReadinessGap> gaps
) {
}

View File

@@ -0,0 +1,352 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.quest.Prerequisite;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.structure.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service applicatif du Pilier B (« guidage / readiness ») : calcule à la volée
* l'état de préparation d'une campagne et la liste des manques (« gaps ») à combler,
* chacun cliquable vers l'éditeur concerné côté front.
*
* <p><b>Déterministe, sans IA, sans persistance</b> (aucune colonne readiness — recalcul
* à chaque appel, comme le statut de quête). <b>Orthogonalité stricte</b> : n'injecte
* AUCUN repository du Play Context ; le readiness ne dépend jamais d'un Playthrough,
* d'un flag ou d'une progression. Purement indicatif : aucune sévérité ne bloque une action.</p>
*
* <p>Chargement en une passe façon {@code CampaignStructuralContextBuilder} : arcs →
* chapitres (par arc) → scènes (par chapitre), + quêtes et bestiaire de la campagne
* chargés une seule fois, indexés par id pour résoudre les références faibles sans N+1.</p>
*
* <p>Périmètre MVP : le noyau BLOQUANT (vides, branches/portes cassées, quête sans nœud
* ou à nœud mort, prérequis cassé) + le trio « combat » RECOMMANDÉ (combat annoncé sans
* ennemi, réf d'ennemi cassée en scène et en pièce). Les règles narratives/ambiance et
* les orphelins (état que l'UI empêche) sont hors MVP.</p>
*/
@Service
public class CampaignReadinessService {
private static final String SCENE_FALLBACK_NAME = "Scène";
private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final QuestRepository questRepository;
private final EnemyRepository enemyRepository;
public CampaignReadinessService(CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
QuestRepository questRepository,
EnemyRepository enemyRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.questRepository = questRepository;
this.enemyRepository = enemyRepository;
}
/** Évalue la préparation d'une campagne (arbre + quêtes) et agrège son statut. */
public CampaignReadinessAssessment assess(String campaignId) {
List<ReadinessGap> gaps = new ArrayList<>();
String campaignName = campaignRepository.findById(campaignId)
.map(Campaign::getName).orElse(null);
// Bestiaire de la campagne, indexé pour résoudre les weak refs sans N+1.
// On écarte les ids vides/blancs : ils ne doivent jamais « résoudre » une référence.
Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream()
.map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet());
List<Quest> quests = questRepository.findByCampaignId(campaignId);
// Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides
// (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres).
Set<String> arcsWithQuests = quests.stream()
.map(Quest::getArcId).filter(id -> id != null && !id.isBlank())
.collect(Collectors.toSet());
List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId));
arcs.sort(Comparator.comparingInt(Arc::getOrder));
// Index global chapitres / scènes (cibles possibles des nœuds de quête).
Set<String> allChapterIds = new HashSet<>();
Set<String> allSceneIds = new HashSet<>();
int totalScenes = 0;
for (Arc arc : arcs) {
ArcScan scan = checkArc(arc, arcsWithQuests, enemyIds, gaps);
allChapterIds.addAll(scan.chapterIds());
allSceneIds.addAll(scan.sceneIds());
totalScenes += scan.sceneCount();
}
// Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat).
if (totalScenes == 0 && !anyQuestHasNodes(quests)) {
gaps.add(new ReadinessGap(ReadinessEntityType.CAMPAIGN, campaignId, campaignName,
"CAMP-001-NO-CONTENT",
"Campagne vide : ajoutez un arc avec une scène, ou créez une quête, pour commencer à jouer.",
ReadinessSeverity.BLOCKING, null, null));
}
Set<String> questIds = quests.stream()
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet());
for (Quest quest : quests) {
checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps);
}
return aggregate(campaignId, gaps);
}
/** Résultat du scan d'un arc : chapitres/scènes indexés (cibles des nœuds de quête) + total scènes. */
private record ArcScan(Set<String> chapterIds, Set<String> sceneIds, int sceneCount) {}
private ArcScan checkArc(Arc arc, Set<String> arcsWithQuests, Set<String> enemyIds, List<ReadinessGap> gaps) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
// Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la
// plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs).
boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId());
if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) {
gaps.add(emptyArcGap(arc));
}
Set<String> chapterIds = new HashSet<>();
Set<String> sceneIds = new HashSet<>();
int sceneCount = 0;
for (Chapter chapter : chapters) {
chapterIds.add(chapter.getId());
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
if (scenes.isEmpty()) {
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
labelOr(chapter.getName(), "Chapitre"), "CHAP-001-NO-SCENE",
"Chapitre vide : ajoutez au moins une scène pour pouvoir le jouer.",
ReadinessSeverity.BLOCKING, arc.getId(), chapter.getId()));
}
Set<String> chapterSceneIds = scenes.stream()
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
sceneCount += scenes.size();
for (Scene scene : scenes) {
sceneIds.add(scene.getId());
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
}
}
return new ArcScan(chapterIds, sceneIds, sceneCount);
}
private ReadinessGap emptyArcGap(Arc arc) {
String msg = arc.getType() == ArcType.HUB
? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le."
: "Arc vide : ajoutez un chapitre, ou supprimez-le.";
return new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
"ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null);
}
private static boolean anyQuestHasNodes(List<Quest> quests) {
return quests.stream().anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
}
private void checkScene(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) {
checkSceneName(scene, arcId, chapterId, gaps);
checkSceneBranches(scene, arcId, chapterId, chapterSceneIds, gaps);
checkSceneCombat(scene, arcId, chapterId, enemyIds, gaps);
checkSceneEnemyRefs(scene, arcId, chapterId, enemyIds, gaps);
checkSceneRooms(scene, arcId, chapterId, enemyIds, gaps);
}
/** SCENE-001 — scène sans titre. */
private void checkSceneName(Scene scene, String arcId, String chapterId, List<ReadinessGap> gaps) {
if (isBlank(scene.getName())) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME",
"Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.",
ReadinessSeverity.BLOCKING));
}
}
/** SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence). */
private void checkSceneBranches(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, List<ReadinessGap> gaps) {
List<SceneBranch> branches = scene.getBranches();
if (branches == null) return;
boolean invalid = branches.stream().anyMatch(b ->
isBlank(b.targetSceneId())
|| b.targetSceneId().equals(scene.getId())
|| !chapterSceneIds.contains(b.targetSceneId()));
if (invalid) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID",
"Branche cassée : une sortie de « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
+ " » pointe dans le vide, hors du chapitre, ou sur elle-même.",
ReadinessSeverity.BLOCKING));
}
}
/** SCENE-011 — combat annoncé sans adversaire (règle produit clé). */
private void checkSceneCombat(Scene scene, String arcId, String chapterId,
Set<String> enemyIds, List<ReadinessGap> gaps) {
if (isBlank(scene.getCombatDifficulty())) return;
boolean hasEnemyText = !isBlank(scene.getEnemies());
boolean hasResolvedEnemy = scene.getEnemyIds() != null
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id));
if (!hasEnemyText && !hasResolvedEnemy) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-011-COMBAT-NO-ENEMY",
"Combat annoncé sans adversaire : ajoutez une fiche du bestiaire ou décrivez les ennemis.",
ReadinessSeverity.RECOMMENDED));
}
}
/** SCENE-012 — référence d'ennemi cassée (fiche supprimée). */
private void checkSceneEnemyRefs(Scene scene, String arcId, String chapterId,
Set<String> enemyIds, List<ReadinessGap> gaps) {
if (scene.getEnemyIds() == null) return;
boolean broken = scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
if (broken) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN",
"Ennemi introuvable : « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
+ " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.",
ReadinessSeverity.RECOMMENDED));
}
}
/** SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes. */
private void checkSceneRooms(Scene scene, String arcId, String chapterId,
Set<String> enemyIds, List<ReadinessGap> gaps) {
List<Room> rooms = scene.getRooms();
if (rooms == null || rooms.isEmpty()) return;
String name = labelOr(scene.getName(), SCENE_FALLBACK_NAME);
Set<String> roomIds = rooms.stream()
.map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet());
if (rooms.stream().anyMatch(room -> hasInvalidBranch(room, roomIds))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID",
"Porte cassée : dans « " + name
+ " », une sortie de pièce pointe hors de la scène ou dans le vide.",
ReadinessSeverity.BLOCKING));
}
if (rooms.stream().anyMatch(room -> hasBrokenEnemyRef(room, enemyIds))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN",
"Ennemi introuvable dans une pièce de « " + name
+ " » : la référence pointe vers une fiche supprimée.",
ReadinessSeverity.RECOMMENDED));
}
}
private static boolean hasInvalidBranch(Room room, Set<String> roomIds) {
if (room.getBranches() == null) return false;
return room.getBranches().stream().anyMatch(rb ->
isBlank(rb.targetRoomId())
|| rb.targetRoomId().equals(room.getId())
|| !roomIds.contains(rb.targetRoomId()));
}
private static boolean hasBrokenEnemyRef(Room room, Set<String> enemyIds) {
if (room.getEnemyIds() == null) return false;
return room.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
}
private void checkQuest(Quest quest, Set<String> allChapterIds, Set<String> allSceneIds,
Set<String> questIds, List<ReadinessGap> gaps) {
String name = labelOr(quest.getName(), "Quête");
// QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide.
if (quest.getNodes() == null || quest.getNodes().isEmpty()) {
gaps.add(questGap(quest, "QUEST-001-NO-NODES",
"Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " »."));
} else if (quest.getNodes().stream().anyMatch(n ->
n.nodeType() == null
|| isBlank(n.nodeId())
|| (n.nodeType() == NodeType.CHAPTER && !allChapterIds.contains(n.nodeId()))
|| (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) {
gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN",
"Nœud de quête cassé : dans « " + name
+ " », un chapitre ou une scène référencé n'existe plus."));
}
// CAMP-010 — prérequis QuestCompleted pointant une quête disparue.
if (quest.getPrerequisites() != null && quest.getPrerequisites().stream()
.filter(p -> p instanceof Prerequisite.QuestCompleted)
.map(p -> ((Prerequisite.QuestCompleted) p).questId())
.anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) {
gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ",
"Prérequis cassé : « " + name
+ " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage."));
}
}
private CampaignReadinessAssessment aggregate(String campaignId, List<ReadinessGap> gaps) {
Map<String, Integer> counts = new LinkedHashMap<>();
counts.put(ReadinessSeverity.BLOCKING.name(), 0);
counts.put(ReadinessSeverity.RECOMMENDED.name(), 0);
counts.put(ReadinessSeverity.OPTIONAL.name(), 0);
for (ReadinessGap g : gaps) {
counts.merge(g.severity().name(), 1, Integer::sum);
}
ReadinessStatus status;
if (counts.get(ReadinessSeverity.BLOCKING.name()) > 0) {
status = ReadinessStatus.DRAFT;
} else if (counts.get(ReadinessSeverity.RECOMMENDED.name()) > 0) {
status = ReadinessStatus.PLAYABLE;
} else {
status = ReadinessStatus.POLISHED;
}
gaps.sort(Comparator.comparingInt(g -> severityRank(g.severity())));
return new CampaignReadinessAssessment(campaignId, status, counts, gaps);
}
private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId,
String ruleId, String message, ReadinessSeverity severity) {
return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(),
labelOr(scene.getName(), SCENE_FALLBACK_NAME), ruleId, message, severity, arcId, chapterId);
}
private ReadinessGap questGap(Quest quest, String ruleId, String message) {
return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(),
labelOr(quest.getName(), "Quête"), ruleId, message, ReadinessSeverity.BLOCKING, null, null);
}
private static int severityRank(ReadinessSeverity severity) {
return switch (severity) {
case BLOCKING -> 0;
case RECOMMENDED -> 1;
case OPTIONAL -> 2;
};
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static String labelOr(String value, String fallback) {
return isBlank(value) ? fallback : value;
}
}

View File

@@ -1,10 +1,8 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.quest.Prerequisite;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -12,35 +10,33 @@ import java.util.TreeSet;
/** /**
* Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet}) * Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet})
* référencés par les chapitres d'une Campagne. * référencés par les quêtes d'une Campagne.
* *
* <p>Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés * <p>Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés
* globalement. Un fait existe dès qu'au moins une quête le référence dans ses * globalement. Un fait existe dès qu'au moins une quête le référence dans ses
* prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans * prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans
* la Partie, autocomplete dans l'éditeur de prérequis).</p> * la Partie, autocomplete dans l'éditeur de prérequis de quête).</p>
*
* <p>Niveau 1 : lit désormais les quêtes (entité de première classe), plus les
* chapitres HUB.</p>
*/ */
@Service @Service
public class CampaignReferencedFlagsService { public class CampaignReferencedFlagsService {
private final ArcRepository arcRepository; private final QuestRepository questRepository;
private final ChapterRepository chapterRepository;
public CampaignReferencedFlagsService(ArcRepository arcRepository, public CampaignReferencedFlagsService(QuestRepository questRepository) {
ChapterRepository chapterRepository) { this.questRepository = questRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
} }
/** Retourne la liste triée alphabétiquement des noms de faits référencés. */ /** Retourne la liste triée alphabétiquement des noms de faits référencés. */
public List<String> listForCampaign(String campaignId) { public List<String> listForCampaign(String campaignId) {
TreeSet<String> unique = new TreeSet<>(); TreeSet<String> unique = new TreeSet<>();
for (Arc arc : arcRepository.findByCampaignId(campaignId)) { for (Quest quest : questRepository.findByCampaignId(campaignId)) {
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) { if (quest.getPrerequisites() == null) continue;
if (chapter.getPrerequisites() == null) continue; for (Prerequisite p : quest.getPrerequisites()) {
for (Prerequisite p : chapter.getPrerequisites()) { if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) { unique.add(f.flagName());
unique.add(f.flagName());
}
} }
} }
} }

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.application.playcontext.PlaythroughService; import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -48,7 +48,7 @@ public class CampaignService {
this.playthroughService = playthroughService; this.playthroughService = playthroughService;
} }
public record CampaignData(String name, String description, String loreId, String gameSystemId) {} public record CampaignData(String name, String description, String loreId, String gameSystemId, int playerCount) {}
public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {} public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
@@ -59,6 +59,7 @@ public class CampaignService {
.loreId(normalizeId(data.loreId())) .loreId(normalizeId(data.loreId()))
.gameSystemId(normalizeId(data.gameSystemId())) .gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0) .arcsCount(0)
.playerCount(data.playerCount())
.build(); .build();
Campaign saved = campaignRepository.save(campaign); Campaign saved = campaignRepository.save(campaign);
@@ -87,9 +88,26 @@ public class CampaignService {
campaign.setDescription(data.description()); campaign.setDescription(data.description());
campaign.setLoreId(normalizeId(data.loreId())); campaign.setLoreId(normalizeId(data.loreId()));
campaign.setGameSystemId(normalizeId(data.gameSystemId())); campaign.setGameSystemId(normalizeId(data.gameSystemId()));
campaign.setPlayerCount(data.playerCount());
return campaignRepository.save(campaign); return campaignRepository.save(campaign);
} }
/**
* Sauvegarde les positions du graphe de campagne (JSON opaque, état de
* présentation possédé par le front). Null/vide = retour à la disposition
* automatique. Taille bornée : ce champ ne doit pas devenir un fourre-tout.
*/
public void updateGraphPositions(String id, String positionsJson) {
Campaign campaign = campaignRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Campaign non trouvé avec l'ID: " + id));
String value = (positionsJson == null || positionsJson.isBlank()) ? null : positionsJson;
if (value != null && value.length() > 200_000) {
throw new IllegalArgumentException("Positions de graphe trop volumineuses");
}
campaign.setGraphPositions(value);
campaignRepository.save(campaign);
}
private String normalizeId(String id) { private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id; return (id == null || id.isBlank()) ? null : id;
} }

View File

@@ -1,6 +1,8 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
@@ -80,6 +82,35 @@ public class ChapterService {
return chapterRepository.save(chapter); return chapterRepository.save(chapter);
} }
/**
* Patch CIBLÉ champ-par-champ d'un chapitre (Pilier A — co-création). Applique
* UNIQUEMENT les {@link FieldProposal} reçus ; les autres champs restent INTACTS
* (contraste voulu avec {@link #updateChapter} qui écrase tout via BeanUtils).
*/
@Transactional
public Chapter patchChapter(String id, List<FieldProposal> fields) {
Chapter chapter = chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapter non trouvé avec l'ID: " + id));
if (fields != null) {
for (FieldProposal f : fields) {
if (f == null || f.key() == null) continue;
applyField(chapter, f.key(), f.proposedValue());
}
}
return chapterRepository.save(chapter);
}
/** Whitelist STRICTE des champs étoffables d'un chapitre ; clé inconnue ignorée. */
private void applyField(Chapter chapter, String key, String value) {
switch (key) {
case "description" -> chapter.setDescription(value);
case "gmNotes" -> chapter.setGmNotes(value);
case "playerObjectives" -> chapter.setPlayerObjectives(value);
case "narrativeStakes" -> chapter.setNarrativeStakes(value);
default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ }
}
}
/** Compte des scènes qui tomberont avec le chapitre. */ /** Compte des scènes qui tomberont avec le chapitre. */
public DeletionImpact getDeletionImpact(String id) { public DeletionImpact getDeletionImpact(String id) {
return new DeletionImpact(sceneRepository.findByChapterId(id).size()); return new DeletionImpact(sceneRepository.findByChapterId(id).size());
@@ -97,4 +128,19 @@ public class ChapterService {
public boolean chapterExists(String id) { public boolean chapterExists(String id) {
return chapterRepository.existsById(id); return chapterRepository.existsById(id);
} }
/**
* Réordonne (et déplace) les chapitres d'un arc : {@code order} = position. Si
* {@code arcId} est fourni, on réaffecte le chapitre à cet arc. Transactionnel.
*/
@Transactional
public void reorderChapters(String arcId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
chapterRepository::findById,
(chapter, i) -> {
if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId);
chapter.setOrder(i);
},
chapterRepository::save);
}
} }

View File

@@ -1,104 +0,0 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service applicatif : enrichit des ChapterDTO avec leur {@link QuestStatus} effectif,
* relatif à un Playthrough donné.
*
* <p>Depuis l'introduction de Playthrough : la progression et les flags vivent au niveau
* de la Partie, plus de la Campagne. L'enrichissement nécessite donc un playthroughId.</p>
*/
@Service
public class ChapterStatusEnricher {
private final PlaythroughRepository playthroughRepository;
private final QuestProgressionRepository progressionRepository;
private final PlaythroughFlagRepository flagRepository;
private final SessionRepository sessionRepository;
private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator();
public ChapterStatusEnricher(PlaythroughRepository playthroughRepository,
QuestProgressionRepository progressionRepository,
PlaythroughFlagRepository flagRepository,
SessionRepository sessionRepository) {
this.playthroughRepository = playthroughRepository;
this.progressionRepository = progressionRepository;
this.flagRepository = flagRepository;
this.sessionRepository = sessionRepository;
}
/** Contexte d'évaluation + map chapterId -> ProgressionStatus pour ce Playthrough. */
public record PlaythroughEvalSnapshot(
PrerequisiteEvaluator.EvaluationContext ctx,
Map<String, ProgressionStatus> progressionByChapterId
) {}
/** Construit le snapshot d'évaluation pour un Playthrough. */
public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) {
if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) {
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()),
Collections.emptyMap()
);
}
Map<String, Boolean> flags = flagRepository.findByPlaythroughId(playthroughId);
Set<String> completedQuestIds = progressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
Map<String, ProgressionStatus> progressionMap = new HashMap<>();
for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
progressionMap.put(qp.getChapterId(), qp.getStatus());
}
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
progressionMap
);
}
/** Calcule le statut effectif d'un seul chapitre relatif à un Playthrough. */
public QuestStatus computeFor(Chapter chapter, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
ProgressionStatus progression = snap.progressionByChapterId()
.getOrDefault(chapter.getId(), ProgressionStatus.NOT_STARTED);
return evaluator.computeStatus(progression, chapter.getPrerequisites(), snap.ctx());
}
/**
* Injecte le {@code effectiveStatus} et le {@code progressionStatus} dans une liste de DTOs.
* Un seul build du snapshot pour toute la liste (optimal pour les vues qui listent un arc).
*/
public void enrich(List<ChapterDTO> dtos, List<Chapter> domain, String playthroughId) {
if (dtos == null || dtos.isEmpty()) return;
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, Chapter> byId = domain.stream()
.collect(Collectors.toMap(Chapter::getId, c -> c));
for (ChapterDTO dto : dtos) {
Chapter c = byId.get(dto.getId());
if (c == null) continue;
ProgressionStatus progression = snap.progressionByChapterId()
.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = evaluator.computeStatus(progression, c.getPrerequisites(), snap.ctx());
dto.setProgressionStatus(progression.name());
dto.setEffectiveStatus(status.name());
}
}
}

View File

@@ -1,6 +1,6 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -41,9 +41,9 @@ public class CharacterService {
.name(data.name()) .name(data.name())
.portraitImageId(data.portraitImageId()) .portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId()) .headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()) .values(copyStringMap(data.values()))
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()) .imageValues(copyStringListMap(data.imageValues()))
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()) .keyValueValues(copyStringMapMap(data.keyValueValues()))
.playthroughId(data.playthroughId()) .playthroughId(data.playthroughId())
.order(order) .order(order)
.build(); .build();
@@ -64,9 +64,9 @@ public class CharacterService {
existing.setName(data.name()); existing.setName(data.name());
existing.setPortraitImageId(data.portraitImageId()); existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId()); existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()); existing.setValues(copyStringMap(data.values()));
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()); existing.setImageValues(copyStringListMap(data.imageValues()));
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()); existing.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }
@@ -89,4 +89,16 @@ public class CharacterService {
.max() .max()
.orElse(-1) + 1; .orElse(-1) + 1;
} }
private static Map<String, String> copyStringMap(Map<String, String> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, List<String>> copyStringListMap(Map<String, List<String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, Map<String, String>> copyStringMapMap(Map<String, Map<String, String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
} }

View File

@@ -1,13 +1,16 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy; import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.EnemyRepository; import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.images.Image;
import com.loremind.application.images.ImageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap; import java.io.ByteArrayInputStream;
import java.util.List; import java.util.*;
import java.util.Map;
import java.util.Optional;
/** /**
* Service d'application pour les fiches d'ennemis (bestiaire de campagne). * Service d'application pour les fiches d'ennemis (bestiaire de campagne).
@@ -16,10 +19,14 @@ import java.util.Optional;
@Service @Service
public class EnemyService { public class EnemyService {
private final EnemyRepository enemyRepository; private static final Logger log = LoggerFactory.getLogger(EnemyService.class);
public EnemyService(EnemyRepository enemyRepository) { private final EnemyRepository enemyRepository;
private final ImageService imageService;
public EnemyService(EnemyRepository enemyRepository, ImageService imageService) {
this.enemyRepository = enemyRepository; this.enemyRepository = enemyRepository;
this.imageService = imageService;
} }
public record EnemyData( public record EnemyData(
@@ -37,18 +44,10 @@ public class EnemyService {
public Enemy createEnemy(EnemyData data) { public Enemy createEnemy(EnemyData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId()); int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
Enemy enemy = Enemy.builder() Enemy enemy = Enemy.builder().build();
.name(data.name()) applyCommonEnemyFields(enemy, data);
.level(normalize(data.level())) enemy.setCampaignId(data.campaignId());
.folder(normalize(data.folder())) enemy.setOrder(order);
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
return enemyRepository.save(enemy); return enemyRepository.save(enemy);
} }
@@ -63,17 +62,11 @@ public class EnemyService {
public Enemy updateEnemy(String id, EnemyData data) { public Enemy updateEnemy(String id, EnemyData data) {
Enemy existing = enemyRepository.findById(id) Enemy existing = enemyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
existing.setName(data.name()); applyCommonEnemyFields(existing, data);
existing.setLevel(normalize(data.level()));
existing.setFolder(normalize(data.folder()));
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }
// campaignId immuable après création.
return enemyRepository.save(existing); return enemyRepository.save(existing);
} }
@@ -81,6 +74,139 @@ public class EnemyService {
enemyRepository.deleteById(id); enemyRepository.deleteById(id);
} }
/**
* Réordonne (et reclasse) les ennemis d'un dossier : {@code order} = position, et
* le dossier de chaque ennemi est posé à {@code folder} (glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderEnemies(String folder, List<String> orderedIds) {
String f = normalize(folder);
ReorderSupport.reorder(orderedIds,
enemyRepository::findById,
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
enemyRepository::save);
}
// --- Import de monstres depuis un compendium Foundry -------------------
/**
* Un monstre du catalogue Foundry : nom + référence + snapshot de stats + dossier
* + vignette du portrait (data URL base64, optionnelle).
*/
public record MonsterImport(String name, String foundryRef, Map<String, String> stats,
String folder, String imgData) {}
/**
* Dossier LoreMind d'un monstre importé : on conserve l'arborescence Foundry
* sous un dossier racine « Foundry » ("Foundry/Briarban", "Foundry" si aucun).
*/
private static String foundryFolder(String path) {
String p = path == null ? "" : path.trim();
return p.isEmpty() ? "Foundry" : "Foundry/" + p;
}
/**
* Décode une vignette de portrait (data URL base64 "data:image/webp;base64,…")
* et l'enregistre comme image LoreMind. Renvoie l'id de l'image, ou null si
* absente/illisible (l'import ne doit jamais échouer à cause d'un portrait).
*/
private String storePortrait(String dataUrl, String name) {
if (dataUrl == null || !dataUrl.startsWith("data:")) return null;
int comma = dataUrl.indexOf(',');
if (comma < 0) return null;
String header = dataUrl.substring(5, comma); // "image/webp;base64"
String contentType = header.split(";")[0].toLowerCase();
byte[] bytes;
try {
bytes = Base64.getDecoder().decode(dataUrl.substring(comma + 1));
} catch (IllegalArgumentException e) {
return null;
}
if (bytes.length == 0) return null;
String ext = pickExtension(contentType);
// Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non
// standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg").
String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext;
String filename = (name == null || name.isBlank() ? "monstre" : name.replaceAll("[^a-zA-Z0-9._-]", "_")) + "." + ext;
try {
Image img = imageService.upload(filename, mimeType,
new ByteArrayInputStream(bytes), bytes.length);
return img.getId();
} catch (RuntimeException e) {
log.warn("Portrait de monstre ignoré (\"{}\") : {}", name, e.getMessage());
return null;
}
}
/** Extension déduite du content-type ; "webp" par défaut si non reconnu. */
private static String pickExtension(String contentType) {
if (contentType.contains("png")) return "png";
if (contentType.contains("jpeg") || contentType.contains("jpg")) return "jpg";
if (contentType.contains("gif")) return "gif";
return "webp";
}
public record MonsterImportResult(int created, int updated) {}
/**
* Importe (upsert) des monstres Foundry dans le bestiaire d'une campagne.
* Dédup par {@code foundryRef} : un monstre déjà importé est mis à jour (nom),
* jamais dupliqué. Fiche minimale (nom + référence) ; les stats restent côté
* Foundry et sont ré-instanciées à l'export.
*/
public MonsterImportResult importFoundryMonsters(String campaignId, List<MonsterImport> monsters) {
List<Enemy> existing = enemyRepository.findByCampaignId(campaignId);
Map<String, Enemy> byRef = new HashMap<>();
for (Enemy e : existing) {
if (e.getFoundryRef() != null) byRef.put(e.getFoundryRef(), e);
}
int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1;
int created = 0;
int updated = 0;
for (MonsterImport m : monsters) {
if (isBlank(m.foundryRef()) || isBlank(m.name())) continue;
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
Enemy ex = byRef.get(m.foundryRef());
if (ex != null) {
updateMonster(ex, m, stats);
updated++;
} else {
byRef.put(m.foundryRef(), createMonster(campaignId, m, stats, order++));
created++;
}
}
return new MonsterImportResult(created, updated);
}
/** Met à jour un monstre déjà importé : nom, snapshot de stats, dossier, portrait manquant. */
private void updateMonster(Enemy ex, MonsterImport m, Map<String, String> stats) {
ex.setName(m.name());
ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(foundryFolder(m.folder())); // ré-aligne sur l'arborescence Foundry
// Portrait : seulement s'il manque (pas de ré-upload à chaque import).
if (ex.getPortraitImageId() == null) {
String portrait = storePortrait(m.imgData(), m.name());
if (portrait != null) ex.setPortraitImageId(portrait);
}
enemyRepository.save(ex);
}
private Enemy createMonster(String campaignId, MonsterImport m, Map<String, String> stats, int order) {
return enemyRepository.save(Enemy.builder()
.name(m.name())
.foundryRef(m.foundryRef())
.foundryStats(stats)
.folder(foundryFolder(m.folder()))
.portraitImageId(storePortrait(m.imgData(), m.name()))
.campaignId(campaignId)
.order(order)
.values(new HashMap<>())
.imageValues(new HashMap<>())
.keyValueValues(new HashMap<>())
.build());
}
public List<Enemy> searchEnemies(String query) { public List<Enemy> searchEnemies(String query) {
if (query == null || query.isBlank()) return List.of(); if (query == null || query.isBlank()) return List.of();
return enemyRepository.searchByName(query.trim()); return enemyRepository.searchByName(query.trim());
@@ -93,10 +219,37 @@ public class EnemyService {
return trimmed.isEmpty() ? null : trimmed; return trimmed.isEmpty() ? null : trimmed;
} }
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private int nextOrderFor(String campaignId) { private int nextOrderFor(String campaignId) {
return enemyRepository.findByCampaignId(campaignId).stream() return enemyRepository.findByCampaignId(campaignId).stream()
.mapToInt(Enemy::getOrder) .mapToInt(Enemy::getOrder)
.max() .max()
.orElse(-1) + 1; .orElse(-1) + 1;
} }
private static Map<String, String> copyStringMap(Map<String, String> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, List<String>> copyStringListMap(Map<String, List<String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, Map<String, String>> copyStringMapMap(Map<String, Map<String, String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static void applyCommonEnemyFields(Enemy enemy, EnemyData data) {
enemy.setName(data.name());
enemy.setLevel(normalize(data.level()));
enemy.setFolder(normalize(data.folder()));
enemy.setPortraitImageId(data.portraitImageId());
enemy.setHeaderImageId(data.headerImageId());
enemy.setValues(copyStringMap(data.values()));
enemy.setImageValues(copyStringListMap(data.imageValues()));
enemy.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
}
} }

View File

@@ -1,12 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.itemcatalog.CatalogItem;
import com.loremind.domain.campaigncontext.CatalogItem; import com.loremind.domain.campaigncontext.itemcatalog.ItemCatalog;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator; import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository; import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@@ -21,18 +18,15 @@ public class ItemCatalogService {
private final ItemCatalogRepository repository; private final ItemCatalogRepository repository;
private final ItemCatalogGenerator generator; private final ItemCatalogGenerator generator;
private final CampaignRepository campaignRepository; private final CampaignContextFormatter campaignContextFormatter;
private final GameSystemRepository gameSystemRepository;
public ItemCatalogService( public ItemCatalogService(
ItemCatalogRepository repository, ItemCatalogRepository repository,
ItemCatalogGenerator generator, ItemCatalogGenerator generator,
CampaignRepository campaignRepository, CampaignContextFormatter campaignContextFormatter) {
GameSystemRepository gameSystemRepository) {
this.repository = repository; this.repository = repository;
this.generator = generator; this.generator = generator;
this.campaignRepository = campaignRepository; this.campaignContextFormatter = campaignContextFormatter;
this.gameSystemRepository = gameSystemRepository;
} }
public record CatalogData( public record CatalogData(
@@ -89,7 +83,8 @@ public class ItemCatalogService {
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
public ItemCatalog generateProposal(String campaignId, String description) { public ItemCatalog generateProposal(String campaignId, String description) {
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId)); ItemCatalogGenerator.GeneratedCatalog g = generator.generate(
description, campaignContextFormatter.format(campaignId));
return ItemCatalog.builder() return ItemCatalog.builder()
.name(g.name()) .name(g.name())
.description(g.description()) .description(g.description())
@@ -102,22 +97,6 @@ public class ItemCatalogService {
return items != null ? new ArrayList<>(items) : new ArrayList<>(); return items != null ? new ArrayList<>(items) : new ArrayList<>();
} }
private String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
}
private int nextOrderFor(String campaignId) { private int nextOrderFor(String campaignId) {
return repository.findByCampaignId(campaignId).stream() return repository.findByCampaignId(campaignId).stream()
.mapToInt(ItemCatalog::getOrder) .mapToInt(ItemCatalog::getOrder)

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Notebook; import com.loremind.domain.campaigncontext.notebook.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage; import com.loremind.domain.campaigncontext.notebook.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource; import com.loremind.domain.campaigncontext.notebook.NotebookSource;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NotebookIndexer; import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
import com.loremind.domain.campaigncontext.ports.NotebookRepository; import com.loremind.domain.campaigncontext.ports.NotebookRepository;

View File

@@ -1,16 +1,11 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** /**
* Service d'application pour les fiches de PNJ (campagne). * Service d'application pour les fiches de PNJ (campagne).
@@ -19,11 +14,9 @@ import java.util.Optional;
public class NpcService { public class NpcService {
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final CampaignRepository campaignRepository;
public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) { public NpcService(NpcRepository npcRepository) {
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.campaignRepository = campaignRepository;
} }
public record NpcData( public record NpcData(
@@ -66,21 +59,6 @@ public class NpcService {
return npcRepository.findByCampaignId(campaignId); return npcRepository.findByCampaignId(campaignId);
} }
/**
* PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}).
* Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent.
* Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé.
*/
public List<Npc> getNpcsByLoreId(String loreId) {
List<Npc> out = new ArrayList<>();
for (Campaign campaign : campaignRepository.findAll()) {
if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) {
out.addAll(npcRepository.findByCampaignId(campaign.getId()));
}
}
return out;
}
public Npc updateNpc(String id, NpcData data) { public Npc updateNpc(String id, NpcData data) {
Npc existing = npcRepository.findById(id) Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
@@ -102,6 +80,19 @@ public class NpcService {
npcRepository.deleteById(id); npcRepository.deleteById(id);
} }
/**
* Réordonne (et reclasse) les PNJ d'un dossier : {@code order} = position, et le
* dossier de chaque PNJ est posé à {@code folder} (déplacement par glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderNpcs(String folder, List<String> orderedIds) {
String f = normalizeFolder(folder);
ReorderSupport.reorder(orderedIds,
npcRepository::findById,
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
npcRepository::save);
}
public List<Npc> searchNpcs(String query) { public List<Npc> searchNpcs(String query) {
if (query == null || query.isBlank()) return List.of(); if (query == null || query.isBlank()) return List.of();
return npcRepository.searchByName(query.trim()); return npcRepository.searchByName(query.trim());

View File

@@ -0,0 +1,207 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.shared.ReorderSupport;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Service d'application pour le contexte Quest (Niveau 1).
* Orchestre la logique métier via le Port {@code QuestRepository}.
*/
@Service
public class QuestService {
/** Nom de l'arc technique hébergeant les conteneurs des quêtes libres (invisible). */
static final String SYSTEM_ARC_NAME = "Quêtes libres";
private final QuestRepository questRepository;
private final QuestProgressionRepository progressionRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final ArcRepository arcRepository;
public QuestService(QuestRepository questRepository,
QuestProgressionRepository progressionRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
ArcRepository arcRepository) {
this.questRepository = questRepository;
this.progressionRepository = progressionRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.arcRepository = arcRepository;
}
/**
* Création à partir d'une Quest complète. L'id est forcé à null (généré par la DB).
*
* <p>TOUTE quête créée sans nœud reçoit son CONTENEUR de scènes (chapitre jumeau,
* même nom, masqué dans l'arbre par la fusion quête/jumeau) : une quête est un espace
* jouable où le MJ crée ses scènes à la volée — qu'elle vive dans un arc HUB (le
* conteneur y est rangé) ou LIBRE (le conteneur va dans l'arc technique {@code SYSTEM}
* de la campagne, invisible et non exporté). Lier des nœuds existants à la création
* (quête « transversale ») court-circuite le provisioning.</p>
*/
@Transactional
public Quest createQuest(Quest input) {
input.setId(null);
if (nullSafeNodes(input.getNodes()).isEmpty()) {
provisionContainer(input);
}
return questRepository.save(input);
}
/**
* Provisionne le conteneur de scènes d'une quête sans nœud et le référence
* (mutation de {@code quest.nodes} — la sauvegarde reste à la charge de l'appelant).
*/
private void provisionContainer(Quest quest) {
String containerArcId = quest.getArcId() != null && !quest.getArcId().isBlank()
? quest.getArcId()
: systemArcIdFor(quest.getCampaignId());
int order = chapterRepository.findByArcId(containerArcId).stream()
.mapToInt(Chapter::getOrder).max().orElse(-1) + 1;
Chapter container = chapterRepository.save(Chapter.builder()
.name(quest.getName())
.description("") // le narratif vit sur la quête, pas sur le conteneur
.arcId(containerArcId)
.order(order)
.build());
quest.setNodes(new ArrayList<>(List.of(
new QuestNodeRef(NodeType.CHAPTER, container.getId(), 0))));
}
/** Arc technique (SYSTEM) de la campagne — créé au premier besoin. */
private String systemArcIdFor(String campaignId) {
return arcRepository.findByCampaignId(campaignId).stream()
.filter(a -> a.getType() == ArcType.SYSTEM)
.map(Arc::getId)
.findFirst()
.orElseGet(() -> arcRepository.save(Arc.builder()
.name(SYSTEM_ARC_NAME)
.description("")
.campaignId(campaignId)
.type(ArcType.SYSTEM)
.order(9999)
.build()).getId());
}
/** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */
private boolean isContainerOf(Quest quest, Chapter chapter) {
if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true;
return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId())
.map(a -> a.getType() == ArcType.SYSTEM)
.orElse(false);
}
public Optional<Quest> getQuestById(String id) {
return questRepository.findById(id);
}
public List<Quest> getQuestsByCampaignId(String campaignId) {
return questRepository.findByCampaignId(campaignId);
}
/** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */
@Transactional
public Quest updateQuest(String id, Quest updated) {
Optional<Quest> existing = questRepository.findById(id);
if (existing.isEmpty()) {
throw new IllegalArgumentException("Quest non trouvée avec l'ID: " + id);
}
Quest quest = existing.get();
String oldName = quest.getName();
BeanUtils.copyProperties(updated, quest, "id");
Quest saved = questRepository.save(quest);
// Le conteneur jumeau porte le même nom que la quête (fusion dans l'arbre) :
// il suit le renommage, sinon le guidage citerait encore l'ancien nom.
// Vaut pour les quêtes de hub COMME pour les quêtes libres (conteneur en arc SYSTEM).
if (oldName != null && !oldName.equals(saved.getName())) {
for (QuestNodeRef node : nullSafeNodes(saved.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue;
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
if (oldName.equals(ch.getName()) && isContainerOf(saved, ch)) {
ch.setName(saved.getName());
chapterRepository.save(ch);
}
});
}
}
// Auto-réparation : une quête historique restée sans nœud (créée avant le
// provisioning systématique) reçoit son espace de scènes à la première sauvegarde.
if (nullSafeNodes(saved.getNodes()).isEmpty()) {
provisionContainer(saved);
return questRepository.save(saved);
}
return saved;
}
/**
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
*
* <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
* nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
* satisfait → la quête dépendante reste LOCKED (pas de corruption).</p>
*/
@Transactional
public void deleteQuest(String id) {
Quest quest = questRepository.findById(id).orElse(null);
progressionRepository.deleteByQuestId(id);
questRepository.deleteById(id);
if (quest == null) return;
// Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE
// (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître
// comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune
// perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du
// contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut.
List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId());
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue;
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
boolean container = isContainerOf(quest, ch);
boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty();
boolean referencedElsewhere = remaining.stream()
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
&& ch.getId().equals(n.nodeId())));
if (container && empty && !referencedElsewhere) {
chapterRepository.deleteById(ch.getId());
}
});
}
}
private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
return nodes != null ? nodes : List.of();
}
/** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */
@Transactional
public void reorderQuests(String campaignId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
questRepository::findById,
(quest, i) -> {
if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId);
quest.setOrder(i);
},
questRepository::save);
}
}

View File

@@ -0,0 +1,122 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service applicatif : enrichit des {@link QuestDTO} avec leur {@link QuestStatus}
* effectif, relatif à un Playthrough donné.
*
* <p>Depuis l'introduction de Playthrough, la progression et les flags vivent au niveau
* de la Partie. L'enrichissement nécessite donc un playthroughId ; sans lui (ou s'il est
* inconnu), le snapshot est vide et tout est NOT_STARTED / AVAILABLE.</p>
*/
@Service
public class QuestStatusEnricher {
private final PlaythroughRepository playthroughRepository;
private final QuestProgressionRepository progressionRepository;
private final PlaythroughFlagRepository flagRepository;
private final SessionRepository sessionRepository;
private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator();
public QuestStatusEnricher(PlaythroughRepository playthroughRepository,
QuestProgressionRepository progressionRepository,
PlaythroughFlagRepository flagRepository,
SessionRepository sessionRepository) {
this.playthroughRepository = playthroughRepository;
this.progressionRepository = progressionRepository;
this.flagRepository = flagRepository;
this.sessionRepository = sessionRepository;
}
/** Contexte d'évaluation + map questId -> ProgressionStatus pour ce Playthrough. */
public record PlaythroughEvalSnapshot(
PrerequisiteEvaluator.EvaluationContext ctx,
Map<String, ProgressionStatus> progressionByQuestId
) {}
/** Construit le snapshot d'évaluation pour un Playthrough (court-circuit si null / inconnu). */
public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) {
if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) {
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()),
Collections.emptyMap()
);
}
Map<String, Boolean> flags = flagRepository.findByPlaythroughId(playthroughId);
Set<String> completedQuestIds = progressionRepository.findCompletedQuestIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
Map<String, ProgressionStatus> progressionMap = new HashMap<>();
for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
progressionMap.put(qp.getQuestId(), qp.getStatus());
}
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
progressionMap
);
}
/** Calcule le statut effectif d'une seule quête relatif à un Playthrough. */
public QuestStatus computeFor(Quest quest, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
ProgressionStatus progression = snap.progressionByQuestId()
.getOrDefault(quest.getId(), ProgressionStatus.NOT_STARTED);
return evaluator.computeStatus(progression, quest.getPrerequisites(), snap.ctx());
}
/**
* Statut effectif de PLUSIEURS quêtes avec un seul build du snapshot (contrairement
* à {@link #computeFor} qui reconstruit le snapshot à chaque appel). Utilisé par les
* read-models qui balaient toutes les quêtes d'une campagne (préparation de séance).
*/
public Map<String, QuestStatus> computeAll(List<Quest> quests, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, QuestStatus> out = new HashMap<>();
for (Quest q : quests) {
if (q == null || q.getId() == null) continue;
ProgressionStatus progression = snap.progressionByQuestId()
.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
out.put(q.getId(), evaluator.computeStatus(progression, q.getPrerequisites(), snap.ctx()));
}
return out;
}
/**
* Injecte {@code progressionStatus} + {@code effectiveStatus} dans une liste de DTOs.
* Un seul build du snapshot pour toute la liste.
*/
public void enrich(List<QuestDTO> dtos, List<Quest> domain, String playthroughId) {
if (dtos == null || dtos.isEmpty()) return;
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, Quest> byId = domain.stream()
.collect(Collectors.toMap(Quest::getId, q -> q));
for (QuestDTO dto : dtos) {
Quest q = byId.get(dto.getId());
if (q == null) continue;
ProgressionStatus progression = snap.progressionByQuestId()
.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = evaluator.computeStatus(progression, q.getPrerequisites(), snap.ctx());
dto.setProgressionStatus(progression.name());
dto.setEffectiveStatus(status.name());
}
}
}

View File

@@ -1,12 +1,10 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.randomtable.RandomTable;
import com.loremind.domain.campaigncontext.RandomTable; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.RandomTableEntry; import com.loremind.domain.campaigncontext.randomtable.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator; import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository; import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@@ -21,18 +19,15 @@ public class RandomTableService {
private final RandomTableRepository repository; private final RandomTableRepository repository;
private final RandomTableGenerator generator; private final RandomTableGenerator generator;
private final CampaignRepository campaignRepository; private final CampaignContextFormatter campaignContextFormatter;
private final GameSystemRepository gameSystemRepository;
public RandomTableService( public RandomTableService(
RandomTableRepository repository, RandomTableRepository repository,
RandomTableGenerator generator, RandomTableGenerator generator,
CampaignRepository campaignRepository, CampaignContextFormatter campaignContextFormatter) {
GameSystemRepository gameSystemRepository) {
this.repository = repository; this.repository = repository;
this.generator = generator; this.generator = generator;
this.campaignRepository = campaignRepository; this.campaignContextFormatter = campaignContextFormatter;
this.gameSystemRepository = gameSystemRepository;
} }
public record TableData( public record TableData(
@@ -85,6 +80,15 @@ public class RandomTableService {
repository.deleteById(id); repository.deleteById(id);
} }
/** Réordonne les tables aléatoires d'une campagne : {@code order} = position. */
@org.springframework.transaction.annotation.Transactional
public void reorderTables(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
repository::findById,
RandomTable::setOrder,
repository::save);
}
public List<RandomTable> searchTables(String query) { public List<RandomTable> searchTables(String query) {
if (query == null || query.isBlank()) return List.of(); if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim()); return repository.searchByName(query.trim());
@@ -93,7 +97,8 @@ public class RandomTableService {
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
public RandomTable generateProposal(String campaignId, String description, String diceFormula) { public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula; String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
RandomTableGenerator.GeneratedTable g = generator.generate(description, formula, buildContext(campaignId)); RandomTableGenerator.GeneratedTable g = generator.generate(
description, formula, campaignContextFormatter.format(campaignId));
return RandomTable.builder() return RandomTable.builder()
.name(g.name()) .name(g.name())
.description(g.description()) .description(g.description())
@@ -105,24 +110,7 @@ public class RandomTableService {
/** Brode un court récit IA sur un résultat tiré (pour la partie). */ /** Brode un court récit IA sur un résultat tiré (pour la partie). */
public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) { public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) {
return generator.improvise(tableName, resultLabel, resultDetail, buildContext(campaignId)); return generator.improvise(tableName, resultLabel, resultDetail, campaignContextFormatter.format(campaignId));
}
/** Contexte libre (nom de campagne + description + système) pour orienter l'IA. */
private String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
} }
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) { private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {

View File

@@ -0,0 +1,34 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
/**
* Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B).
*
* <p>Le message est déjà rédigé (orienté action, bienveillant) côté back ; le front
* l'affiche tel quel. {@code arcId}/{@code chapterId} sont le CONTEXTE de navigation
* (nullable selon l'entité) : le front construit le lien profond vers l'éditeur à
* partir de {@code entityType}, {@code entityId} et de ces ancêtres — aucune route
* Angular n'est codée côté back.</p>
*
* @param entityType type de l'entité concernée
* @param entityId id de l'entité concernée
* @param entityName libellé lisible de l'entité (peut être {@code null} si sans nom)
* @param ruleId identifiant stable de la règle (ex. {@code SCENE-011-COMBAT-NO-ENEMY})
* @param message message utilisateur prêt à afficher
* @param severity gravité du manque
* @param arcId arc parent (navigation), ou {@code null}
* @param chapterId chapitre parent (navigation), ou {@code null}
*/
public record ReadinessGap(
ReadinessEntityType entityType,
String entityId,
String entityName,
String ruleId,
String message,
ReadinessSeverity severity,
String arcId,
String chapterId
) {
}

View File

@@ -1,10 +1,14 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.generation.SceneDraft;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.structure.SceneBranch;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -84,7 +88,96 @@ public class SceneService {
return sceneRepository.save(scene); return sceneRepository.save(scene);
} }
/**
* Patch CIBLÉ champ-par-champ d'une scène (Pilier A — co-création). Applique
* UNIQUEMENT les {@link FieldProposal} reçus (valeurs acceptées par l'utilisateur) sur
* les champs correspondants ; tous les autres champs restent INTACTS.
*
* <p>Contraste volontaire avec {@link #updateScene} : ce dernier fait un
* {@code BeanUtils.copyProperties} qui écrase MÊME avec des null — inadapté ici où l'on
* ne veut toucher que les champs proposés. Les branches ne sont pas modifiées (pas de
* revalidation du graphe nécessaire).</p>
*/
@Transactional
public Scene patchScene(String id, List<FieldProposal> fields) {
Scene scene = sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scene non trouvée avec l'ID: " + id));
if (fields != null) {
for (FieldProposal f : fields) {
if (f == null || f.key() == null) continue;
applyField(scene, f.key(), f.proposedValue());
}
}
return sceneRepository.save(scene);
}
/**
* Applique une valeur sur le champ nommé. Whitelist STRICTE alignée sur
* {@code NarrativeEntityContextBuilder.fromScene()} : toute clé inconnue est ignorée
* (jamais d'écrasement hors de la liste connue).
*/
private void applyField(Scene scene, String key, String value) {
switch (key) {
case "description" -> scene.setDescription(value);
case "location" -> scene.setLocation(value);
case "timing" -> scene.setTiming(value);
case "atmosphere" -> scene.setAtmosphere(value);
case "playerNarration" -> scene.setPlayerNarration(value);
case "choicesConsequences" -> scene.setChoicesConsequences(value);
case "combatDifficulty" -> scene.setCombatDifficulty(value);
case "enemies" -> scene.setEnemies(value);
case "gmSecretNotes" -> scene.setGmSecretNotes(value);
default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ }
}
}
/**
* Crée en bloc des scènes à partir d'ébauches IA acceptées (Pilier A — capacité
* « create »). Les scènes sont AJOUTÉES à la fin du chapitre (ordre = suite des scènes
* existantes). Les ébauches sans titre sont ignorées. Transactionnel.
*/
@Transactional
public List<Scene> createDraftScenes(String chapterId, List<SceneDraft> drafts) {
if (drafts == null || drafts.isEmpty()) return List.of();
int order = sceneRepository.findByChapterId(chapterId).stream()
.mapToInt(Scene::getOrder).max().orElse(-1) + 1;
List<Scene> created = new ArrayList<>();
for (SceneDraft d : drafts) {
if (d == null || d.name() == null || d.name().isBlank()) continue;
Scene scene = Scene.builder()
.name(d.name().trim())
.description(d.description())
.playerNarration(d.playerNarration())
.chapterId(chapterId)
.order(order++)
.build();
created.add(createScene(scene)); // createScene(Scene) force id=null + rooms
}
return created;
}
/**
* Supprime la scène ET nettoie les branches des scènes sœurs qui pointaient vers elle
* (sinon elles deviennent des références mortes : invisibles dans le graphe — qui filtre
* les cibles inexistantes — mais signalées « branche cassée » par le guidage, ce qui est
* incompréhensible pour l'utilisateur). Les branches étant intra-chapitre, le nettoyage
* se limite aux sœurs du même chapitre. Transactionnel : atomique.
*/
@Transactional
public void deleteScene(String id) { public void deleteScene(String id) {
sceneRepository.findById(id).ifPresent(scene -> {
for (Scene sibling : sceneRepository.findByChapterId(scene.getChapterId())) {
List<SceneBranch> branches = sibling.getBranches();
if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue;
List<SceneBranch> kept = branches.stream()
.filter(b -> !id.equals(b.targetSceneId()))
.toList();
if (kept.size() != branches.size()) {
sibling.setBranches(kept);
sceneRepository.save(sibling);
}
}
});
sceneRepository.deleteById(id); sceneRepository.deleteById(id);
} }
@@ -92,6 +185,25 @@ public class SceneService {
return sceneRepository.existsById(id); return sceneRepository.existsById(id);
} }
/**
* Réordonne les scènes d'un chapitre : {@code order} = position. Si {@code chapterId}
* diffère (scène déplacée vers un autre chapitre), on réaffecte le chapitre et on
* vide ses branches (elles ne valent que dans le chapitre d'origine). Transactionnel.
*/
@Transactional
public void reorderScenes(String chapterId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
sceneRepository::findById,
(scene, i) -> {
if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) {
scene.setChapterId(chapterId);
scene.setBranches(new ArrayList<>());
}
scene.setOrder(i);
},
sceneRepository::save);
}
/** /**
* Vérifie les invariants du graphe narratif : * Vérifie les invariants du graphe narratif :
* 1. Pas d'auto-référence (scène qui pointe sur elle-même). * 1. Pas d'auto-référence (scène qui pointe sur elle-même).

View File

@@ -11,11 +11,9 @@ import java.util.Optional;
/** /**
* Service d'application du contexte Conversation. * Service d'application du contexte Conversation.
*
* Regroupe les cas d'usage CRUD + append message + rename. Un seul * Regroupe les cas d'usage CRUD + append message + rename. Un seul
* service suffit — le contexte est simple et les operations fortement * service suffit — le contexte est simple et les operations fortement
* liees (meme aggregat). * liees (meme aggregat).
*
* Regles metier : * Regles metier :
* - exactement un ancrage parent (loreId XOR campaignId) ; * - exactement un ancrage parent (loreId XOR campaignId) ;
* - entityType et entityId vont ensemble (tous deux null = niveau racine, * - entityType et entityId vont ensemble (tous deux null = niveau racine,

View File

@@ -0,0 +1,119 @@
package com.loremind.application.files;
import com.loremind.domain.files.StoredFile;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.files.ports.StoredFileRepository;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Optional;
import java.util.Set;
/**
* Service d'application pour les fichiers generiques (port {@link FileStorage}
* + {@link StoredFileRepository}). Pendant de
* {@link com.loremind.application.images.ImageService}, mais SANS la contrainte
* "image" : accepte aussi video et sidecars JSON (battlemaps Universal VTT).
* <p>
* Validation MIME volontairement permissive : l'appli est mono-utilisateur
* (bureau / self-hosted), le risque "upload piege" est faible, et les outils de
* cartes (Dungeon Alchemist...) servent parfois le sidecar {@code .dd2vtt} sans
* type MIME fiable.
*/
@Service
public class StoredFileService {
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
/** Types MIME explicitement autorises (en plus de tout {@code image/*} et {@code video/*}). */
private static final Set<String> ALLOWED_EXACT_MIME = Set.of(
"application/json",
DEFAULT_CONTENT_TYPE,
"text/plain"
);
/** Coherent avec spring.servlet.multipart.max-file-size (application.properties). */
private static final long MAX_SIZE_BYTES = 128L * 1024 * 1024; // 128 Mo
private final StoredFileRepository repository;
private final FileStorage storage;
public StoredFileService(StoredFileRepository repository, FileStorage storage) {
this.repository = repository;
this.storage = storage;
}
/**
* Use case upload : valide -> envoie le binaire -> persiste les metadonnees.
* En cas d'echec DB apres ecriture du binaire, compense (supprime l'orphelin).
*/
public StoredFile upload(String filename, String contentType, InputStream data, long sizeBytes) {
String resolvedType = resolveContentType(contentType);
validateUpload(filename, resolvedType, sizeBytes);
String storageKey = storage.upload(filename, resolvedType, data, sizeBytes);
try {
StoredFile file = StoredFile.builder()
.filename(filename)
.contentType(resolvedType)
.sizeBytes(sizeBytes)
.storageKey(storageKey)
.uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
.build();
return repository.save(file);
} catch (RuntimeException ex) {
storage.delete(storageKey);
throw ex;
}
}
public Optional<StoredFile> getById(String id) {
return repository.findById(id);
}
public Optional<InputStream> downloadById(String id) {
return repository.findById(id)
.map(f -> storage.download(f.getStorageKey()));
}
public void deleteById(String id) {
repository.findById(id).ifPresent(f -> {
storage.delete(f.getStorageKey());
repository.deleteById(id);
});
}
// --- Validation --------------------------------------------------------
private String resolveContentType(String contentType) {
if (contentType == null || contentType.isBlank()) {
return DEFAULT_CONTENT_TYPE;
}
return contentType.toLowerCase();
}
private void validateUpload(String filename, String contentType, long sizeBytes) {
if (filename == null || filename.isBlank()) {
throw new IllegalArgumentException("Le nom du fichier est requis.");
}
if (!isAllowedMime(contentType)) {
throw new IllegalArgumentException("Type de fichier non supporte : " + contentType);
}
if (sizeBytes <= 0) {
throw new IllegalArgumentException("Le fichier est vide.");
}
if (sizeBytes > MAX_SIZE_BYTES) {
throw new IllegalArgumentException(
"Fichier trop volumineux (max " + (MAX_SIZE_BYTES / 1024 / 1024) + " Mo).");
}
}
private boolean isAllowedMime(String contentType) {
return contentType.startsWith("image/")
|| contentType.startsWith("video/")
|| ALLOWED_EXACT_MIME.contains(contentType);
}
}

View File

@@ -27,8 +27,13 @@ import java.util.regex.Pattern;
@Service @Service
public class GameSystemContextBuilder { public class GameSystemContextBuilder {
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */ /**
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$"); * Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1.
* Le titre est forcé à commencer par un caractère non-blanc ({@code \S.*}) : la
* frontière entre les espaces de tête et le titre devient non ambiguë (classes de
* caractères disjointes), ce qui élimine tout risque de backtracking polynomial.
*/
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(\\S.*)$");
private final GameSystemRepository gameSystemRepository; private final GameSystemRepository gameSystemRepository;

View File

@@ -4,11 +4,15 @@ import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult; import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter; import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
@Service @Service
public class GameSystemService { public class GameSystemService {
@@ -58,6 +62,7 @@ public class GameSystemService {
List<TemplateField> characterTemplate, List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate, List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate, List<TemplateField> enemyTemplate,
String foundryActorType,
String author, String author,
boolean isPublic boolean isPublic
) {} ) {}
@@ -67,6 +72,7 @@ public class GameSystemService {
.name(data.name()) .name(data.name())
.description(data.description()) .description(data.description())
.rulesMarkdown(data.rulesMarkdown()) .rulesMarkdown(data.rulesMarkdown())
.foundryActorType(normalize(data.foundryActorType()))
.author(normalize(data.author())) .author(normalize(data.author()))
.isPublic(data.isPublic()) .isPublic(data.isPublic())
.build(); .build();
@@ -93,17 +99,49 @@ public class GameSystemService {
existing.replaceCharacterTemplate(data.characterTemplate()); existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate()); existing.replaceNpcTemplate(data.npcTemplate());
existing.replaceEnemyTemplate(data.enemyTemplate()); existing.replaceEnemyTemplate(data.enemyTemplate());
existing.setFoundryActorType(normalize(data.foundryActorType()));
existing.setAuthor(normalize(data.author())); existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic()); existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing); return gameSystemRepository.save(existing);
} }
public void deleteGameSystem(String id) { /** Un champ scalaire d'une structure d'acteur Foundry importée. */
gameSystemRepository.deleteById(id); public record FoundryStructField(String path, String label, String type) {}
/**
* Remplace le template ENNEMI par une structure importée d'un système Foundry :
* chaque champ devient un TemplateField mappé à son chemin Foundry. Pose aussi le
* type d'acteur. L'utilisateur élague/renomme ensuite dans l'éditeur de template.
*/
public GameSystem importFoundryStructure(String id, String actorType, List<FoundryStructField> fields) {
GameSystem gs = gameSystemRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id));
List<TemplateField> template = new ArrayList<>();
Set<String> usedNames = new HashSet<>();
for (FoundryStructField f : fields != null ? fields : List.<FoundryStructField>of()) {
TemplateField field = toTemplateField(f, usedNames);
if (field != null) template.add(field);
}
gs.replaceEnemyTemplate(template);
gs.setFoundryActorType(normalize(actorType));
return gameSystemRepository.save(gs);
} }
public boolean gameSystemExists(String id) { /** Convertit un champ Foundry en TemplateField, ou null si son chemin est vide ou son nom déjà pris. */
return gameSystemRepository.existsById(id); private static TemplateField toTemplateField(FoundryStructField f, Set<String> usedNames) {
if (f.path() == null || f.path().isBlank()) return null;
String label = (f.label() != null && !f.label().isBlank()) ? f.label().trim() : f.path();
// Nom unique : libellé si libre, sinon le chemin (toujours unique).
String name = usedNames.contains(label.toLowerCase()) ? f.path() : label;
if (usedNames.contains(name.toLowerCase())) return null;
usedNames.add(name.toLowerCase());
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
return new TemplateField(name, type, null, null, f.path());
}
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
} }
public List<GameSystem> searchGameSystems(String query) { public List<GameSystem> searchGameSystems(String query) {

View File

@@ -1,12 +1,12 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -93,26 +93,26 @@ public class CampaignStructuralContextBuilder {
// les enemyIds des pièces sans N+1 sur le repo. // les enemyIds des pièces sans N+1 sur le repo.
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream() Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Enemy::getId, com.loremind.domain.campaigncontext.bestiary.Enemy::getId,
CampaignStructuralContextBuilder::enemyLabel, CampaignStructuralContextBuilder::enemyLabel,
(a, b) -> a)); (a, b) -> a));
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream() List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Arc::getOrder)) .sorted(Comparator.comparingInt(Arc::getOrder))
.map(arc -> toArcSummary(arc, enemyLabelById)) .map(arc -> toArcSummary(arc, enemyLabelById))
.collect(Collectors.toList()); .toList();
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank()) List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
? List.of() ? List.of()
: characterRepository.findByPlaythroughId(playthroughId).stream() : characterRepository.findByPlaythroughId(playthroughId).stream()
.sorted(Comparator.comparingInt(Character::getOrder)) .sorted(Comparator.comparingInt(Character::getOrder))
.map(this::toCharacterSummary) .map(this::toCharacterSummary)
.collect(Collectors.toList()); .toList();
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream() List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Npc::getOrder)) .sorted(Comparator.comparingInt(Npc::getOrder))
.map(this::toNpcSummary) .map(this::toNpcSummary)
.collect(Collectors.toList()); .toList();
return new CampaignStructuralContext( return new CampaignStructuralContext(
campaign.getName(), campaign.getName(),
@@ -140,27 +140,36 @@ public class CampaignStructuralContextBuilder {
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide * Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown). * du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
*/ */
private static String extractSnippet(java.util.Map<String, String> values) { private static String extractSnippet(Map<String, String> values) {
if (values == null || values.isEmpty()) return ""; if (values == null || values.isEmpty()) return "";
for (String value : values.values()) { return values.values().stream()
if (value == null || value.isBlank()) continue; .filter(v -> v != null && !v.isBlank())
String firstLine = value.lines() .map(CampaignStructuralContextBuilder::firstMeaningfulLine)
.map(String::strip) .filter(l -> !l.isEmpty())
.filter(l -> !l.isEmpty() && !l.startsWith("#")) .findFirst()
.findFirst() .map(CampaignStructuralContextBuilder::truncateSnippet)
.orElse(""); .orElse("");
if (firstLine.isEmpty()) continue; }
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + ""; /** 1re ligne non vide et non-titre ("# ...") d'une valeur de template, ou "" si aucune. */
} private static String firstMeaningfulLine(String value) {
return ""; return value.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
}
private static String truncateSnippet(String firstLine) {
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
} }
private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) { private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream() List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(chapter -> toChapterSummary(chapter, enemyLabelById)) .map(chapter -> toChapterSummary(chapter, enemyLabelById))
.collect(Collectors.toList()); .toList();
return new ArcSummary( return new ArcSummary(
arc.getName(), arc.getName(),
arc.getDescription(), arc.getDescription(),
@@ -181,7 +190,7 @@ public class CampaignStructuralContextBuilder {
List<SceneSummary> summaries = scenes.stream() List<SceneSummary> summaries = scenes.stream()
.map(s -> toSceneSummary(s, nameById, enemyLabelById)) .map(s -> toSceneSummary(s, nameById, enemyLabelById))
.collect(Collectors.toList()); .toList();
return new ChapterSummary( return new ChapterSummary(
chapter.getName(), chapter.getName(),
@@ -199,7 +208,7 @@ public class CampaignStructuralContextBuilder {
b.label(), b.label(),
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"), nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.condition())) b.condition()))
.collect(Collectors.toList()); .toList();
List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById); List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
@@ -221,8 +230,8 @@ public class CampaignStructuralContextBuilder {
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of(); if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream() Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Room::getId, com.loremind.domain.campaigncontext.structure.Room::getId,
com.loremind.domain.campaigncontext.Room::getName, com.loremind.domain.campaigncontext.structure.Room::getName,
(a, b) -> a)); (a, b) -> a));
return scene.getRooms().stream() return scene.getRooms().stream()
.map(r -> { .map(r -> {
@@ -233,12 +242,12 @@ public class CampaignStructuralContextBuilder {
b.label(), b.label(),
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"), nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition())) b.condition()))
.collect(Collectors.toList()); .toList();
return new RoomSummary( return new RoomSummary(
r.getName(), r.getFloor(), r.getDescription(), r.getName(), r.getFloor(), r.getDescription(),
roomEnemiesText(r, enemyLabelById), hints); roomEnemiesText(r, enemyLabelById), hints);
}) })
.collect(Collectors.toList()); .toList();
} }
/** /**
@@ -247,7 +256,7 @@ public class CampaignStructuralContextBuilder {
* libre. L'un ou l'autre peut être vide. * libre. L'un ou l'autre peut être vide.
*/ */
private static String roomEnemiesText( private static String roomEnemiesText(
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) { com.loremind.domain.campaigncontext.structure.Room room, Map<String, String> enemyLabelById) {
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream() String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
.map(enemyLabelById::get) .map(enemyLabelById::get)
.filter(l -> l != null && !l.isBlank()) .filter(l -> l != null && !l.isBlank())
@@ -259,7 +268,7 @@ public class CampaignStructuralContextBuilder {
} }
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */ /** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) { private static String enemyLabel(com.loremind.domain.campaigncontext.bestiary.Enemy enemy) {
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip(); String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")"; return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
} }

View File

@@ -0,0 +1,94 @@
package com.loremind.application.generationcontext;
import com.loremind.application.campaigncontext.CampaignContextFormatter;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.generation.SceneDraft;
import com.loremind.domain.campaigncontext.generation.SceneDraftProposal;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* Use case (Pilier A — capacité « create ») : produit une PROPOSITION d'ébauches de scènes
* pour un chapitre (non persistée). Répond directement à la « page blanche » / au manque
* de guidage « Chapitre vide » (Pilier B).
*
* <p>Contexte compact : le chapitre (objectifs/enjeux) + les scènes DÉJÀ présentes (pour
* éviter les doublons) + méta campagne. Zéro écriture — la création est un second appel.</p>
*/
@Service
public class DraftScenesUseCase {
private static final int MIN_COUNT = 1;
private static final int MAX_COUNT = 8;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CampaignContextFormatter campaignContextFormatter;
private final SceneDraftAssistant assistant;
public DraftScenesUseCase(ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CampaignContextFormatter campaignContextFormatter,
SceneDraftAssistant assistant) {
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.campaignContextFormatter = campaignContextFormatter;
this.assistant = assistant;
}
public SceneDraftProposal execute(String chapterId, String campaignId, String instruction, int count) {
Chapter chapter = chapterRepository.findById(chapterId)
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + chapterId));
int n = Math.max(MIN_COUNT, Math.min(MAX_COUNT, count));
String context = buildContext(chapter, campaignId);
List<SceneDraft> drafts = assistant.draftScenes(context, instruction, n).stream()
.filter(d -> d != null && d.name() != null && !d.name().isBlank())
.limit(n)
.toList();
return new SceneDraftProposal(chapterId, drafts);
}
private String buildContext(Chapter chapter, String campaignId) {
StringBuilder sb = new StringBuilder();
sb.append("Chapitre : ").append(blankToLabel(chapter.getName())).append("\n");
appendIf(sb, "Synopsis", chapter.getDescription());
appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives());
appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes());
List<Scene> existing = sceneRepository.findByChapterId(chapter.getId());
if (!existing.isEmpty()) {
String names = existing.stream()
.map(Scene::getName)
.filter(nm -> nm != null && !nm.isBlank())
.collect(Collectors.joining(" ; "));
if (!names.isEmpty()) {
sb.append("Scènes DÉJÀ présentes (ne pas dupliquer) : ").append(names).append("\n");
}
}
if (campaignId != null && !campaignId.isBlank()) {
String campaignBlock = campaignContextFormatter.format(campaignId);
if (!campaignBlock.isBlank()) {
sb.append(campaignBlock).append("\n");
}
}
return sb.toString();
}
private static void appendIf(StringBuilder sb, String label, String value) {
if (value != null && !value.isBlank()) {
sb.append(label).append(" : ").append(value.trim()).append("\n");
}
}
private static String blankToLabel(String value) {
return value == null || value.isBlank() ? "(sans titre)" : value;
}
}

View File

@@ -30,6 +30,8 @@ import java.util.Map;
@Service @Service
public class GeneratePageValuesUseCase { public class GeneratePageValuesUseCase {
private static final String FOR_PAGE = ") pour la page ";
private final PageRepository pageRepository; private final PageRepository pageRepository;
private final TemplateRepository templateRepository; private final TemplateRepository templateRepository;
private final LoreRepository loreRepository; private final LoreRepository loreRepository;
@@ -96,22 +98,19 @@ public class GeneratePageValuesUseCase {
} }
return templateRepository.findById(templateId) return templateRepository.findById(templateId)
.orElseThrow(() -> new IllegalStateException( .orElseThrow(() -> new IllegalStateException(
"Template introuvable (id=" + templateId "Template introuvable (id=" + templateId + FOR_PAGE + pageId));
+ ") pour la page " + pageId));
} }
private Lore loadLore(String loreId, String pageId) { private Lore loadLore(String loreId, String pageId) {
return loreRepository.findById(loreId) return loreRepository.findById(loreId)
.orElseThrow(() -> new IllegalStateException( .orElseThrow(() -> new IllegalStateException(
"Lore introuvable (id=" + loreId "Lore introuvable (id=" + loreId + FOR_PAGE + pageId));
+ ") pour la page " + pageId));
} }
private LoreNode loadFolder(String nodeId, String pageId) { private LoreNode loadFolder(String nodeId, String pageId) {
return loreNodeRepository.findById(nodeId) return loreNodeRepository.findById(nodeId)
.orElseThrow(() -> new IllegalStateException( .orElseThrow(() -> new IllegalStateException(
"Dossier parent introuvable (id=" + nodeId "Dossier parent introuvable (id=" + nodeId + FOR_PAGE + pageId));
+ ") pour la page " + pageId));
} }
private void requireNonEmptyFields(Template template) { private void requireNonEmptyFields(Template template) {

View File

@@ -110,7 +110,7 @@ public class LoreStructuralContextBuilder {
return allPages.stream() return allPages.stream()
.filter(p -> nodeId.equals(p.getNodeId())) .filter(p -> nodeId.equals(p.getNodeId()))
.map(p -> toPageSummary(p, templateNameById, pageTitleById)) .map(p -> toPageSummary(p, templateNameById, pageTitleById))
.collect(Collectors.toList()); .toList();
} }
private PageSummary toPageSummary( private PageSummary toPageSummary(
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilder {
return relatedIds.stream() return relatedIds.stream()
.map(pageTitleById::get) .map(pageTitleById::get)
.filter(title -> title != null && !title.isBlank()) .filter(title -> title != null && !title.isBlank())
.collect(Collectors.toList()); .toList();
} }
private List<String> extractUniqueTags(List<Page> pages) { private List<String> extractUniqueTags(List<Page> pages) {
@@ -168,6 +168,6 @@ public class LoreStructuralContextBuilder {
.filter(p -> p.getTags() != null) .filter(p -> p.getTags() != null)
.flatMap(p -> p.getTags().stream()) .flatMap(p -> p.getTags().stream())
.distinct() .distinct()
.collect(Collectors.toList()); .toList();
} }
} }

View File

@@ -0,0 +1,108 @@
package com.loremind.application.generationcontext;
import com.loremind.application.campaigncontext.CampaignContextFormatter;
import com.loremind.domain.campaigncontext.generation.EntityFieldPatchProposal;
import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Use case (Pilier A — co-création) : produit une PROPOSITION d'étoffage des champs d'une
* entité narrative (arc / chapitre / scène), non persistée. Générique par {@code entityType}
* grâce à {@link NarrativeFieldCatalog} (source de vérité des champs) : la même mécanique
* couvre les trois types, seul le catalogue de champs change.
*
* <p>Contexte volontairement COMPACT (entité + méta campagne). Zéro écriture — l'application
* est un second appel explicite (human-in-the-loop).</p>
*/
@Service
public class NarrativeAssistFieldsUseCase {
private final NarrativeFieldCatalog catalog;
private final CampaignContextFormatter campaignContextFormatter;
private final NarrativeFieldAssistant assistant;
public NarrativeAssistFieldsUseCase(
NarrativeFieldCatalog catalog,
CampaignContextFormatter campaignContextFormatter,
NarrativeFieldAssistant assistant) {
this.catalog = catalog;
this.campaignContextFormatter = campaignContextFormatter;
this.assistant = assistant;
}
public EntityFieldPatchProposal execute(String entityType, String entityId, String campaignId, String instruction) {
NarrativeFieldCatalog.Snapshot snap = catalog.read(entityType, entityId);
List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream()
.map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label()))
.toList();
Set<String> allowed = snap.defs().stream()
.map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet());
String context = buildContext(campaignId, snap);
List<NarrativeFieldAssistant.ProposedField> proposed =
assistant.assist(snap.entityType(), context, instruction, specs);
List<FieldProposal> fields = new ArrayList<>();
for (NarrativeFieldAssistant.ProposedField pf : proposed) {
FieldProposal proposal = toFieldProposal(pf, allowed, snap);
if (proposal != null) fields.add(proposal);
}
return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields);
}
/** Convertit un champ proposé par l'IA en FieldProposal, ou null si sa clé n'est pas autorisée ou sa valeur vide. */
private static FieldProposal toFieldProposal(
NarrativeFieldAssistant.ProposedField pf, Set<String> allowed, NarrativeFieldCatalog.Snapshot snap) {
if (pf.key() == null || !allowed.contains(pf.key())) return null;
if (pf.value() == null || pf.value().isBlank()) return null;
String current = snap.current().getOrDefault(pf.key(), "");
return new FieldProposal(pf.key(), current, pf.value());
}
/** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */
private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) {
StringBuilder sb = new StringBuilder();
sb.append(entityLabel(snap.entityType())).append(" : ")
.append(blankToLabel(snap.title())).append("\n");
sb.append("État actuel des champs :\n");
boolean any = false;
for (Map.Entry<String, String> e : snap.current().entrySet()) {
String v = e.getValue();
if (v != null && !v.isBlank()) {
sb.append("- ").append(e.getKey()).append(" : ").append(v.trim()).append("\n");
any = true;
}
}
if (!any) {
sb.append("- (tous les champs sont vides — à créer de zéro)\n");
}
if (campaignId != null && !campaignId.isBlank()) {
String campaignBlock = campaignContextFormatter.format(campaignId);
if (!campaignBlock.isBlank()) {
sb.append(campaignBlock).append("\n");
}
}
return sb.toString();
}
private static String entityLabel(String entityType) {
return switch (entityType == null ? "" : entityType) {
case "arc" -> "Arc";
case "chapter" -> "Chapitre";
case "scene" -> "Scène";
default -> "Entité";
};
}
private static String blankToLabel(String value) {
return value == null || value.isBlank() ? "(sans titre)" : value;
}
}

View File

@@ -1,10 +1,10 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
@@ -28,6 +28,9 @@ import java.util.Map;
@Component @Component
public class NarrativeEntityContextBuilder { public class NarrativeEntityContextBuilder {
/** Longueur max d'une valeur de stat dans le résumé d'ennemi lié (contexte focus compact). */
private static final int STAT_VALUE_MAX_LEN = 100;
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
@@ -144,14 +147,14 @@ public class NarrativeEntityContextBuilder {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (String enemyId : s.getEnemyIds()) { for (String enemyId : s.getEnemyIds()) {
enemyRepository.findById(enemyId).ifPresent(e -> { enemyRepository.findById(enemyId).ifPresent(e -> {
if (sb.length() > 0) sb.append("\n"); if (!sb.isEmpty()) sb.append("\n");
sb.append("- ").append(e.getName()); sb.append("- ").append(e.getName());
if (e.getLevel() != null && !e.getLevel().isBlank()) { if (e.getLevel() != null && !e.getLevel().isBlank()) {
sb.append(" (").append(e.getLevel().trim()).append(")"); sb.append(" (").append(e.getLevel().trim()).append(")");
} }
String stats = e.getValues().entrySet().stream() String stats = e.getValues().entrySet().stream()
.filter(en -> en.getValue() != null && !en.getValue().isBlank()) .filter(en -> en.getValue() != null && !en.getValue().isBlank())
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100)) .map(en -> en.getKey() + ": " + truncate(en.getValue().trim()))
.collect(java.util.stream.Collectors.joining(" ; ")); .collect(java.util.stream.Collectors.joining(" ; "));
if (!stats.isEmpty()) sb.append("").append(stats); if (!stats.isEmpty()) sb.append("").append(stats);
}); });
@@ -159,8 +162,8 @@ public class NarrativeEntityContextBuilder {
return sb.toString(); return sb.toString();
} }
private static String truncate(String value, int maxLen) { private static String truncate(String value) {
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + ""; return value.length() <= STAT_VALUE_MAX_LEN ? value : value.substring(0, STAT_VALUE_MAX_LEN - 1).stripTrailing() + "";
} }
private NarrativeEntityContext fromCharacter(Character c) { private NarrativeEntityContext fromCharacter(Character c) {

View File

@@ -0,0 +1,129 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
/**
* Catalogue des champs ÉTOFFABLES par l'IA (Pilier A), par type d'entité narrative.
* SOURCE DE VÉRITÉ unique côté génération : ordre + clés + libellés + valeurs actuelles.
*
* <p>Les clés correspondent aux setters des services (patch) et aux contrôles de
* formulaire côté front. Le champ {@code name}/{@code type} n'est jamais étoffable
* (identité / enum). La liaison clé → setter reste dans chaque service (persistance).</p>
*/
@Component
public class NarrativeFieldCatalog {
/** Définition d'un champ : clé technique + libellé (guide le prompt du Brain). */
public record FieldDef(String key, String label) {}
/** Instantané d'une entité pour l'étoffage : titre + valeurs actuelles + champs. */
public record Snapshot(String entityType, String title,
LinkedHashMap<String, String> current, List<FieldDef> defs) {}
private static final String FIELD_DESCRIPTION = "description";
private static final String FIELD_GM_NOTES = "gmNotes";
private static final List<FieldDef> ARC_DEFS = List.of(
new FieldDef(FIELD_DESCRIPTION, "description / synopsis de l'arc"),
new FieldDef("themes", "thèmes explorés"),
new FieldDef("stakes", "enjeux globaux pour les personnages"),
new FieldDef("rewards", "récompenses et progression"),
new FieldDef("resolution", "dénouement prévu"),
new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
private static final List<FieldDef> CHAPTER_DEFS = List.of(
new FieldDef(FIELD_DESCRIPTION, "synopsis du chapitre"),
new FieldDef("playerObjectives", "objectifs des joueurs"),
new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"),
new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
private static final List<FieldDef> SCENE_DEFS = List.of(
new FieldDef(FIELD_DESCRIPTION, "description courte de la scène"),
new FieldDef("location", "lieu où se déroule la scène"),
new FieldDef("timing", "moment / temporalité"),
new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"),
new FieldDef("playerNarration", "texte de mise en scène lu aux joueurs"),
new FieldDef("choicesConsequences", "choix offerts aux joueurs et leurs conséquences"),
new FieldDef("combatDifficulty", "difficulté de combat estimée"),
new FieldDef("enemies", "ennemis / créatures présentes (texte libre)"),
new FieldDef("gmSecretNotes", "notes secrètes du MJ (cachées des joueurs)"));
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public NarrativeFieldCatalog(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/** Charge l'entité et son instantané d'étoffage. @throws IllegalArgumentException si type/entité inconnu. */
public Snapshot read(String entityType, String entityId) {
return switch (normalize(entityType)) {
case "arc" -> arcSnapshot(entityId);
case "chapter" -> chapterSnapshot(entityId);
case "scene" -> sceneSnapshot(entityId);
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
};
}
private Snapshot arcSnapshot(String id) {
Arc a = arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put(FIELD_DESCRIPTION, nz(a.getDescription()));
cur.put("themes", nz(a.getThemes()));
cur.put("stakes", nz(a.getStakes()));
cur.put("rewards", nz(a.getRewards()));
cur.put("resolution", nz(a.getResolution()));
cur.put(FIELD_GM_NOTES, nz(a.getGmNotes()));
return new Snapshot("arc", a.getName(), cur, ARC_DEFS);
}
private Snapshot chapterSnapshot(String id) {
Chapter c = chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put(FIELD_DESCRIPTION, nz(c.getDescription()));
cur.put("playerObjectives", nz(c.getPlayerObjectives()));
cur.put("narrativeStakes", nz(c.getNarrativeStakes()));
cur.put(FIELD_GM_NOTES, nz(c.getGmNotes()));
return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS);
}
private Snapshot sceneSnapshot(String id) {
Scene s = sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put(FIELD_DESCRIPTION, nz(s.getDescription()));
cur.put("location", nz(s.getLocation()));
cur.put("timing", nz(s.getTiming()));
cur.put("atmosphere", nz(s.getAtmosphere()));
cur.put("playerNarration", nz(s.getPlayerNarration()));
cur.put("choicesConsequences", nz(s.getChoicesConsequences()));
cur.put("combatDifficulty", nz(s.getCombatDifficulty()));
cur.put("enemies", nz(s.getEnemies()));
cur.put("gmSecretNotes", nz(s.getGmSecretNotes()));
return new Snapshot("scene", s.getName(), cur, SCENE_DEFS);
}
private static String normalize(String entityType) {
return entityType == null ? "" : entityType.trim().toLowerCase();
}
private static String nz(String v) {
return v == null ? "" : v;
}
}

View File

@@ -1,13 +1,10 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator; import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ProgressionStatus; import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.generationcontext.SessionContext; import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary; import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary; import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
@@ -29,7 +26,6 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Construit le SessionContext injecté dans le prompt IA pendant une partie. * Construit le SessionContext injecté dans le prompt IA pendant une partie.
@@ -46,8 +42,7 @@ public class SessionStructuralContextBuilder {
private final SessionRepository sessionRepository; private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository; private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository; private final PlaythroughRepository playthroughRepository;
private final ArcRepository arcRepository; private final QuestRepository questRepository;
private final ChapterRepository chapterRepository;
private final PlaythroughFlagRepository playthroughFlagRepository; private final PlaythroughFlagRepository playthroughFlagRepository;
private final QuestProgressionRepository questProgressionRepository; private final QuestProgressionRepository questProgressionRepository;
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator(); private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
@@ -55,23 +50,17 @@ public class SessionStructuralContextBuilder {
public SessionStructuralContextBuilder(SessionRepository sessionRepository, public SessionStructuralContextBuilder(SessionRepository sessionRepository,
SessionEntryRepository entryRepository, SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository, PlaythroughRepository playthroughRepository,
ArcRepository arcRepository, QuestRepository questRepository,
ChapterRepository chapterRepository,
PlaythroughFlagRepository playthroughFlagRepository, PlaythroughFlagRepository playthroughFlagRepository,
QuestProgressionRepository questProgressionRepository) { QuestProgressionRepository questProgressionRepository) {
this.sessionRepository = sessionRepository; this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository; this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository; this.playthroughRepository = playthroughRepository;
this.arcRepository = arcRepository; this.questRepository = questRepository;
this.chapterRepository = chapterRepository;
this.playthroughFlagRepository = playthroughFlagRepository; this.playthroughFlagRepository = playthroughFlagRepository;
this.questProgressionRepository = questProgressionRepository; this.questProgressionRepository = questProgressionRepository;
} }
public Optional<SessionContext> buildOptional(String sessionId) {
return sessionRepository.findById(sessionId).map(this::toContext);
}
public SessionContext build(String sessionId) { public SessionContext build(String sessionId) {
Session session = sessionRepository.findById(sessionId) Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId)); .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -103,7 +92,7 @@ public class SessionStructuralContextBuilder {
return kept.stream() return kept.stream()
.map(e -> toSummary(e, null)) .map(e -> toSummary(e, null))
.collect(Collectors.toList()); .toList();
} }
/** /**
@@ -169,23 +158,14 @@ public class SessionStructuralContextBuilder {
Map<String, Boolean> flags = playthroughFlagRepository.findByPlaythroughId(playthroughId); Map<String, Boolean> flags = playthroughFlagRepository.findByPlaythroughId(playthroughId);
List<String> activeFlags = buildActiveFlags(flags); List<String> activeFlags = buildActiveFlags(flags);
List<Arc> arcs = arcRepository.findByCampaignId(campaignId); // Map questId -> ProgressionStatus pour ce Playthrough
Map<String, Arc> arcsById = arcs.stream() Map<String, ProgressionStatus> progressionByQuest = new HashMap<>();
.filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a));
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) { for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
progressionByChapter.put(qp.getChapterId(), qp.getStatus()); progressionByQuest.put(qp.getQuestId(), qp.getStatus());
} }
// IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted) // IDs des quêtes COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId); var completedIds = questProgressionRepository.findCompletedQuestIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size(); int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
PrerequisiteEvaluator.EvaluationContext ctx = PrerequisiteEvaluator.EvaluationContext ctx =
@@ -195,29 +175,24 @@ public class SessionStructuralContextBuilder {
List<QuestSummary> inProgress = new ArrayList<>(); List<QuestSummary> inProgress = new ArrayList<>();
List<String> lockedTitles = new ArrayList<>(); List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) { // Niveau 1 : les quêtes sont des entités orthogonales rattachées à la campagne
boolean isHub = arc.getType() == ArcType.HUB; // (plus des chapitres HUB). arcName n'a plus de sens => null.
for (Chapter c : chapterRepository.findByArcId(arc.getId())) { for (Quest q : questRepository.findByCampaignId(campaignId)) {
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty(); ProgressionStatus prog = progressionByQuest.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré QuestStatus status = prerequisiteEvaluator.computeStatus(prog, q.getPrerequisites(), ctx);
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED); switch (status) {
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx); case AVAILABLE:
Arc parent = arcsById.get(c.getArcId()); available.add(new QuestSummary(q.getName(), null, q.getDescription()));
String arcName = parent != null ? parent.getName() : null; break;
switch (status) { case IN_PROGRESS:
case AVAILABLE: inProgress.add(new QuestSummary(q.getName(), null, q.getDescription()));
available.add(new QuestSummary(c.getName(), arcName, c.getDescription())); break;
break; case LOCKED:
case IN_PROGRESS: lockedTitles.add(q.getName());
inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription())); break;
break; case COMPLETED:
case LOCKED: // Omis (déjà dans le journal des EVENTs).
lockedTitles.add(c.getName()); break;
break;
case COMPLETED:
// Omis (déjà dans le journal des EVENTs).
break;
}
} }
} }
@@ -229,6 +204,6 @@ public class SessionStructuralContextBuilder {
.filter(Map.Entry::getValue) .filter(Map.Entry::getValue)
.map(Map.Entry::getKey) .map(Map.Entry::getKey)
.sorted() .sorted()
.collect(Collectors.toList()); .toList();
} }
} }

View File

@@ -7,7 +7,7 @@ import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.GameSystemContext; import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -15,7 +15,6 @@ import com.loremind.domain.generationcontext.ports.AiChatProvider;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
/** /**
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context. * Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
@@ -72,10 +71,7 @@ public class StreamChatForCampaignUseCase {
String entityType, String entityType,
String entityId, String entityId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage, ChatStreamCallbacks callbacks) {
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Campaign campaign = campaignRepository.findById(campaignId) Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
@@ -94,7 +90,7 @@ public class StreamChatForCampaignUseCase {
.gameSystemContext(gameSystemContext) .gameSystemContext(gameSystemContext)
.build(); .build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError); aiChatProvider.streamChat(request, callbacks);
} }
/** /**

View File

@@ -2,7 +2,7 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.PageContext; import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Service;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer;
/** /**
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore. * Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
@@ -61,10 +60,7 @@ public class StreamChatForLoreUseCase {
String loreId, String loreId,
String pageId, String pageId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage, ChatStreamCallbacks callbacks) {
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
LoreStructuralContext loreContext = loreContextBuilder.build(loreId); LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
PageContext pageContext = (pageId == null || pageId.isBlank()) PageContext pageContext = (pageId == null || pageId.isBlank())
@@ -77,7 +73,7 @@ public class StreamChatForLoreUseCase {
.pageContext(pageContext) .pageContext(pageContext)
.build(); .build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError); aiChatProvider.streamChat(request, callbacks);
} }
/** /**

View File

@@ -7,7 +7,7 @@ import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.GameSystemContext; import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext; import com.loremind.domain.generationcontext.SessionContext;
@@ -19,7 +19,6 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
/** /**
* Use case applicatif : chat IA pendant une Session de jeu. * Use case applicatif : chat IA pendant une Session de jeu.
@@ -71,10 +70,7 @@ public class StreamChatForSessionUseCase {
public void execute( public void execute(
String sessionId, String sessionId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage, ChatStreamCallbacks callbacks) {
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Session session = sessionRepository.findById(sessionId) Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId)); .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -102,7 +98,7 @@ public class StreamChatForSessionUseCase {
.sessionContext(sessionContext) .sessionContext(sessionContext)
.build(); .build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError); aiChatProvider.streamChat(request, callbacks);
} }
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) { private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {

View File

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import java.io.InputStream; import java.io.InputStream;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -60,7 +61,7 @@ public class ImageService {
.contentType(contentType) .contentType(contentType)
.sizeBytes(sizeBytes) .sizeBytes(sizeBytes)
.storageKey(storageKey) .storageKey(storageKey)
.uploadedAt(LocalDateTime.now()) .uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
.build(); .build();
return imageRepository.save(image); return imageRepository.save(image);
} catch (RuntimeException ex) { } catch (RuntimeException ex) {

View File

@@ -52,8 +52,8 @@ public class LicenseService {
this.repository = repository; this.repository = repository;
this.jwtVerifier = jwtVerifier; this.jwtVerifier = jwtVerifier;
this.relay = relay; this.relay = relay;
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L; this.gracePeriodSeconds = gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L; this.refreshBeforeExpirySeconds = refreshBeforeExpiryDays * 86_400L;
this.licensingEnabled = licensingEnabled; this.licensingEnabled = licensingEnabled;
} }
@@ -134,9 +134,9 @@ public class LicenseService {
* Etat courant de la licence pour exposition UI / decision technique. * Etat courant de la licence pour exposition UI / decision technique.
*/ */
public LicenseSnapshot getCurrentSnapshot() { public LicenseSnapshot getCurrentSnapshot() {
Optional<License> opt = repository.findCurrent(); return repository.findCurrent()
if (opt.isEmpty()) return LicenseSnapshot.none(); .map(l -> snapshotOf(l, Instant.now()))
return snapshotOf(opt.get(), Instant.now()); .orElseGet(LicenseSnapshot::none);
} }
/** /**
@@ -161,31 +161,32 @@ public class LicenseService {
/** /**
* Tente un refresh si la licence est proche de l'expiration. Idempotent. * Tente un refresh si la licence est proche de l'expiration. Idempotent.
* Appele par le daemon planifie + manuellement via l'UI ("Reessayer"). * Appele par le daemon planifie + manuellement via l'UI ("Reessayer").
*
* @return true si un refresh a ete tente (avec ou sans succes)
*/ */
public boolean refreshIfNeeded() { public void refreshIfNeeded() {
Optional<License> opt = repository.findCurrent(); Optional<License> opt = repository.findCurrent();
if (opt.isEmpty()) return false; if (opt.isEmpty()) return;
License current = opt.get(); License current = opt.get();
Instant now = Instant.now(); Instant now = Instant.now();
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds(); long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
if (secondsUntilExpiry > refreshBeforeExpirySeconds) { if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
return false; return;
} }
return doRefresh(current, now); doRefresh(current, now);
} }
/** /**
* Force un refresh immediat (bouton UI "Reessayer maintenant"). * Force un refresh immediat (bouton UI "Reessayer maintenant").
*/ */
public boolean forceRefresh() { public void forceRefresh() {
return repository.findCurrent() repository.findCurrent().ifPresent(license -> doRefresh(license, Instant.now()));
.map(license -> doRefresh(license, Instant.now()))
.orElse(false);
} }
private boolean doRefresh(License current, Instant now) { /**
* Tente le refresh aupres du relais et persiste le resultat (succes ou echec) sur
* la licence. Le succes/echec est lu depuis {@code lastRefreshSucceeded}, pas
* depuis un retour de methode : le tentative est deja actee cote persistance.
*/
private void doRefresh(License current, Instant now) {
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt()); log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
try { try {
String newJwt = relay.refreshToken(current.getRawJwt()); String newJwt = relay.refreshToken(current.getRawJwt());
@@ -199,7 +200,6 @@ public class LicenseService {
current.setLastRefreshSucceeded(true); current.setLastRefreshSucceeded(true);
repository.save(current); repository.save(current);
log.info("License refreshed successfully (new expiry {})", claims.expiresAt()); log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
return true;
} catch (LicenseRelay.RelayException e) { } catch (LicenseRelay.RelayException e) {
current.setLastRefreshAttemptAt(now); current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false); current.setLastRefreshSucceeded(false);
@@ -209,13 +209,11 @@ public class LicenseService {
} else { } else {
log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage()); log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
} }
return true;
} catch (JwtVerifier.JwtVerificationException e) { } catch (JwtVerifier.JwtVerificationException e) {
current.setLastRefreshAttemptAt(now); current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false); current.setLastRefreshSucceeded(false);
repository.save(current); repository.save(current);
log.error("Relay returned a JWT that fails verification: {}", e.getMessage()); log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
return true;
} }
} }

View File

@@ -1,6 +1,7 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode; import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.lorecontext.Page; import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;
@@ -45,10 +46,20 @@ public class LoreNodeService {
.icon(changes.getIcon()) .icon(changes.getIcon())
.parentId(changes.getParentId()) .parentId(changes.getParentId())
.loreId(changes.getLoreId()) .loreId(changes.getLoreId())
.order(nextOrderFor(changes.getLoreId(), changes.getParentId()))
.build(); .build();
return loreNodeRepository.save(loreNode); return loreNodeRepository.save(loreNode);
} }
/** Prochain ordre pour un dossier neuf : après ses frères (même lore + même parent). */
private int nextOrderFor(String loreId, String parentId) {
if (loreId == null) return 0;
return loreNodeRepository.findByLoreId(loreId).stream()
.filter(n -> java.util.Objects.equals(n.getParentId(), parentId))
.mapToInt(LoreNode::getOrder)
.max().orElse(-1) + 1;
}
public Optional<LoreNode> getLoreNodeById(String id) { public Optional<LoreNode> getLoreNodeById(String id) {
return loreNodeRepository.findById(id); return loreNodeRepository.findById(id);
} }
@@ -57,6 +68,37 @@ public class LoreNodeService {
return loreNodeRepository.findAll(); return loreNodeRepository.findAll();
} }
/**
* Réordonne (et déplace) des dossiers frères : {@code order} = position, et
* {@code parentId} = dossier parent cible (null = racine). Évite les cycles : un
* dossier n'est pas réaffecté sous lui-même ni sous un de ses descendants.
*/
@Transactional
public void reorderNodes(String parentId, List<String> orderedIds) {
String parent = (parentId == null || parentId.isBlank()) ? null : parentId;
ReorderSupport.reorder(orderedIds,
loreNodeRepository::findById,
(node, i) -> {
if (parent == null || !isSelfOrDescendant(parent, node.getId())) {
node.setParentId(parent);
}
node.setOrder(i);
},
loreNodeRepository::save);
}
/** True si {@code candidateId} est {@code nodeId} ou un de ses descendants (anti-cycle). */
private boolean isSelfOrDescendant(String candidateId, String nodeId) {
String cur = candidateId;
int guard = 0;
while (cur != null && guard++ < 100) {
if (cur.equals(nodeId)) return true;
LoreNode n = loreNodeRepository.findById(cur).orElse(null);
cur = n != null ? n.getParentId() : null;
}
return false;
}
public List<LoreNode> getLoreNodesByLoreId(String loreId) { public List<LoreNode> getLoreNodesByLoreId(String loreId) {
return loreNodeRepository.findByLoreId(loreId); return loreNodeRepository.findByLoreId(loreId);
} }

View File

@@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Service d'application pour le contexte Lore. * Service d'application pour le contexte Lore.
@@ -72,7 +71,7 @@ public class LoreService {
public List<Lore> getAllLores() { public List<Lore> getAllLores() {
return loreRepository.findAll().stream() return loreRepository.findAll().stream()
.map(this::withCounts) .map(this::withCounts)
.collect(Collectors.toList()); .toList();
} }
/** /**

View File

@@ -4,7 +4,7 @@ import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.loremind.domain.shared.CollectionUtils; import com.loremind.domain.shared.ReorderSupport;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@@ -33,6 +33,7 @@ public class PageService {
.nodeId(nodeId) .nodeId(nodeId)
.templateId(templateId) .templateId(templateId)
.title(title) .title(title)
.order(nextOrderFor(nodeId))
.values(new HashMap<>()) .values(new HashMap<>())
.tags(new ArrayList<>()) .tags(new ArrayList<>())
.relatedPageIds(new ArrayList<>()) .relatedPageIds(new ArrayList<>())
@@ -40,6 +41,14 @@ public class PageService {
return pageRepository.save(page); return pageRepository.save(page);
} }
/** Prochain ordre pour une page neuve : après les pages existantes du même dossier. */
private int nextOrderFor(String nodeId) {
if (nodeId == null) return 0;
return pageRepository.findByNodeId(nodeId).stream()
.mapToInt(Page::getOrder)
.max().orElse(-1) + 1;
}
public Optional<Page> getPageById(String id) { public Optional<Page> getPageById(String id) {
return pageRepository.findById(id); return pageRepository.findById(id);
} }
@@ -74,17 +83,26 @@ public class PageService {
existing.setTitle(changes.getTitle()); existing.setTitle(changes.getTitle());
existing.setNodeId(changes.getNodeId()); existing.setNodeId(changes.getNodeId());
existing.setValues(CollectionUtils.copyMap(changes.getValues())); existing.applyEditableContent(changes);
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
existing.setNotes(changes.getNotes());
existing.setTags(CollectionUtils.copyList(changes.getTags()));
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
return pageRepository.save(existing); return pageRepository.save(existing);
} }
public void deletePage(String id) { public void deletePage(String id) {
pageRepository.deleteById(id); pageRepository.deleteById(id);
} }
/**
* Réordonne (et déplace) les pages d'un dossier : {@code order} = position dans la
* liste, et {@code nodeId} = dossier cible (déplacement par glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderPages(String nodeId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
pageRepository::findById,
(page, i) -> {
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
page.setOrder(i);
},
pageRepository::save);
}
} }

View File

@@ -0,0 +1,141 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Clock;
import com.loremind.domain.playcontext.ClockTrigger;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Service applicatif des Horloges de progression (Clocks) d'une Partie.
* CRUD + <b>avancer / reculer</b> d'un segment, borné à [0, segments].
*/
@Service
public class ClockService {
/** Garde-fou : taille maximale d'une horloge. */
private static final int MAX_SEGMENTS = 60;
private final ClockRepository clockRepository;
private final PlaythroughRepository playthroughRepository;
public ClockService(ClockRepository clockRepository, PlaythroughRepository playthroughRepository) {
this.clockRepository = clockRepository;
this.playthroughRepository = playthroughRepository;
}
public List<Clock> getByPlaythrough(String playthroughId) {
return clockRepository.findByPlaythroughId(playthroughId);
}
public Clock create(String playthroughId, String name, String description, int segments,
ClockTrigger triggerType, String triggerRef, String frontId) {
if (playthroughId == null || !playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Le nom de l'horloge est requis.");
}
int order = clockRepository.findByPlaythroughId(playthroughId).size();
ClockTrigger type = triggerType != null ? triggerType : ClockTrigger.NONE;
Clock clock = Clock.builder()
.playthroughId(playthroughId)
.name(name.trim())
.description(description)
.segments(clampSegments(segments))
.filled(0)
.order(order)
.triggerType(type)
.triggerRef(normalizeRef(type, triggerRef))
.frontId(blankToNull(frontId))
.build();
return clockRepository.save(clock);
}
public Clock update(String id, String name, String description, int segments,
ClockTrigger triggerType, String triggerRef, String frontId) {
Clock clock = require(id);
if (name != null && !name.isBlank()) clock.setName(name.trim());
clock.setDescription(description);
int seg = clampSegments(segments);
clock.setSegments(seg);
if (clock.getFilled() > seg) clock.setFilled(seg); // ré-borne si on réduit la taille
ClockTrigger type = triggerType != null ? triggerType : ClockTrigger.NONE;
clock.setTriggerType(type);
clock.setTriggerRef(normalizeRef(type, triggerRef));
clock.setFrontId(blankToNull(frontId));
return clockRepository.save(clock);
}
// ----- Avancement automatique (co-MJ) : le monde qui réagit -----
/** Un Fait vient de passer à vrai → avance les horloges liées (FLAG_SET + ce fait). */
public void onFlagRaised(String playthroughId, String flagName) {
advanceMatching(playthroughId, ClockTrigger.FLAG_SET, flagName);
}
/** Une quête vient de passer à COMPLETED → avance les horloges liées à cette quête. */
public void onQuestCompleted(String playthroughId, String questId) {
advanceMatching(playthroughId, ClockTrigger.QUEST_COMPLETED, questId);
}
/** Une séance vient de se clôturer → avance les horloges « fin de séance » de la Partie. */
public void onSessionEnded(String playthroughId) {
advanceMatching(playthroughId, ClockTrigger.SESSION_ENDED, null);
}
private void advanceMatching(String playthroughId, ClockTrigger type, String ref) {
if (playthroughId == null) return;
for (Clock c : clockRepository.findByPlaythroughId(playthroughId)) {
boolean match = c.getTriggerType() == type
&& (type == ClockTrigger.SESSION_ENDED || java.util.Objects.equals(c.getTriggerRef(), ref));
if (match && c.getFilled() < c.getSegments()) {
c.setFilled(c.getFilled() + 1);
clockRepository.save(c);
}
}
}
private String normalizeRef(ClockTrigger type, String ref) {
boolean needsRef = type == ClockTrigger.FLAG_SET || type == ClockTrigger.QUEST_COMPLETED;
if (!needsRef || ref == null || ref.isBlank()) return null;
return ref.trim();
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
/** +1 segment (sans dépasser {@code segments}). */
public Clock advance(String id) {
Clock clock = require(id);
if (clock.getFilled() < clock.getSegments()) clock.setFilled(clock.getFilled() + 1);
return clockRepository.save(clock);
}
/** 1 segment (sans descendre sous 0). */
public Clock regress(String id) {
Clock clock = require(id);
if (clock.getFilled() > 0) clock.setFilled(clock.getFilled() - 1);
return clockRepository.save(clock);
}
public void delete(String id) {
if (!clockRepository.existsById(id)) {
throw new IllegalArgumentException("Horloge introuvable : " + id);
}
clockRepository.deleteById(id);
}
private Clock require(String id) {
return clockRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Horloge introuvable : " + id));
}
private int clampSegments(int segments) {
if (segments < 1) return 1;
return Math.min(segments, MAX_SEGMENTS);
}
}

View File

@@ -0,0 +1,75 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Clock;
import com.loremind.domain.playcontext.Front;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.domain.playcontext.ports.FrontRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Service applicatif des Fronts (menaces regroupant des horloges) d'une Partie.
*/
@Service
public class FrontService {
private final FrontRepository frontRepository;
private final ClockRepository clockRepository;
private final PlaythroughRepository playthroughRepository;
public FrontService(FrontRepository frontRepository, ClockRepository clockRepository,
PlaythroughRepository playthroughRepository) {
this.frontRepository = frontRepository;
this.clockRepository = clockRepository;
this.playthroughRepository = playthroughRepository;
}
public List<Front> getByPlaythrough(String playthroughId) {
return frontRepository.findByPlaythroughId(playthroughId);
}
public Front create(String playthroughId, String name, String description) {
if (playthroughId == null || !playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Le nom du front est requis.");
}
int order = frontRepository.findByPlaythroughId(playthroughId).size();
Front front = Front.builder()
.playthroughId(playthroughId)
.name(name.trim())
.description(description)
.order(order)
.build();
return frontRepository.save(front);
}
public Front update(String id, String name, String description) {
Front front = require(id);
if (name != null && !name.isBlank()) front.setName(name.trim());
front.setDescription(description);
return frontRepository.save(front);
}
/** Supprime un front et ORPHELINE ses horloges (frontId -> null : on ne perd pas d'horloges). */
@Transactional
public void delete(String id) {
Front front = require(id);
for (Clock c : clockRepository.findByPlaythroughId(front.getPlaythroughId())) {
if (id.equals(c.getFrontId())) {
c.setFrontId(null);
clockRepository.save(c);
}
}
frontRepository.deleteById(id);
}
private Front require(String id) {
return frontRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Front introuvable : " + id));
}
}

View File

@@ -7,6 +7,7 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -38,7 +39,7 @@ public class SessionEntryService {
} }
validateContent(data.content()); validateContent(data.content());
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
SessionEntry entry = SessionEntry.builder() SessionEntry entry = SessionEntry.builder()
.sessionId(sessionId) .sessionId(sessionId)
.type(data.type() != null ? data.type() : EntryType.NOTE) .type(data.type() != null ? data.type() : EntryType.NOTE)

View File

@@ -0,0 +1,52 @@
package com.loremind.application.playcontext;
import com.loremind.application.campaigncontext.ReadinessGap;
import java.time.LocalDateTime;
import java.util.List;
/**
* Bilan de PRÉPARATION DE SÉANCE d'une Partie (Phase 3 co-MJ — « PlanDeSéance »).
* Read-model pur, calculé à la volée : croise la position des joueurs (quêtes en cours /
* disponibles, dernière séance), le contenu probable (nœuds des quêtes actives), les
* manques de guidage CIBLÉS sur ce contenu, et les horloges en mouvement.
*
* @param playthroughId la Partie évaluée
* @param lastSession dernière séance (ou {@code null} si aucune)
* @param questsInProgress quêtes EN COURS (le « où en sont les joueurs »)
* @param questsAvailable quêtes DISPONIBLES (les prochaines pistes probables)
* @param questsCompleted quêtes TERMINÉES (rappel discret + possibilité de rouvrir)
* @param hotspots chapitres / scènes probables (nœuds des quêtes actives, dédupliqués)
* @param gaps manques de guidage restreints au contenu probable (à combler avant de jouer) ;
* si la campagne n'utilise pas de quêtes, tous les manques de la campagne
* @param otherGapCount manques ailleurs dans la campagne (information, non bloquant pour la séance)
* @param clocks horloges entamées ({@code filled > 0}), avec le nom de leur menace
*/
public record SessionPrepReport(
String playthroughId,
LastSessionInfo lastSession,
List<QuestInfo> questsInProgress,
List<QuestInfo> questsAvailable,
List<QuestInfo> questsCompleted,
List<NodeInfo> hotspots,
List<ReadinessGap> gaps,
int otherGapCount,
List<ClockInfo> clocks
) {
/** Dernière séance tenue (ou en cours) de la Partie. */
public record LastSessionInfo(String id, String name, LocalDateTime startedAt,
LocalDateTime endedAt, boolean active) {}
/** Quête résumée (statut implicite par la liste qui la porte). */
public record QuestInfo(String id, String name, String icon) {}
/**
* Nœud narratif probable. {@code nodeType} = "CHAPTER"|"SCENE" ; {@code arcId}/
* {@code chapterId} = contexte de navigation pour le lien profond côté front.
*/
public record NodeInfo(String nodeType, String id, String name, String arcId, String chapterId) {}
/** Horloge en mouvement (le front affiche « presque pleine » quand il reste ≤ 1 segment). */
public record ClockInfo(String id, String name, int segments, int filled, String frontName) {}
}

View File

@@ -0,0 +1,203 @@
package com.loremind.application.playcontext;
import com.loremind.application.campaigncontext.CampaignReadinessAssessment;
import com.loremind.application.campaigncontext.CampaignReadinessService;
import com.loremind.application.campaigncontext.QuestStatusEnricher;
import com.loremind.application.campaigncontext.ReadinessGap;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.Front;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.domain.playcontext.ports.FrontRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Read-model « Préparer la prochaine séance » (Phase 3 co-MJ). Croise, pour UNE Partie :
*
* <ol>
* <li><b>Position des joueurs</b> — quêtes EN COURS / DISPONIBLES (statut effectif via
* {@link QuestStatusEnricher}, un seul snapshot) + dernière séance ;</li>
* <li><b>Contenu probable</b> — les chapitres/scènes traversés par ces quêtes actives ;</li>
* <li><b>Manques ciblés</b> — les gaps du guidage ({@link CampaignReadinessService})
* restreints à ce contenu probable : « quoi combler AVANT la prochaine séance » ;</li>
* <li><b>Menaces en mouvement</b> — horloges entamées, avec leur front.</li>
* </ol>
*
* <p>Déterministe, sans IA, zéro persistance. Si la campagne n'utilise pas de quêtes, la
* notion de « contenu probable » n'existe pas : on renvoie alors TOUS les manques (toute
* la campagne est potentiellement la prochaine séance).</p>
*/
@Service
public class SessionPrepService {
private final PlaythroughRepository playthroughRepository;
private final SessionRepository sessionRepository;
private final ClockRepository clockRepository;
private final FrontRepository frontRepository;
private final QuestRepository questRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final QuestStatusEnricher statusEnricher;
private final CampaignReadinessService readinessService;
public SessionPrepService(PlaythroughRepository playthroughRepository,
SessionRepository sessionRepository,
ClockRepository clockRepository,
FrontRepository frontRepository,
QuestRepository questRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
QuestStatusEnricher statusEnricher,
CampaignReadinessService readinessService) {
this.playthroughRepository = playthroughRepository;
this.sessionRepository = sessionRepository;
this.clockRepository = clockRepository;
this.frontRepository = frontRepository;
this.questRepository = questRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.statusEnricher = statusEnricher;
this.readinessService = readinessService;
}
public SessionPrepReport prepare(String playthroughId) {
Playthrough playthrough = playthroughRepository.findById(playthroughId)
.orElseThrow(() -> new IllegalArgumentException("Partie non trouvée: " + playthroughId));
String campaignId = playthrough.getCampaignId();
// 1) Position : quêtes actives + dernière séance.
List<Quest> quests = questRepository.findByCampaignId(campaignId);
Map<String, QuestStatus> statusById = statusEnricher.computeAll(quests, playthroughId);
List<Quest> inProgress = byStatus(quests, statusById, QuestStatus.IN_PROGRESS);
List<Quest> available = byStatus(quests, statusById, QuestStatus.AVAILABLE);
List<Quest> completed = byStatus(quests, statusById, QuestStatus.COMPLETED);
// 2) Contenu probable : nœuds des quêtes actives (en cours d'abord), dédupliqués.
LinkedHashSet<String> hotspotChapterIds = new LinkedHashSet<>();
LinkedHashSet<String> hotspotSceneIds = new LinkedHashSet<>();
List<SessionPrepReport.NodeInfo> hotspots = new ArrayList<>();
for (Quest quest : concat(inProgress, available)) {
for (QuestNodeRef node : nullSafe(quest.getNodes())) {
resolveNode(node, hotspotChapterIds, hotspotSceneIds, hotspots);
}
}
// 3) Manques ciblés sur ce contenu probable. Sans quête, tout est « probable ».
Set<String> hotspotQuestIds = concat(inProgress, available).stream()
.map(Quest::getId).collect(Collectors.toSet());
CampaignReadinessAssessment assessment = readinessService.assess(campaignId);
List<ReadinessGap> focused;
if (quests.isEmpty()) {
focused = assessment.gaps();
} else {
focused = assessment.gaps().stream()
.filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds))
.toList();
}
int otherGapCount = assessment.gaps().size() - focused.size();
// 4) Menaces en mouvement : horloges entamées, avec le nom de leur front.
Map<String, String> frontNames = frontRepository.findByPlaythroughId(playthroughId).stream()
.collect(Collectors.toMap(Front::getId, Front::getName, (a, b) -> a));
List<SessionPrepReport.ClockInfo> clocks = clockRepository.findByPlaythroughId(playthroughId).stream()
.filter(c -> c.getFilled() > 0)
.map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(),
c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null))
.toList();
return new SessionPrepReport(
playthroughId,
lastSessionOf(playthroughId),
toQuestInfos(inProgress),
toQuestInfos(available),
toQuestInfos(completed),
hotspots,
focused,
otherGapCount,
clocks);
}
/** Résout un nœud de quête vers son info navigable ; les refs mortes sont ignorées. */
private void resolveNode(QuestNodeRef node,
Set<String> chapterIds, Set<String> sceneIds,
List<SessionPrepReport.NodeInfo> out) {
if (node == null || node.nodeId() == null || node.nodeType() == null) return;
switch (node.nodeType()) {
case CHAPTER -> chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
if (chapterIds.add(ch.getId())) {
out.add(new SessionPrepReport.NodeInfo("CHAPTER", ch.getId(), ch.getName(), ch.getArcId(), ch.getId()));
}
});
case SCENE -> sceneRepository.findById(node.nodeId()).ifPresent(sc -> {
if (sceneIds.add(sc.getId())) {
String arcId = chapterRepository.findById(sc.getChapterId())
.map(Chapter::getArcId).orElse(null);
out.add(new SessionPrepReport.NodeInfo("SCENE", sc.getId(), sc.getName(), arcId, sc.getChapterId()));
}
});
}
}
/** Un gap est « ciblé » s'il touche une entité probable (ou une scène d'un chapitre probable). */
private boolean isFocused(ReadinessGap gap,
Set<String> chapterIds, Set<String> sceneIds, Set<String> questIds) {
return switch (gap.entityType()) {
case SCENE -> sceneIds.contains(gap.entityId())
|| (gap.chapterId() != null && chapterIds.contains(gap.chapterId()));
case CHAPTER -> chapterIds.contains(gap.entityId());
case QUEST -> questIds.contains(gap.entityId());
default -> false;
};
}
private SessionPrepReport.LastSessionInfo lastSessionOf(String playthroughId) {
List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId);
return sessions.stream()
.max(Comparator.comparing(Session::getStartedAt,
Comparator.nullsFirst(Comparator.naturalOrder())))
.map(s -> new SessionPrepReport.LastSessionInfo(
s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive()))
.orElse(null);
}
private static List<Quest> byStatus(List<Quest> quests, Map<String, QuestStatus> statusById, QuestStatus wanted) {
return quests.stream()
.filter(q -> statusById.get(q.getId()) == wanted)
.sorted(Comparator.comparingInt(Quest::getOrder))
.toList();
}
private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) {
return quests.stream()
.map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon()))
.toList();
}
private static List<Quest> concat(List<Quest> a, List<Quest> b) {
List<Quest> out = new ArrayList<>(a);
out.addAll(b);
return out;
}
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
}

View File

@@ -0,0 +1,85 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRecapAssistant;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
/**
* Génère le récap « précédemment dans… » à lire à l'OUVERTURE d'une séance : résume le
* journal de la SÉANCE PRÉCÉDENTE (même Partie, la plus récente commencée avant celle-ci ;
* ou la dernière terminée si la courante est la plus récente). Zéro persistance : le MJ
* lit le récap, et peut choisir côté front de le consigner au journal.
*/
@Service
public class SessionRecapService {
/** Plafond du transcript envoyé au LLM (garde le budget de contexte local ~16k). */
private static final int MAX_TRANSCRIPT_CHARS = 12_000;
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final SessionRecapAssistant recapAssistant;
public SessionRecapService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
SessionRecapAssistant recapAssistant) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.recapAssistant = recapAssistant;
}
/** Récap généré + nom de la séance résumée (pour l'afficher au MJ). */
public record RecapResult(String previousSessionName, String recap) {}
public RecapResult recapPreviousSession(String sessionId) {
Session current = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
Session previous = findPreviousSession(current)
.orElseThrow(() -> new IllegalArgumentException(
"Aucune séance précédente à résumer pour cette Partie."));
List<SessionEntry> entries = entryRepository.findBySessionId(previous.getId());
if (entries.isEmpty()) {
throw new IllegalArgumentException(
"La séance précédente (« " + previous.getName() + " ») n'a aucune entrée de journal à résumer.");
}
String transcript = buildTranscript(entries);
String context = "Séance : " + previous.getName();
return new RecapResult(previous.getName(), recapAssistant.generateRecap(transcript, context));
}
/** Séance la plus récente de la même Partie commencée AVANT la courante. */
private Optional<Session> findPreviousSession(Session current) {
LocalDateTime pivot = current.getStartedAt();
return sessionRepository.findByPlaythroughId(current.getPlaythroughId()).stream()
.filter(s -> !s.getId().equals(current.getId()))
.filter(s -> s.getStartedAt() != null
&& (pivot == null || s.getStartedAt().isBefore(pivot)))
.max(Comparator.comparing(Session::getStartedAt));
}
/** Journal chronologique, une ligne par entrée, plafonné (on garde la FIN — le dénouement). */
private static String buildTranscript(List<SessionEntry> entries) {
StringBuilder sb = new StringBuilder();
for (SessionEntry e : entries) {
if (e.getContent() == null || e.getContent().isBlank()) continue;
sb.append("[").append(e.getType() != null ? e.getType().name() : "NOTE").append("] ")
.append(e.getContent().trim()).append("\n");
}
String transcript = sb.toString();
if (transcript.length() > MAX_TRANSCRIPT_CHARS) {
transcript = "\n" + transcript.substring(transcript.length() - MAX_TRANSCRIPT_CHARS);
}
return transcript;
}
}

View File

@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -23,17 +24,21 @@ import java.util.Optional;
public class SessionService { public class SessionService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final String SESSION_NOT_FOUND = "Session introuvable : ";
private final SessionRepository sessionRepository; private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository; private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository; private final PlaythroughRepository playthroughRepository;
private final ClockService clockService;
public SessionService(SessionRepository sessionRepository, public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository, SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository) { PlaythroughRepository playthroughRepository,
ClockService clockService) {
this.sessionRepository = sessionRepository; this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository; this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository; this.playthroughRepository = playthroughRepository;
this.clockService = clockService;
} }
/** /**
@@ -54,7 +59,7 @@ public class SessionService {
"). Termine-la avant d'en lancer une nouvelle."); "). Termine-la avant d'en lancer une nouvelle.");
}); });
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
Session session = Session.builder() Session session = Session.builder()
.name(generateDefaultName(now)) .name(generateDefaultName(now))
.playthroughId(playthroughId) .playthroughId(playthroughId)
@@ -65,11 +70,26 @@ public class SessionService {
public Session endSession(String id) { public Session endSession(String id) {
Session session = sessionRepository.findById(id) Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id)); .orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
if (!session.isActive()) { if (!session.isActive()) {
throw new IllegalStateException("Cette session est déjà terminée."); throw new IllegalStateException("Cette session est déjà terminée.");
} }
session.setEndedAt(LocalDateTime.now()); session.setEndedAt(LocalDateTime.now(ZoneId.systemDefault()));
Session saved = sessionRepository.save(session);
// Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie.
clockService.onSessionEnded(saved.getPlaythroughId());
return saved;
}
/**
* Épingle (ou dés-épingle avec {@code null}) la scène courante de la session — mode
* cockpit : « on en est là ». Weak ref : on ne valide pas l'existence de la scène
* (l'UI ne propose que des scènes réelles ; une scène supprimée rend l'épingle caduque).
*/
public Session setCurrentScene(String id, String sceneId) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null);
return sessionRepository.save(session); return sessionRepository.save(session);
} }
@@ -78,7 +98,7 @@ public class SessionService {
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide."); throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
} }
Session session = sessionRepository.findById(id) Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id)); .orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
session.setName(newName.trim()); session.setName(newName.trim());
return sessionRepository.save(session); return sessionRepository.save(session);
} }
@@ -106,7 +126,7 @@ public class SessionService {
@Transactional @Transactional
public void deleteSession(String id) { public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) { if (!sessionRepository.existsById(id)) {
throw new IllegalArgumentException("Session introuvable : " + id); throw new IllegalArgumentException(SESSION_NOT_FOUND + id);
} }
entryRepository.deleteBySessionId(id); entryRepository.deleteBySessionId(id);
sessionRepository.deleteById(id); sessionRepository.deleteById(id);

View File

@@ -1,14 +0,0 @@
package com.loremind.domain.campaigncontext;
/**
* Type structurel d'un Arc.
* - LINEAR : narration séquentielle classique (chapitres joués dans l'ordre).
* - HUB : narration non linéaire ; les chapitres sont des "quêtes" satellites
* potentiellement parallèles, soumises à des prérequis pour être débloquées.
*
* Value Object du domaine (Bounded Context : Campaign).
*/
public enum ArcType {
LINEAR,
HUB
}

View File

@@ -24,6 +24,9 @@ public class Campaign {
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
private int arcsCount; private int arcsCount;
/** Nombre de joueurs attendus à la table (métadonnée saisie à la création). */
private int playerCount;
/** /**
* Référence faible vers un Lore. Nullable. * Référence faible vers un Lore. Nullable.
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context. * Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
@@ -35,6 +38,12 @@ public class Campaign {
*/ */
private String gameSystemId; private String gameSystemId;
/**
* Positions des nœuds du graphe de campagne (JSON opaque, état de
* présentation possédé par le front). Null = disposition automatique.
*/
private String graphPositions;
public boolean isLinkedToLore() { public boolean isLinkedToLore() {
return this.loreId != null && !this.loreId.isBlank(); return this.loreId != null && !this.loreId.isBlank();
} }

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@@ -47,6 +47,22 @@ public class Enemy {
/** Référence vers la Campaign parente (cross-aggregate via ID). */ /** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId; private String campaignId;
/**
* Référence vers l'acteur Foundry d'origine (UUID de compendium, ex.
* {@code Compendium.nimble.monsters.Actor.abc123}). Renseigné quand l'ennemi
* a été importé depuis un compendium Foundry ; permet, à l'export, de poser
* un token du VRAI acteur (stats natives). Null pour un ennemi fait main.
*/
private String foundryRef;
/**
* Instantané (figé) des stats de l'acteur Foundry d'origine, aplati en
* cle->valeur, pour AFFICHAGE en prep dans LoreMind. Dependant du systeme,
* non synchronise (les stats vivantes restent cote Foundry). Jamais null apres
* construction.
*/
private Map<String, String> foundryStats;
/** Ordre d'affichage dans la liste. */ /** Ordre d'affichage dans la liste. */
private int order; private int order;
@@ -67,4 +83,9 @@ public class Enemy {
if (keyValueValues == null) keyValueValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues; return keyValueValues;
} }
public Map<String, String> getFoundryStats() {
if (foundryStats == null) foundryStats = new HashMap<>();
return foundryStats;
}
} }

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
/** /**
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne. * Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
import java.util.List; import java.util.List;
@@ -11,12 +11,13 @@ import java.util.List;
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) { public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
/** /**
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la * {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply).
* campagne (rempli côté UI lors de la revue pré-chargée) l'apply ne le * <p>
* recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à cer. * {@code existingId} (nullable, porté aussi par Chapter/SceneProposal) : si psent,
* le nœud existe DÉJÀ dans la campagne (rempli côté UI lors de la revue pré-chargée)
* l'apply ne le recrée pas, il l'utilise comme parent des nouveaux enfants.
* Null = à créer.
*/ */
/** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
public record ArcProposal( public record ArcProposal(
String name, String description, String type, String name, String description, String type,
List<ChapterProposal> chapters, String existingId) { List<ChapterProposal> chapters, String existingId) {

View File

@@ -0,0 +1,21 @@
package com.loremind.domain.campaigncontext.generation;
import java.util.List;
/**
* Proposition de patch champ-par-champ d'une entité de scénario (Pilier A — co-création
* « propose → applique »). Éphémère et non persistée (comme {@code CampaignImportProposal}).
*
* <p>Le front affiche chaque {@link FieldProposal} (diff avant/après), l'utilisateur
* accepte/rejette champ par champ, puis renvoie CE MÊME record filtré aux seuls champs
* acceptés au endpoint d'application. Aucun statut d'acceptation n'est porté ici : le grain
* d'acceptation vit côté UI, l'apply ne reçoit que les champs retenus.</p>
*
* @param target type d'entité ciblée : {@code "scene"} (tranche 1), à terme aussi
* {@code "arc"|"chapter"|"npc"}
* @param targetId id de l'entité DÉJÀ persistée à patcher
* @param type {@code "patch"} (tranche 1) ou {@code "create"} (extension future)
* @param fields un {@link FieldProposal} par champ proposé/accepté
*/
public record EntityFieldPatchProposal(String target, String targetId, String type, List<FieldProposal> fields) {
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.campaigncontext.generation;
/**
* Proposition IA pour UN champ textuel d'une entité (Pilier A — co-création).
*
* <p>Value Object immuable, non persisté (éphémère, comme les records {@code *Proposal}
* de l'import). La {@code key} est alignée sur les champs exposés par
* {@code NarrativeEntityContextBuilder.fromScene()} : c'est la source de vérité qui relie
* la génération, l'affichage (diff avant/après) et l'application (patch ciblé).</p>
*
* @param key clé du champ (ex. {@code "playerNarration"}, {@code "atmosphere"})
* @param currentValue valeur actuelle du champ (echo pour la diff UI ; ignorée à l'apply)
* @param proposedValue valeur proposée par l'IA pour ce champ
*/
public record FieldProposal(String key, String currentValue, String proposedValue) {
}

View File

@@ -0,0 +1,13 @@
package com.loremind.domain.campaigncontext.generation;
/**
* Ébauche de scène proposée par l'IA (Pilier A — capacité « create »). Value Object
* immuable et non persisté : l'utilisateur révise la liste, puis les ébauches ACCEPTÉES
* sont créées comme vraies {@link Scene} dans le chapitre ciblé.
*
* @param name titre court de la scène (obligatoire à la création)
* @param description résumé bref (facultatif)
* @param playerNarration texte de mise en scène lu aux joueurs (facultatif)
*/
public record SceneDraft(String name, String description, String playerNarration) {
}

View File

@@ -0,0 +1,14 @@
package com.loremind.domain.campaigncontext.generation;
import java.util.List;
/**
* Proposition de PEUPLEMENT d'un chapitre en scènes (Pilier A — capacité « create »).
* Éphémère : le front affiche les ébauches, l'utilisateur accepte/rejette, puis renvoie
* ce record filtré aux ébauches retenues au endpoint d'application (qui crée les scènes).
*
* @param chapterId chapitre cible où créer les scènes
* @param scenes ébauches proposées / retenues
*/
public record SceneDraftProposal(String chapterId, List<SceneDraft> scenes) {
}

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.itemcatalog;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.itemcatalog;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.notebook;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.notebook;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.notebook;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,7 +1,7 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.CampaignImportProgress; import com.loremind.domain.campaigncontext.generation.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal;
import java.util.function.Consumer; import java.util.function.Consumer;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.bestiary.Character;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Enemy; import com.loremind.domain.campaigncontext.bestiary.Enemy;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.CatalogItem; import com.loremind.domain.campaigncontext.itemcatalog.CatalogItem;
import java.util.List; import java.util.List;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.ItemCatalog; import com.loremind.domain.campaigncontext.itemcatalog.ItemCatalog;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -0,0 +1,32 @@
package com.loremind.domain.campaigncontext.ports;
import java.util.List;
/**
* Port de sortie (Pilier A — co-création) : demande à l'IA d'ÉTOFFER les champs d'une
* entité narrative (arc, chapitre ou scène). Implémenté par un client du Brain. One-shot.
*
* <p>Générique par {@code entityType} : le Core est la SOURCE DE VÉRITÉ des champs
* (clé + libellé), le Brain ne fait que rédiger. Double garde-fou : whitelist stricte
* de clés côté Core ET côté adapter.</p>
*/
public interface NarrativeFieldAssistant {
/** Spécification d'un champ à proposer : clé technique + libellé lisible (pour le prompt). */
record FieldSpec(String key, String label) {}
/** Un champ proposé par l'IA (clé appartenant à la whitelist fournie). */
record ProposedField(String key, String value) {}
/**
* Génère des propositions de valeurs pour les champs d'une entité narrative.
*
* @param entityType {@code "arc"|"chapter"|"scene"} (pour la formulation du prompt)
* @param context contexte narratif compact (état actuel de l'entité + campagne)
* @param instruction consigne libre optionnelle du MJ (peut être vide/nulle)
* @param fields champs autorisés (whitelist) avec leur libellé
* @return champs proposés (uniquement des clés autorisées, valeurs non vides) ; vide
* si l'IA n'a rien de pertinent à proposer
*/
List<ProposedField> assist(String entityType, String context, String instruction, List<FieldSpec> fields);
}

View File

@@ -16,11 +16,22 @@ public interface NotebookChatStreamer {
record Progress(int current, int total) {} record Progress(int current, int total) {}
/** /**
* Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil * Callbacks du stream, invoqués au fil de l'eau : {@code onSourcesJson} UNE fois
* de l'eau : {@code onSourcesJson} UNE fois avant le premier token (JSON brut des * avant le premier token (JSON brut des passages utilisés — transparence UI),
* passages utilisés — transparence UI), {@code onToken} par fragment, * {@code onToken} par fragment, {@code onProgress} (mode approfondi uniquement)
* {@code onProgress} (mode approfondi uniquement) pendant la lecture du document, * pendant la lecture du document, {@code onDone} à la fin, {@code onError} en
* {@code onDone} à la fin, {@code onError} en cas d'échec. * cas d'échec. Regroupés en un seul objet (Argument Object) : ils voyagent
* toujours ensemble entre ce port, son adaptateur et le controller SSE.
*/
record Callbacks(
Consumer<String> onSourcesJson,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError) {}
/**
* Streame la réponse ancrée sur les sources.
* *
* @param deep true = analyse approfondie (map-reduce sur tout le document) ; * @param deep true = analyse approfondie (map-reduce sur tout le document) ;
* false = chat RAG (top-k). * false = chat RAG (top-k).
@@ -30,9 +41,5 @@ public interface NotebookChatStreamer {
List<Msg> messages, List<Msg> messages,
String context, String context,
boolean deep, boolean deep,
Consumer<String> onSourcesJson, Callbacks callbacks);
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError);
} }

View File

@@ -1,8 +1,8 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Notebook; import com.loremind.domain.campaigncontext.notebook.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage; import com.loremind.domain.campaigncontext.notebook.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource; import com.loremind.domain.campaigncontext.notebook.NotebookSource;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.bestiary.Npc;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -0,0 +1,27 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.quest.Quest;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Quests.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface QuestRepository {
Quest save(Quest quest);
Optional<Quest> findById(String id);
List<Quest> findByCampaignId(String campaignId);
/** Quêtes rattachées à un arc (HUB). Vide si aucune. */
List<Quest> findByArcId(String arcId);
List<Quest> findAll();
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTableEntry; import com.loremind.domain.campaigncontext.randomtable.RandomTableEntry;
import java.util.List; import java.util.List;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTable; import com.loremind.domain.campaigncontext.randomtable.RandomTable;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.generation.SceneDraft;
import java.util.List;
/**
* Port de sortie (Pilier A — capacité « create ») : demande à l'IA d'ÉBAUCHER des scènes
* pour un chapitre. Implémenté par un client du Brain. One-shot (pas de streaming).
*/
public interface SceneDraftAssistant {
/**
* Génère des ébauches de scènes cohérentes avec le contexte fourni.
*
* @param context contexte narratif compact (chapitre + campagne + scènes existantes)
* @param instruction consigne libre optionnelle du MJ (peut être vide/nulle)
* @param count nombre de scènes souhaité (indicatif)
* @return ébauches proposées (titres non vides) ; vide si rien de pertinent
*/
List<SceneDraft> draftScenes(String context, String instruction, int count);
}

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible, * Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible,

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Échec de génération IA d'un catalogue d'objets (Brain injoignable, erreur du * Échec de génération IA d'un catalogue d'objets (Brain injoignable, erreur du

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.campaigncontext.ports.exceptions;
/**
* Erreur de génération lors de l'étoffage IA d'une entité narrative (Brain injoignable,
* réponse inexploitable…). Traduite en HTTP 502 par le controller.
*/
public class NarrativeAssistException extends RuntimeException {
public NarrativeAssistException(String message) {
super(message);
}
public NarrativeAssistException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle). * Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du * Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du

View File

@@ -0,0 +1,14 @@
package com.loremind.domain.campaigncontext.quest;
/**
* Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}.
*
* <p>Une quête est ORTHOGONALE à l'arbre Arc→Chapitre→Scène : elle peut pointer
* un chapitre entier (CHAPTER) ou une scène précise (SCENE), et traverser
* plusieurs nœuds. Le schéma supporte les deux dès le départ (décision D3) ;
* l'UI démarre sur les chapitres.</p>
*/
public enum NodeType {
CHAPTER,
SCENE
}

View File

@@ -1,12 +1,10 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
/** /**
* Condition de déblocage d'une quête (Chapter dans un Arc HUB). * Condition de déblocage d'une quête (Chapter dans un Arc HUB).
*
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter * Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans * un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
* {@link PrerequisiteEvaluator}. * {@link PrerequisiteEvaluator}.
*
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique * Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
* (pas de OR pour le moment). * (pas de OR pour le moment).
*/ */

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -7,7 +7,6 @@ import java.util.Set;
/** /**
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête * Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
* et en dérive le {@link QuestStatus} effectif. * et en dérive le {@link QuestStatus} effectif.
*
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que * NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité * switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
* compile-time est compensée par le throw final qui fait crasher tout nouvel * compile-time est compensée par le throw final qui fait crasher tout nouvel
@@ -51,15 +50,12 @@ public final class PrerequisiteEvaluator {
List<Prerequisite> prerequisites, List<Prerequisite> prerequisites,
EvaluationContext ctx EvaluationContext ctx
) { ) {
switch (progression) { return switch (progression) {
case COMPLETED: return QuestStatus.COMPLETED; case COMPLETED -> QuestStatus.COMPLETED;
case IN_PROGRESS: return QuestStatus.IN_PROGRESS; case IN_PROGRESS -> QuestStatus.IN_PROGRESS;
case NOT_STARTED: case NOT_STARTED -> areAllSatisfied(prerequisites, ctx)
return areAllSatisfied(prerequisites, ctx) ? QuestStatus.AVAILABLE
? QuestStatus.AVAILABLE : QuestStatus.LOCKED;
: QuestStatus.LOCKED; };
default:
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
}
} }
} }

Some files were not shown because too many files have changed in this diff Show More