Compare commits

..

16 Commits

Author SHA1 Message Date
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
510 changed files with 29899 additions and 3544 deletions

View File

@@ -249,7 +249,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
files: core/target/dist-out/*.AppImage
files: core/target/dist-out/DM_Loremind*.AppImage
fail_on_unmatched_files: true
# BETA uniquement : artefact PRIVE (pas de release publique).
@@ -258,5 +258,5 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: loremind-beta-${{ steps.ver.outputs.version }}-appimage
path: core/target/dist-out/*.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/
brain/htmlcov/
brain/.coverage
foundry-module/

View File

@@ -1,4 +1,4 @@
# LoreMind
# DM Loremind
[English](README.md) · **Français**
@@ -10,33 +10,45 @@
[![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)
## 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)
## 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
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.
### 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
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
@@ -49,20 +61,20 @@ Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.
Quelques limites à connaître :
- 10 utilisateurs maximum simultanés (instances isolées)
- 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
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
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
## Licence
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
DM Loremind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- 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.
- 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)
@@ -10,15 +10,15 @@
[![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)
## 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)
## 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
@@ -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.
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
@@ -49,20 +61,20 @@ A demo instance is available at **[loremind-demo.igmlcreation.fr](https://loremi
A few limitations to be aware of:
- 10 concurrent users maximum (isolated instances)
- 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
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
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback
## License
LoreMind is distributed under the **[GNU AGPL v3](LICENSE)** license.
DM Loremind is distributed under the **[GNU AGPL v3](LICENSE)** license.
In practice:
- 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.
- 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.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 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.language import get_user_language
from app.domain.models import PageGenerationContext
@@ -131,3 +136,157 @@ async def summarize_conversation_title(
if not title:
title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"])
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(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.17.2",
version="1.0.0",
)
logger = logging.getLogger(__name__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.17.2</version>
<version>1.0.0</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -148,6 +148,23 @@
<artifactId>tink</artifactId>
<version>1.21.0</version>
</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>
<build>

View File

@@ -2,8 +2,12 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.FieldProposal;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.shared.ReorderSupport;
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 org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@@ -23,13 +27,16 @@ public class ArcService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final QuestRepository questRepository;
public ArcService(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
SceneRepository sceneRepository,
QuestRepository questRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.questRepository = questRepository;
}
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
@@ -86,6 +93,37 @@ public class ArcService {
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
* qui disparaîtront avec l'arc.
@@ -111,10 +149,28 @@ public class ArcService {
}
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);
}
public boolean arcExists(String 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,
id -> arcRepository.findById(id).orElse(null),
(arc, i) -> arc.setOrder(i),
arcRepository::save);
}
}

View File

@@ -0,0 +1,23 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.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,320 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.ReadinessEntityType;
import com.loremind.domain.campaigncontext.ReadinessSeverity;
import com.loremind.domain.campaigncontext.ReadinessStatus;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.RoomBranch;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.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 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());
// 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;
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));
for (Arc arc : arcs) {
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) {
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.";
gaps.add(new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
"ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null));
}
for (Chapter chapter : chapters) {
allChapterIds.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());
totalScenes += scenes.size();
for (Scene scene : scenes) {
allSceneIds.add(scene.getId());
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
}
}
}
Set<String> questIds = quests.stream()
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet());
// Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat).
boolean anyQuestWithNodes = quests.stream()
.anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
if (totalScenes == 0 && !anyQuestWithNodes) {
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));
}
for (Quest quest : quests) {
checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps);
}
return aggregate(campaignId, gaps);
}
private void checkScene(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) {
String name = labelOr(scene.getName(), "Scène");
// SCENE-001 — scène sans titre.
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).
List<SceneBranch> branches = scene.getBranches();
if (branches != null && branches.stream().anyMatch(b ->
isBlank(b.targetSceneId())
|| b.targetSceneId().equals(scene.getId())
|| !chapterSceneIds.contains(b.targetSceneId()))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID",
"Branche cassée : une sortie de « " + 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é).
if (!isBlank(scene.getCombatDifficulty())) {
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).
if (scene.getEnemyIds() != null
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN",
"Ennemi introuvable : « " + 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.
List<Room> rooms = scene.getRooms();
if (rooms != null && !rooms.isEmpty()) {
Set<String> roomIds = rooms.stream()
.map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet());
boolean roomBranchInvalid = false;
boolean roomEnemyBroken = false;
for (Room room : rooms) {
if (room.getBranches() != null) {
for (RoomBranch rb : room.getBranches()) {
if (isBlank(rb.targetRoomId())
|| rb.targetRoomId().equals(room.getId())
|| !roomIds.contains(rb.targetRoomId())) {
roomBranchInvalid = true;
}
}
}
if (room.getEnemyIds() != null) {
for (String id : room.getEnemyIds()) {
if (!isBlank(id) && !enemyIds.contains(id)) {
roomEnemyBroken = true;
}
}
}
}
if (roomBranchInvalid) {
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 (roomEnemyBroken) {
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 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 + " ».",
ReadinessSeverity.BLOCKING));
} 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.",
ReadinessSeverity.BLOCKING));
}
// 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.",
ReadinessSeverity.BLOCKING));
}
}
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(), "Scène"), ruleId, message, severity, arcId, chapterId);
}
private ReadinessGap questGap(Quest quest, String ruleId, String message, ReadinessSeverity severity) {
return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(),
labelOr(quest.getName(), "Quête"), ruleId, message, severity, 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;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -12,35 +10,33 @@ import java.util.TreeSet;
/**
* 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
* 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
* 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
public class CampaignReferencedFlagsService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final QuestRepository questRepository;
public CampaignReferencedFlagsService(ArcRepository arcRepository,
ChapterRepository chapterRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
public CampaignReferencedFlagsService(QuestRepository questRepository) {
this.questRepository = questRepository;
}
/** Retourne la liste triée alphabétiquement des noms de faits référencés. */
public List<String> listForCampaign(String campaignId) {
TreeSet<String> unique = new TreeSet<>();
for (Arc arc : arcRepository.findByCampaignId(campaignId)) {
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
if (chapter.getPrerequisites() == null) continue;
for (Prerequisite p : chapter.getPrerequisites()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
for (Quest quest : questRepository.findByCampaignId(campaignId)) {
if (quest.getPrerequisites() == null) continue;
for (Prerequisite p : quest.getPrerequisites()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
}
}

View File

@@ -48,7 +48,7 @@ public class CampaignService {
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) {}
@@ -59,6 +59,7 @@ public class CampaignService {
.loreId(normalizeId(data.loreId()))
.gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0)
.playerCount(data.playerCount())
.build();
Campaign saved = campaignRepository.save(campaign);
@@ -87,9 +88,26 @@ public class CampaignService {
campaign.setDescription(data.description());
campaign.setLoreId(normalizeId(data.loreId()));
campaign.setGameSystemId(normalizeId(data.gameSystemId()));
campaign.setPlayerCount(data.playerCount());
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) {
return (id == null || id.isBlank()) ? null : id;
}

View File

@@ -1,6 +1,8 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.FieldProposal;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
@@ -80,6 +82,35 @@ public class ChapterService {
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. */
public DeletionImpact getDeletionImpact(String id) {
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
@@ -97,4 +128,19 @@ public class ChapterService {
public boolean chapterExists(String 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,
id -> chapterRepository.findById(id).orElse(null),
(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,9 +1,16 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.shared.ReorderSupport;
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 java.io.ByteArrayInputStream;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -16,10 +23,14 @@ import java.util.Optional;
@Service
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.imageService = imageService;
}
public record EnemyData(
@@ -81,6 +92,128 @@ public class EnemyService {
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,
id -> enemyRepository.findById(id).orElse(null),
(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 = contentType.contains("png") ? "png"
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
: contentType.contains("gif") ? "gif" : "webp";
// 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;
}
}
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, updated = 0;
for (MonsterImport m : monsters) {
if (m.foundryRef() == null || m.foundryRef().isBlank()
|| m.name() == null || m.name().isBlank()) {
continue;
}
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
String folder = foundryFolder(m.folder());
Enemy ex = byRef.get(m.foundryRef());
if (ex != null) {
ex.setName(m.name());
ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(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);
updated++;
} else {
Enemy saved = enemyRepository.save(Enemy.builder()
.name(m.name())
.foundryRef(m.foundryRef())
.foundryStats(stats)
.folder(folder)
.portraitImageId(storePortrait(m.imgData(), m.name()))
.campaignId(campaignId)
.order(order++)
.values(new HashMap<>())
.imageValues(new HashMap<>())
.keyValueValues(new HashMap<>())
.build());
byRef.put(m.foundryRef(), saved);
created++;
}
}
return new MonsterImportResult(created, updated);
}
public List<Enemy> searchEnemies(String query) {
if (query == null || query.isBlank()) return List.of();
return enemyRepository.searchByName(query.trim());

View File

@@ -1,8 +1,7 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service;
@@ -19,11 +18,9 @@ import java.util.Optional;
public class NpcService {
private final NpcRepository npcRepository;
private final CampaignRepository campaignRepository;
public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) {
public NpcService(NpcRepository npcRepository) {
this.npcRepository = npcRepository;
this.campaignRepository = campaignRepository;
}
public record NpcData(
@@ -66,21 +63,6 @@ public class NpcService {
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) {
Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
@@ -102,6 +84,19 @@ public class NpcService {
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,
id -> npcRepository.findById(id).orElse(null),
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
npcRepository::save);
}
public List<Npc> searchNpcs(String query) {
if (query == null || query.isBlank()) return List.of();
return npcRepository.searchByName(query.trim());

View File

@@ -0,0 +1,219 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.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);
}
/** Quêtes rattachées à un arc HUB. */
public List<Quest> getQuestsByArcId(String arcId) {
return questRepository.findByArcId(arcId);
}
public List<Quest> getAllQuests() {
return questRepository.findAll();
}
/** 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>TODO (Phase 5) : signaler/nettoyer les {@code Prerequisite.QuestCompleted} pendants
* d'autres quêtes qui pointaient celle-ci. É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();
}
public boolean questExists(String id) {
return questRepository.existsById(id);
}
/** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */
@Transactional
public void reorderQuests(String campaignId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> questRepository.findById(id).orElse(null),
(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.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest;
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.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

@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
@@ -85,6 +86,15 @@ public class RandomTableService {
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,
id -> repository.findById(id).orElse(null),
(table, i) -> table.setOrder(i),
repository::save);
}
public List<RandomTable> searchTables(String query) {
if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim());

View File

@@ -0,0 +1,34 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.ReadinessEntityType;
import com.loremind.domain.campaigncontext.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;
import com.loremind.domain.campaigncontext.FieldProposal;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneDraft;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@@ -84,7 +88,96 @@ public class SceneService {
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) {
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()))
.collect(Collectors.toList());
if (kept.size() != branches.size()) {
sibling.setBranches(kept);
sceneRepository.save(sibling);
}
}
});
sceneRepository.deleteById(id);
}
@@ -92,6 +185,25 @@ public class SceneService {
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,
id -> sceneRepository.findById(id).orElse(null),
(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 :
* 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.
*
* Regroupe les cas d'usage CRUD + append message + rename. Un seul
* service suffit — le contexte est simple et les operations fortement
* liees (meme aggregat).
*
* Regles metier :
* - exactement un ancrage parent (loreId XOR campaignId) ;
* - entityType et entityId vont ensemble (tous deux null = niveau racine,

View File

@@ -0,0 +1,118 @@
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.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 {
/** 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",
"application/octet-stream",
"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 static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
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())
.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

@@ -4,11 +4,15 @@ import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Service
public class GameSystemService {
@@ -58,6 +62,7 @@ public class GameSystemService {
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String foundryActorType,
String author,
boolean isPublic
) {}
@@ -67,6 +72,7 @@ public class GameSystemService {
.name(data.name())
.description(data.description())
.rulesMarkdown(data.rulesMarkdown())
.foundryActorType(normalize(data.foundryActorType()))
.author(normalize(data.author()))
.isPublic(data.isPublic())
.build();
@@ -93,11 +99,41 @@ public class GameSystemService {
existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate());
existing.replaceEnemyTemplate(data.enemyTemplate());
existing.setFoundryActorType(normalize(data.foundryActorType()));
existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing);
}
/** Un champ scalaire d'une structure d'acteur Foundry importée. */
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 ? List.<FoundryStructField>of() : fields) {
if (f.path() == null || f.path().isBlank()) continue;
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())) continue;
usedNames.add(name.toLowerCase());
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
template.add(new TemplateField(name, type, null, null, f.path()));
}
gs.replaceEnemyTemplate(template);
gs.setFoundryActorType(normalize(actorType));
return gameSystemRepository.save(gs);
}
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
}

View File

@@ -0,0 +1,105 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneDraft;
import com.loremind.domain.campaigncontext.SceneDraftProposal;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
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 CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
private final SceneDraftAssistant assistant;
public DraftScenesUseCase(ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository,
SceneDraftAssistant assistant) {
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
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)
.collect(Collectors.toList());
return new SceneDraftProposal(chapterId, drafts);
}
private String buildContext(Chapter chapter, String campaignId) {
StringBuilder sb = new StringBuilder();
sb.append("Chapitre : ").append(blankToLabel(chapter.getName(), "(sans titre)")).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()) {
campaignRepository.findById(campaignId).ifPresent(c -> {
sb.append("Campagne : ").append(c.getName());
if (c.getDescription() != null && !c.getDescription().isBlank()) {
sb.append("").append(c.getDescription().trim());
}
sb.append("\n");
if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) {
gameSystemRepository.findById(c.getGameSystemId())
.ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).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, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
}

View File

@@ -0,0 +1,112 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.EntityFieldPatchProposal;
import com.loremind.domain.campaigncontext.FieldProposal;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
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 CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
private final NarrativeFieldAssistant assistant;
public NarrativeAssistFieldsUseCase(
NarrativeFieldCatalog catalog,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository,
NarrativeFieldAssistant assistant) {
this.catalog = catalog;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
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()))
.collect(Collectors.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) {
if (pf.key() == null || !allowed.contains(pf.key())) continue;
if (pf.value() == null || pf.value().isBlank()) continue;
String current = snap.current().getOrDefault(pf.key(), "");
fields.add(new FieldProposal(pf.key(), current, pf.value()));
}
return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields);
}
/** 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(), "(sans titre)")).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()) {
campaignRepository.findById(campaignId).ifPresent(c -> {
sb.append("Campagne : ").append(c.getName());
if (c.getDescription() != null && !c.getDescription().isBlank()) {
sb.append("").append(c.getDescription().trim());
}
sb.append("\n");
if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) {
gameSystemRepository.findById(c.getGameSystemId())
.ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).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, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
}

View File

@@ -0,0 +1,126 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.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 List<FieldDef> ARC_DEFS = List.of(
new FieldDef("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("gmNotes", "notes privées du MJ"));
private static final List<FieldDef> CHAPTER_DEFS = List.of(
new FieldDef("description", "synopsis du chapitre"),
new FieldDef("playerObjectives", "objectifs des joueurs"),
new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"),
new FieldDef("gmNotes", "notes privées du MJ"));
private static final List<FieldDef> SCENE_DEFS = List.of(
new FieldDef("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("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("gmNotes", 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("description", nz(c.getDescription()));
cur.put("playerObjectives", nz(c.getPlayerObjectives()));
cur.put("narrativeStakes", nz(c.getNarrativeStakes()));
cur.put("gmNotes", 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("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;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestStatus;
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.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
@@ -46,8 +43,7 @@ public class SessionStructuralContextBuilder {
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final QuestRepository questRepository;
private final PlaythroughFlagRepository playthroughFlagRepository;
private final QuestProgressionRepository questProgressionRepository;
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
@@ -55,15 +51,13 @@ public class SessionStructuralContextBuilder {
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
QuestRepository questRepository,
PlaythroughFlagRepository playthroughFlagRepository,
QuestProgressionRepository questProgressionRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.questRepository = questRepository;
this.playthroughFlagRepository = playthroughFlagRepository;
this.questProgressionRepository = questProgressionRepository;
}
@@ -169,23 +163,14 @@ public class SessionStructuralContextBuilder {
Map<String, Boolean> flags = playthroughFlagRepository.findByPlaythroughId(playthroughId);
List<String> activeFlags = buildActiveFlags(flags);
List<Arc> arcs = arcRepository.findByCampaignId(campaignId);
Map<String, Arc> arcsById = arcs.stream()
.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<>();
// Map questId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByQuest = new HashMap<>();
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)
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
// IDs des quêtes COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedQuestIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
PrerequisiteEvaluator.EvaluationContext ctx =
@@ -195,29 +180,24 @@ public class SessionStructuralContextBuilder {
List<QuestSummary> inProgress = new ArrayList<>();
List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
boolean isHub = arc.getType() == ArcType.HUB;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId());
String arcName = parent != null ? parent.getName() : null;
switch (status) {
case AVAILABLE:
available.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
break;
case IN_PROGRESS:
inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
break;
case LOCKED:
lockedTitles.add(c.getName());
break;
case COMPLETED:
// Omis (déjà dans le journal des EVENTs).
break;
}
// Niveau 1 : les quêtes sont des entités orthogonales rattachées à la campagne
// (plus des chapitres HUB). arcName n'a plus de sens => null.
for (Quest q : questRepository.findByCampaignId(campaignId)) {
ProgressionStatus prog = progressionByQuest.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, q.getPrerequisites(), ctx);
switch (status) {
case AVAILABLE:
available.add(new QuestSummary(q.getName(), null, q.getDescription()));
break;
case IN_PROGRESS:
inProgress.add(new QuestSummary(q.getName(), null, q.getDescription()));
break;
case LOCKED:
lockedTitles.add(q.getName());
break;
case COMPLETED:
// Omis (déjà dans le journal des EVENTs).
break;
}
}

View File

@@ -1,6 +1,7 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
@@ -45,10 +46,20 @@ public class LoreNodeService {
.icon(changes.getIcon())
.parentId(changes.getParentId())
.loreId(changes.getLoreId())
.order(nextOrderFor(changes.getLoreId(), changes.getParentId()))
.build();
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) {
return loreNodeRepository.findById(id);
}
@@ -57,6 +68,37 @@ public class LoreNodeService {
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,
id -> loreNodeRepository.findById(id).orElse(null),
(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) {
return loreNodeRepository.findByLoreId(loreId);
}

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service;
import com.loremind.domain.shared.CollectionUtils;
import com.loremind.domain.shared.ReorderSupport;
import java.util.ArrayList;
import java.util.HashMap;
@@ -33,6 +34,7 @@ public class PageService {
.nodeId(nodeId)
.templateId(templateId)
.title(title)
.order(nextOrderFor(nodeId))
.values(new HashMap<>())
.tags(new ArrayList<>())
.relatedPageIds(new ArrayList<>())
@@ -40,6 +42,14 @@ public class PageService {
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) {
return pageRepository.findById(id);
}
@@ -76,6 +86,7 @@ public class PageService {
existing.setNodeId(changes.getNodeId());
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
existing.setImageFraming(CollectionUtils.copyMap(changes.getImageFraming()));
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
existing.setNotes(changes.getNotes());
@@ -87,4 +98,19 @@ public class PageService {
public void deletePage(String 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,
id -> pageRepository.findById(id).orElse(null),
(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

@@ -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,204 @@
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.Chapter;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.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.time.LocalDateTime;
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))
.collect(Collectors.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))
.collect(Collectors.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.<LocalDateTime>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))
.collect(Collectors.toList());
}
private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) {
return quests.stream()
.map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon()))
.collect(Collectors.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

@@ -27,13 +27,16 @@ public class SessionService {
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ClockService clockService;
public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository) {
PlaythroughRepository playthroughRepository,
ClockService clockService) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.clockService = clockService;
}
/**
@@ -70,6 +73,21 @@ public class SessionService {
throw new IllegalStateException("Cette session est déjà terminée.");
}
session.setEndedAt(LocalDateTime.now());
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 introuvable : " + id));
session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null);
return sessionRepository.save(session);
}

View File

@@ -55,12 +55,6 @@ public class Arc {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -3,12 +3,17 @@ 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.
* - HUB : narration non linéaire ; contient des QUÊTES (entités Quest rattachées).
* - SYSTEM : arc TECHNIQUE (« Quêtes libres », un par campagne au besoin) qui héberge
* les conteneurs de scènes des quêtes LIBRES (hors arc). Masqué de la
* narration dans l'arbre (ses quêtes s'affichent sous « Quêtes ») ; dans
* les exports (PDF / Foundry / backup) il apparaît sous son nom — ses
* scènes sont du vrai contenu jouable.
*
* Value Object du domaine (Bounded Context : Campaign).
*/
public enum ArcType {
LINEAR,
HUB
HUB,
SYSTEM
}

View File

@@ -24,6 +24,9 @@ public class Campaign {
private LocalDateTime updatedAt;
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.
* 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;
/**
* 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() {
return this.loreId != null && !this.loreId.isBlank();
}

View File

@@ -21,14 +21,6 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc
/**
* Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE.
* Pertinent surtout pour les chapitres d'un Arc HUB ; ignoré pour LINEAR.
* Donnée de SCÉNARIO — partagée par toutes les Parties de la campagne.
*/
@Builder.Default
private List<Prerequisite> prerequisites = new ArrayList<>();
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
@@ -50,12 +42,6 @@ public class Chapter {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans pour ce chapitre (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -47,6 +47,22 @@ public class Enemy {
/** Référence vers la Campaign parente (cross-aggregate via ID). */
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. */
private int order;
@@ -67,4 +83,9 @@ public class Enemy {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
public Map<String, String> getFoundryStats() {
if (foundryStats == null) foundryStats = new HashMap<>();
return foundryStats;
}
}

View File

@@ -0,0 +1,21 @@
package com.loremind.domain.campaigncontext;
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;
/**
* 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,17 @@
package com.loremind.domain.campaigncontext;
/**
* Type d'un lien narratif entre nœuds ({@link SceneBranch}) — Niveau 2.
*
* <p>{@code EXIT} = sortie / choix narratif : c'est la sémantique historique des
* branches, et la valeur par défaut (branches existantes / bundles antérieurs).
* {@code CLUE} et {@code LEAD} enrichissent le graphe façon « Three Clue Rule ».</p>
*/
public enum LinkType {
/** Sortie / choix narratif (défaut, comportement historique). */
EXIT,
/** Indice menant à une information. */
CLUE,
/** Piste vers un autre nœud. */
LEAD
}

View File

@@ -0,0 +1,14 @@
package com.loremind.domain.campaigncontext;
/**
* 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

@@ -0,0 +1,67 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité de domaine (Aggregate Root) représentant une Quête.
*
* <p>Niveau 1 : la Quête est une entité de PREMIÈRE CLASSE, ORTHOGONALE à
* l'arbre Arc→Chapitre→Scène. Elle est rattachée à la Campagne (décision D2) et
* référence des nœuds narratifs arbitraires via {@link QuestNodeRef}. Elle porte
* ses propres conditions de déblocage (réutilise le sealed {@link Prerequisite}).</p>
*
* <p>Elle remplace le double rôle historique du {@code Chapter} en mode HUB :
* {@code Prerequisite.QuestCompleted.questId} et {@code QuestProgression.questId}
* pointent désormais une {@code Quest.id}. Entité pure, sans dépendance technique.</p>
*/
@Data
@Builder
public class Quest {
private String id;
private String campaignId; // Rattachement campagne (orthogonalité, décision D2)
/**
* Arc de rattachement (weak ref, NULLABLE). Non nul ⇒ quête d'un ARC HUB (affichée
* sous cet arc). Null ⇒ quête TRANSVERSE (peut couvrir plusieurs arcs ; visible dans
* la liste « Quêtes »). Rétro-compat : les quêtes existantes ont {@code arcId=null}.
*/
private String arcId;
private String name;
private String description; // Synopsis de la quête
private String icon; // Clé d'icône (cf. CAMPAIGN_ICON_OPTIONS côté front)
private int order; // Ordre d'affichage dans la campagne
/**
* Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE.
* Réutilise le sealed {@link Prerequisite} (migré depuis Chapter en mode HUB).
* Donnée de SCÉNARIO — l'état réel par Partie vit dans QuestProgression (Play Context).
*/
@Builder.Default
private List<Prerequisite> prerequisites = new ArrayList<>();
/**
* Nœuds narratifs (Chapitres / Scènes) traversés par la quête (NN, weak refs).
*/
@Builder.Default
private List<QuestNodeRef> nodes = new ArrayList<>();
// Champs narratifs (repris de l'usage HUB du Chapter)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs pour cette quête
private String narrativeStakes; // Enjeux narratifs dramatiques
/** IDs des pages du Lore associées (weak cross-context references). */
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant la quête. */
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.campaigncontext;
/**
* Value Object : lien faible d'une {@link Quest} vers un nœud narratif
* (Chapitre ou Scène) qu'elle traverse.
*
* <p>Référence faible (pas de FK dure cross-aggregate, cohérent avec
* {@code relatedPageIds}) : une quête peut couvrir plusieurs nœuds et un nœud
* peut servir plusieurs quêtes (NN). Immuable (record), comme
* {@link SceneBranch} / {@link RoomBranch}.</p>
*
* @param nodeType type du nœud cible (CHAPTER|SCENE)
* @param nodeId id du Chapter ou de la Scene (weak ref)
* @param order ordre d'affichage du nœud dans la quête
*/
public record QuestNodeRef(NodeType nodeType, String nodeId, int order) {
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.campaigncontext;
/**
* Type de l'entité de scénario ciblée par un manque de readiness. Le front s'en
* sert (avec {@code arcId}/{@code chapterId} du gap) pour construire le lien profond
* vers l'éditeur concerné.
*/
public enum ReadinessEntityType {
CAMPAIGN,
ARC,
CHAPTER,
SCENE,
QUEST,
NPC,
ENEMY
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.campaigncontext;
/**
* Gravité d'un manque de préparation détecté par le guidage (Pilier B).
*
* <p>Purement indicatif : aucun niveau ne BLOQUE une action. Le guidage conseille,
* il n'interdit rien (un MJ qui improvise n'est jamais empêché).</p>
*/
public enum ReadinessSeverity {
/** Empêche de jouer proprement : structure cassée, entité vide, référence morte. */
BLOCKING,
/** Fortement conseillé pour la cohérence / le confort de jeu (ex. combat sans ennemi). */
RECOMMENDED,
/** Finition (ambiance, illustrations…). Masqué par défaut dans l'UI. */
OPTIONAL
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext;
/**
* Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »).
*
* <p>À NE PAS confondre avec la progression EN PARTIE ({@link ProgressionStatus} /
* {@link QuestStatus}, Play Context). Le readiness décrit « ce scénario est-il prêt
* à jouer ? » et se calcule uniquement depuis les champs du Campaign Context —
* jamais depuis un Playthrough. Ordre implicite DRAFT &lt; PLAYABLE &lt; POLISHED,
* agrégé vers le haut en MIN() (le maillon faible tire l'ensemble vers le bas).</p>
*/
public enum ReadinessStatus {
/** Il reste au moins un manque BLOQUANT (structure cassée, vide, référence morte). */
DRAFT,
/** Plus aucun manque bloquant : jouable, mais il reste des manques recommandés. */
PLAYABLE,
/** Plus aucun manque bloquant ni recommandé : prêt « clé en main ». */
POLISHED
}

View File

@@ -24,6 +24,14 @@ public class Scene {
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
/**
* Type narratif du nœud (Niveau 2 — graphe de nœuds typés). Défaut
* {@link SceneType#GENERIC} (scène non typée). Métadonnée : n'altère pas
* le comportement existant des scènes.
*/
@Builder.Default
private SceneType type = SceneType.GENERIC;
// === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
private String timing; // Moment (ex: Soir, à la tombée de la nuit)
@@ -65,12 +73,23 @@ public class Scene {
private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans.
* Vocation "outil de table" : plan de donjon, carte du lieu, schema tactique.
* Rendu different des illustrations : vignettes plus grandes, ratio natif preserve.
* "Battlemaps" de la scene destinees a l'export Foundry : liste de paires
* { media + sidecar } etiquetees ({@link SceneBattlemap}). Plusieurs cartes =
* variantes de la meme scene (Jour/Nuit, etages, avant/apres). Le media est un
* fichier image ou video ({@link com.loremind.domain.files.StoredFile}), le sidecar
* le .json/.dd2vtt Universal VTT (grille, murs, portes, lumieres) sorti de
* Dungeon Alchemist / Dungeondraft. Non affichees dans l'appli : passees telles
* quelles a l'export pour recreer les Scenes cote Foundry. Vide = scene sans carte.
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private List<SceneBattlemap> battlemaps = new ArrayList<>();
/**
* Position X/Y du nœud dans la vue graphe du chapitre (Niveau 2). {@code null} =
* pas encore positionné → le layout automatique (BFS) place le nœud à l'ouverture.
*/
private Double graphX;
private Double graphY;
/**
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre).

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext;
/**
* Value Object représentant UNE battlemap d'une Scene destinée à l'export Foundry :
* paire { media + sidecar Universal VTT } plus un libellé libre. Une scène peut en
* porter plusieurs (variantes « Jour » / « Nuit », étages, états avant/après…).
* <p>
* Record Java : immuable, sans dépendance technique (même pattern que
* {@link SceneBranch}) — Jackson le (dé)sérialise nativement via le constructeur
* canonique pour le stockage JSON en base.
*
* @param label Libellé de la variante (ex : "Jour", "Nuit"). Jamais null (normalisé "").
* @param mediaFileId ID du fichier media image/video ({@link com.loremind.domain.files.StoredFile}).
* Null = carte sans fond (sidecar seul).
* @param dataFileId ID du fichier sidecar Universal VTT (.json/.dd2vtt). Null si absent.
*/
public record SceneBattlemap(String label, String mediaFileId, String dataFileId) {
/** Normalise un libellé absent en chaîne vide (une seule représentation du « sans nom »). */
public SceneBattlemap {
if (label == null) label = "";
}
}

View File

@@ -15,8 +15,19 @@ package com.loremind.domain.campaigncontext;
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
* @param kind Type de lien (Niveau 2). {@code null} normalisé en {@link LinkType#EXIT}.
*/
public record SceneBranch(String label, String targetSceneId, String condition) {
public record SceneBranch(String label, String targetSceneId, String condition, LinkType kind) {
/** Normalise un {@code kind} absent (branches / bundles antérieurs au Niveau 2) vers EXIT. */
public SceneBranch {
if (kind == null) kind = LinkType.EXIT;
}
/** Constructeur 3-args rétro-compatible : {@code kind} par défaut {@link LinkType#EXIT}. */
public SceneBranch(String label, String targetSceneId, String condition) {
this(label, targetSceneId, condition, LinkType.EXIT);
}
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
public static SceneBranch of(String label, String targetSceneId) {

View File

@@ -0,0 +1,13 @@
package com.loremind.domain.campaigncontext;
/**
* É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;
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

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext;
/**
* Type narratif d'un nœud (Scène) — Niveau 2 (graphe de nœuds typés).
*
* <p>Métadonnée qui oriente le rendu et l'édition dans la vue graphe ; elle
* n'altère pas le comportement existant des scènes. {@code GENERIC} = scène non
* typée (valeur par défaut, et celle des scènes antérieures au Niveau 2).</p>
*/
public enum SceneType {
/** Scène non typée (défaut). */
GENERIC,
/** Un lieu à explorer. */
LOCATION,
/** Une rencontre / un combat. */
ENCOUNTER,
/** Une situation centrée sur un PNJ. */
NPC,
/** Un événement scénarisé. */
EVENT,
/** Une révélation / un indice majeur. */
REVELATION
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.campaigncontext.ports;
/**
* 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

@@ -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

@@ -0,0 +1,27 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.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

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.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

@@ -0,0 +1,44 @@
package com.loremind.domain.files;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entite de domaine representant un FICHIER generique uploade par l'utilisateur.
* <p>
* Pendant de {@link com.loremind.domain.images.Image}, mais SANS la contrainte
* "image" : un StoredFile peut etre une video, un JSON, etc. Sert notamment aux
* "battlemaps" de scene (paire media + sidecar JSON Universal VTT) destinees a
* l'export Foundry, qui ne sont ni des images (mp4) ni limitees a 10 Mo.
* <p>
* Meme design que Image :
* - Metadata en DB relationnelle (table {@code stored_files})
* - Binaire sur object storage (MinIO/S3) ou filesystem, reference par {@code storageKey}
* - Le domaine ne connait que la cle opaque.
* <p>
* Architecture Hexagonale : entite pure, aucune dependance technique.
*/
@Data
@Builder
public class StoredFile {
/** Identifiant stable (String pour rester agnostique vis-a-vis du stockage). */
private String id;
/** Nom original du fichier uploade (ex: "cellier.dd2vtt", "donjon.mp4"). */
private String filename;
/** Type MIME (ex: "video/mp4", "application/json", "image/png"). */
private String contentType;
/** Taille en octets. */
private long sizeBytes;
/** Cle opaque dans le stockage (ex: "files/abc123.mp4"). */
private String storageKey;
/** Horodatage de l'upload initial (le fichier est immuable apres creation). */
private LocalDateTime uploadedAt;
}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.files.ports;
import java.io.InputStream;
/**
* Port de sortie pour le stockage du BINAIRE des fichiers generiques.
* <p>
* Pendant de {@link com.loremind.domain.images.ports.ImageStorage}, mais pour
* des fichiers non-images (video, JSON...). Separe du port image pour que les
* deux familles aient des regles (MIME, taille, prefixe de cle) independantes.
* <p>
* Les implementations (MinIO, filesystem) traduisent la cle opaque selon leur
* logique physique. Convention de cle : prefixe {@code files/}.
*/
public interface FileStorage {
/**
* Envoie un flux binaire et retourne la cle generee (prefixe {@code files/}).
*/
String upload(String filename, String contentType, InputStream data, long sizeBytes);
/**
* Stocke un flux binaire SOUS UNE CLE IMPOSEE (pas de generation). Utilise
* par l'import de contenu pour reinjecter un fichier sous sa cle d'origine.
* Ecrase si la cle existe deja.
*/
void store(String storageKey, String contentType, InputStream data, long sizeBytes);
/** Recupere le flux binaire associe a une cle, ou null si inexistante. */
InputStream download(String storageKey);
/** Supprime le binaire. No-op silencieux si la cle n'existe pas. */
void delete(String storageKey);
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.files.ports;
import com.loremind.domain.files.StoredFile;
import java.util.Optional;
/**
* Port de sortie pour la persistance des metadonnees de fichiers generiques.
* <p>
* Pendant de {@link com.loremind.domain.images.ports.ImageRepository}. Ne
* manipule QUE les metadonnees ; le binaire est gere par {@link FileStorage}.
*/
public interface StoredFileRepository {
StoredFile save(StoredFile file);
Optional<StoredFile> findById(String id);
Optional<StoredFile> findByStorageKey(String storageKey);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -58,6 +58,13 @@ public class GameSystem {
*/
private List<TemplateField> enemyTemplate;
/**
* Type d'acteur Foundry à créer pour les ennemis maison de ce système
* (ex: {@code npc}, {@code monster}). Renseigné par l'import de structure Foundry ;
* couplé aux {@code foundryPath} des champs de {@link #enemyTemplate}. Nullable.
*/
private String foundryActorType;
/** Auteur déclaré — futur marketplace. Nullable. */
private String author;

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.lorecontext;
/**
* Cadrage d'une image dans son bloc IMAGE d'une page de lore.
* <p>
* Donnée purement présentationnelle, définie PAR IMAGE et PAR PAGE :
* <ul>
* <li>{@link #x}, {@link #y} : object-position en pourcentage (0..100).
* 50/50 = centré (défaut).</li>
* <li>{@link #scale} : facteur de zoom (>= 1). 1 = ajusté plein cadre (cover).</li>
* </ul>
* <p>
* Stockée sur la Page dans {@code imageFraming} (fieldKey → imageId → cadrage),
* sérialisée en JSON. Entité pure du domaine : aucune dépendance technique.
*/
public record ImageFraming(double x, double y, double scale) {
}

View File

@@ -24,6 +24,8 @@ public class LoreNode {
private String icon;
private String parentId; // Auto-référence pour l'arborescence
private String loreId; // Référence vers le Lore parent
/** Position du dossier parmi ses frères (glisser-déposer). */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -29,6 +29,9 @@ public class Page {
private String templateId;
private String title;
/** Position de la page dans son dossier (glisser-déposer). */
private int order;
/** Valeurs des champs dynamiques TEXT définis par le Template. */
private Map<String, String> values;
@@ -39,6 +42,13 @@ public class Page {
*/
private Map<String, List<String>> imageValues;
/**
* Cadrage (pan/zoom) des images dans leur bloc IMAGE : fieldKey → imageId →
* {@link ImageFraming}. Purement presentationnel ; l'absence d'entree = cadrage
* par defaut (centre, plein cadre). Voir {@link ImageFraming}.
*/
private Map<String, Map<String, ImageFraming>> imageFraming;
/**
* Valeurs des champs KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
* fiches de personnage) : fieldName → (label → valeur). Les labels sont

View File

@@ -0,0 +1,55 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Horloge de progression (Clock, façon <i>Blades in the Dark</i>) — état dynamique de partie.
*
* <p>Appartient à un {@link Playthrough}. Compteur à {@code segments} segments dont
* {@code filled} sont remplis ; pleine ({@code filled == segments}) → un effet narratif
* se déclenche. Orthogonale à l'arbre Arc/Chapitre/Scène, comme {@code Session} /
* {@code QuestProgression} : c'est de l'état de Partie, pas du scénario.</p>
*/
@Data
@Builder
public class Clock {
private String id;
/** Weak reference vers le Playthrough parent. */
private String playthroughId;
private String name;
/** Ce qui se passe quand l'horloge est pleine (optionnel). */
private String description;
/** Nombre total de segments (≥ 1). */
private int segments;
/** Segments remplis (borné à [0, segments]). */
private int filled;
/** Ordre d'affichage parmi les horloges de la Partie. */
private int order;
/** Déclencheur d'avancement automatique (co-MJ). Défaut {@link ClockTrigger#NONE}. */
@Builder.Default
private ClockTrigger triggerType = ClockTrigger.NONE;
/** Cible du déclencheur : nom du fait (FLAG_SET) ou id de quête (QUEST_COMPLETED) ; sinon null. */
private String triggerRef;
/** Front (menace) auquel l'horloge appartient, ou null (horloge libre). Weak reference. */
private String frontId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public boolean isComplete() {
return filled >= segments;
}
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.playcontext;
/**
* Déclencheur d'avancement automatique d'une {@link Clock} (co-MJ) : l'horloge
* avance d'un segment quand l'événement lié survient dans la Partie. Le MJ garde
* la main (il peut reculer). {@code triggerRef} précise la cible selon le type.
*/
public enum ClockTrigger {
/** Aucun : l'horloge n'avance qu'à la main. */
NONE,
/** Quand un Fait (flag) passe à vrai — {@code triggerRef} = nom du fait. */
FLAG_SET,
/** Quand une quête passe à COMPLETED — {@code triggerRef} = id de la quête. */
QUEST_COMPLETED,
/** À la clôture d'une séance de la Partie — pas de {@code triggerRef}. */
SESSION_ENDED
}

View File

@@ -0,0 +1,31 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Front (menace) d'une Partie : regroupement nommé d'horloges de progression
* ({@link Clock}) sous une même menace (ex. « La montée du Culte »). État de
* Partie, comme les horloges — orthogonal à l'arbre Arc/Chapitre/Scène.
*/
@Data
@Builder
public class Front {
private String id;
/** Weak reference vers le Playthrough parent. */
private String playthroughId;
private String name;
private String description;
/** Ordre d'affichage parmi les fronts de la Partie. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -11,7 +11,7 @@ import lombok.Data;
* le scénario et l'état de jeu : ici, la progression est exclusivement
* propre à une instance jouée (Playthrough).</p>
*
* <p>Référence le Chapter par weak reference (chapterId) pour respecter les
* <p>Référence la Quest par weak reference (questId) pour respecter les
* Bounded Contexts. Le type {@link ProgressionStatus} reste défini dans
* Campaign Context (c'est un Value Object générique, partageable).</p>
*
@@ -24,6 +24,6 @@ public class QuestProgression {
private String id;
private String playthroughId;
private String chapterId;
private String questId;
private ProgressionStatus status;
}

View File

@@ -31,6 +31,12 @@ public class Session {
/** Null = session en cours ; renseigné = session terminée. */
private LocalDateTime endedAt;
/**
* Scène courante épinglée pendant la séance (mode cockpit). Weak ref nullable vers
* une Scene du scénario ; null = rien d'épinglé. Sert de repère « on en est là ».
*/
private String currentSceneId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.Clock;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Horloges de progression (Clocks).
*/
public interface ClockRepository {
Clock save(Clock clock);
Optional<Clock> findById(String id);
/** Horloges d'une Partie, triées par {@code order} croissant. */
List<Clock> findByPlaythroughId(String playthroughId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.Front;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Fronts (menaces regroupant des horloges).
*/
public interface FrontRepository {
Front save(Front front);
Optional<Front> findById(String id);
/** Fronts d'une Partie, triés par {@code order} croissant. */
List<Front> findByPlaythroughId(String playthroughId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -17,15 +17,18 @@ public interface QuestProgressionRepository {
/** Liste toutes les progressions explicites d'un Playthrough. */
List<QuestProgression> findByPlaythroughId(String playthroughId);
/** Set des IDs de chapitres en COMPLETED pour un Playthrough donné (fast path éval). */
Set<String> findCompletedChapterIdsByPlaythroughId(String playthroughId);
/** Set des IDs de quêtes en COMPLETED pour un Playthrough donné (fast path éval). */
Set<String> findCompletedQuestIdsByPlaythroughId(String playthroughId);
/**
* Crée ou met à jour le statut d'une quête pour un Playthrough.
* Si {@code status == NOT_STARTED}, la ligne est supprimée (sémantique "absence").
*/
void setStatus(String playthroughId, String chapterId, ProgressionStatus status);
void setStatus(String playthroughId, String questId, ProgressionStatus status);
/** Supprime toutes les progressions d'un Playthrough (cascade applicative). */
void deleteAllByPlaythroughId(String playthroughId);
/** Supprime toutes les progressions d'une quête (cascade à la suppression d'une Quest). */
void deleteByQuestId(String questId);
}

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.playcontext.ports;
/**
* Port de sortie (mode séance) : génère le récapitulatif « précédemment… » d'une séance
* à partir de son journal. Implémenté par un client du Brain. One-shot, texte libre.
*/
public interface SessionRecapAssistant {
/**
* Résume le journal d'une séance en un récap à lire aux joueurs à l'ouverture
* de la séance suivante.
*
* @param transcript entrées du journal, chronologiques, une par ligne
* @param context méta courte (nom de la campagne / de la séance), peut être vide
* @return le récap rédigé (jamais null ; peut être court si le journal est maigre)
*/
String generateRecap(String transcript, String context);
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.playcontext.ports;
/**
* Erreur de génération du récap de séance (Brain injoignable, réponse vide…).
* Traduite en HTTP 502 par le controller.
*/
public class SessionRecapException extends RuntimeException {
public SessionRecapException(String message) {
super(message);
}
public SessionRecapException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.shared;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.ObjIntConsumer;
/**
* Squelette commun du réordonnancement par glisser-déposer : parcourt la liste
* ordonnée d'ids, charge chaque entité, lui applique sa position (et toute
* réaffectation de parent via {@code applyOrder}), puis la sauve. Les ids inconnus
* sont ignorés. Évite de recopier cette boucle dans chaque service ordonnable.
*/
public final class ReorderSupport {
private ReorderSupport() {}
/**
* @param orderedIds liste ordonnée des ids (null = no-op)
* @param finder id → entité (ou null si introuvable)
* @param applyOrder (entité, index) → pose l'ordre et l'éventuel parent
* @param saver persiste l'entité
*/
public static <T> void reorder(List<String> orderedIds,
Function<String, T> finder,
ObjIntConsumer<T> applyOrder,
Consumer<T> saver) {
if (orderedIds == null) return;
int i = 0;
for (String id : orderedIds) {
T entity = finder.apply(id);
if (entity != null) {
applyOrder.accept(entity, i);
saver.accept(entity);
}
i++;
}
}
}

View File

@@ -0,0 +1,45 @@
package com.loremind.domain.shared.template;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Value Object decrivant le placement d'un bloc dans la grille du template
* (kernel partage).
* <p>
* Modele : grille responsive a 12 colonnes (facon page-builder), inspire de
* l'app "Lore" d'Amsel. Chaque bloc occupe un rectangle de la grille :
* <ul>
* <li>{@link #x} : colonne de depart, 0..11</li>
* <li>{@link #y} : ligne de depart, 0..n</li>
* <li>{@link #w} : largeur en colonnes, 1..12</li>
* <li>{@link #h} : hauteur en lignes, 1..n</li>
* </ul>
* <p>
* Tous les champs sont nullables : un {@code pos} absent (null) ou des
* coordonnees nulles signifient "auto-flow" — le bloc est empile a la suite
* des precedents, exactement comme le rendu historique en une seule colonne.
* C'est ce qui garantit la retrocompatibilite des templates existants (qui
* n'ont aucune donnee de placement) : ils continuent de s'afficher empiles.
* <p>
* Donnee purement presentationnelle : ignoree par l'IA, l'export campagne et
* l'export Foundry, qui ne lisent que l'ordre et les valeurs par nom de champ.
* <p>
* Entite pure du domaine : aucune dependance technique.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BlockPosition {
/** Colonne de depart dans la grille 12 colonnes (0..11). Null = auto-flow. */
private Integer x;
/** Ligne de depart (0..n). Null = auto-flow. */
private Integer y;
/** Largeur en colonnes (1..12). Null = pleine largeur par defaut. */
private Integer w;
/** Hauteur en lignes (1..n). Null = hauteur du contenu. */
private Integer h;
}

View File

@@ -17,12 +17,29 @@ import java.util.List;
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* Ignore pour les autres types.
* <p>
* {@link #id} est la cle STABLE du bloc : les valeurs des Pages s'ancrent
* dessus (et non sur {@link #name}), ce qui permet de renommer un bloc sans
* orpheliner son contenu. Pour les templates anterieurs (qui n'ont pas d'id),
* l'id est retro-rempli avec le nom courant a la lecture : comme les valeurs
* existantes sont deja rangees par nom, {@code id == name} au depart et rien
* n'a besoin d'etre migre.
* <p>
* {@link #pos} porte le placement du bloc dans la grille 12 colonnes du
* template (voir {@link BlockPosition}). Null = auto-flow empile (rendu
* historique).
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TemplateField {
/**
* Identifiant STABLE du bloc, cle d'ancrage des valeurs de Page.
* Retro-rempli avec {@link #name} pour les templates sans id. Immuable :
* survit aux renommages du bloc.
*/
private String id;
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
private String name;
/** Type du champ, pilote le rendu et la generation IA. */
@@ -37,14 +54,40 @@ public class TemplateField {
*/
private List<String> labels;
/**
* Chemin Foundry de ce champ (ex: {@code attributes.hp.value}) quand le template
* est calqué sur un système Foundry importé. Permet, a l'export, de construire un
* acteur typé en posant {@code system.<foundryPath> = valeur}. Null = non mappé.
*/
private String foundryPath;
/**
* Placement du bloc dans la grille 12 colonnes du template. Null = auto-flow
* (le bloc s'empile a la suite des precedents, comme le rendu historique).
*/
private BlockPosition pos;
/** Constructeur de retrocompat : type seul, layout/labels=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null, null);
this(name, type, null, null, null);
}
/** Constructeur de retrocompat : type + layout, labels=null. */
public TemplateField(String name, FieldType type, ImageLayout layout) {
this(name, type, layout, null);
this(name, type, layout, null, null);
}
/** Constructeur de retrocompat (4 args) : sans foundryPath. */
public TemplateField(String name, FieldType type, ImageLayout layout, List<String> labels) {
this(name, type, layout, labels, null);
}
/**
* Constructeur de retrocompat (5 args) : sans id ni pos. Conserve la
* signature publique historique (id retro-rempli plus tard, pos=null).
*/
public TemplateField(String name, FieldType type, ImageLayout layout, List<String> labels, String foundryPath) {
this(null, name, type, layout, labels, foundryPath, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */

View File

@@ -0,0 +1,95 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.ports.NarrativeAssistException;
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Adapter de sortie : étoffe les champs d'une entité narrative via le Brain (POST one-shot).
* Générique par {@code entityType}. Le Core envoie les champs AVEC leur libellé (source de
* vérité) ; double garde-fou : on ne retient que les clés autorisées, valeurs non vides.
*/
@Component
public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
private static final String GENERATE_PATH = "/generate/narrative-fields";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainNarrativeFieldClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
@SuppressWarnings("unchecked")
public List<ProposedField> assist(String entityType, String context, String instruction, List<FieldSpec> fields) {
List<Map<String, String>> fieldPayload = new ArrayList<>();
Set<String> allowed = new HashSet<>();
if (fields != null) {
for (FieldSpec f : fields) {
if (f == null || f.key() == null) continue;
allowed.add(f.key());
Map<String, String> fm = new LinkedHashMap<>();
fm.put("key", f.key());
fm.put("label", f.label() == null ? f.key() : f.label());
fieldPayload.add(fm);
}
}
Map<String, Object> req = new LinkedHashMap<>();
req.put("entity_type", entityType == null ? "" : entityType);
req.put("context", context == null ? "" : context);
req.put("instruction", instruction == null ? "" : instruction);
req.put("fields", fieldPayload);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
Map<String, Object> resp;
try {
resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class);
} catch (ResourceAccessException e) {
throw new NarrativeAssistException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new NarrativeAssistException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new NarrativeAssistException("Erreur inattendue lors de l'appel au Brain.", e);
}
if (resp == null) {
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
}
List<ProposedField> out = new ArrayList<>();
Object rawFields = resp.get("fields");
if (rawFields instanceof Map<?, ?> m) {
for (Map.Entry<?, ?> e : m.entrySet()) {
String key = e.getKey() == null ? null : e.getKey().toString();
if (key == null || !allowed.contains(key)) continue; // whitelist stricte
String value = e.getValue() == null ? null : e.getValue().toString();
if (value == null || value.isBlank()) continue; // pas de remplissage vide
out.add(new ProposedField(key, value.trim()));
}
}
return out;
}
}

View File

@@ -0,0 +1,91 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.SceneDraft;
import com.loremind.domain.campaigncontext.ports.NarrativeAssistException;
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Adapter de sortie : ébauche des scènes pour un chapitre via le Brain (POST one-shot).
* Calqué sur les autres clients Brain. Ne retient que les ébauches avec un titre non vide.
*/
@Component
public class BrainSceneDraftClient implements SceneDraftAssistant {
private static final String GENERATE_PATH = "/generate/scene-drafts";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainSceneDraftClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
@SuppressWarnings("unchecked")
public List<SceneDraft> draftScenes(String context, String instruction, int count) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("context", context == null ? "" : context);
req.put("instruction", instruction == null ? "" : instruction);
req.put("count", count);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
Map<String, Object> resp;
try {
resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class);
} catch (ResourceAccessException e) {
throw new NarrativeAssistException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new NarrativeAssistException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new NarrativeAssistException("Erreur inattendue lors de l'appel au Brain.", e);
}
if (resp == null) {
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
}
List<SceneDraft> out = new ArrayList<>();
Object rawScenes = resp.get("scenes");
if (rawScenes instanceof List<?> list) {
for (Object item : list) {
if (!(item instanceof Map<?, ?> m)) continue;
String name = asString(m.get("name"));
if (name == null || name.isBlank()) continue; // un titre est obligatoire
out.add(new SceneDraft(
name.trim(),
trimOrNull(asString(m.get("description"))),
trimOrNull(asString(m.get("playerNarration")))));
}
}
return out;
}
private static String asString(Object o) {
return o != null ? o.toString() : null;
}
private static String trimOrNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
}

View File

@@ -0,0 +1,64 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.playcontext.ports.SessionRecapAssistant;
import com.loremind.domain.playcontext.ports.SessionRecapException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Adapter de sortie : récap « précédemment… » d'une séance via le Brain (POST one-shot).
* Calqué sur les autres clients Brain.
*/
@Component
public class BrainSessionRecapClient implements SessionRecapAssistant {
private static final String GENERATE_PATH = "/generate/session-recap";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainSessionRecapClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
@SuppressWarnings("unchecked")
public String generateRecap(String transcript, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("transcript", transcript == null ? "" : transcript);
req.put("context", context == null ? "" : context);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
Map<String, Object> resp;
try {
resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class);
} catch (ResourceAccessException e) {
throw new SessionRecapException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new SessionRecapException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new SessionRecapException("Erreur inattendue lors de l'appel au Brain.", e);
}
Object recap = resp != null ? resp.get("recap") : null;
if (recap == null || recap.toString().isBlank()) {
throw new SessionRecapException("Le Brain a renvoyé un récap vide.");
}
return recap.toString().trim();
}
}

View File

@@ -7,6 +7,8 @@ import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.awt.SplashScreen;
/**
* En mode bureau (profil "local"), ouvre le navigateur par defaut sur
* l'application des que le serveur est pret. L'app n'ayant pas de fenetre
@@ -25,7 +27,26 @@ public class DesktopBrowserOpener {
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
// Ferme le splash natif (affiche par la JVM via -splash des le double-clic)
// juste avant d'ouvrir le navigateur : le relais visuel est assure.
closeSplash();
log.info("[Desktop] Application prete — ouverture du navigateur.");
DesktopSingleInstance.openAppInBrowser();
}
/**
* Ferme l'ecran de demarrage si l'app a ete lancee avec {@code -splash:...}
* (cas des installeurs jpackage). No-op silencieux sinon (lancement dev sans
* splash, ou environnement sans affichage).
*/
private void closeSplash() {
try {
SplashScreen splash = SplashScreen.getSplashScreen();
if (splash != null) {
splash.close();
}
} catch (Exception e) {
log.debug("[Desktop] Pas de splash a fermer : {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,53 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.lorecontext.ImageFraming;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.Map;
/**
* Convertit une {@code Map<String, Map<String, ImageFraming>>} en JSON et inversement.
* <p>
* Utilisé pour {@code Page.imageFraming} : pour chaque champ IMAGE (clé = id du bloc),
* un cadrage (pan/zoom) par image. Exemple :
* {"blk-illu": {"img-42": {"x":50.0,"y":30.0,"scale":1.4}}}
* <p>
* Tolérant aux propriétés inconnues (évolution de {@link ImageFraming}).
* Adaptateur technique pur : le domaine ignore ce converter.
*/
@Converter
public class ImageFramingMapJsonConverter
implements AttributeConverter<Map<String, Map<String, ImageFraming>>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private static final TypeReference<Map<String, Map<String, ImageFraming>>> TYPE_REF =
new TypeReference<>() {};
@Override
public String convertToDatabaseColumn(Map<String, Map<String, ImageFraming>> attribute) {
if (attribute == null || attribute.isEmpty()) return "{}";
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur serialisation Map<String, Map<String,ImageFraming>> -> JSON", e);
}
}
@Override
public Map<String, Map<String, ImageFraming>> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
try {
return MAPPER.readValue(dbData, TYPE_REF);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur deserialisation JSON -> Map<String, Map<String,ImageFraming>>", e);
}
}
}

View File

@@ -0,0 +1,67 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Convertit une {@code List<QuestNodeRef>} (VO du domaine) en JSON stocké en
* colonne TEXT, et inversement.
* <p>
* Même approche que {@code PrerequisiteListJsonConverter} : conversion manuelle
* map ↔ record pour garder le domaine pur (zéro annotation Jackson) et un format
* on-disk stable, indépendant des noms de classes Java.
* <p>
* Format JSON :
* [ {"nodeType": "CHAPTER", "nodeId": "42", "order": 0}, ... ]
*/
@Converter
public class QuestNodeListJsonConverter implements AttributeConverter<List<QuestNodeRef>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<QuestNodeRef> attribute) {
if (attribute == null || attribute.isEmpty()) return "[]";
try {
List<Map<String, Object>> raw = new ArrayList<>(attribute.size());
for (QuestNodeRef n : attribute) {
Map<String, Object> m = new HashMap<>();
m.put("nodeType", n.nodeType().name());
m.put("nodeId", n.nodeId());
m.put("order", n.order());
raw.add(m);
}
return MAPPER.writeValueAsString(raw);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<QuestNodeRef> → JSON", e);
}
}
@Override
public List<QuestNodeRef> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return new ArrayList<>();
try {
List<Map<String, Object>> raw = MAPPER.readValue(dbData, new TypeReference<>() {});
List<QuestNodeRef> result = new ArrayList<>(raw.size());
for (Map<String, Object> m : raw) {
NodeType type = NodeType.valueOf(String.valueOf(m.get("nodeType")));
String nodeId = String.valueOf(m.get("nodeId"));
Object o = m.get("order");
int order = (o instanceof Number num) ? num.intValue() : 0;
result.add(new QuestNodeRef(type, nodeId, order));
}
return result;
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → List<QuestNodeRef>", e);
}
}
}

View File

@@ -0,0 +1,45 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.SceneBattlemap;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
/**
* Convertit une List<SceneBattlemap> du domaine en chaîne JSON stockée en base,
* et inversement. Même pattern que SceneBranchListJsonConverter.
*/
@Converter
public class SceneBattlemapListJsonConverter implements AttributeConverter<List<SceneBattlemap>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<SceneBattlemap> attribute) {
if (attribute == null || attribute.isEmpty()) {
return "[]";
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<SceneBattlemap> → JSON", e);
}
}
@Override
public List<SceneBattlemap> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return Collections.emptyList();
}
try {
return MAPPER.readValue(dbData, new TypeReference<>() {
});
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → List<SceneBattlemap>", e);
}
}
}

View File

@@ -3,6 +3,7 @@ package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.shared.template.BlockPosition;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
@@ -61,9 +62,14 @@ public class TemplateFieldListJsonConverter
for (JsonNode item : root) {
if (item.isTextual()) {
// Format legacy : chaine simple, on suppose TEXT par defaut.
result.add(TemplateField.text(item.asText()));
// L'id stable est retro-rempli avec le nom (les valeurs de Page
// sont deja rangees par nom -> id == name, aucune migration).
String name = item.asText();
TemplateField legacy = TemplateField.text(name);
legacy.setId(name);
result.add(legacy);
} else if (item.isObject()) {
// Nouveau format : {name, type}
// Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?}
String name = item.path("name").asText(null);
String typeStr = item.path("type").asText("TEXT");
FieldType type;
@@ -95,8 +101,27 @@ public class TemplateFieldListJsonConverter
}
}
}
// foundryPath : lu via hasNonNull pour eviter le piege NullNode
// (asText() renverrait la chaine "null"). Historiquement omis a la
// relecture -> il etait perdu au save+reload : corrige ici.
String foundryPath = item.hasNonNull("foundryPath")
? item.get("foundryPath").asText() : null;
// id : explicite si present, sinon retro-rempli avec le nom.
String id = item.hasNonNull("id") ? item.get("id").asText() : null;
if (id == null || id.isBlank()) {
id = name;
}
BlockPosition pos = readPos(item.path("pos"));
if (name != null && !name.isBlank()) {
result.add(new TemplateField(name, type, layout, labels));
result.add(TemplateField.builder()
.id(id)
.name(name)
.type(type)
.layout(layout)
.labels(labels)
.foundryPath(foundryPath)
.pos(pos)
.build());
}
}
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
@@ -108,6 +133,31 @@ public class TemplateFieldListJsonConverter
}
}
/**
* Lit le placement {@code pos: {x, y, w, h}} d'un bloc. Renvoie null si le
* noeud n'est pas un objet ou si toutes les coordonnees sont absentes
* (-> auto-flow empile, comportement historique).
*/
private static BlockPosition readPos(JsonNode posNode) {
if (posNode == null || !posNode.isObject()) {
return null;
}
Integer x = intOrNull(posNode, "x");
Integer y = intOrNull(posNode, "y");
Integer w = intOrNull(posNode, "w");
Integer h = intOrNull(posNode, "h");
if (x == null && y == null && w == null && h == null) {
return null;
}
return new BlockPosition(x, y, w, h);
}
/** Lit un entier optionnel d'un noeud JSON ; null si absent ou non numerique. */
private static Integer intOrNull(JsonNode node, String field) {
JsonNode n = node.path(field);
return n.isNumber() ? n.asInt() : null;
}
// typeRef garde pour reference future si on veut deserialiser directement.
@SuppressWarnings("unused")
private static final TypeReference<List<TemplateField>> TYPE_REF =

View File

@@ -86,12 +86,6 @@ public class ArcJpaEntity {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images "cartes / plans". */
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -37,6 +37,9 @@ public class CampaignJpaEntity {
@Column(name = "arcs_count", nullable = false)
private int arcsCount;
@Column(name = "player_count", nullable = false)
private int playerCount;
/**
* ID du Lore associé (nullable).
* Pas de @ManyToOne / pas de FK : c'est une weak reference inter-contexte.
@@ -52,6 +55,13 @@ public class CampaignJpaEntity {
@Column(name = "game_system_id")
private String gameSystemId;
/**
* Positions personnalisées des nœuds du graphe de campagne (JSON opaque,
* possédé par le front). Null = disposition automatique.
*/
@Column(name = "graph_positions", columnDefinition = "TEXT")
private String graphPositions;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();

View File

@@ -1,7 +1,5 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
@@ -39,16 +37,6 @@ public class ChapterJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
/**
* Conditions de déblocage (combinées en ET). Sérialisées en JSON dans une colonne TEXT
* via {@link PrerequisiteListJsonConverter} (sealed type côté domaine).
* Donnée de SCÉNARIO — l'état réel est sur Playthrough.QuestProgression.
*/
@Column(name = "prerequisites", columnDefinition = "TEXT")
@Convert(converter = PrerequisiteListJsonConverter.class)
@Builder.Default
private List<Prerequisite> prerequisites = new ArrayList<>();
@Column
private String icon;
@@ -72,11 +60,6 @@ public class ChapterJpaEntity {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -0,0 +1,76 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.playcontext.ClockTrigger;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des Horloges (Clocks) en PostgreSQL.
* Adaptateur d'infrastructure — n'est PAS dans le domaine.
*/
@Entity
@Table(name = "clocks", indexes = @Index(name = "ix_clock_playthrough", columnList = "playthrough_id"))
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ClockJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "playthrough_id", nullable = false)
private Long playthroughId;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
private int segments;
@Column(nullable = false)
private int filled;
@Column(name = "\"order\"", nullable = false)
private int order;
/** Déclencheur d'avancement auto (co-MJ). Défaut NONE ; colonne dotée d'un default SQL (V16). */
@Enumerated(EnumType.STRING)
@Column(name = "trigger_type", length = 32)
@Builder.Default
private ClockTrigger triggerType = ClockTrigger.NONE;
@Column(name = "trigger_ref")
private String triggerRef;
/** Front (menace) auquel l'horloge appartient (null = libre). Weak reference, pas de FK. */
@Column(name = "front_id")
private Long frontId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -63,6 +63,15 @@ public class EnemyJpaEntity {
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
/** UUID de l'acteur de compendium Foundry d'origine (import bestiaire). Nullable. */
@Column(name = "foundry_ref", length = 512)
private String foundryRef;
/** Instantané figé des stats Foundry (aplati cle->valeur), JSON en TEXT. */
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "foundry_stats", columnDefinition = "TEXT")
private Map<String, String> foundryStats;
@Column(name = "\"order\"", nullable = false)
private int order;

View File

@@ -0,0 +1,56 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des Fronts en PostgreSQL.
* Adaptateur d'infrastructure — n'est PAS dans le domaine.
*/
@Entity
@Table(name = "fronts", indexes = @Index(name = "ix_front_playthrough", columnList = "playthrough_id"))
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FrontJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "playthrough_id", nullable = false)
private Long playthroughId;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -51,6 +51,10 @@ public class GameSystemJpaEntity {
@Column(name = "enemy_template", columnDefinition = "TEXT")
private List<TemplateField> enemyTemplate;
/** Type d'acteur Foundry pour les ennemis maison (import de structure). Nullable. */
@Column(name = "foundry_actor_type")
private String foundryActorType;
@Column
private String author;

View File

@@ -36,6 +36,10 @@ public class LoreNodeJpaEntity {
@Column(name = "lore_id", nullable = false)
private Long loreId;
/** Position parmi les dossiers frères. Colonne quotée : `order` est un mot-clé SQL. */
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -1,5 +1,7 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.lorecontext.ImageFraming;
import com.loremind.infrastructure.persistence.converter.ImageFramingMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
@@ -45,6 +47,10 @@ public class PageJpaEntity {
@Column(nullable = false)
private String title;
/** Position dans le dossier. Colonne quotée : `order` est un mot-clé SQL. */
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "values_json", columnDefinition = "TEXT")
@Convert(converter = StringMapJsonConverter.class)
private Map<String, String> values;
@@ -54,6 +60,11 @@ public class PageJpaEntity {
@Convert(converter = StringListMapJsonConverter.class)
private Map<String, List<String>> imageValues;
/** Cadrage (pan/zoom) des images : fieldKey → imageId → ImageFraming. JSON TEXT. */
@Column(name = "image_framing", columnDefinition = "TEXT")
@Convert(converter = ImageFramingMapJsonConverter.class)
private Map<String, Map<String, ImageFraming>> imageFraming;
/** Valeurs des champs KEY_VALUE_LIST : fieldName → (label → valeur). JSON TEXT. */
@Column(name = "key_value_values", columnDefinition = "TEXT")
@Convert(converter = StringMapMapJsonConverter.class)

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