Compare commits

...

71 Commits

Author SHA1 Message Date
504c4b7b6c Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
2026-06-03 15:44:48 +02:00
e3fc96a1bc Ajout d'un mode "jeu" (possibilité de lancer des sessions dans une campagne). Cela permet de faire de prendre des notes en live au cours d'une partie et d'avoir plusieurs outils sous la main pour aider le mj :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de parler à une IA pour règle de jeu ou élément de lore / campagne au cours d'une partie comme aide mémoire
- Onglet dédié aux personnages de la campagne
- Onglet dédié aux scènes
- Onglet avec dès pour ceux qui souhaitent ;

Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
2026-05-20 14:59:26 +02:00
bc05b1536d Correction du bug de switch entre lore / campagne et la sidebar qui ne s'actualise pas en conséquence. Ajout d'un test playwright pour éviter toute régression à l'avenir 2026-05-19 19:15:00 +02:00
0e350142de mise à jour vers 0.8.7-beta
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:45:56 +02:00
11cb708de9 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
Some checks failed
E2E Tests / e2e (push) Failing after 29s
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:36:00 +02:00
53343c0a75 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:25:54 +02:00
43f45d68a1 Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable 2026-05-19 18:05:17 +02:00
4bbc6333fc Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
dc908b8d93 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-05-19 14:06:57 +02:00
ae9455c244 Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
e346bb4b85 Changement du readme 2026-05-17 18:04:24 +02:00
a1c9e67013 Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Passage V0.8.3
2026-04-30 15:53:38 +02:00
b0edfb5aab Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
117334315f Refonte du système JDR + système de personnage joueurs / non joueurs :
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
8e0c1c1017 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
cb9eb14054 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
fd14e1e572 Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-28 19:12:09 +02:00
f3c445cfa1 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0 2026-04-28 19:04:11 +02:00
1a5a87629d Autre patch dockerfile
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-27 22:11:43 +02:00
e61bcd5dbb Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build 2026-04-27 21:56:04 +02:00
bc8fb30398 Patch dockerfile pour ne plus que le build plante 2026-04-27 21:43:13 +02:00
ec45104a80 Correction package-lock 2026-04-27 19:17:01 +02:00
3f8cdd177b Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément 2026-04-27 19:03:58 +02:00
4ed9104a53 Correction du soucis de mise à jour via l'application
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-27 16:19:56 +02:00
defbf2d1f7 Passage V0.7.0
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-27 15:51:13 +02:00
d2cf9f8c5c Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
2026-04-27 15:48:04 +02:00
a92e31b187 Mise à jour du readme d'accueil pour l'accès à la documentation 2026-04-27 08:27:38 +02:00
00627d1543 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 19:08:49 +02:00
47a8fc529d Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
c524516b2a Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 09:12:36 +02:00
6a4ae5c326 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-26 01:55:02 +02:00
4e01fdfa9c Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 01:45:39 +02:00
b60a790c70 Passage version 0.6.9
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 01:30:35 +02:00
147d0701db Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork. 2026-04-26 01:19:58 +02:00
98e54613ce Mise en place v0.6.8
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
c47f987cef Mise à jour de la conf pour être sur que le cache angular est bien refresh
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
7cd0a8253a Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-25 18:37:40 +02:00
7b189acee9 Ajout d'un .bat pour l'exécution du .ps1 2026-04-25 18:34:52 +02:00
08c91dd4c6 Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
dbf0b13189 Ajout d'un script pour installation automatique du produit
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

Passage version 0.6.6
2026-04-25 13:24:32 +02:00
aba02ca9cf Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
854110683b Correction d'un test unitaire
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
3ad8145572 Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
0d27ea71b0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e 2026-04-25 00:51:32 +02:00
f6b4e3ff82 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur 2026-04-25 00:45:04 +02:00
2a438b79ce Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus 2026-04-25 00:25:53 +02:00
6ab7f64909 Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
0ad1d09802 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
c6a228a48f Remplace docker SDK par appels HTTP directs (zero deps)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-24 07:39:49 +02:00
9eabd2abf8 Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
a23f82d635 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
280dced405 Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
987187943e Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
2e7d46cc63 Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
93deb73ade Version 0.6.1
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-23 14:36:09 +02:00
4f1b8fdd55 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
035a2553d1 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
5bbae9e130 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
5121e2d2e3 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
5f5b2ecb87 Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
ceabf53c0a Correction d'un test unitaire
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-22 13:38:48 +02:00
43e375e169 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-22 13:33:47 +02:00
68fd1698b3 Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
735dd2eee0 Corrections visuel ; optimisation du chargement des pages (préchargement anticité, sinon temps de latence chaque fois qu'on visite un type de page une première fois) 2026-04-22 13:17:05 +02:00
5ea3a5097f Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
2026-04-22 11:58:50 +02:00
63a3c21bd2 Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
49a82d05f7 Chat persistant pour la partie lore et la partie campagne pour chaque page / scène.....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m23s
Build & Push Images / build (web) (push) Successful in 1m26s
Correction du carroussel
Passage en v0.4.0
Correction du docker compose pour tout le temps utiliser le bon port que ce soit prod ou dev
2026-04-21 23:35:43 +02:00
b0fe8de708 Passage v 0.3.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-21 16:56:57 +02:00
71449bee1b Amélioration de l'UI : meilleur affichage des images que ce soit dans la partie lore ou la partie campagne (partie campagne : visualisation scrapbooking). Possibilité de réordonner les champs dans les templates...
Passage v0.3.0
2026-04-21 16:56:27 +02:00
1e34f7f954 Mise en place de tests unitaires et de jacoco pour la partie core 2026-04-21 15:19:45 +02:00
e185dabc45 Mise à jour de l'install.md 2026-04-21 15:01:32 +02:00
565 changed files with 41947 additions and 2948 deletions

View File

@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
# 1min.ai (si LLM_PROVIDER=onemin) # 1min.ai (si LLM_PROVIDER=onemin)
ONEMIN_API_KEY= ONEMIN_API_KEY=
ONEMIN_MODEL=gpt-4o-mini ONEMIN_MODEL=gpt-4o-mini
# --- Mises a jour automatiques (Watchtower) ------------------------------
# Watchtower verifie les nouvelles versions de core/brain/web et permet
# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
# MinIO sont exclus volontairement.
#
# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
# COMPOSE_PROFILES=autoupdate
# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
# WATCHTOWER_SCHEDULE=0 0 4 * * *
# TZ=Europe/Paris

95
.gitea/workflows/e2e.yml Normal file
View File

@@ -0,0 +1,95 @@
name: E2E Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Create .env for stack
run: |
cat > .env <<'EOF'
POSTGRES_PASSWORD=ci-postgres-pass
BRAIN_INTERNAL_SECRET=ci-brain-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD=ci-admin-pass
WEB_PORT=8081
LLM_PROVIDER=ollama
EOF
- name: Build & start stack
run: |
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
- name: Attach runner to compose network
run: |
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
if [ -z "$NET" ]; then
echo "Compose network not found" >&2
docker network ls
exit 1
fi
echo "Connecting $(hostname) to network $NET"
docker network connect "$NET" "$(hostname)"
- name: Wait for web to be ready
run: |
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
- name: Install web deps
working-directory: web
run: npm ci
- name: Work around runner clock skew for apt
run: |
sudo tee /etc/apt/apt.conf.d/99no-check-valid-until >/dev/null <<'EOF'
Acquire::Check-Valid-Until "false";
Acquire::Check-Date "false";
EOF
- name: Install Playwright browsers
working-directory: web
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: web
env:
E2E_BASE_URL: http://web
CI: 'true'
run: npm run e2e
- name: Dump container logs on failure
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: web/playwright-report/
retention-days: 14
- name: Stop stack
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v

View File

@@ -6,8 +6,10 @@ on:
- 'v*' - 'v*'
env: env:
REGISTRY: git.igmlcreation.fr GITEA_REGISTRY: git.igmlcreation.fr
REGISTRY_USER: ietm64 GITEA_REGISTRY_USER: ietm64
GHCR_REGISTRY: ghcr.io
GHCR_NAMESPACE: igmlcreation
jobs: jobs:
build: build:
@@ -26,19 +28,114 @@ jobs:
- name: Login to Gitea Registry - name: Login to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.REGISTRY_USER }} username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }} password: ${{ secrets.DOCKER_PAT }}
- name: Extract version # Login to GHCR (GitHub Container Registry) pour distribuer les images
id: meta # publiquement aux utilisateurs finaux. Reputation domaine plus elevee
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT # que git.igmlcreation.fr (mieux pour les antivirus / SmartScreen).
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build & push ${{ matrix.component }} # Detection du canal :
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
# loremind-beta-<component> ; backup Gitea avec :version)
- name: Extract version & channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
# Build & push canal STABLE
- name: Build & push ${{ matrix.component }} (stable)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./${{ matrix.component }} context: ./${{ matrix.component }}
push: true push: true
tags: | tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest ${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }} ${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Build & push canal BETA
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
# via le relais Patreon aux tiers Compagnon).
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
# pas faire upgrader les installs branchees sur Gitea).
- name: Build & push ${{ matrix.component }} (beta)
if: steps.meta.outputs.channel == 'beta'
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Job separe pour le sidecar `switcher`.
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
# donc uniquement sur les releases stables — pas la peine de re-publier
# une variante beta du switcher, c'est une infrastructure neutre.
build-switcher:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Detect channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
- name: Login to Gitea Registry
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Login to GHCR
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build & push switcher (stable only)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5
with:
context: ./switcher
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}

18
.gitignore vendored
View File

@@ -7,6 +7,11 @@
brain/data/settings.json brain/data/settings.json
*.key *.key
*.pem *.pem
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
# publique par construction). Sans cette exception, le module licensing
# est silencieusement desactive dans les builds CI.
!core/src/main/resources/licensing/jwt-public-key.pem
# ============================================================================ # ============================================================================
# Java / Spring Boot / Maven # Java / Spring Boot / Maven
@@ -53,6 +58,12 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
coverage/ coverage/
# Playwright (E2E)
web/test-results/
web/playwright-report/
web/blob-report/
web/playwright/.cache/
# ============================================================================ # ============================================================================
# IDE / Editeurs # IDE / Editeurs
# ============================================================================ # ============================================================================
@@ -85,8 +96,15 @@ Thumbs.db
# Documentation hors-code (conservee hors du repo) # Documentation hors-code (conservee hors du repo)
# ============================================================================ # ============================================================================
docs/ docs/
loremind-docs/
# ============================================================================ # ============================================================================
# Docker Compose override (dev uniquement, non-distribue aux end users) # Docker Compose override (dev uniquement, non-distribue aux end users)
# ============================================================================ # ============================================================================
docker-compose.override.yml docker-compose.override.yml
# ============================================================================
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
# ============================================================================
relay/
scripts/bump-version.mjs

View File

@@ -1,75 +0,0 @@
# Installation de LoreMindMJ
## Prerequis
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) / [Mac](https://www.docker.com/products/docker-desktop/))
ou **Docker Engine + Compose v2** (Linux).
- (Optionnel) **[Ollama](https://ollama.com/)** si tu veux un LLM local.
Sinon, une cle API [1min.ai](https://1min.ai) suffit.
## Installation (5 minutes)
1. Telecharge `docker-compose.yml` et `.env.example` depuis la [derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) dans un dossier a toi.
2. Renomme `.env.example` en `.env` et ouvre-le dans un editeur texte. Trois variables sont **obligatoires** :
- `POSTGRES_PASSWORD` : mot de passe de la base (choisis-en un).
- `ADMIN_PASSWORD` : protege l'ecran Parametres de l'appli. Tu le taperas dans une popup du navigateur.
- `BRAIN_INTERNAL_SECRET` : secret interne partage entre les services. Genere une valeur aleatoire :
```
openssl rand -hex 32
```
(Sous Windows sans openssl : utilise un generateur en ligne type "random hex string 64 chars".)
Sans ces trois variables, `docker compose up` refusera de demarrer — c'est volontaire pour eviter un deploiement non-securise par defaut.
3. Dans un terminal, place-toi dans le dossier et lance :
```
docker compose up -d
```
Le premier demarrage telecharge les images (~500 Mo) et initialise la base. Compte 1-2 minutes.
4. Ouvre http://localhost:8081 dans ton navigateur. Bon jeu !
## Mise a jour
```
docker compose pull
docker compose up -d
```
Les donnees (base Postgres, images MinIO, settings Brain) sont dans des volumes Docker et survivent aux mises a jour.
## LLM : Ollama ou 1min.ai ?
**Ollama (local, gratuit)** — Edite `.env` :
```
LLM_PROVIDER=ollama
LLM_MODEL=gemma4:26b
```
Telecharge le modele au prealable : `ollama pull gemma4:26b`.
**1min.ai (cloud, paye)** — Edite `.env` :
```
LLM_PROVIDER=onemin
ONEMIN_API_KEY=sk-...
ONEMIN_MODEL=open-mistral-nemo
```
Tu peux aussi changer tout ca a chaud depuis l'ecran Parametres de l'appli.
## Problemes frequents
- **Port 8081 deja pris** : change `WEB_PORT=8082` (ou autre) dans `.env`.
- **Ollama injoignable** : verifie qu'Ollama tourne (`ollama serve`) et que le modele est bien telecharge.
- **"set ADMIN_PASSWORD in .env" / "set BRAIN_INTERNAL_SECRET in .env"** au lancement : tu as oublie une des variables obligatoires de l'etape 2.
- **Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres** : c'est normal. Utilise `admin` (ou ce que tu as mis dans `ADMIN_USERNAME`) et ton `ADMIN_PASSWORD`.
- **Tout casser et repartir de zero** : `docker compose down -v` supprime les volumes (attention, perte de donnees).
## Sauvegarde
Les donnees sont dans les volumes Docker : `loremindmj_postgres-data`, `loremindmj_minio-data`, `loremindmj_brain-data`.
Sauvegarde rapide de la base :
```
docker compose exec postgres pg_dump -U loremind loremind > backup.sql
```

View File

@@ -1,70 +1,66 @@
# LoreMind # LoreMind
Application web d'aide aux Maîtres de Jeu (JDR) pour centraliser la gestion de l'univers (Lore) et le suivi des campagnes, avec un moteur IA intégré pour générer du contenu structuré. > Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages avec un assistant IA contextuel.
## Fonctionnalités [![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/)
[![Démo](https://img.shields.io/badge/d%C3%A9mo-en%20ligne-orange)](https://loremind-demo.igmlcreation.fr/)
[![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)
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers ## Découvrir LoreMind en vidéo
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
- Export vers FoundryVTT : Transfert structuré des données vers votre VTT préféré (en développement)
## Captures d'écran [![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
### Page d'accueil ![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
![Accueil](docs/maquettes/général/Accueil.png)
### Recherche ## Ce que ça fait
![Recherche](docs/maquettes/général/Ecran de recherche.png)
## Stack Technologique 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.
LoreMind utilise une architecture distribuée pour séparer les responsabilités : ### Lore
- **Frontend** : Angular (Interface utilisateur, affichage du lore, formulaires de templates) 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.
- **Backend Core** : Java (Spring Boot) - Orchestration, persistance, export VTT
- **Backend IA** : Python - Traitement des LLM et génération de contenu
- **Base de données** : PostgreSQL avec JSONB pour les templates flexibles
## Architecture ### Game System
### Backend Java (Domain-Driven Design & Hexagonal) 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.
Le Backend Core respecte strictement : ### Campaign
- **Domain-Driven Design (DDD)** : Séparation en Bounded Contexts autonomes
- **Architecture Hexagonale (Ports et Adaptateurs)** : Domaine pur sans dépendances techniques
#### Bounded Contexts 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.
- **LoreContext** : Gestion de l'encyclopédie de l'univers
- **CampaignContext** : Suivi des sessions et chronologie
- **GenerationContext** : Gestion des requêtes IA et templates
#### Couches ### Assistant IA
- **Domaine (Core)** : Entités métier pures et interfaces (Ports)
- **Application** : Orchestration des flux (Use Cases)
- **Infrastructure** : Implémentation technique (Adapters)
## Installation 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.
Pour installer LoreMind chez vous (Docker requis), suivez le guide **[INSTALL.md](INSTALL.md)** — 3 étapes, 5 minutes chrono : 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.
1. Télécharger `docker-compose.yml` + `.env.example` depuis la [dernière release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) ## Documentation
2. Renommer `.env.example``.env` et changer `POSTGRES_PASSWORD`
3. `docker compose up -d` → ouvrir http://localhost:8081
Mise à jour : `docker compose pull && docker compose up -d`. Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
## Développement (contributeurs) ## Démo en ligne
Pour builder les images localement depuis les sources : Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
```bash Quelques limites à connaître :
git clone https://git.igmlcreation.fr/ietm64/LoreMindMJ.git - 10 utilisateurs maximum simultanés (instances isolées)
cd LoreMindMJ - Session limitée à 20 minutes avant réinitialisation
# Créer un docker-compose.override.yml local (voir docs de contrib) - Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
docker compose up -d --build
```
## License ## Soutenir le projet
[À définir] 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)**.
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.

View File

@@ -20,10 +20,16 @@ from app.domain.models import (
CampaignStructuralContext, CampaignStructuralContext,
ChatMessage, ChatMessage,
ChapterSummary, ChapterSummary,
CharacterSummary,
NpcSummary,
GameSystemContext,
JournalEntrySummary,
LoreStructuralContext, LoreStructuralContext,
NarrativeEntityContext, NarrativeEntityContext,
PageContext, PageContext,
PageSummary, PageSummary,
QuestSummary,
SessionContext,
) )
from app.domain.ports import LLMChatProvider from app.domain.ports import LLMChatProvider
@@ -63,16 +69,18 @@ class ChatUseCase:
page_context: PageContext | None = None, page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None, campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user. """Streame les tokens de la réponse assistant pour le dernier message user.
Les 4 contextes sont tous optionnels, mais au moins l'un des deux Les contextes sont tous optionnels, mais au moins l'un des deux
"niveaux haut" (lore_context ou campaign_context) doit être fourni "niveaux haut" (lore_context ou campaign_context) doit être fourni
pour que le prompt ait du sens. Le controller (main.py) applique pour que le prompt ait du sens. Le controller (main.py) applique
cette règle à la frontière HTTP. cette règle à la frontière HTTP.
""" """
system_prompt = self._build_system_prompt( system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
) )
async for token in self._llm.stream_chat( async for token in self._llm.stream_chat(
messages, messages,
@@ -81,6 +89,22 @@ class ChatUseCase:
): ):
yield token yield token
def build_system_prompt(
self,
lore_context: LoreStructuralContext | None = None,
page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None,
) -> str:
"""Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte).
"""
return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
)
# --- Construction du system prompt -------------------------------------- # --- Construction du system prompt --------------------------------------
def _build_system_prompt( def _build_system_prompt(
@@ -89,16 +113,22 @@ class ChatUseCase:
page: PageContext | None, page: PageContext | None,
campaign: CampaignStructuralContext | None, campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None, narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None,
session: SessionContext | None = None,
) -> str: ) -> str:
sections = [_BASE_SYSTEM] sections = [_BASE_SYSTEM]
if lore is not None: if lore is not None:
sections.append(self._format_lore(lore)) sections.append(self._format_lore(lore))
if campaign is not None: if campaign is not None:
sections.append(self._format_campaign(campaign, lore_present=lore is not None)) sections.append(self._format_campaign(campaign, lore_present=lore is not None))
if game_system is not None:
sections.append(self._format_game_system(game_system))
if page is not None: if page is not None:
sections.append(self._format_page(page)) sections.append(self._format_page(page))
if narrative is not None: if narrative is not None:
sections.append(self._format_narrative_entity(narrative)) sections.append(self._format_narrative_entity(narrative))
if session is not None:
sections.append(self._format_session(session))
return "\n\n".join(sections) return "\n\n".join(sections)
# --- Blocs Lore --------------------------------------------------------- # --- Blocs Lore ---------------------------------------------------------
@@ -176,14 +206,69 @@ class ChatUseCase:
if lore_present if lore_present
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)" else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
) )
characters_block = ChatUseCase._format_characters(ctx.characters)
npcs_block = ChatUseCase._format_npcs(ctx.npcs)
return ( return (
"--- CAMPAGNE COURANTE ---\n" "--- CAMPAGNE COURANTE ---\n"
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n" f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
f"{characters_block}"
f"{npcs_block}\n"
"Structure narrative (les flèches → indiquent des transitions de scène " "Structure narrative (les flèches → indiquent des transitions de scène "
"déclenchées par un choix des joueurs) :\n" "déclenchées par un choix des joueurs) :\n"
f"{arcs_block}" f"{arcs_block}"
) )
@staticmethod
def _format_characters(characters: list[CharacterSummary]) -> str:
"""Bloc PJ — liste nom + snippet. Rappel anti-hallucination IA.
Si la campagne n'a aucun PJ, on le signale explicitement : l'IA ne
doit pas inventer "les héros" ou leurs noms dans ses suggestions.
"""
if not characters:
return (
"\nPersonnages joueurs : aucune fiche pour l'instant. Ne suppose "
"ni noms ni classes pour les PJ tant que le MJ ne les a pas créés.\n"
)
lines = ["\nPersonnages joueurs (PJ) :"]
for c in characters:
if c.snippet:
lines.append(f"- **{c.name}** — {c.snippet}")
else:
lines.append(f"- **{c.name}** (fiche vide)")
lines.append(
"Pour une fiche complète (stats, backstory), n'invente rien : "
"demande au MJ d'ouvrir l'éditeur du PJ pour te donner les détails."
)
return "\n".join(lines) + "\n"
@staticmethod
def _format_npcs(npcs: list[NpcSummary]) -> str:
"""Bloc PNJ — symétrique aux PJ avec sa propre instruction anti-halluci.
Distinction importante : pour les PNJ, l'IA est ENCOURAGÉE à proposer de
nouveaux PNJ (création créative = OK). En revanche, elle ne doit pas
référencer comme existant un PNJ qui n'est pas dans la liste ci-dessous.
"""
if not npcs:
return (
"\nPersonnages non-joueurs (PNJ) : aucun défini pour l'instant. "
"Tu peux librement proposer de nouveaux PNJ au MJ, mais ne "
"fais pas comme s'ils existaient déjà dans la campagne.\n"
)
lines = ["\nPersonnages non-joueurs (PNJ) connus :"]
for n in npcs:
if n.snippet:
lines.append(f"- **{n.name}** — {n.snippet}")
else:
lines.append(f"- **{n.name}** (fiche vide)")
lines.append(
"Pour une fiche complète d'un PNJ existant (apparence, motivations), "
"n'invente rien : demande au MJ d'ouvrir l'éditeur du PNJ. Tu peux "
"en revanche proposer librement de NOUVEAUX PNJ."
)
return "\n".join(lines) + "\n"
@staticmethod @staticmethod
def _format_arcs(arcs: list[ArcSummary]) -> str: def _format_arcs(arcs: list[ArcSummary]) -> str:
if not arcs: if not arcs:
@@ -211,7 +296,8 @@ class ChatUseCase:
else: else:
for scene in chapter.scenes: for scene in chapter.scenes:
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count) sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
block.append(f" - {scene.name} (scène){sc_hint}") scene_kind = " (lieu explorable)" if scene.rooms else " (scène)"
block.append(f" - {scene.name}{scene_kind}{sc_hint}")
if scene.description: if scene.description:
block.append(f" Description : {scene.description}") block.append(f" Description : {scene.description}")
for br in scene.branches: for br in scene.branches:
@@ -219,6 +305,19 @@ class ChatUseCase:
block.append( block.append(
f'"{br.label}" vers {br.target_scene_name}{cond}' f'"{br.label}" vers {br.target_scene_name}{cond}'
) )
# Pièces du lieu explorable (mode donjon)
for room in scene.rooms:
floor = f" [étage {room.floor}]" if room.floor is not None else ""
block.append(f"{room.name}{floor}")
if room.description:
block.append(f" {room.description}")
if room.enemies:
block.append(f" Ennemis : {room.enemies}")
for rb in room.branches:
cond = f" (si : {rb.condition})" if rb.condition else ""
block.append(
f'"{rb.label}" vers {rb.target_room_name}{cond}'
)
return block return block
@staticmethod @staticmethod
@@ -234,12 +333,182 @@ class ChatUseCase:
noun = "illustration" if count == 1 else "illustrations" noun = "illustration" if count == 1 else "illustrations"
return f" [{count} {noun}]" return f" [{count} {noun}]"
# --- Bloc Système de JDR ------------------------------------------------
@staticmethod
def _format_game_system(gs: GameSystemContext) -> str:
"""Bloc des règles du système de JDR de la campagne.
Les sections ont été filtrées côté Core selon l'intent (combat,
classes, lore...). Si aucune section n'a matché, on affiche juste
le nom du système comme rappel de cadre.
"""
desc = f"\nDescription : {gs.system_description}" if gs.system_description else ""
if not gs.sections:
return (
"--- SYSTÈME DE JDR ---\n"
f"Nom : {gs.system_name}{desc}\n"
"(Aucune section de règles pertinente pour ce type de génération — "
"reste cohérent avec l'univers et les conventions du système.)"
)
sections_block = "\n\n".join(
f"### {title}\n{content}" for title, content in gs.sections.items()
)
return (
"--- SYSTÈME DE JDR ---\n"
f"Nom : {gs.system_name}{desc}\n\n"
"Respecte scrupuleusement les règles et conventions ci-dessous quand "
"tu proposes des stats, classes, rencontres, mécaniques ou éléments "
"d'ambiance. Les noms propres (classes, sorts, monstres) doivent "
"venir de ces règles — n'en invente pas d'autres.\n\n"
f"{sections_block}"
)
# --- Bloc Session de jeu (Play Context) ---------------------------------
_ENTRY_TYPE_LABELS = {
"NOTE": "Note du MJ",
"EVENT": "Évènement",
"DICE_ROLL": "Jet de dés",
"PLAYER_ACTION": "Action joueur",
}
@staticmethod
def _format_session(sc: SessionContext) -> str:
"""Bloc journal de la session en cours + résumé des sessions précédentes.
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
rappeler un évènement antérieur, ou rebondir sur une action joueur.
Pour les sessions PRÉCÉDENTES de la même campagne, on ne remonte que
les EVENTs (les moments marquants) pour préserver le contexte LLM.
"""
status = "EN COURS" if sc.active else "TERMINÉE"
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
previous_block = ChatUseCase._format_previous_events(sc.previous_events)
hub_block = ChatUseCase._format_hub_status(sc)
if not sc.entries:
current_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
else:
lines: list[str] = []
for e in sc.entries:
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
content = e.content.replace("\n", "\n ")
lines.append(f"- {label}{ts} : {content}")
current_block = "\n".join(lines)
return (
"--- SESSION DE JEU EN COURS ---\n"
f"Nom : {sc.session_name}\n"
f"Statut : {status}{started}\n"
f"{hub_block}"
f"{previous_block}"
"\nJournal chronologique de la session courante (du plus ancien au plus récent) :\n"
f"{current_block}\n\n"
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
"- Tenir compte des évènements déjà capturés (sessions précédentes + journal courant).\n"
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
"qui s'enchaînent à ce qui vient de se passer.\n"
"- Éviter les longs développements : le MJ est en train d'animer une partie, "
"il a besoin d'idées immédiatement actionnables."
)
@staticmethod
def _format_hub_status(sc: SessionContext) -> str:
"""Bloc Hub : quêtes ouvertes + flags actifs.
Vide si la campagne n'a aucun Arc HUB (toutes les listes vides côté Core).
Les quêtes LOCKED apparaissent par leur TITRE uniquement : l'IA sait
qu'elles existent (utile pour les teasers, les rumeurs en jeu) mais ne
peut pas spoiler leurs détails.
"""
if (not sc.available_quests
and not sc.in_progress_quests
and not sc.locked_quest_titles
and not sc.active_flags):
return ""
lines = ["", "État du Hub (quêtes parallèles et faits narratifs) :"]
if sc.in_progress_quests:
lines.append(" Quêtes en cours :")
lines.extend(ChatUseCase._format_quest_lines(sc.in_progress_quests))
if sc.available_quests:
lines.append(" Quêtes disponibles (non démarrées, prêtes à être lancées) :")
lines.extend(ChatUseCase._format_quest_lines(sc.available_quests))
if sc.locked_quest_titles:
titles = ", ".join(f'"{t}"' for t in sc.locked_quest_titles)
lines.append(
" Quêtes encore verrouillées (existent mais non accessibles — "
f"tu peux y faire allusion sous forme de rumeurs sans spoiler leur contenu) : {titles}"
)
if sc.active_flags:
lines.append(
" Faits actifs : " + ", ".join(f"`{f}`" for f in sc.active_flags)
)
lines.append(
" Conseille des actions cohérentes avec ces quêtes ouvertes. Ne fais "
"PAS comme si une quête verrouillée était déjà accessible aux PJ."
)
lines.append("") # séparateur visuel avant le récap des sessions précédentes
return "\n".join(lines)
@staticmethod
def _format_quest_lines(quests: list[QuestSummary]) -> list[str]:
"""Sérialise une liste de QuestSummary en lignes indentées."""
out: list[str] = []
for q in quests:
arc = f" [arc : {q.arc_name}]" if q.arc_name else ""
out.append(f" - {q.name}{arc}")
if q.description:
out.append(f" Synopsis : {q.description}")
return out
@staticmethod
def _format_previous_events(events: list[JournalEntrySummary]) -> str:
"""Bloc "Story so far" : EVENTs marquants des sessions antérieures.
Vide si la campagne en est à sa première session. On groupe par
session source pour aider l'IA à situer chaque évènement temporellement.
"""
if not events:
return ""
# Groupement par session source en préservant l'ordre d'apparition.
grouped: dict[str, list[JournalEntrySummary]] = {}
for e in events:
key = e.source_session_name or "(session inconnue)"
grouped.setdefault(key, []).append(e)
lines = ["\nRécapitulatif des sessions précédentes (évènements marquants uniquement) :"]
for session_name, items in grouped.items():
lines.append(f"{session_name} :")
for e in items:
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
content = e.content.replace("\n", "\n ")
lines.append(f" - {content}{ts}")
lines.append("") # ligne vide avant le bloc journal courant
return "\n".join(lines) + "\n"
@staticmethod @staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str: def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene.""" """Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get( type_label = {
ne.entity_type.lower(), ne.entity_type.upper() "arc": "ARC",
) "chapter": "CHAPITRE",
"scene": "SCÈNE",
"character": "FICHE DE PERSONNAGE (PJ)",
"npc": "FICHE DE PNJ",
}.get(ne.entity_type.lower(), ne.entity_type.upper())
if ne.fields: if ne.fields:
fields_block = "\n".join( fields_block = "\n".join(
f'- "{key}" : {value or "(vide)"}' f'- "{key}" : {value or "(vide)"}'

View File

@@ -122,9 +122,29 @@ class SceneBranchHint:
condition: str | None = None condition: str | None = None
@dataclass(frozen=True)
class RoomBranchHint:
"""Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
label: str
target_room_name: str
condition: str | None = None
@dataclass(frozen=True)
class RoomSummary:
"""Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHint] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
class SceneSummary: class SceneSummary:
"""Résumé d'une scène : nom + description courte + illustrations + branches.""" """Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
name: str name: str
description: str | None description: str | None
@@ -133,6 +153,8 @@ class SceneSummary:
illustration_count: int = 0 illustration_count: int = 0
# Connexions narratives sortantes (livre dont vous etes le heros). # Connexions narratives sortantes (livre dont vous etes le heros).
branches: list[SceneBranchHint] = field(default_factory=list) branches: list[SceneBranchHint] = field(default_factory=list)
# Pièces du lieu explorable (vide = scène classique).
rooms: list[RoomSummary] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -169,6 +191,34 @@ class CampaignStructuralContext:
campaign_name: str campaign_name: str
campaign_description: str | None campaign_description: str | None
arcs: list[ArcSummary] arcs: list[ArcSummary]
characters: list["CharacterSummary"] = field(default_factory=list)
npcs: list["NpcSummary"] = field(default_factory=list)
@dataclass(frozen=True)
class CharacterSummary:
"""Résumé d'un PJ : nom + snippet court extrait du markdown de la fiche.
La fiche complète n'est JAMAIS dans ce résumé — elle n'arrive que si le PJ
est l'entité focus (via NarrativeEntityContext entity_type="character").
Ça plafonne le coût token à ~40 tokens/PJ quel que soit le détail des fiches.
"""
name: str
snippet: str
@dataclass(frozen=True)
class NpcSummary:
"""Résumé d'un PNJ : symétrique à CharacterSummary.
Permet à l'IA de connaître les PNJ d'une campagne (nom + snippet) sans
injecter leurs fiches complètes. Évolution prévue : entity_type="npc"
pour focus sur la fiche complète.
"""
name: str
snippet: str
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -184,3 +234,70 @@ class NarrativeEntityContext:
entity_type: str entity_type: str
title: str title: str
fields: dict[str, str] fields: dict[str, str]
@dataclass(frozen=True)
class GameSystemContext:
"""Règles d'un système de JDR (D&D, Nimble, homebrew...) injectées
dans le system prompt pour que l'IA respecte les mécaniques du jeu.
Les sections ont été présélectionnées côté Core selon l'intent
(SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions,
GENERIC → toutes). Indexées par titre H2 original.
Campagne uniquement au MVP : jamais présent sur un chat Lore.
"""
system_name: str
system_description: str | None
sections: dict[str, str]
@dataclass(frozen=True)
class JournalEntrySummary:
"""Une entrée du journal d'une Session.
`source_session_name` n'est renseigné que pour les entrées issues de
sessions précédentes (option 3 : continuité narrative entre séances).
"""
type: str
content: str
occurred_at: str | None
source_session_name: str | None = None
@dataclass(frozen=True)
class QuestSummary:
"""Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
dans available_quests / in_progress_quests / locked_quest_titles.
"""
name: str
arc_name: str
description: str | None = None
@dataclass(frozen=True)
class SessionContext:
"""Contexte d'une Session de jeu en cours (Play Context).
Combine plusieurs niveaux :
- `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
- `previous_events` : EVENTs marquants des sessions précédentes (continuité)
- `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
- `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
- `active_flags` : noms des flags de campagne actuellement à true
"""
session_name: str
active: bool
started_at: str | None
entries: list[JournalEntrySummary]
previous_events: list[JournalEntrySummary]
available_quests: list[QuestSummary] = field(default_factory=list)
in_progress_quests: list[QuestSummary] = field(default_factory=list)
locked_quest_titles: list[str] = field(default_factory=list)
active_flags: list[str] = field(default_factory=list)

View File

@@ -61,7 +61,16 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:
response = await client.post(url, json=payload) response = await client.post(url, json=payload)
response.raise_for_status() if response.status_code >= 400:
body = response.text
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise LLMProviderError( raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}" f"Erreur lors de l'appel à Ollama : {exc}"
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:
async with client.stream("POST", url, json=payload) as response: async with client.stream("POST", url, json=payload) as response:
response.raise_for_status() if response.status_code >= 400:
# On lit le body d'erreur pour le remonter a l'utilisateur,
# sinon on ne voit que "500 Internal Server Error" sans
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
# num_ctx trop grand pour la VRAM, etc.).
body = (await response.aread()).decode("utf-8", errors="replace")
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
async for line in response.aiter_lines(): async for line in response.aiter_lines():
if not line.strip(): if not line.strip():
continue continue

View File

@@ -9,6 +9,7 @@ from typing import Annotated, AsyncIterator, Literal
import hmac import hmac
import httpx import httpx
import tiktoken
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -21,14 +22,22 @@ from app.domain.models import (
ArcSummary, ArcSummary,
CampaignStructuralContext, CampaignStructuralContext,
ChapterSummary, ChapterSummary,
CharacterSummary,
NpcSummary,
ChatMessage, ChatMessage,
GameSystemContext,
JournalEntrySummary,
LoreStructuralContext, LoreStructuralContext,
NarrativeEntityContext, NarrativeEntityContext,
PageContext, PageContext,
PageGenerationContext, PageGenerationContext,
PageSummary, PageSummary,
QuestSummary,
RoomBranchHint,
RoomSummary,
SceneBranchHint, SceneBranchHint,
SceneSummary, SceneSummary,
SessionContext,
) )
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider from app.infrastructure.ollama_adapter import OllamaLLMProvider
@@ -37,10 +46,27 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.2.0", version="0.9.1-beta",
) )
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
# suffisant pour une jauge visuelle à l'utilisateur.
_TOKEN_ENCODER: tiktoken.Encoding | None = None
def _count_tokens(text: str | None) -> int:
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
if not text:
return 0
global _TOKEN_ENCODER
if _TOKEN_ENCODER is None:
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
return len(_TOKEN_ENCODER.encode(text))
# Chemins exemptes d'auth inter-service : healthcheck docker + introspection # Chemins exemptes d'auth inter-service : healthcheck docker + introspection
# FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain # FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain
# n'est pas expose en dehors du reseau interne donc pas un risque). # n'est pas expose en dehors du reseau interne donc pas un risque).
@@ -148,6 +174,24 @@ class SceneBranchHintDTO(BaseModel):
condition: str | None = None condition: str | None = None
class RoomBranchHintDTO(BaseModel):
"""Sortie d'une pièce vers une autre pièce du même lieu (donjon)."""
label: str
target_room_name: str
condition: str | None = None
class RoomSummaryDTO(BaseModel):
"""Pièce d'un lieu explorable. Omise par le Core si la scène est classique."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHintDTO] = Field(default_factory=list)
class SceneSummaryDTO(BaseModel): class SceneSummaryDTO(BaseModel):
"""Résumé d'une scène : nom + description courte (synopsis).""" """Résumé d'une scène : nom + description courte (synopsis)."""
@@ -158,6 +202,8 @@ class SceneSummaryDTO(BaseModel):
illustration_count: int = 0 illustration_count: int = 0
# Branches narratives sortantes, omises cote Core si vides. # Branches narratives sortantes, omises cote Core si vides.
branches: list[SceneBranchHintDTO] = Field(default_factory=list) branches: list[SceneBranchHintDTO] = Field(default_factory=list)
# Pièces du lieu explorable, omises par Core si scène classique.
rooms: list[RoomSummaryDTO] = Field(default_factory=list)
class ChapterSummaryDTO(BaseModel): class ChapterSummaryDTO(BaseModel):
@@ -178,29 +224,98 @@ class ArcSummaryDTO(BaseModel):
illustration_count: int = 0 illustration_count: int = 0
class CharacterSummaryDTO(BaseModel):
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
name: str
snippet: str = ""
class NpcSummaryDTO(BaseModel):
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
name: str
snippet: str = ""
class CampaignContextDTO(BaseModel): class CampaignContextDTO(BaseModel):
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis.""" """Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
campaign_name: str campaign_name: str
campaign_description: str | None = None campaign_description: str | None = None
arcs: list[ArcSummaryDTO] = Field(default_factory=list) arcs: list[ArcSummaryDTO] = Field(default_factory=list)
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
class NarrativeEntityDTO(BaseModel): class NarrativeEntityDTO(BaseModel):
"""Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel.""" """Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
entity_type: str = Field(pattern="^(arc|chapter|scene)$") entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
title: str title: str
fields: dict[str, str] = Field(default_factory=dict) fields: dict[str, str] = Field(default_factory=dict)
class GameSystemContextDTO(BaseModel):
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
vides si aucune section ne matchait l'intent de génération courant.
"""
system_name: str
system_description: str | None = None
sections: dict[str, str] = Field(default_factory=dict)
class JournalEntrySummaryDTO(BaseModel):
"""Une entrée du journal de session.
`source_session_name` est présent uniquement pour les évènements issus
des sessions précédentes — sert à ancrer temporellement dans le prompt.
"""
type: str
content: str
occurred_at: str | None = None
source_session_name: str | None = None
class QuestSummaryDTO(BaseModel):
"""Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine."""
name: str
arc_name: str
description: str | None = None
class SessionContextDTO(BaseModel):
"""Contexte d'une Session de jeu en cours (Play Context).
Combine le journal complet (`entries`), les EVENTs des sessions précédentes
(`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes
Hub de la campagne (disponibles / en cours / verrouillées) plus les flags
narratifs actuellement actifs.
"""
session_name: str
active: bool
started_at: str | None = None
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list)
available_quests: list[QuestSummaryDTO] = Field(default_factory=list)
in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list)
locked_quest_titles: list[str] = Field(default_factory=list)
active_flags: list[str] = Field(default_factory=list)
class ChatStreamRequestDTO(BaseModel): class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels. """Requête de chat streamé : historique + contextes structurels.
Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels, Les contextes (lore, page, campaign, narrative_entity, session) sont
mais au moins l'un des deux "niveaux haut" (lore_context ou optionnels, mais au moins l'un des contextes "racines" (lore_context,
campaign_context) doit être fourni. Le validateur `check_scope` applique campaign_context ou session_context) doit être fourni. Le validateur
cette règle à la frontière HTTP. `check_scope` applique cette règle à la frontière HTTP.
""" """
messages: list[ChatMessageDTO] = Field(min_length=1) messages: list[ChatMessageDTO] = Field(min_length=1)
@@ -208,10 +323,16 @@ class ChatStreamRequestDTO(BaseModel):
page_context: PageContextDTO | None = None page_context: PageContextDTO | None = None
campaign_context: CampaignContextDTO | None = None campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None narrative_entity: NarrativeEntityDTO | None = None
game_system_context: GameSystemContextDTO | None = None
session_context: SessionContextDTO | None = None
def has_scope(self) -> bool: def has_scope(self) -> bool:
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni.""" """Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni."""
return self.lore_context is not None or self.campaign_context is not None return (
self.lore_context is not None
or self.campaign_context is not None
or self.session_context is not None
)
# --- Factories d'injection de dépendance --- # --- Factories d'injection de dépendance ---
@@ -334,8 +455,37 @@ async def chat_stream(
page_context = _to_page_context(body.page_context) page_context = _to_page_context(body.page_context)
campaign_context = _to_campaign_context(body.campaign_context) campaign_context = _to_campaign_context(body.campaign_context)
narrative_entity = _to_narrative_entity(body.narrative_entity) narrative_entity = _to_narrative_entity(body.narrative_entity)
game_system_context = _to_game_system_context(body.game_system_context)
session_context = _to_session_context(body.session_context)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
# Cette duplication évite de complexifier le contrat stream() avec un
# paramètre optionnel system_prompt précalculé.
system_prompt_preview = use_case.build_system_prompt(
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
session_context=session_context,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
history_msgs = messages[:-1] if messages else []
settings = get_settings()
usage_payload = {
"system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0,
"max": settings.llm_num_ctx,
}
async def event_stream() -> AsyncIterator[str]: async def event_stream() -> AsyncIterator[str]:
# Event 'usage' émis en tout premier : le frontend peut afficher la
# jauge avant même le premier token de réponse.
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
try: try:
async for token in use_case.stream( async for token in use_case.stream(
messages, messages,
@@ -343,6 +493,8 @@ async def chat_stream(
page_context=page_context, page_context=page_context,
campaign_context=campaign_context, campaign_context=campaign_context,
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context,
session_context=session_context,
): ):
# json.dumps avec ensure_ascii=False pour préserver les accents # json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
@@ -353,6 +505,60 @@ async def chat_stream(
return StreamingResponse(event_stream(), media_type="text/event-stream") return StreamingResponse(event_stream(), media_type="text/event-stream")
# --- Auto-titre d'une conversation persistee --------------------------------
class SummarizeTitleMessageDTO(BaseModel):
role: Literal["user", "assistant", "system"]
content: str
class SummarizeTitleRequestDTO(BaseModel):
"""Premiers messages d'une conversation pour auto-generer un titre court."""
messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list)
class SummarizeTitleResponseDTO(BaseModel):
title: str
_TITLE_SYSTEM_PROMPT = (
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
"en francais et capturer le sujet metier (pas 'Conversation IA')."
)
@app.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
async def summarize_conversation_title(
body: SummarizeTitleRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> SummarizeTitleResponseDTO:
"""Genere un titre court a partir des premiers echanges de la conversation.
Appele par le core apres le 1er couple user/assistant, pour remplacer le
titre provisoire "Nouvelle conversation" par quelque chose de parlant.
"""
if not body.messages:
raise HTTPException(status_code=422, detail="Au moins un message requis")
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :"
try:
raw = await llm.generate(prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".")
if len(title) > 80:
title = title[:80].rstrip()
if not title:
title = "Nouvelle conversation"
return SummarizeTitleResponseDTO(title=title)
# --- Mapping DTO → domaine (frontière HTTP) --------------------------------- # --- Mapping DTO → domaine (frontière HTTP) ---------------------------------
@@ -417,6 +623,23 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
) )
for br in sc.branches for br in sc.branches
], ],
rooms=[
RoomSummary(
name=room.name,
floor=room.floor,
description=room.description,
enemies=room.enemies,
branches=[
RoomBranchHint(
label=rb.label,
target_room_name=rb.target_room_name,
condition=rb.condition,
)
for rb in room.branches
],
)
for room in sc.rooms
],
) )
for sc in ch.scenes for sc in ch.scenes
], ],
@@ -426,10 +649,20 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
) )
for arc in dto.arcs for arc in dto.arcs
] ]
characters = [
CharacterSummary(name=c.name, snippet=c.snippet)
for c in dto.characters
]
npcs = [
NpcSummary(name=n.name, snippet=n.snippet)
for n in dto.npcs
]
return CampaignStructuralContext( return CampaignStructuralContext(
campaign_name=dto.campaign_name, campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description, campaign_description=dto.campaign_description,
arcs=arcs, arcs=arcs,
characters=characters,
npcs=npcs,
) )
@@ -449,6 +682,9 @@ class SettingsDTO(BaseModel):
onemin_model: str onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme. # True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool onemin_api_key_set: bool
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int
class SettingsUpdateDTO(BaseModel): class SettingsUpdateDTO(BaseModel):
@@ -460,6 +696,7 @@ class SettingsUpdateDTO(BaseModel):
onemin_model: str | None = None onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement. # Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None onemin_api_key: str | None = None
llm_num_ctx: int | None = None
def _to_settings_dto(s: Settings) -> SettingsDTO: def _to_settings_dto(s: Settings) -> SettingsDTO:
@@ -469,6 +706,7 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
llm_model=s.llm_model, llm_model=s.llm_model,
onemin_model=s.onemin_model, onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key), onemin_api_key_set=bool(s.onemin_api_key),
llm_num_ctx=s.llm_num_ctx,
) )
@@ -512,6 +750,120 @@ async def list_ollama_models(
return {"models": sorted(models)} return {"models": sorted(models)}
class OllamaModelInfoDTO(BaseModel):
"""Info utile extraite de /api/show pour un modele Ollama donne.
`context_length` = fenetre de contexte max supportee par le modele
(extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert
pour borner le slider de num_ctx dans les Parametres.
"""
context_length: int = 0
@app.post("/models/ollama/info", response_model=OllamaModelInfoDTO)
async def get_ollama_model_info(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> OllamaModelInfoDTO:
"""Retourne les metadonnees d'un modele Ollama via /api/show.
On passe par POST (et pas GET /models/ollama/{name}) parce que les noms
Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente
mal dans une URL — le body JSON evite le probleme d'escaping.
Le champ qui nous interesse est `model_info["<arch>.context_length"]`
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
scanne donc tous les champs finissant par `.context_length`.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/show"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.post(url, json={"model": name})
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return OllamaModelInfoDTO(context_length=0)
model_info = data.get("model_info") or {}
for key, value in model_info.items():
if key.endswith(".context_length") and isinstance(value, int):
return OllamaModelInfoDTO(context_length=value)
return OllamaModelInfoDTO(context_length=0)
@app.post("/models/ollama/pull")
async def pull_ollama_model(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
"""Telecharge un modele depuis Ollama et streame la progression.
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
digest, success. On reemet ce flux tel quel au client (le front
parsera les lignes et affichera une barre de progression).
Le timeout est intentionnellement tres long (60 min) car certains
modeles font 30+ Go.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/pull"
async def stream() -> AsyncIterator[bytes]:
# On utilise un timeout long pour la lecture (60 min) mais court pour
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
if r.status_code != 200:
# Ollama renvoie un message JSON d'erreur. On le passe
# tel quel au client en preservant le code HTTP.
body_text = await r.aread()
yield body_text
return
async for chunk in r.aiter_bytes():
yield chunk
except httpx.HTTPError as e:
# Erreur reseau : on emet une ligne JSON d'erreur compatible
# avec le format NDJSON d'Ollama.
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
yield err.encode("utf-8")
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
return StreamingResponse(stream(), media_type="application/x-ndjson")
@app.delete("/models/ollama/{name:path}")
async def delete_ollama_model(
name: str,
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, str]:
"""Supprime un modele du serveur Ollama.
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
sans avoir besoin de URL-encoder cote client.
"""
if not name.strip():
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/delete"
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.request("DELETE", url, json={"model": name})
if response.status_code == 404:
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
response.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
return {"status": "deleted", "name": name}
@app.get("/models/onemin") @app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]: def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur. """Catalogue statique des modeles 1min.ai, groupes par fournisseur.
@@ -596,3 +948,46 @@ def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityConte
title=dto.title, title=dto.title,
fields=dict(dto.fields), fields=dict(dto.fields),
) )
def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
if dto is None:
return None
return GameSystemContext(
system_name=dto.system_name,
system_description=dto.system_description,
sections=dict(dto.sections),
)
def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
if dto is None:
return None
return SessionContext(
session_name=dto.session_name,
active=dto.active,
started_at=dto.started_at,
entries=[_to_journal_entry(e) for e in dto.entries],
previous_events=[_to_journal_entry(e) for e in dto.previous_events],
available_quests=[_to_quest_summary(q) for q in dto.available_quests],
in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests],
locked_quest_titles=list(dto.locked_quest_titles),
active_flags=list(dto.active_flags),
)
def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary:
return QuestSummary(
name=dto.name,
arc_name=dto.arc_name,
description=dto.description,
)
def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary:
return JournalEntrySummary(
type=dto.type,
content=dto.content,
occurred_at=dto.occurred_at,
source_session_name=dto.source_session_name,
)

View File

@@ -3,4 +3,10 @@ uvicorn[standard]==0.32.*
httpx==0.27.* httpx==0.27.*
pydantic-settings==2.6.* pydantic-settings==2.6.*
pydantic pydantic
# Comptage de tokens pour la jauge de contexte (UI chat drawer).
# L'encodage cl100k_base (GPT-4/3.5) donne une approximation correcte pour
# la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant
# pour une jauge visuelle.
tiktoken==0.8.*

11
core/lombok.config Normal file
View File

@@ -0,0 +1,11 @@
## LoreMind Core - Configuration Lombok
#
# addLombokGeneratedAnnotation : ajoute @lombok.Generated sur toutes les
# methodes generees par Lombok (equals, hashCode, toString, builders,
# getters/setters, etc.). JaCoCo 0.8.2+ reconnait cette annotation et
# exclut automatiquement ces methodes du rapport de couverture.
#
# Objectif : mesurer la couverture UNIQUEMENT sur le code que nous ecrivons,
# pas sur le bytecode auto-genere (qui fausse les metriques : branches et
# instructions gonflees par les equals/hashCode).
lombok.addLombokGeneratedAnnotation = true

View File

@@ -8,13 +8,13 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version> <version>3.2.12</version>
<relativePath/> <relativePath/>
</parent> </parent>
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.2.0</version> <version>0.9.1-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -83,6 +83,28 @@
<artifactId>minio</artifactId> <artifactId>minio</artifactId>
<version>8.5.11</version> <version>8.5.11</version>
</dependency> </dependency>
<!-- Nimbus JOSE+JWT — verification des JWT Ed25519 (EdDSA) emis par le relais
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle. -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.40</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.14.1</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -98,6 +120,39 @@
</exclude> </exclude>
</excludes> </excludes>
</configuration> </configuration>
<executions>
<!-- Genere META-INF/build-info.properties (project.version)
consomme par Spring BuildProperties pour exposer la
version courante a l'application (UpdateCheckService). -->
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires.
Rapport HTML auto-genere a chaque `mvn test` dans target/site/jacoco/. -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin> </plugin>
</plugins> </plugins>
</build> </build>

View File

@@ -2,12 +2,14 @@ package com.loremind;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* Classe principale de l'application LoreMind. * Classe principale de l'application LoreMind.
* Point d'entrée Spring Boot qui démarre l'application. * Point d'entrée Spring Boot qui démarre l'application.
*/ */
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class LoreMindApplication { public class LoreMindApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -1,9 +1,13 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -17,21 +21,44 @@ import java.util.Optional;
public class ArcService { public class ArcService {
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ArcService(ArcRepository arcRepository) { public ArcService(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
} }
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) { public Arc createArc(String name, String description, String campaignId, int order) {
return createArc(name, description, campaignId, order, null);
}
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
Arc arc = Arc.builder() Arc arc = Arc.builder()
.name(name) .name(name)
.description(description) .description(description)
.campaignId(campaignId) .campaignId(campaignId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return arcRepository.save(arc); return arcRepository.save(arc);
} }
/**
* Création à partir d'un Arc complet (utilisé par le controller pour faire passer
* les nouveaux champs comme type sans démultiplier les paramètres).
*/
public Arc createArc(Arc input) {
input.setId(null);
return arcRepository.save(input);
}
public Optional<Arc> getArcById(String id) { public Optional<Arc> getArcById(String id) {
return arcRepository.findById(id); return arcRepository.findById(id);
} }
@@ -59,7 +86,31 @@ public class ArcService {
return arcRepository.save(arc); return arcRepository.save(arc);
} }
/**
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
* qui disparaîtront avec l'arc.
*/
public DeletionImpact getDeletionImpact(String id) {
List<Chapter> chapters = chapterRepository.findByArcId(id);
int sceneTotal = 0;
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
return new DeletionImpact(chapters.size(), sceneTotal);
}
/**
* Supprime l'arc et toutes ses entités dépendantes (chapitres → scènes).
* Transactionnel : atomique.
*/
@Transactional
public void deleteArc(String id) { public void deleteArc(String id) {
for (Chapter chapter : chapterRepository.findByArcId(id)) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(id); arcRepository.deleteById(id);
} }

View File

@@ -0,0 +1,49 @@
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 org.springframework.stereotype.Service;
import java.util.List;
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.
*
* <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>
*/
@Service
public class CampaignReferencedFlagsService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
public CampaignReferencedFlagsService(ArcRepository arcRepository,
ChapterRepository chapterRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
}
/** 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());
}
}
}
}
return List.copyOf(unique);
}
}

View File

@@ -1,43 +1,71 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
* Service d'application pour le contexte Campaign. * Service d'application pour le contexte Campaign (scénario).
* Orchestre la logique métier en utilisant le Port CampaignRepository. *
* Fait partie de la couche Application de l'Architecture Hexagonale. * <p>Depuis Playthrough : les PJ et les sessions ne sont plus rattachés directement
* à la Campagne ; ils dépendent d'un Playthrough (Partie). La cascade de suppression
* d'une campagne englobe donc ses Playthroughs (qui à leur tour cascadent).</p>
*/ */
@Service @Service
public class CampaignService { public class CampaignService {
private final CampaignRepository campaignRepository; private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final PlaythroughRepository playthroughRepository;
private final PlaythroughService playthroughService;
public CampaignService(CampaignRepository campaignRepository) { public CampaignService(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
PlaythroughRepository playthroughRepository,
PlaythroughService playthroughService) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.playthroughRepository = playthroughRepository;
this.playthroughService = playthroughService;
} }
/** public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
* Parameter Object pour la création / mise à jour d'une Campaign.
* Évite une signature à rallonge et rend les évolutions futures (theme, public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
* coverImageUrl, etc.) sans casser les appelants.
*
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
*/
public record CampaignData(String name, String description, String loreId) {}
public Campaign createCampaign(CampaignData data) { public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder() Campaign campaign = Campaign.builder()
.name(data.name()) .name(data.name())
.description(data.description()) .description(data.description())
.loreId(normalizeLoreId(data.loreId())) .loreId(normalizeId(data.loreId()))
.gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0) .arcsCount(0)
.build(); .build();
return campaignRepository.save(campaign); Campaign saved = campaignRepository.save(campaign);
// Une campagne sans Partie n'a pas de sens jouable : on crée d'office
// une "Partie principale" pour que l'utilisateur puisse jouer immédiatement.
playthroughService.create(saved.getId(), "Partie principale", null);
return saved;
} }
public Optional<Campaign> getCampaignById(String id) { public Optional<Campaign> getCampaignById(String id) {
@@ -57,19 +85,48 @@ public class CampaignService {
Campaign campaign = existingCampaign.get(); Campaign campaign = existingCampaign.get();
campaign.setName(data.name()); campaign.setName(data.name());
campaign.setDescription(data.description()); campaign.setDescription(data.description());
campaign.setLoreId(normalizeLoreId(data.loreId())); campaign.setLoreId(normalizeId(data.loreId()));
campaign.setGameSystemId(normalizeId(data.gameSystemId()));
return campaignRepository.save(campaign); return campaignRepository.save(campaign);
} }
/** private String normalizeId(String id) {
* Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien". return (id == null || id.isBlank()) ? null : id;
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
*/
private String normalizeLoreId(String loreId) {
return (loreId == null || loreId.isBlank()) ? null : loreId;
} }
public DeletionImpact getDeletionImpact(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
int chapterTotal = 0;
int sceneTotal = 0;
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
chapterTotal += chapters.size();
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
}
int playthroughTotal = playthroughRepository.findByCampaignId(id).size();
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, playthroughTotal);
}
@Transactional
public void deleteCampaign(String id) { public void deleteCampaign(String id) {
// 1. Cascade des Playthroughs (qui cascadent eux-mêmes sur PJ/sessions/valeurs flags/progressions).
for (Playthrough p : playthroughRepository.findByCampaignId(id)) {
playthroughService.delete(p.getId());
}
// 2. Cascade du scénario : arcs → chapitres → scènes
// (Pas de déclarations globales de faits : ils existent implicitement via les
// prérequis FLAG_SET des chapitres, qui partent avec les chapitres.)
for (Arc arc : arcRepository.findByCampaignId(id)) {
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(arc.getId());
}
campaignRepository.deleteById(id); campaignRepository.deleteById(id);
} }

View File

@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -17,21 +19,41 @@ import java.util.Optional;
public class ChapterService { public class ChapterService {
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ChapterService(ChapterRepository chapterRepository) { public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
} }
/** Compte des scènes qui seront supprimées en cascade avec le chapitre. */
public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) { public Chapter createChapter(String name, String description, String arcId, int order) {
return createChapter(name, description, arcId, order, null);
}
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
Chapter chapter = Chapter.builder() Chapter chapter = Chapter.builder()
.name(name) .name(name)
.description(description) .description(description)
.arcId(arcId) .arcId(arcId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return chapterRepository.save(chapter); return chapterRepository.save(chapter);
} }
/**
* Création à partir d'un Chapter complet (utilisé par le controller pour faire passer
* les nouveaux champs comme progressionStatus / prerequisites sans démultiplier les
* paramètres). L'id est forcé à null pour laisser la DB le générer.
*/
public Chapter createChapter(Chapter input) {
input.setId(null);
return chapterRepository.save(input);
}
public Optional<Chapter> getChapterById(String id) { public Optional<Chapter> getChapterById(String id) {
return chapterRepository.findById(id); return chapterRepository.findById(id);
} }
@@ -58,7 +80,17 @@ public class ChapterService {
return chapterRepository.save(chapter); return chapterRepository.save(chapter);
} }
/** Compte des scènes qui tomberont avec le chapitre. */
public DeletionImpact getDeletionImpact(String id) {
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
}
/** Supprime le chapitre et toutes ses scènes. Transactionnel : atomique. */
@Transactional
public void deleteChapter(String id) { public void deleteChapter(String id) {
for (var scene : sceneRepository.findByChapterId(id)) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(id); chapterRepository.deleteById(id);
} }

View File

@@ -0,0 +1,104 @@
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

@@ -0,0 +1,87 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Service d'application pour les fiches de personnages (PJ).
* Les PJ appartiennent désormais à un Playthrough (Partie), pas à la Campagne.
*/
@Service
public class CharacterService {
private final CharacterRepository characterRepository;
public CharacterService(CharacterRepository characterRepository) {
this.characterRepository = characterRepository;
}
public record CharacterData(
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String playthroughId,
Integer order
) {}
public Character createCharacter(CharacterData data) {
int order = data.order() != null
? data.order()
: nextOrderFor(data.playthroughId());
Character character = Character.builder()
.name(data.name())
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.playthroughId(data.playthroughId())
.order(order)
.build();
return characterRepository.save(character);
}
public Optional<Character> getCharacterById(String id) {
return characterRepository.findById(id);
}
public List<Character> getCharactersByPlaythroughId(String playthroughId) {
return characterRepository.findByPlaythroughId(playthroughId);
}
public Character updateCharacter(String id, CharacterData data) {
Character existing = characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) {
existing.setOrder(data.order());
}
// playthroughId immuable après création.
return characterRepository.save(existing);
}
public void deleteCharacter(String id) {
characterRepository.deleteById(id);
}
private int nextOrderFor(String playthroughId) {
return characterRepository.findByPlaythroughId(playthroughId).stream()
.mapToInt(Character::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -0,0 +1,85 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Service d'application pour les fiches de PNJ (campagne).
*/
@Service
public class NpcService {
private final NpcRepository npcRepository;
public NpcService(NpcRepository npcRepository) {
this.npcRepository = npcRepository;
}
public record NpcData(
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order
) {}
public Npc createNpc(NpcData data) {
int order = data.order() != null
? data.order()
: nextOrderFor(data.campaignId());
Npc npc = Npc.builder()
.name(data.name())
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
return npcRepository.save(npc);
}
public Optional<Npc> getNpcById(String id) {
return npcRepository.findById(id);
}
public List<Npc> getNpcsByCampaignId(String campaignId) {
return npcRepository.findByCampaignId(campaignId);
}
public Npc updateNpc(String id, NpcData data) {
Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) {
existing.setOrder(data.order());
}
return npcRepository.save(existing);
}
public void deleteNpc(String id) {
npcRepository.deleteById(id);
}
private int nextOrderFor(String campaignId) {
return npcRepository.findByCampaignId(campaignId).stream()
.mapToInt(Npc::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -26,11 +26,16 @@ public class SceneService {
} }
public Scene createScene(String name, String description, String chapterId, int order) { public Scene createScene(String name, String description, String chapterId, int order) {
return createScene(name, description, chapterId, order, null);
}
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
Scene scene = Scene.builder() Scene scene = Scene.builder()
.name(name) .name(name)
.description(description) .description(description)
.chapterId(chapterId) .chapterId(chapterId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return sceneRepository.save(scene); return sceneRepository.save(scene);
} }
@@ -93,7 +98,7 @@ public class SceneService {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
for (SceneBranch b : branches) { for (SceneBranch b : branches) {
String target = b.getTargetSceneId(); String target = b.targetSceneId();
if (target == null || target.isBlank()) { if (target == null || target.isBlank()) {
throw new IllegalArgumentException("Une branche doit avoir une scène de destination"); throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
} }

View File

@@ -0,0 +1,134 @@
package com.loremind.application.conversationcontext;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
import org.springframework.stereotype.Service;
import java.util.List;
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,
* tous deux non-null = niveau entite precise).
*/
@Service
public class ConversationService {
private final ConversationRepository repository;
private final ConversationTitleGenerator titleGenerator;
public ConversationService(ConversationRepository repository,
ConversationTitleGenerator titleGenerator) {
this.repository = repository;
this.titleGenerator = titleGenerator;
}
/** Donnees de creation d'une conversation. Titre optionnel — sera auto-genere si absent. */
public record CreateData(
String title,
String loreId,
String campaignId,
String entityType,
String entityId) {}
public Conversation create(CreateData data) {
validateAnchor(data.loreId(), data.campaignId(), data.entityType(), data.entityId());
String title = (data.title() == null || data.title().isBlank())
? "Nouvelle conversation"
: data.title().trim();
Conversation conv = Conversation.builder()
.title(title)
.loreId(data.loreId())
.campaignId(data.campaignId())
.entityType(data.entityType())
.entityId(data.entityId())
.build();
return repository.save(conv);
}
public Optional<Conversation> getById(String id) {
return repository.findById(id);
}
public List<Conversation> listByContext(String loreId, String campaignId, String entityType, String entityId) {
validateAnchor(loreId, campaignId, entityType, entityId);
return repository.findByContext(loreId, campaignId, entityType, entityId);
}
public void rename(String id, String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Le titre ne peut pas etre vide");
}
if (repository.findById(id).isEmpty()) {
throw new IllegalArgumentException("Conversation introuvable : " + id);
}
repository.updateTitle(id, title.trim());
}
public void delete(String id) {
repository.deleteById(id);
}
/**
* Auto-genere un titre a partir des premiers messages et le persiste.
* Appele typiquement apres le 1er couple user/assistant pour remplacer
* le titre provisoire. Echec silencieux (fallback dans l'adaptateur) —
* on n'empeche pas la conversation de fonctionner si le Brain est down.
*/
public String autoGenerateTitle(String conversationId) {
Conversation conv = repository.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation introuvable : " + conversationId));
List<ConversationMessage> seeds = conv.getMessages();
if (seeds == null || seeds.isEmpty()) {
return conv.getTitle();
}
String title = titleGenerator.generate(seeds);
repository.updateTitle(conversationId, title);
return title;
}
/**
* Ajoute un message (user ou assistant) a une conversation existante.
* L'horodatage et l'id sont assignes par la couche persistance.
*/
public ConversationMessage appendMessage(String conversationId, String role, String content) {
if (role == null || (!role.equals("user") && !role.equals("assistant") && !role.equals("system"))) {
throw new IllegalArgumentException("Role invalide : " + role);
}
if (content == null || content.isEmpty()) {
throw new IllegalArgumentException("Contenu vide interdit");
}
ConversationMessage msg = ConversationMessage.builder()
.role(role)
.content(content)
.build();
return repository.appendMessage(conversationId, msg);
}
// ---------- Validation ----------
private void validateAnchor(String loreId, String campaignId, String entityType, String entityId) {
boolean hasLore = loreId != null && !loreId.isBlank();
boolean hasCamp = campaignId != null && !campaignId.isBlank();
if (hasLore == hasCamp) {
throw new IllegalArgumentException("Exactement un parent attendu : loreId XOR campaignId");
}
boolean hasType = entityType != null && !entityType.isBlank();
boolean hasEntId = entityId != null && !entityId.isBlank();
if (hasType != hasEntId) {
throw new IllegalArgumentException("entityType et entityId doivent etre tous deux null ou tous deux non-null");
}
}
}

View File

@@ -0,0 +1,88 @@
package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.generationcontext.GameSystemContext;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Construit un {@link GameSystemContext} à partir d'un gameSystemId et d'un intent.
* <p>
* Pipeline :
* 1. Charge le GameSystem (retourne Optional.empty si introuvable — dégradation gracieuse).
* 2. Parse le markdown par titres H2 (## Section) → Map<Titre, Contenu>.
* 3. Filtre les sections selon l'intent via les alias {@link GenerationIntent#getSectionAliases()}.
* GENERIC = pas de filtre.
* <p>
* Parsing à la volée (pas de cache) : les règles d'un système font
* typiquement 5-20kB, le coût de parsing est négligeable devant l'appel LLM.
*/
@Service
public class GameSystemContextBuilder {
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
private final GameSystemRepository gameSystemRepository;
public GameSystemContextBuilder(GameSystemRepository gameSystemRepository) {
this.gameSystemRepository = gameSystemRepository;
}
public Optional<GameSystemContext> buildOptional(String gameSystemId, GenerationIntent intent) {
if (gameSystemId == null || gameSystemId.isBlank()) return Optional.empty();
return gameSystemRepository.findById(gameSystemId)
.map(gs -> build(gs, intent));
}
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
Map<String, String> filtered = filterByIntent(allSections, intent);
return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
}
/**
* Découpe le markdown par titres H2. Préserve l'ordre d'apparition (LinkedHashMap).
* Le contenu avant le premier H2 est ignoré (préambule libre).
*/
Map<String, String> parseH2Sections(String markdown) {
Map<String, String> sections = new LinkedHashMap<>();
if (markdown == null || markdown.isBlank()) return sections;
Matcher m = H2_HEADER.matcher(markdown);
String currentTitle = null;
int currentContentStart = -1;
while (m.find()) {
if (currentTitle != null) {
sections.put(currentTitle, markdown.substring(currentContentStart, m.start()).strip());
}
currentTitle = m.group(1).trim();
currentContentStart = m.end();
}
if (currentTitle != null) {
sections.put(currentTitle, markdown.substring(currentContentStart).strip());
}
return sections;
}
private Map<String, String> filterByIntent(Map<String, String> sections, GenerationIntent intent) {
if (intent.matchesAllSections()) return sections;
Map<String, String> filtered = new LinkedHashMap<>();
for (Map.Entry<String, String> e : sections.entrySet()) {
String titleLower = e.getKey().toLowerCase();
boolean match = intent.getSectionAliases().stream().anyMatch(titleLower::contains);
if (match) {
filtered.put(e.getKey(), e.getValue());
}
}
return filtered;
}
}

View File

@@ -0,0 +1,84 @@
package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class GameSystemService {
private final GameSystemRepository gameSystemRepository;
public GameSystemService(GameSystemRepository gameSystemRepository) {
this.gameSystemRepository = gameSystemRepository;
}
/**
* Parameter Object pour la création / mise à jour d'un GameSystem.
* Les templates peuvent etre null (interpretes comme listes vides).
*/
public record GameSystemData(
String name,
String description,
String rulesMarkdown,
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
String author,
boolean isPublic
) {}
public GameSystem createGameSystem(GameSystemData data) {
GameSystem gameSystem = GameSystem.builder()
.name(data.name())
.description(data.description())
.rulesMarkdown(data.rulesMarkdown())
.author(normalize(data.author()))
.isPublic(data.isPublic())
.build();
gameSystem.replaceCharacterTemplate(data.characterTemplate());
gameSystem.replaceNpcTemplate(data.npcTemplate());
return gameSystemRepository.save(gameSystem);
}
public Optional<GameSystem> getGameSystemById(String id) {
return gameSystemRepository.findById(id);
}
public List<GameSystem> getAllGameSystems() {
return gameSystemRepository.findAll();
}
public GameSystem updateGameSystem(String id, GameSystemData data) {
GameSystem existing = gameSystemRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setDescription(data.description());
existing.setRulesMarkdown(data.rulesMarkdown());
existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate());
existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing);
}
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
}
public boolean gameSystemExists(String id) {
return gameSystemRepository.existsById(id);
}
public List<GameSystem> searchGameSystems(String query) {
if (query == null || query.isBlank()) return List.of();
return gameSystemRepository.searchByName(query.trim());
}
private String normalize(String value) {
return (value == null || value.isBlank()) ? null : value;
}
}

View File

@@ -3,15 +3,23 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint; import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomBranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -38,24 +46,40 @@ public class CampaignStructuralContextBuilder {
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
private final NpcRepository npcRepository;
public CampaignStructuralContextBuilder( public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository) { SceneRepository sceneRepository,
CharacterRepository characterRepository,
NpcRepository npcRepository) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
this.npcRepository = npcRepository;
}
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
/**
* Construit la carte narrative d'une Campagne. Sans playthroughId, les PJ
* sont omis (ils sont propres à une Partie).
*/
public CampaignStructuralContext build(String campaignId) {
return build(campaignId, null);
} }
/** /**
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes, * Variante avec playthroughId : injecte les PJ de la Partie indiquée.
* nom + description courte à chaque niveau). * Les PNJ restent campagne-scope (donnée de scénario).
* @throws IllegalArgumentException si la Campagne est introuvable
*/ */
public CampaignStructuralContext build(String campaignId) { public CampaignStructuralContext build(String campaignId, String playthroughId) {
Campaign campaign = campaignRepository.findById(campaignId) Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId)); "Campagne non trouvée avec l'ID: " + campaignId));
@@ -65,11 +89,58 @@ public class CampaignStructuralContextBuilder {
.map(this::toArcSummary) .map(this::toArcSummary)
.collect(Collectors.toList()); .collect(Collectors.toList());
return CampaignStructuralContext.builder() List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
.campaignName(campaign.getName()) ? List.of()
.campaignDescription(campaign.getDescription()) : characterRepository.findByPlaythroughId(playthroughId).stream()
.arcs(arcs) .sorted(Comparator.comparingInt(Character::getOrder))
.build(); .map(this::toCharacterSummary)
.collect(Collectors.toList());
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Npc::getOrder))
.map(this::toNpcSummary)
.collect(Collectors.toList());
return new CampaignStructuralContext(
campaign.getName(),
campaign.getDescription(),
arcs,
characters,
npcs);
}
/**
* Projette un PJ vers un résumé court : nom + 1re ligne "signifiante" du
* markdown (ni vide, ni un titre). Permet à l'IA de savoir "qui est Thorin"
* sans injecter toute sa fiche.
*/
private CharacterSummary toCharacterSummary(Character c) {
return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
}
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
private NpcSummary toNpcSummary(Npc n) {
return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
}
/**
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
*/
private static String extractSnippet(java.util.Map<String, String> values) {
if (values == null || values.isEmpty()) return "";
for (String value : values.values()) {
if (value == null || value.isBlank()) continue;
String firstLine = value.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
if (firstLine.isEmpty()) continue;
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
}
return "";
} }
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc) {
@@ -77,12 +148,11 @@ public class CampaignStructuralContextBuilder {
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary) .map(this::toChapterSummary)
.collect(Collectors.toList()); .collect(Collectors.toList());
return ArcSummary.builder() return new ArcSummary(
.name(arc.getName()) arc.getName(),
.description(arc.getDescription()) arc.getDescription(),
.illustrationCount(countImages(arc.getIllustrationImageIds())) countImages(arc.getIllustrationImageIds()),
.chapters(chapters) chapters);
.build();
} }
private ChapterSummary toChapterSummary(Chapter chapter) { private ChapterSummary toChapterSummary(Chapter chapter) {
@@ -99,32 +169,59 @@ public class CampaignStructuralContextBuilder {
.map(s -> toSceneSummary(s, nameById)) .map(s -> toSceneSummary(s, nameById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return ChapterSummary.builder() return new ChapterSummary(
.name(chapter.getName()) chapter.getName(),
.description(chapter.getDescription()) chapter.getDescription(),
.illustrationCount(countImages(chapter.getIllustrationImageIds())) countImages(chapter.getIllustrationImageIds()),
.scenes(summaries) summaries);
.build();
} }
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) { private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
List<BranchHint> hints = scene.getBranches() == null List<BranchHint> hints = scene.getBranches() == null
? List.of() ? List.of()
: scene.getBranches().stream() : scene.getBranches().stream()
.map(b -> BranchHint.builder() .map(b -> new BranchHint(
.label(b.getLabel()) b.label(),
.targetSceneName(nameById.getOrDefault( nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.getTargetSceneId(), "(scène inconnue)")) b.condition()))
.condition(b.getCondition())
.build())
.collect(Collectors.toList()); .collect(Collectors.toList());
return SceneSummary.builder() List<RoomSummary> rooms = toRoomSummaries(scene);
.name(scene.getName())
.description(scene.getDescription()) return new SceneSummary(
.illustrationCount(countImages(scene.getIllustrationImageIds())) scene.getName(),
.branches(hints) scene.getDescription(),
.build(); countImages(scene.getIllustrationImageIds()),
hints,
rooms);
}
/**
* Projette les pièces d'une scène en RoomSummary pour le contexte IA.
* Pas de notes MJ, pas de loot ni pièges : le prompt reste lisible. L'IA
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
* suffisant pour proposer de la narration ou anticiper les choix.
*/
private List<RoomSummary> toRoomSummaries(Scene scene) {
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Room::getId,
com.loremind.domain.campaigncontext.Room::getName,
(a, b) -> a));
return scene.getRooms().stream()
.map(r -> {
List<RoomBranchHint> hints = r.getBranches() == null
? List.of()
: r.getBranches().stream()
.map(b -> new RoomBranchHint(
b.label(),
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition()))
.collect(Collectors.toList());
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints);
})
.collect(Collectors.toList());
} }
/** Helper defensif : compte les illustrations attachees (null-safe). */ /** Helper defensif : compte les illustrations attachees (null-safe). */

View File

@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
requireNonEmptyFields(template); requireNonEmptyFields(template);
GenerationContext context = GenerationContext.builder() // Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
.loreName(lore.getName()) // necessitent un workflow different (pas de generation LLM texte).
.loreDescription(lore.getDescription()) GenerationContext context = new GenerationContext(
.folderName(folder.getName()) lore.getName(),
.templateName(template.getName()) lore.getDescription(),
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE folder.getName(),
// necessitent un workflow different (pas de generation LLM texte). template.getName(),
.templateFields(template.textFieldNames()) template.textFieldNames(),
.pageTitle(page.getTitle()) page.getTitle());
.build();
GenerationResult result = aiProvider.generatePage(context); GenerationResult result = aiProvider.generatePage(context);
return result.values(); return result.values();

View File

@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
Map<String, String> pageTitleById = pages.stream() Map<String, String> pageTitleById = pages.stream()
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a)); .collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
return LoreStructuralContext.builder() return new LoreStructuralContext(
.loreName(lore.getName()) lore.getName(),
.loreDescription(lore.getDescription()) lore.getDescription(),
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById)) buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
.tags(extractUniqueTags(pages)) extractUniqueTags(pages));
.build();
} }
private Map<String, List<PageSummary>> buildFoldersMap( private Map<String, List<PageSummary>> buildFoldersMap(
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
Page page, Page page,
Map<String, String> templateNameById, Map<String, String> templateNameById,
Map<String, String> pageTitleById) { Map<String, String> pageTitleById) {
return PageSummary.builder() return new PageSummary(
.title(page.getTitle()) page.getTitle(),
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?")) templateNameById.getOrDefault(page.getTemplateId(), "?"),
.values(truncatedValues(page.getValues())) truncatedValues(page.getValues()),
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList()) page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById)) resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
.build();
} }
/** /**

View File

@@ -2,9 +2,13 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -26,20 +30,26 @@ public class NarrativeEntityContextBuilder {
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
private final NpcRepository npcRepository;
public NarrativeEntityContextBuilder( public NarrativeEntityContextBuilder(
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository) { SceneRepository sceneRepository,
CharacterRepository characterRepository,
NpcRepository npcRepository) {
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
this.npcRepository = npcRepository;
} }
/** /**
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext. * Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
* *
* @param entityType "arc", "chapter" ou "scene" (insensible à la casse) * @param entityType "arc", "chapter", "scene", "character" ou "npc" (insensible à la casse)
* @param entityId l'ID de l'entité * @param entityId l'ID de l'entité
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable * @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
*/ */
@@ -49,6 +59,8 @@ public class NarrativeEntityContextBuilder {
case "arc" -> fromArc(loadArc(entityId)); case "arc" -> fromArc(loadArc(entityId));
case "chapter" -> fromChapter(loadChapter(entityId)); case "chapter" -> fromChapter(loadChapter(entityId));
case "scene" -> fromScene(loadScene(entityId)); case "scene" -> fromScene(loadScene(entityId));
case "character" -> fromCharacter(loadCharacter(entityId));
case "npc" -> fromNpc(loadNpc(entityId));
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType); default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
}; };
} }
@@ -70,6 +82,16 @@ public class NarrativeEntityContextBuilder {
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id)); .orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
} }
private Character loadCharacter(String id) {
return characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
}
private Npc loadNpc(String id) {
return npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("PNJ non trouvé: " + id));
}
// --- Mapping entité → VO ------------------------------------------------ // --- Mapping entité → VO ------------------------------------------------
private NarrativeEntityContext fromArc(Arc a) { private NarrativeEntityContext fromArc(Arc a) {
@@ -80,11 +102,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "rewards", a.getRewards()); putField(fields, "rewards", a.getRewards());
putField(fields, "resolution", a.getResolution()); putField(fields, "resolution", a.getResolution());
putField(fields, "gmNotes", a.getGmNotes()); putField(fields, "gmNotes", a.getGmNotes());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("arc", a.getName(), fields);
.entityType("arc")
.title(a.getName())
.fields(fields)
.build();
} }
private NarrativeEntityContext fromChapter(Chapter c) { private NarrativeEntityContext fromChapter(Chapter c) {
@@ -93,11 +111,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "playerObjectives", c.getPlayerObjectives()); putField(fields, "playerObjectives", c.getPlayerObjectives());
putField(fields, "narrativeStakes", c.getNarrativeStakes()); putField(fields, "narrativeStakes", c.getNarrativeStakes());
putField(fields, "gmNotes", c.getGmNotes()); putField(fields, "gmNotes", c.getGmNotes());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("chapter", c.getName(), fields);
.entityType("chapter")
.title(c.getName())
.fields(fields)
.build();
} }
private NarrativeEntityContext fromScene(Scene s) { private NarrativeEntityContext fromScene(Scene s) {
@@ -111,11 +125,25 @@ public class NarrativeEntityContextBuilder {
putField(fields, "combatDifficulty", s.getCombatDifficulty()); putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies()); putField(fields, "enemies", s.getEnemies());
putField(fields, "gmSecretNotes", s.getGmSecretNotes()); putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("scene", s.getName(), fields);
.entityType("scene") }
.title(s.getName())
.fields(fields) private NarrativeEntityContext fromCharacter(Character c) {
.build(); Map<String, String> fields = new LinkedHashMap<>();
if (c.getValues() != null) {
// Champs templates exposes individuellement — meilleur pour le LLM que
// l'ancien blob markdown monolithique.
c.getValues().forEach((k, v) -> putField(fields, k, v));
}
return new NarrativeEntityContext("character", c.getName(), fields);
}
private NarrativeEntityContext fromNpc(Npc n) {
Map<String, String> fields = new LinkedHashMap<>();
if (n.getValues() != null) {
n.getValues().forEach((k, v) -> putField(fields, k, v));
}
return new NarrativeEntityContext("npc", n.getName(), fields);
} }
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */ /** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */

View File

@@ -0,0 +1,233 @@
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.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
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.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
*
* <p>Depuis la refonte Playthrough : la session connaît son playthroughId ; la progression
* et les flags viennent du Playthrough (pas plus de la Campagne ni du Chapter).</p>
*/
@Component
public class SessionStructuralContextBuilder {
private static final int MAX_CURRENT_ENTRIES = 80;
private static final int MAX_PREVIOUS_EVENTS = 60;
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final PlaythroughFlagRepository playthroughFlagRepository;
private final QuestProgressionRepository questProgressionRepository;
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
PlaythroughFlagRepository playthroughFlagRepository,
QuestProgressionRepository questProgressionRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.playthroughFlagRepository = playthroughFlagRepository;
this.questProgressionRepository = questProgressionRepository;
}
public Optional<SessionContext> buildOptional(String sessionId) {
return sessionRepository.findById(sessionId).map(this::toContext);
}
public SessionContext build(String sessionId) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
return toContext(session);
}
private SessionContext toContext(Session session) {
List<JournalEntrySummary> currentEntries = loadCurrentEntries(session);
List<JournalEntrySummary> previousEvents = loadPreviousEvents(session);
HubStatus hub = computeHubStatus(session.getPlaythroughId());
return new SessionContext(
session.getName(),
session.isActive(),
session.getStartedAt(),
currentEntries,
previousEvents,
hub.available(),
hub.inProgress(),
hub.lockedTitles(),
hub.activeFlags());
}
private List<JournalEntrySummary> loadCurrentEntries(Session session) {
List<SessionEntry> allEntries = entryRepository.findBySessionId(session.getId());
List<SessionEntry> kept = allEntries.size() <= MAX_CURRENT_ENTRIES
? allEntries
: allEntries.subList(allEntries.size() - MAX_CURRENT_ENTRIES, allEntries.size());
return kept.stream()
.map(e -> toSummary(e, null))
.collect(Collectors.toList());
}
/**
* EVENTs des sessions précédentes du MÊME Playthrough (même table).
* On ne mélange jamais les EVENTs de tables différentes.
*/
private List<JournalEntrySummary> loadPreviousEvents(Session current) {
if (current.getPlaythroughId() == null) return List.of();
List<Session> siblingSessions = sessionRepository.findByPlaythroughId(current.getPlaythroughId());
List<JournalEntrySummary> events = new ArrayList<>();
for (Session past : siblingSessions) {
if (past.getId().equals(current.getId())) continue;
for (SessionEntry entry : entryRepository.findBySessionId(past.getId())) {
if (entry.getType() == EntryType.EVENT) {
events.add(toSummary(entry, past.getName()));
}
}
}
events.sort(Comparator.comparing(
JournalEntrySummary::occurredAt,
Comparator.nullsLast(Comparator.naturalOrder())));
if (events.size() > MAX_PREVIOUS_EVENTS) {
return events.subList(events.size() - MAX_PREVIOUS_EVENTS, events.size());
}
return events;
}
private JournalEntrySummary toSummary(SessionEntry entry, String sourceSessionName) {
return new JournalEntrySummary(
entry.getType() != null ? entry.getType().name() : "NOTE",
entry.getContent(),
entry.getOccurredAt(),
sourceSessionName);
}
/** Agrégat interne des données Hub à injecter dans le SessionContext. */
private record HubStatus(
List<QuestSummary> available,
List<QuestSummary> inProgress,
List<String> lockedTitles,
List<String> activeFlags
) {}
/**
* Calcule l'état des quêtes Hub du Playthrough courant :
* - AVAILABLE / IN_PROGRESS → résumé complet
* - LOCKED → titre uniquement (anti-spoiler)
* - COMPLETED → omis (déjà raconté par les EVENTs)
*/
private HubStatus computeHubStatus(String playthroughId) {
if (playthroughId == null) {
return new HubStatus(List.of(), List.of(), List.of(), List.of());
}
Optional<Playthrough> maybePlaythrough = playthroughRepository.findById(playthroughId);
if (maybePlaythrough.isEmpty()) {
return new HubStatus(List.of(), List.of(), List.of(), List.of());
}
String campaignId = maybePlaythrough.get().getCampaignId();
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));
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
if (!anyHub) {
return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
}
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
progressionByChapter.put(qp.getChapterId(), qp.getStatus());
}
// IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
PrerequisiteEvaluator.EvaluationContext ctx =
new PrerequisiteEvaluator.EvaluationContext(completedIds, sessionCount, flags);
List<QuestSummary> available = new ArrayList<>();
List<QuestSummary> inProgress = new ArrayList<>();
List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
if (arc.getType() != ArcType.HUB) continue;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
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;
}
}
}
return new HubStatus(available, inProgress, lockedTitles, activeFlags);
}
private List<String> buildActiveFlags(Map<String, Boolean> flags) {
return flags.entrySet().stream()
.filter(Map.Entry::getValue)
.map(Map.Entry::getKey)
.sorted()
.collect(Collectors.toList());
}
}

View File

@@ -1,10 +1,14 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -33,6 +37,7 @@ public class StreamChatForCampaignUseCase {
private final CampaignStructuralContextBuilder campaignContextBuilder; private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder; private final LoreStructuralContextBuilder loreContextBuilder;
private final NarrativeEntityContextBuilder narrativeEntityContextBuilder; private final NarrativeEntityContextBuilder narrativeEntityContextBuilder;
private final GameSystemContextBuilder gameSystemContextBuilder;
private final AiChatProvider aiChatProvider; private final AiChatProvider aiChatProvider;
public StreamChatForCampaignUseCase( public StreamChatForCampaignUseCase(
@@ -40,11 +45,13 @@ public class StreamChatForCampaignUseCase {
CampaignStructuralContextBuilder campaignContextBuilder, CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder, LoreStructuralContextBuilder loreContextBuilder,
NarrativeEntityContextBuilder narrativeEntityContextBuilder, NarrativeEntityContextBuilder narrativeEntityContextBuilder,
GameSystemContextBuilder gameSystemContextBuilder,
AiChatProvider aiChatProvider) { AiChatProvider aiChatProvider) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder; this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder; this.loreContextBuilder = loreContextBuilder;
this.narrativeEntityContextBuilder = narrativeEntityContextBuilder; this.narrativeEntityContextBuilder = narrativeEntityContextBuilder;
this.gameSystemContextBuilder = gameSystemContextBuilder;
this.aiChatProvider = aiChatProvider; this.aiChatProvider = aiChatProvider;
} }
@@ -65,6 +72,7 @@ public class StreamChatForCampaignUseCase {
String entityType, String entityType,
String entityId, String entityId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Runnable onComplete, Runnable onComplete,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -76,15 +84,17 @@ public class StreamChatForCampaignUseCase {
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId); CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId);
LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign); LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign);
NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId); NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId);
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign, entityType);
ChatRequest request = ChatRequest.builder() ChatRequest request = ChatRequest.builder()
.messages(messages) .messages(messages)
.loreContext(loreContext) .loreContext(loreContext)
.campaignContext(campaignContext) .campaignContext(campaignContext)
.narrativeEntity(narrativeEntity) .narrativeEntity(narrativeEntity)
.gameSystemContext(gameSystemContext)
.build(); .build();
aiChatProvider.streamChat(request, onToken, onComplete, onError); aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
} }
/** /**
@@ -102,4 +112,16 @@ public class StreamChatForCampaignUseCase {
if (entityId == null || entityId.isBlank()) return null; if (entityId == null || entityId.isBlank()) return null;
return narrativeEntityContextBuilder.build(entityType, entityId); return narrativeEntityContextBuilder.build(entityType, entityId);
} }
/**
* Charge le GameSystemContext si la campagne est liée à un GameSystem.
* L'entityType détermine quelles sections de règles sont injectées
* (SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions, autre → toutes).
* Retourne null en cas de GameSystem introuvable (dégradation gracieuse).
*/
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign, String entityType) {
if (!campaign.isLinkedToGameSystem()) return null;
GenerationIntent intent = GenerationIntent.fromNarrativeEntityType(entityType);
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), intent).orElse(null);
}
} }

View File

@@ -2,6 +2,7 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.PageContext; import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -60,6 +61,7 @@ public class StreamChatForLoreUseCase {
String loreId, String loreId,
String pageId, String pageId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Runnable onComplete, Runnable onComplete,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -75,7 +77,7 @@ public class StreamChatForLoreUseCase {
.pageContext(pageContext) .pageContext(pageContext)
.build(); .build();
aiChatProvider.streamChat(request, onToken, onComplete, onError); aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
} }
/** /**
@@ -105,11 +107,6 @@ public class StreamChatForLoreUseCase {
? page.getValues() ? page.getValues()
: Collections.emptyMap(); : Collections.emptyMap();
return PageContext.builder() return new PageContext(page.getTitle(), templateName, templateFields, values);
.title(page.getTitle())
.templateName(templateName)
.templateFields(templateFields)
.values(values)
.build();
} }
} }

View File

@@ -0,0 +1,122 @@
package com.loremind.application.generationcontext;
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat IA pendant une Session de jeu.
* <p>
* Orchestre la composition des contextes :
* 1. Charge la Session puis la Campagne associée (weak reference).
* 2. Construit le CampaignStructuralContext (carte narrative + PJ/PNJ).
* 3. Construit le LoreStructuralContext si la campagne est liée à un Lore.
* 4. Construit le GameSystemContext si elle a un système de JDR.
* 5. Construit le SessionContext (journal horodaté, statut).
* 6. Délègue au port {@link AiChatProvider} pour le streaming.
* </p>
*
* <p>La conversation est éphémère (pas de persistance) : pendant une partie,
* l'utilité est d'avoir une assistance immédiate, pas de garder un historique.
* Le journal de session joue déjà ce rôle de mémoire persistante.</p>
*/
@Service
public class StreamChatForSessionUseCase {
private final SessionRepository sessionRepository;
private final PlaythroughRepository playthroughRepository;
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final GameSystemContextBuilder gameSystemContextBuilder;
private final SessionStructuralContextBuilder sessionContextBuilder;
private final AiChatProvider aiChatProvider;
public StreamChatForSessionUseCase(
SessionRepository sessionRepository,
PlaythroughRepository playthroughRepository,
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
GameSystemContextBuilder gameSystemContextBuilder,
SessionStructuralContextBuilder sessionContextBuilder,
AiChatProvider aiChatProvider) {
this.sessionRepository = sessionRepository;
this.playthroughRepository = playthroughRepository;
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.gameSystemContextBuilder = gameSystemContextBuilder;
this.sessionContextBuilder = sessionContextBuilder;
this.aiChatProvider = aiChatProvider;
}
public void execute(
String sessionId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
// Chaîne de résolution : Session → Playthrough → Campaign.
Playthrough playthrough = playthroughRepository.findById(session.getPlaythroughId())
.orElseThrow(() -> new IllegalArgumentException(
"Partie associée à la session introuvable : " + session.getPlaythroughId()));
Campaign campaign = campaignRepository.findById(playthrough.getCampaignId())
.orElseThrow(() -> new IllegalArgumentException(
"Campagne associée à la partie introuvable : " + playthrough.getCampaignId()));
// Le campaign context inclut les PJ de CE Playthrough (les PJ sont par-table).
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId(), playthrough.getId());
LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
SessionContext sessionContext = sessionContextBuilder.build(sessionId);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.campaignContext(campaignContext)
.gameSystemContext(gameSystemContext)
.sessionContext(sessionContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
}
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
if (!campaign.isLinkedToLore()) return null;
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
}
/**
* Pendant une session active, on injecte les sections les plus utiles en partie
* (combats, PNJ, mécaniques) — intent SCENE est le plus proche de ce besoin.
*/
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign) {
if (!campaign.isLinkedToGameSystem()) return null;
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), GenerationIntent.SCENE)
.orElse(null);
}
}

View File

@@ -0,0 +1,135 @@
package com.loremind.application.licensing;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
*
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
* dans un volume partage. Quand on depose une commande, il :
* <ol>
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
* <li>Lance docker compose pull + up -d</li>
* <li>Ecrit son resultat dans {@code result.json}</li>
* </ol>
*
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
* sur l'hote. Le sidecar valide strictement le contenu de la commande
* (channel ∈ {stable, beta} uniquement).
*
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
* presence de "loremind-beta-" => canal beta, sinon stable.
*/
@Service
public class ChannelSwitcherService {
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
public enum Channel { STABLE, BETA }
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SwitchResult(
String id,
SwitchStatus status,
Channel channel,
String message,
Instant completedAt) {}
private final Path switcherDataPath;
private final String imageNamespace;
private final ObjectMapper json = new ObjectMapper();
public ChannelSwitcherService(
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
// qui peut etre absente dans les .env legacy).
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
this.switcherDataPath = Path.of(switcherDataPath);
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
switcherDataPath, this.imageNamespace);
}
/**
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
*/
public Channel getCurrentChannel() {
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
}
/**
* Indique si le sidecar est disponible (volume partage accessible).
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
* avec instructions manuelles).
*/
public boolean isSwitcherAvailable() {
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
}
/**
* Depose une commande de switch dans le volume partage. Renvoie l'ID
* de la commande, que le client peut utiliser pour poller le status.
*
* @throws IllegalStateException si le sidecar n'est pas disponible
* @throws IOException si l'ecriture du fichier echoue
*/
public String requestSwitch(Channel target) throws IOException {
if (!isSwitcherAvailable()) {
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
}
String id = UUID.randomUUID().toString();
Map<String, Object> command = new LinkedHashMap<>();
command.put("id", id);
command.put("channel", target.name().toLowerCase());
command.put("requestedAt", Instant.now().toString());
Path commandFile = switcherDataPath.resolve("command.json");
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
try {
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} finally {
// Cleanup au cas ou move aurait echoue avant le rename.
Files.deleteIfExists(tmp);
}
log.info("Switch command written: id={} channel={}", id, target);
return id;
}
/**
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
*/
public SwitchResult getLastResult() {
Path resultFile = switcherDataPath.resolve("result.json");
if (!Files.exists(resultFile)) return null;
try {
return json.readValue(resultFile.toFile(), SwitchResult.class);
} catch (IOException e) {
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,261 @@
package com.loremind.application.licensing;
import com.loremind.domain.licensing.License;
import com.loremind.domain.licensing.LicenseClaims;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.JwtVerifier;
import com.loremind.domain.licensing.ports.LicenseRelay;
import com.loremind.domain.licensing.ports.LicenseRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
/**
* Service application pour la gestion de la licence Patreon.
* <p>
* Responsabilites :
* <ul>
* <li>Installer un nouveau JWT recu du relais (apres OAuth utilisateur)</li>
* <li>Calculer le {@link LicenseStatus} courant en respectant la grace period</li>
* <li>Renouveler le JWT avant expiration en appelant le relais</li>
* <li>Activer/desactiver le toggle "canal beta" cote utilisateur</li>
* <li>Distribuer les credentials registry pour le pull beta</li>
* </ul>
*/
@Service
public class LicenseService {
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
private final LicenseRepository repository;
private final JwtVerifier jwtVerifier;
private final LicenseRelay relay;
private final long gracePeriodSeconds;
private final long refreshBeforeExpirySeconds;
public LicenseService(
LicenseRepository repository,
JwtVerifier jwtVerifier,
LicenseRelay relay,
@Value("${licensing.grace-period-days:14}") int gracePeriodDays,
@Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) {
this.repository = repository;
this.jwtVerifier = jwtVerifier;
this.relay = relay;
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
}
/**
* @return true si le verifier est configure (cle publique presente).
* L'UI peut masquer toute la section Patreon si false.
*/
public boolean isLicensingEnabled() {
return jwtVerifier.isConfigured();
}
/**
* Genere ou retourne l'instance_id stable de cette installation.
* Stocke dans la licence elle-meme. Si pas de licence, en cree un volatil
* (sera persiste a la prochaine connexion).
*/
public String getOrCreateInstanceId() {
return repository.findCurrent()
.map(License::getInstanceId)
.orElseGet(() -> "li-" + UUID.randomUUID());
}
/**
* Construit l'URL OAuth pour ouvrir dans le navigateur de l'utilisateur.
*/
public String buildConnectUrl() {
return relay.buildConnectUrl(getOrCreateInstanceId());
}
/**
* Installe un JWT recu du relais (l'utilisateur l'a colle dans l'UI ou
* recu via deep-link). Verifie la signature, extrait les claims, persiste.
*/
public LicenseSnapshot installToken(String rawJwt) throws InstallException {
if (!jwtVerifier.isConfigured()) {
throw new InstallException("Licensing feature not enabled (no public key configured)");
}
LicenseClaims claims;
try {
claims = jwtVerifier.verify(rawJwt);
} catch (JwtVerifier.JwtVerificationException e) {
throw new InstallException("Invalid JWT: " + e.getMessage());
}
Instant now = Instant.now();
if (claims.expiresAt().isBefore(now)) {
throw new InstallException("JWT already expired");
}
Optional<License> existing = repository.findCurrent();
License toSave = License.builder()
.id("current")
.rawJwt(rawJwt)
.patreonUserId(claims.subject())
.tierId(claims.tierId())
.instanceId(claims.instanceId())
.issuedAt(claims.issuedAt())
.expiresAt(claims.expiresAt())
.lastRefreshAttemptAt(now)
.lastRefreshSucceeded(true)
// Au premier install, on active le canal beta par defaut.
// Sur reinstall apres deconnexion, on respecte la valeur precedente.
.betaChannelEnabled(existing.map(License::isBetaChannelEnabled).orElse(true))
.createdAt(existing.map(License::getCreatedAt).orElse(now))
.build();
License saved = repository.save(toSave);
log.info("Patreon license installed for user={} tier={} expires={}",
saved.getPatreonUserId(), saved.getTierId(), saved.getExpiresAt());
return snapshotOf(saved, now);
}
/**
* Etat courant de la licence pour exposition UI / decision technique.
*/
public LicenseSnapshot getCurrentSnapshot() {
Optional<License> opt = repository.findCurrent();
if (opt.isEmpty()) return LicenseSnapshot.none();
return snapshotOf(opt.get(), Instant.now());
}
/**
* Supprime la licence (deconnexion volontaire de Patreon par l'utilisateur).
*/
public void disconnect() {
repository.deleteCurrent();
log.info("Patreon license removed (user disconnect)");
}
/**
* Active ou desactive le canal beta. Necessite une licence valide ou en grace.
*/
public LicenseSnapshot setBetaChannelEnabled(boolean enabled) {
License current = repository.findCurrent()
.orElseThrow(() -> new IllegalStateException("No license installed"));
current.setBetaChannelEnabled(enabled);
License saved = repository.save(current);
return snapshotOf(saved, Instant.now());
}
/**
* Tente un refresh si la licence est proche de l'expiration. Idempotent.
* Appele par le daemon planifie + manuellement via l'UI ("Reessayer").
*
* @return true si un refresh a ete tente (avec ou sans succes)
*/
public boolean refreshIfNeeded() {
Optional<License> opt = repository.findCurrent();
if (opt.isEmpty()) return false;
License current = opt.get();
Instant now = Instant.now();
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
return false;
}
return doRefresh(current, now);
}
/**
* Force un refresh immediat (bouton UI "Reessayer maintenant").
*/
public boolean forceRefresh() {
return repository.findCurrent()
.map(license -> doRefresh(license, Instant.now()))
.orElse(false);
}
private boolean doRefresh(License current, Instant now) {
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
try {
String newJwt = relay.refreshToken(current.getRawJwt());
LicenseClaims claims = jwtVerifier.verify(newJwt);
current.setRawJwt(newJwt);
current.setIssuedAt(claims.issuedAt());
current.setExpiresAt(claims.expiresAt());
current.setTierId(claims.tierId());
current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(true);
repository.save(current);
log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
return true;
} catch (LicenseRelay.RelayException e) {
current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false);
repository.save(current);
if (e.getKind() == LicenseRelay.RelayErrorKind.REJECTED) {
log.warn("Relay rejected refresh ({}): tier may have been cancelled", e.getMessage());
} else {
log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
}
return true;
} catch (JwtVerifier.JwtVerificationException e) {
current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false);
repository.save(current);
log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
return true;
}
}
/**
* Recupere les credentials registry pour pull du canal beta.
* @return empty si pas de licence valide ou relais en echec
*/
public Optional<RegistryCredentials> fetchRegistryCredentials() {
LicenseSnapshot snap = getCurrentSnapshot();
if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
return Optional.empty();
}
License current = repository.findCurrent().orElse(null);
if (current == null) return Optional.empty();
try {
return Optional.of(relay.fetchRegistryCredentials(current.getRawJwt()));
} catch (LicenseRelay.RelayException e) {
log.warn("Cannot fetch registry credentials ({}): {}", e.getKind(), e.getMessage());
return Optional.empty();
}
}
private LicenseSnapshot snapshotOf(License l, Instant now) {
LicenseStatus status = computeStatus(l, now);
return new LicenseSnapshot(
status,
l.getPatreonUserId(),
l.getTierId(),
l.getInstanceId(),
l.getExpiresAt(),
l.getLastRefreshAttemptAt(),
l.isLastRefreshSucceeded(),
l.isBetaChannelEnabled()
);
}
private LicenseStatus computeStatus(License l, Instant now) {
if (l.getExpiresAt() == null) return LicenseStatus.NONE;
if (now.isBefore(l.getExpiresAt())) return LicenseStatus.VALID;
long secondsPastExpiry = Duration.between(l.getExpiresAt(), now).getSeconds();
if (secondsPastExpiry <= gracePeriodSeconds) return LicenseStatus.GRACE;
return LicenseStatus.EXPIRED;
}
public static class InstallException extends Exception {
public InstallException(String message) {
super(message);
}
}
}

View File

@@ -1,9 +1,13 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode; import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -16,11 +20,20 @@ import java.util.Optional;
public class LoreNodeService { public class LoreNodeService {
private final LoreNodeRepository loreNodeRepository; private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
public LoreNodeService(LoreNodeRepository loreNodeRepository) { public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
this.loreNodeRepository = loreNodeRepository; this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
} }
/**
* Compte des entités qui seront supprimées en cascade si le dossier est effacé :
* le dossier lui-même n'est pas compté, seuls les descendants (sous-dossiers
* récursifs + pages de l'ensemble du sous-arbre).
*/
public record DeletionImpact(int folders, int pages) {}
/** /**
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs * Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent * souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
@@ -68,7 +81,64 @@ public class LoreNodeService {
return loreNodeRepository.save(existing); return loreNodeRepository.save(existing);
} }
/**
* Calcule l'impact d'une suppression en cascade : nombre de sous-dossiers
* (récursif, sans compter la racine) et de pages dans l'ensemble du sous-arbre.
*/
public DeletionImpact getDeletionImpact(String id) {
List<LoreNode> descendants = collectDescendants(id);
int pageTotal = pageRepository.findByNodeId(id).size();
for (LoreNode descendant : descendants) {
pageTotal += pageRepository.findByNodeId(descendant.getId()).size();
}
return new DeletionImpact(descendants.size(), pageTotal);
}
/**
* Supprime le dossier et tout son sous-arbre (sous-dossiers récursifs + pages).
* Suppression en profondeur d'abord (feuilles → racine) pour limiter les
* références orphelines en cours de transaction. Les FKs applicatives n'ayant
* pas de CASCADE en DB, on orchestre la descente ici.
*/
@Transactional
public void deleteLoreNode(String id) { public void deleteLoreNode(String id) {
List<LoreNode> descendants = collectDescendants(id);
// Descendants retournés en ordre BFS (haut → bas) : on inverse pour
// supprimer les feuilles en premier, puis on finit par la racine.
for (int i = descendants.size() - 1; i >= 0; i--) {
String descendantId = descendants.get(i).getId();
deletePagesOfNode(descendantId);
loreNodeRepository.deleteById(descendantId);
}
deletePagesOfNode(id);
loreNodeRepository.deleteById(id); loreNodeRepository.deleteById(id);
} }
private void deletePagesOfNode(String nodeId) {
for (Page page : pageRepository.findByNodeId(nodeId)) {
pageRepository.deleteById(page.getId());
}
}
/**
* Retourne tous les descendants (hors racine) d'un dossier, en ordre BFS.
* Parcours itératif pour éviter tout risque de débordement de pile sur
* une arborescence profonde malicieuse.
*/
private List<LoreNode> collectDescendants(String rootId) {
List<LoreNode> result = new ArrayList<>();
List<String> frontier = new ArrayList<>();
frontier.add(rootId);
while (!frontier.isEmpty()) {
List<String> nextFrontier = new ArrayList<>();
for (String parentId : frontier) {
for (LoreNode child : loreNodeRepository.findByParentId(parentId)) {
result.add(child);
nextFrontier.add(child.getId());
}
}
frontier = nextFrontier;
}
return result;
}
} }

View File

@@ -1,10 +1,17 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.lorecontext.Lore; import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -26,15 +33,28 @@ public class LoreService {
private final LoreRepository loreRepository; private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository; private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository; private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final CampaignRepository campaignRepository;
public LoreService(LoreRepository loreRepository, public LoreService(LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository, LoreNodeRepository loreNodeRepository,
PageRepository pageRepository) { PageRepository pageRepository,
TemplateRepository templateRepository,
CampaignRepository campaignRepository) {
this.loreRepository = loreRepository; this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository; this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository; this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.campaignRepository = campaignRepository;
} }
/**
* Compte des entités qui seront supprimées / détachées en cascade si le Lore
* est effacé. `detachedCampaigns` : campagnes qui perdront leur référence à
* ce Lore (leur loreId sera nullé) mais resteront présentes.
*/
public record DeletionImpact(int folders, int pages, int templates, int detachedCampaigns) {}
public Lore createLore(String name, String description) { public Lore createLore(String name, String description) {
Lore lore = Lore.builder() Lore lore = Lore.builder()
.name(name) .name(name)
@@ -76,14 +96,61 @@ public class LoreService {
if (existingLore.isEmpty()) { if (existingLore.isEmpty()) {
throw new IllegalArgumentException("Lore non trouvé avec l'ID: " + id); throw new IllegalArgumentException("Lore non trouvé avec l'ID: " + id);
} }
Lore lore = existingLore.get(); Lore lore = existingLore.get();
lore.setName(name); lore.setName(name);
lore.setDescription(description); lore.setDescription(description);
return loreRepository.save(lore); return loreRepository.save(lore);
} }
/**
* Calcule l'impact d'une suppression de Lore en cascade : dossiers + pages
* + templates supprimés, et campagnes qui seront détachées (loreId → null
* sans être supprimées, car une campagne peut vivre sans univers).
*/
public DeletionImpact getDeletionImpact(String id) {
int folders = (int) loreNodeRepository.countByLoreId(id);
int pages = (int) pageRepository.countByLoreId(id);
int templates = templateRepository.findByLoreId(id).size();
int detached = countCampaignsReferencingLore(id);
return new DeletionImpact(folders, pages, templates, detached);
}
/**
* Supprime le Lore et toutes ses entités dépendantes (dossiers, pages, templates).
* Les campagnes qui référençaient ce Lore sont conservées — leur loreId est
* mis à null (une campagne peut légitimement exister sans univers associé).
* Opération transactionnelle : atomique.
*/
@Transactional
public void deleteLore(String id) { public void deleteLore(String id) {
// Pages d'abord : elles référencent nodeId ET loreId, on les supprime
// globalement via loreId pour éviter d'en rater une rattachée à un
// node orphelin (ne devrait pas arriver, mais ceinture+bretelles).
for (Page page : pageRepository.findByLoreId(id)) {
pageRepository.deleteById(page.getId());
}
for (LoreNode node : loreNodeRepository.findByLoreId(id)) {
loreNodeRepository.deleteById(node.getId());
}
for (Template template : templateRepository.findByLoreId(id)) {
templateRepository.deleteById(template.getId());
}
// Détache les campagnes : on garde la campagne, on nulle juste la référence.
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) {
campaign.setLoreId(null);
campaignRepository.save(campaign);
}
}
loreRepository.deleteById(id); loreRepository.deleteById(id);
} }
private int countCampaignsReferencingLore(String id) {
int count = 0;
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) count++;
}
return count;
}
} }

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View File

@@ -0,0 +1,110 @@
package com.loremind.application.playcontext;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
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 org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* Service applicatif pour le cycle de vie d'un Playthrough (Partie).
*
* <p>Cascade de suppression : un Playthrough supprime ses flags, ses progressions,
* ses PJ et ses sessions (avec leurs entrées de journal).</p>
*/
@Service
public class PlaythroughService {
private final PlaythroughRepository playthroughRepository;
private final CampaignRepository campaignRepository;
private final PlaythroughFlagRepository flagRepository;
private final QuestProgressionRepository progressionRepository;
private final CharacterRepository characterRepository;
private final SessionRepository sessionRepository;
private final SessionService sessionService;
public PlaythroughService(PlaythroughRepository playthroughRepository,
CampaignRepository campaignRepository,
PlaythroughFlagRepository flagRepository,
QuestProgressionRepository progressionRepository,
CharacterRepository characterRepository,
SessionRepository sessionRepository,
SessionService sessionService) {
this.playthroughRepository = playthroughRepository;
this.campaignRepository = campaignRepository;
this.flagRepository = flagRepository;
this.progressionRepository = progressionRepository;
this.characterRepository = characterRepository;
this.sessionRepository = sessionRepository;
this.sessionService = sessionService;
}
/** Compte des entités qui seront supprimées en cascade avec la Partie. */
public record DeletionImpact(int sessions, int characters, int flags, int progressions) {}
public Playthrough create(String campaignId, String name, String description) {
if (campaignId == null || campaignId.isBlank()) {
throw new IllegalArgumentException("campaignId requis.");
}
if (!campaignRepository.existsById(campaignId)) {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
}
Playthrough p = Playthrough.builder()
.campaignId(campaignId)
.name((name == null || name.isBlank()) ? "Partie principale" : name.trim())
.description(description)
.build();
return playthroughRepository.save(p);
}
public Playthrough update(String id, Playthrough updated) {
Playthrough existing = playthroughRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Partie introuvable : " + id));
BeanUtils.copyProperties(updated, existing, "id", "campaignId", "createdAt");
return playthroughRepository.save(existing);
}
public Optional<Playthrough> getById(String id) {
return playthroughRepository.findById(id);
}
public List<Playthrough> getByCampaignId(String campaignId) {
return playthroughRepository.findByCampaignId(campaignId);
}
public DeletionImpact getDeletionImpact(String id) {
int sessions = sessionRepository.findByPlaythroughId(id).size();
int characters = characterRepository.findByPlaythroughId(id).size();
int flags = flagRepository.findByPlaythroughId(id).size();
int progressions = progressionRepository.findByPlaythroughId(id).size();
return new DeletionImpact(sessions, characters, flags, progressions);
}
@Transactional
public void delete(String id) {
if (!playthroughRepository.existsById(id)) {
throw new IllegalArgumentException("Partie introuvable : " + id);
}
// Cascade : sessions (et leurs entries), PJ, flags, progressions
for (Session s : sessionRepository.findByPlaythroughId(id)) {
sessionService.deleteSession(s.getId());
}
characterRepository.findByPlaythroughId(id).forEach(c -> characterRepository.deleteById(c.getId()));
flagRepository.deleteAllByPlaythroughId(id);
progressionRepository.deleteAllByPlaythroughId(id);
playthroughRepository.deleteById(id);
}
public boolean exists(String id) {
return playthroughRepository.existsById(id);
}
}

View File

@@ -0,0 +1,81 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le journal d'une Session.
* Gère le cycle CRUD des entrées (note, évènement, jet, action joueur).
*/
@Service
public class SessionEntryService {
private final SessionEntryRepository entryRepository;
private final SessionRepository sessionRepository;
public SessionEntryService(SessionEntryRepository entryRepository,
SessionRepository sessionRepository) {
this.entryRepository = entryRepository;
this.sessionRepository = sessionRepository;
}
/** Données fournies par l'API pour créer ou éditer une entrée. */
public record EntryData(EntryType type, String content, LocalDateTime occurredAt) {}
public SessionEntry createEntry(String sessionId, EntryData data) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("sessionId est requis.");
}
if (!sessionRepository.existsById(sessionId)) {
throw new IllegalArgumentException("Session introuvable : " + sessionId);
}
validateContent(data.content());
LocalDateTime now = LocalDateTime.now();
SessionEntry entry = SessionEntry.builder()
.sessionId(sessionId)
.type(data.type() != null ? data.type() : EntryType.NOTE)
.content(data.content().trim())
.occurredAt(data.occurredAt() != null ? data.occurredAt() : now)
.build();
return entryRepository.save(entry);
}
public SessionEntry updateEntry(String id, EntryData data) {
validateContent(data.content());
SessionEntry existing = entryRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Entrée introuvable : " + id));
if (data.type() != null) existing.setType(data.type());
existing.setContent(data.content().trim());
if (data.occurredAt() != null) existing.setOccurredAt(data.occurredAt());
return entryRepository.save(existing);
}
public Optional<SessionEntry> getById(String id) {
return entryRepository.findById(id);
}
public List<SessionEntry> getBySessionId(String sessionId) {
return entryRepository.findBySessionId(sessionId);
}
public void deleteEntry(String id) {
if (!entryRepository.existsById(id)) {
throw new IllegalArgumentException("Entrée introuvable : " + id);
}
entryRepository.deleteById(id);
}
private void validateContent(String content) {
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("Le contenu d'une entrée ne peut pas être vide.");
}
}
}

View File

@@ -0,0 +1,118 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le Play Context.
* Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
*
* <p>Règle métier : une seule Session peut être active (endedAt null) à la fois.</p>
* <p>Depuis Playthrough : une Session appartient à un Playthrough (pas directement à une Campaign).</p>
*/
@Service
public class SessionService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
}
/**
* Lance une nouvelle session sur le Playthrough donné.
* Échoue si une session est déjà active ou si le Playthrough n'existe pas.
*/
public Session startSession(String playthroughId) {
if (playthroughId == null || playthroughId.isBlank()) {
throw new IllegalArgumentException("playthroughId est requis pour démarrer une session.");
}
if (!playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
// Règle métier : une seule session active par Partie (pas de verrou global cross-Partie).
sessionRepository.findActiveByPlaythroughId(playthroughId).ifPresent(s -> {
throw new IllegalStateException(
"Une session est déjà en cours pour cette Partie (id=" + s.getId() +
"). Termine-la avant d'en lancer une nouvelle.");
});
LocalDateTime now = LocalDateTime.now();
Session session = Session.builder()
.name(generateDefaultName(now))
.playthroughId(playthroughId)
.startedAt(now)
.build();
return sessionRepository.save(session);
}
public Session endSession(String id) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
if (!session.isActive()) {
throw new IllegalStateException("Cette session est déjà terminée.");
}
session.setEndedAt(LocalDateTime.now());
return sessionRepository.save(session);
}
public Session renameSession(String id, String newName) {
if (newName == null || newName.isBlank()) {
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
}
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
session.setName(newName.trim());
return sessionRepository.save(session);
}
public Optional<Session> getById(String id) {
return sessionRepository.findById(id);
}
public Optional<Session> getActive() {
return sessionRepository.findActive();
}
public Optional<Session> getActiveByPlaythrough(String playthroughId) {
return sessionRepository.findActiveByPlaythroughId(playthroughId);
}
public List<Session> getAll() {
return sessionRepository.findAll();
}
public List<Session> getByPlaythroughId(String playthroughId) {
return sessionRepository.findByPlaythroughId(playthroughId);
}
@Transactional
public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) {
throw new IllegalArgumentException("Session introuvable : " + id);
}
entryRepository.deleteBySessionId(id);
sessionRepository.deleteById(id);
}
private String generateDefaultName(LocalDateTime startedAt) {
return "Session du " + startedAt.format(DATE_FORMATTER);
}
}

View File

@@ -21,6 +21,17 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne private int order; // Ordre de l'arc dans la campagne
/**
* Type structurel de l'arc. Détermine son rendu UI et la sémantique de ses chapitres
* (séquence narrative LINEAR vs. quêtes parallèles d'un HUB).
* Défaut LINEAR pour rétro-compatibilité avec les arcs existants.
*/
@Builder.Default
private ArcType type = ArcType.LINEAR;
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String themes; // Thèmes principaux explorés dans cet arc private String themes; // Thèmes principaux explorés dans cet arc
private String stakes; // Enjeux globaux pour les personnages private String stakes; // Enjeux globaux pour les personnages
@@ -38,12 +49,18 @@ public class Arc {
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();
/** /**
* IDs des images (Shared Kernel) servant d'illustrations a cet arc. * IDs des images (Shared Kernel) servant d'illustrations a cet arc (ambiance).
* Galerie ordonnee : la 1ere image est l'illustration principale. * Galerie ordonnee : la 1ere image est l'illustration principale.
*/ */
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); 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 createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

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

View File

@@ -6,8 +6,12 @@ import java.time.LocalDateTime;
/** /**
* Entité de domaine représentant une Campaign. * Entité de domaine représentant une Campaign.
* Conteneur global pour organiser la narration d'une campagne. * Conteneur du SCÉNARIO (générique, ré-utilisable par plusieurs tables).
* Entité pure du domaine, sans dépendance technique. *
* <p>Toute donnée dynamique propre à une table jouée (progression des quêtes,
* flags narratifs, sessions, PJ) vit dans un {@link com.loremind.domain.playcontext.Playthrough}.</p>
*
* <p>Entité pure du domaine, sans dépendance technique.</p>
*/ */
@Data @Data
@Builder @Builder
@@ -21,14 +25,21 @@ public class Campaign {
private int arcsCount; private int arcsCount;
/** /**
* Référence faible (weak reference) vers un Lore. * Référence faible vers un Lore. Nullable.
* Nullable : une campagne peut exister sans univers associé (one-shot, test, pitch libre). * Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context
* (respect des Bounded Contexts en DDD).
*/ */
private String loreId; private String loreId;
/**
* Référence faible vers un GameSystem. Nullable.
*/
private String gameSystemId;
public boolean isLinkedToLore() { public boolean isLinkedToLore() {
return this.loreId != null && !this.loreId.isBlank(); return this.loreId != null && !this.loreId.isBlank();
} }
public boolean isLinkedToGameSystem() {
return this.gameSystemId != null && !this.gameSystemId.isBlank();
}
} }

View File

@@ -21,6 +21,17 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc 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;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT) private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs dans ce chapitre private String playerObjectives; // Objectifs des joueurs dans ce chapitre
@@ -34,11 +45,17 @@ public class Chapter {
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();
/** /**
* IDs des images (Shared Kernel) illustrant ce chapitre. * IDs des images (Shared Kernel) illustrant ce chapitre (ambiance).
*/ */
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); 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 createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@@ -0,0 +1,90 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche de personnage joueur (PJ) d'une campagne.
* <p>
* Champs universels hard-codes : {@code name}, {@code portraitImageId},
* {@code headerImageId}. Tout le reste est piloté par le template PJ du
* GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
* <p>
* Les valeurs des champs templates sont stockées dans deux maps :
* - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
* parsé à l'usage cote presentation)
* - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
* <p>
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
* <p>
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
*/
@Data
@Builder
public class Character {
private String id;
private String name;
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
private String portraitImageId;
/** ID de l'image header/banniere (champ universel hard-codé). Nullable. */
private String headerImageId;
/**
* Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
* (sensible a la casse cote stockage mais comparaison case-insensitive
* dans le domaine GameSystem). Jamais null apres construction.
*/
private Map<String, String> values;
/**
* Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
* liste ordonnee d'IDs d'images. Jamais null apres construction.
*/
private Map<String, List<String>> imageValues;
/**
* Valeurs des champs KEY_VALUE_LIST du template PJ. Cle externe = nom du
* champ template (ex: "Caracteristiques"), cle interne = label predefini
* dans le template (ex: "FOR"), valeur = valeur saisie (ex: "16").
* Les labels suivent l'ordre defini dans TemplateField.labels.
*/
private Map<String, Map<String, String>> keyValueValues;
/**
* Référence vers le Playthrough (= la partie / table) auquel ce PJ appartient.
* Les PJ sont propres à une table jouée, pas au scénario générique de la campagne.
* Weak reference cross-context.
*/
private String playthroughId;
/** Ordre d'affichage dans la liste des PJ de la Partie. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** Garantit que les maps ne sont jamais null cote consommateur. */
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
}

View File

@@ -0,0 +1,69 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche de personnage non-joueur (PNJ) d'une campagne.
* <p>
* Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
* à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
* <p>
* Mêmes champs universels hard-codés et meme structure de templating que Character,
* pilotée par le template PNJ du GameSystem
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
* <p>
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
* via le système Page/Template du LoreContext.
*/
@Data
@Builder
public class Npc {
private String id;
private String name;
/** ID de l'image portrait (champ universel hard-code). Nullable. */
private String portraitImageId;
/** ID de l'image header/banniere (champ universel hard-code). Nullable. */
private String headerImageId;
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
private Map<String, String> values;
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
}

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.campaigncontext;
/**
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
*
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
* {@link PrerequisiteEvaluator}.
*
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
* (pas de OR pour le moment).
*/
public sealed interface Prerequisite
permits Prerequisite.QuestCompleted,
Prerequisite.SessionReached,
Prerequisite.FlagSet {
/** La quête référencée par {@code questId} doit être en COMPLETED. */
record QuestCompleted(String questId) implements Prerequisite {}
/** Le compteur de sessions de la campagne doit avoir atteint {@code minSessionNumber}. */
record SessionReached(int minSessionNumber) implements Prerequisite {}
/** Le flag campagne nommé {@code flagName} doit être à true. */
record FlagSet(String flagName) implements Prerequisite {}
}

View File

@@ -0,0 +1,65 @@
package com.loremind.domain.campaigncontext;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
* et en dérive le {@link QuestStatus} effectif.
*
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
* compile-time est compensée par le throw final qui fait crasher tout nouvel
* implémentant non câblé.
*/
public final class PrerequisiteEvaluator {
/**
* Contexte minimal nécessaire à l'évaluation. On ne passe pas la Campaign entière
* pour ne pas créer de couplage fort ; juste les faits nécessaires.
*/
public record EvaluationContext(
Set<String> completedQuestIds,
int currentSessionCount,
Map<String, Boolean> campaignFlags
) {}
/** True si TOUS les prérequis sont satisfaits (ET logique). Vide => true. */
public boolean areAllSatisfied(List<Prerequisite> prerequisites, EvaluationContext ctx) {
if (prerequisites == null || prerequisites.isEmpty()) return true;
return prerequisites.stream().allMatch(p -> isSatisfied(p, ctx));
}
/** Évalue un seul prérequis. */
public boolean isSatisfied(Prerequisite prereq, EvaluationContext ctx) {
if (prereq instanceof Prerequisite.QuestCompleted q) {
return ctx.completedQuestIds().contains(q.questId());
}
if (prereq instanceof Prerequisite.SessionReached s) {
return ctx.currentSessionCount() >= s.minSessionNumber();
}
if (prereq instanceof Prerequisite.FlagSet f) {
return Boolean.TRUE.equals(ctx.campaignFlags().get(f.flagName()));
}
throw new IllegalStateException("Prerequisite non géré : " + prereq.getClass().getName());
}
/** Dérive le statut effectif à partir de la progression manuelle + des prérequis. */
public QuestStatus computeStatus(
ProgressionStatus progression,
List<Prerequisite> prerequisites,
EvaluationContext ctx
) {
switch (progression) {
case COMPLETED: return QuestStatus.COMPLETED;
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
case NOT_STARTED:
return areAllSatisfied(prerequisites, ctx)
? QuestStatus.AVAILABLE
: QuestStatus.LOCKED;
default:
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.campaigncontext;
/**
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
*
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
* selon les prérequis — voir {@link QuestStatus}.
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
* COMPLETED : marquée terminée par le MJ.
*
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
*/
public enum ProgressionStatus {
NOT_STARTED,
IN_PROGRESS,
COMPLETED
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.campaigncontext;
/**
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
*
* Table de vérité :
* NOT_STARTED + prérequis non remplis -> LOCKED
* NOT_STARTED + prérequis remplis -> AVAILABLE
* IN_PROGRESS -> IN_PROGRESS
* COMPLETED -> COMPLETED
*/
public enum QuestStatus {
LOCKED,
AVAILABLE,
IN_PROGRESS,
COMPLETED
}

View File

@@ -0,0 +1,69 @@
package com.loremind.domain.campaigncontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* Pièce d'un lieu explorable (donjon, crypte…) attaché à une Scene.
*
* <p>Une Scene devient « explorable » dès qu'elle a au moins une Room. Tant
* qu'elle n'en a pas, elle se comporte comme un beat narratif classique.</p>
*
* <p>Pas un record Java parce que la liste {@code branches} est mutable côté
* builder ; on garde la classe Lombok pour la cohérence avec le reste du
* domaine (Arc, Chapter, Scene). L'ID est généré côté front (UUID) au moment
* de la création — pas d'auto-increment DB puisque c'est un Value Object
* sérialisé en JSONB sur Scene.</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Room {
/** ID stable (UUID généré côté client). Sert de cible aux {@link RoomBranch}. */
private String id;
/** Nom de la pièce (« Antichambre », « Salle du trône »). */
private String name;
/** Narration / description lue ou résumée aux joueurs en entrant. */
private String description;
/** Énemis, créatures, boss éventuels (markdown libre). */
private String enemies;
/** Loot / récompenses présentes dans la pièce. */
private String loot;
/** Pièges / dangers environnementaux. */
private String traps;
/** Notes privées du MJ (cachées des joueurs). */
private String gmNotes;
/** Étage / niveau de la pièce. 0 = rez-de-chaussée. Nullable = pas d'étage défini. */
private Integer floor;
/** Ordre d'affichage dans la liste (au sein d'un même étage le cas échéant). */
private int order;
/** IDs d'images d'illustration / ambiance. */
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/** ID de l'image « plan » de la pièce (1 image dédiée, schéma tactique). */
private String mapImageId;
/**
* Sorties vers d'autres pièces. {@link RoomBranch#targetRoomId()} doit pointer
* vers une Room de la même Scene.
*/
@Builder.Default
private List<RoomBranch> branches = new ArrayList<>();
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext;
/**
* Sortie d'une pièce vers une autre pièce de la même Scene explorable.
* Équivalent inter-pièces de {@link SceneBranch}.
*
* <p>Record Java immuable, sérialisé via Jackson dans la liste JSONB
* {@code rooms} de la Scene.</p>
*
* <p>Règle métier : {@code targetRoomId} doit pointer vers une Room de la
* MÊME Scene (validation côté service).</p>
*
* @param label Libellé visible (« Porte nord », « Trappe au sol »).
* @param targetRoomId ID stable de la Room de destination (UUID Room.id).
* @param condition Condition optionnelle (« si les PJ ont la clé en argent »).
*/
public record RoomBranch(String label, String targetRoomId, String condition) {
public static RoomBranch of(String label, String targetRoomId) {
return new RoomBranch(label, targetRoomId, null);
}
}

View File

@@ -21,6 +21,9 @@ public class Scene {
private String chapterId; // Référence vers le Chapter parent private String chapterId; // Référence vers le Chapter parent
private int order; // Ordre de la scène dans le chapitre private int order; // Ordre de la scène dans le chapitre
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// === Contexte et ambiance === // === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or) 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) private String timing; // Moment (ex: Soir, à la tombée de la nuit)
@@ -48,11 +51,19 @@ public class Scene {
/** /**
* IDs des images (Shared Kernel) illustrant cette scene. * IDs des images (Shared Kernel) illustrant cette scene.
* Utile pour carte du lieu, portraits des PNJ principaux, ambiance. * Vocation "ambiance" : portraits, decors, moodboard. Rendu facon editorial.
*/ */
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); 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.
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
/** /**
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre). * Sorties narratives possibles depuis cette scène (graphe intra-chapitre).
* Chaque branche décrit un choix des joueurs et la scène de destination. * Chaque branche décrit un choix des joueurs et la scène de destination.
@@ -61,6 +72,15 @@ public class Scene {
@Builder.Default @Builder.Default
private List<SceneBranch> branches = new ArrayList<>(); private List<SceneBranch> branches = new ArrayList<>();
/**
* Pièces du lieu explorable représenté par cette scène (donjon, crypte, manoir…).
* Vide => scène classique « beat narratif » (comportement inchangé).
* Non vide => la scène devient explorable, l'UI affiche un layout dédié pièce-par-pièce.
* Sérialisé en JSONB.
*/
@Builder.Default
private List<Room> rooms = new ArrayList<>();
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@@ -1,31 +1,25 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
/** /**
* Value Object représentant une "sortie" narrative depuis une Scene. * Value Object représentant une "sortie" narrative depuis une Scene.
* Décrit un choix offert aux joueurs et la scène de destination associée. * Décrit un choix offert aux joueurs et la scène de destination associée.
* <p> * <p>
* Immuable (@Value) : pour "modifier" une branche on la remplace. * Record Java : immuable par construction, sans aucune dépendance technique
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA) * (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
* de reconstruire l'objet en passant par le builder malgré l'absence de setters. * les records nativement via le constructeur canonique — c'est ce dont
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
* <p> * <p>
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter * Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
* (validation portée par SceneService). * (validation portée par SceneService).
*
* @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).
*/ */
@Value public record SceneBranch(String label, String targetSceneId, String condition) {
@Builder
@Jacksonized
public class SceneBranch {
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */ /** Raccourci pour construire une branche sans condition (cas le plus courant). */
String label; public static SceneBranch of(String label, String targetSceneId) {
return new SceneBranch(label, targetSceneId, null);
/** Id de la Scene de destination, intra-chapitre uniquement. */ }
String targetSceneId;
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
String condition;
} }

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Character;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des fiches de personnages (PJ).
*/
public interface CharacterRepository {
Character save(Character character);
Optional<Character> findById(String id);
List<Character> findByPlaythroughId(String playthroughId);
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.Npc;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des fiches de PNJ (campagne).
*/
public interface NpcRepository {
Npc save(Npc npc);
Optional<Npc> findById(String id);
List<Npc> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,47 @@
package com.loremind.domain.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Agregat d'une conversation de chat IA persistee.
*
* Une conversation est ancree sur exactement un niveau de contexte :
* - un Lore (optionnellement une page precise)
* - une Campagne (optionnellement une entite narrative : arc/chapitre/scene)
*
* C'est cet ancrage qui permet au drawer de filtrer les conversations
* a afficher dans la sidebar selon l'ecran en cours.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Conversation {
private String id;
private String title;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** Un seul des deux est non-null. */
private String loreId;
private String campaignId;
/**
* Type d'entite focus, null si la conversation est ancree au niveau
* Lore/Campagne racine (pas sur une page/scene precise).
* Valeurs : "page", "arc", "chapter", "scene", "character".
*/
private String entityType;
private String entityId;
@Builder.Default
private List<ConversationMessage> messages = new ArrayList<>();
}

View File

@@ -0,0 +1,28 @@
package com.loremind.domain.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Un message persiste d'une conversation.
*
* Distinct de {@link com.loremind.domain.generationcontext.ChatMessage}
* qui reste un simple record role+content pour le streaming LLM. Ici
* on ajoute id et horodatage, necessaires pour l'affichage / l'ordre.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationMessage {
private String id;
/** "user" | "assistant" | "system". */
private String role;
private String content;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,44 @@
package com.loremind.domain.conversationcontext.ports;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import java.util.List;
import java.util.Optional;
/**
* Port de persistance des conversations de chat IA.
*
* Les methodes de lecture par contexte acceptent des filtres nullables :
* - `loreId` OU `campaignId` doit etre non-null (mais pas les deux)
* - `entityType` + `entityId` : soit tous les deux null (niveau racine),
* soit tous les deux non-null (niveau entite precise).
*/
public interface ConversationRepository {
Conversation save(Conversation conversation);
Optional<Conversation> findById(String id);
/**
* Liste les conversations filtrees par contexte strict, triees par
* updatedAt desc. Les messages ne sont PAS chargees (liste vide) pour
* garder la payload legere — la sidebar n'affiche que les titres.
*/
List<Conversation> findByContext(
String loreId,
String campaignId,
String entityType,
String entityId);
void deleteById(String id);
/**
* Ajoute un message a une conversation existante. Met a jour updatedAt
* de la conversation parent. Renvoie le message persiste (avec id + ts).
*/
ConversationMessage appendMessage(String conversationId, ConversationMessage message);
/** Rename atomique — ne touche pas aux messages. */
void updateTitle(String conversationId, String title);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.conversationcontext.ports;
import com.loremind.domain.conversationcontext.ConversationMessage;
import java.util.List;
/**
* Port : generation d'un titre court a partir des premiers echanges d'une
* conversation. Implemente via un appel Brain /summarize/conversation-title.
*/
public interface ConversationTitleGenerator {
/** Renvoie un titre en francais (4-7 mots max). Jamais null ni vide. */
String generate(List<ConversationMessage> firstMessages);
}

View File

@@ -0,0 +1,145 @@
package com.loremind.domain.gamesystemcontext;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Entité de domaine représentant un GameSystem (système de JDR).
* <p>
* Porte les règles d'un système (D&D, Nimble, Pathfinder, homebrew...) sous forme
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
* <p>
* Porte aussi deux templates piloтant la structure des fiches PJ et PNJ d'une
* campagne adossée à ce système. Les fiches markdown libres ont laissé place à
* un système de champs typés (TEXT/IMAGE/NUMBER) défini ici.
* <p>
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
* éviter une migration ultérieure.
*/
@Data
@Builder
public class GameSystem {
private String id;
private String name;
private String description;
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
private String rulesMarkdown;
/**
* Template de fiche PJ : champs typés affichés pour chaque personnage joueur.
* Hors champs universels hard-codés (nom, portrait, header). Jamais null après
* persistance — un template vide est représenté par une liste vide.
*/
private List<TemplateField> characterTemplate;
/**
* Template de fiche PNJ. Mêmes règles que {@link #characterTemplate}.
* Distinct du template PJ car les invariants métier divergent (un PNJ peut
* n'avoir qu'un nom + une motivation, un PJ porte généralement une feuille
* de stats complète).
*/
private List<TemplateField> npcTemplate;
/** Auteur déclaré — futur marketplace. Nullable. */
private String author;
/** Flag de partage — futur marketplace. False par défaut. */
private boolean isPublic;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// --- Méthodes métier : templates PJ/PNJ --------------------------------
/**
* Ajoute un champ au template PJ. Refuse les doublons de nom (insensible à la casse)
* pour éviter les collisions de clés dans {@code Character.values}.
*/
public void addCharacterField(TemplateField field) {
characterTemplate = appendField(characterTemplate, field);
}
/** Pendant PNJ de {@link #addCharacterField}. */
public void addNpcField(TemplateField field) {
npcTemplate = appendField(npcTemplate, field);
}
/**
* Retire un champ du template PJ par nom (insensible à la casse).
* No-op silencieux si le champ n'existe pas — appelant n'a pas à pré-vérifier.
*/
public void removeCharacterField(String fieldName) {
characterTemplate = removeFieldByName(characterTemplate, fieldName);
}
public void removeNpcField(String fieldName) {
npcTemplate = removeFieldByName(npcTemplate, fieldName);
}
/**
* Remplace intégralement le template PJ. Utilisé pour le réordonnancement
* et l'édition en bloc côté UI. Valide l'unicité des noms.
*/
public void replaceCharacterTemplate(List<TemplateField> fields) {
characterTemplate = validateAndCopy(fields);
}
public void replaceNpcTemplate(List<TemplateField> fields) {
npcTemplate = validateAndCopy(fields);
}
// --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
if (field == null || field.getName() == null || field.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
List<TemplateField> next = current == null ? new ArrayList<>() : new ArrayList<>(current);
if (containsName(next, field.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + field.getName());
}
next.add(field);
return next;
}
private static List<TemplateField> removeFieldByName(List<TemplateField> current, String fieldName) {
if (current == null || fieldName == null) return current;
List<TemplateField> next = new ArrayList<>(current);
next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName));
return next;
}
private static List<TemplateField> validateAndCopy(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateField> copy = new ArrayList<>(fields.size());
for (TemplateField f : fields) {
if (f == null || f.getName() == null || f.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
if (containsName(copy, f.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + f.getName());
}
copy.add(f);
}
return copy;
}
private static boolean containsName(List<TemplateField> fields, String name) {
return fields.stream().anyMatch(f -> equalsIgnoreCase(f.getName(), name));
}
private static boolean equalsIgnoreCase(String a, String b) {
if (a == null || b == null) return a == b;
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
}
}

View File

@@ -0,0 +1,57 @@
package com.loremind.domain.gamesystemcontext;
import java.util.Set;
/**
* Intent de génération utilisé pour sélectionner les sections d'un GameSystem
* à injecter dans le prompt IA.
* <p>
* Chaque intent porte une liste d'alias (case-insensitive, comparaison par
* {@code contains}) utilisée pour matcher les titres H2 du markdown de règles.
* <p>
* MVP : mapping codé en dur. Évoluera vers un mapping configurable par
* l'utilisateur dans l'éditeur de GameSystem (futur marketplace).
*/
public enum GenerationIntent {
/** Scène (combat / rencontre) : règles de résolution + format de stat block. */
SCENE(Set.of("combat", "monstre", "monster")),
/** Chapitre (segment narratif) : règles de combat + archétypes pour PNJ. */
CHAPTER(Set.of("combat", "classe", "class")),
/** Arc (structure narrative longue) : pas de règles spécifiques — toutes. */
ARC(Set.of()),
/** Fallback : toutes les sections (intent inconnu). */
GENERIC(Set.of());
private final Set<String> sectionAliases;
GenerationIntent(Set<String> sectionAliases) {
this.sectionAliases = sectionAliases;
}
public Set<String> getSectionAliases() {
return sectionAliases;
}
/** True si l'intent veut toutes les sections (pas de filtre). */
public boolean matchesAllSections() {
return sectionAliases.isEmpty();
}
/**
* Mappe un entityType de NarrativeEntityContext ("arc"/"chapter"/"scene")
* vers l'intent correspondant. Tout le reste (null, inconnu) tombe sur GENERIC.
*/
public static GenerationIntent fromNarrativeEntityType(String entityType) {
if (entityType == null) return GENERIC;
return switch (entityType.toLowerCase()) {
case "scene" -> SCENE;
case "chapter" -> CHAPTER;
case "arc" -> ARC;
default -> GENERIC;
};
}
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.gamesystemcontext.ports;
import com.loremind.domain.gamesystemcontext.GameSystem;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des GameSystems.
*/
public interface GameSystemRepository {
GameSystem save(GameSystem gameSystem);
Optional<GameSystem> findById(String id);
List<GameSystem> findAll();
void deleteById(String id);
boolean existsById(String id);
List<GameSystem> searchByName(String query);
}

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List; import java.util.List;
/** /**
@@ -22,55 +18,96 @@ import java.util.List;
* <p> * <p>
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant * La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
* fait par le use case côté application layer). * fait par le use case côté application layer).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
* @param npcs Personnages non-joueurs (PNJ) de la campagne. Vide si aucun.
*/ */
@Value public record CampaignStructuralContext(
@Builder String campaignName,
public class CampaignStructuralContext { String campaignDescription,
List<ArcSummary> arcs,
List<CharacterSummary> characters,
List<NpcSummary> npcs) {
String campaignName; /**
String campaignDescription; * Résumé d'un PJ : nom + snippet court du markdown.
@Singular List<ArcSummary> arcs; * Pas le markdown complet pour maîtriser le coût token (chaque campagne
* peut avoir 4-6 PJ × potentiellement 1-2k tokens/fiche = trop lourd).
* La fiche complète n'est injectée que si le PJ est l'entité focus
* (via NarrativeEntityContext, entity_type="character").
*/
public record CharacterSummary(String name, String snippet) {
}
/** Résumé d'un arc : nom + description courte + ses chapitres. */ /**
@Value * Résumé d'un PNJ : symétrique à {@link CharacterSummary}.
@Builder * Snippet court extrait du markdown — la fiche complète est réservée
public static class ArcSummary { * à un usage focus (à venir, entity_type="npc").
String name; */
String description; public record NpcSummary(String name, String snippet) {
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */ }
int illustrationCount;
@Singular List<ChapterSummary> chapters; /**
* Résumé d'un arc : nom + description courte + ses chapitres.
*
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
*/
public record ArcSummary(
String name,
String description,
int illustrationCount,
List<ChapterSummary> chapters) {
} }
/** Résumé d'un chapitre : nom + description courte + ses scènes. */ /** Résumé d'un chapitre : nom + description courte + ses scènes. */
@Value public record ChapterSummary(
@Builder String name,
public static class ChapterSummary { String description,
String name; int illustrationCount,
String description; List<SceneSummary> scenes) {
int illustrationCount;
@Singular List<SceneSummary> scenes;
} }
/** Résumé d'une scène : nom + description courte + branches narratives. */ /** Résumé d'une scène : nom + description courte + branches + pièces explorables. */
@Value public record SceneSummary(
@Builder String name,
public static class SceneSummary { String description,
String name; int illustrationCount,
String description; List<BranchHint> branches,
int illustrationCount; List<RoomSummary> rooms) {
@Singular List<BranchHint> branches;
} }
/** Indice d'une branche narrative vers une autre scène du même chapitre. */ /**
@Value * Indice d'une branche narrative vers une autre scène du même chapitre.
@Builder *
public static class BranchHint { * @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */ * @param targetSceneName Nom de la scène cible (résolu depuis targetSceneId côté builder).
String label; * @param condition Condition MJ privée (optionnel).
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */ */
String targetSceneName; public record BranchHint(String label, String targetSceneName, String condition) {
/** Condition MJ privée (optionnel). */ }
String condition;
/**
* Pièce d'un lieu explorable (donjon, crypte). Projection volontairement plate
* pour le prompt IA : pas de notes MJ (jamais leakées dans le contexte campagne),
* la narration et les ennemis suffisent à camper la pièce.
*
* @param name Nom de la pièce.
* @param floor Étage (nullable).
* @param description Narration courte.
* @param enemies Ennemis (texte libre).
* @param branches Sorties vers d'autres pièces (noms résolus).
*/
public record RoomSummary(
String name,
Integer floor,
String description,
String enemies,
List<RoomBranchHint> branches) {
}
/** Indice d'une sortie entre pièces ; {@code targetRoomName} déjà résolu. */
public record RoomBranchHint(String label, String targetRoomName, String condition) {
} }
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List; import java.util.List;
/** /**
@@ -21,22 +18,81 @@ import java.util.List;
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas * Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore, * ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
* pas l'inverse). * pas l'inverse).
* <p>
* Record Java : pur domaine. Builder manuel fourni en raison des 6 champs
* dont 5 sont nullables — l'API fluide reste plus lisible aux call sites
* qu'un constructeur à 6 paramètres souvent à null.
*
* @param loreContext Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore.
* @param pageContext Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement).
* @param campaignContext Optionnel : carte narrative d'une Campagne (chat Campagne uniquement).
* @param narrativeEntity Optionnel : entité narrative en cours d'édition (arc/chapter/scene).
* @param gameSystemContext Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
*/ */
@Value public record ChatRequest(
@Builder List<ChatMessage> messages,
public class ChatRequest { LoreStructuralContext loreContext,
PageContext pageContext,
CampaignStructuralContext campaignContext,
NarrativeEntityContext narrativeEntity,
GameSystemContext gameSystemContext,
SessionContext sessionContext) {
List<ChatMessage> messages; public static Builder builder() {
return new Builder();
}
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */ /** Builder fluide : permet d'omettre les contextes non pertinents. */
LoreStructuralContext loreContext; public static final class Builder {
private List<ChatMessage> messages;
private LoreStructuralContext loreContext;
private PageContext pageContext;
private CampaignStructuralContext campaignContext;
private NarrativeEntityContext narrativeEntity;
private GameSystemContext gameSystemContext;
private SessionContext sessionContext;
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */ private Builder() {}
PageContext pageContext;
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */ public Builder messages(List<ChatMessage> messages) {
CampaignStructuralContext campaignContext; this.messages = messages;
return this;
}
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */ public Builder loreContext(LoreStructuralContext loreContext) {
NarrativeEntityContext narrativeEntity; this.loreContext = loreContext;
return this;
}
public Builder pageContext(PageContext pageContext) {
this.pageContext = pageContext;
return this;
}
public Builder campaignContext(CampaignStructuralContext campaignContext) {
this.campaignContext = campaignContext;
return this;
}
public Builder narrativeEntity(NarrativeEntityContext narrativeEntity) {
this.narrativeEntity = narrativeEntity;
return this;
}
public Builder gameSystemContext(GameSystemContext gameSystemContext) {
this.gameSystemContext = gameSystemContext;
return this;
}
public Builder sessionContext(SessionContext sessionContext) {
this.sessionContext = sessionContext;
return this;
}
public ChatRequest build() {
return new ChatRequest(messages, loreContext, pageContext,
campaignContext, narrativeEntity, gameSystemContext, sessionContext);
}
}
} }

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.generationcontext;
/**
* Instantané d'occupation de la fenêtre de contexte à l'instant t du chat.
* <p>
* Émis une fois par tour de chat (juste avant le streaming des tokens) pour
* alimenter la jauge de contexte côté frontend. Les unités sont des tokens
* (approximés via tiktoken côté Brain — ±10% vs le tokenizer réel du modèle).
*
* @param system tokens consommés par le system prompt (contextes Lore/campagne injectés)
* @param history tokens consommés par l'historique de la conversation (hors dernier message)
* @param current tokens du dernier message utilisateur en attente de réponse
* @param max taille maximale configurée de la fenêtre de contexte
*/
public record ChatUsage(int system, int history, int current, int max) {
}

View File

@@ -0,0 +1,21 @@
package com.loremind.domain.generationcontext;
import java.util.Map;
/**
* Value Object représentant les règles de JDR injectées dans un prompt IA.
* <p>
* Contient uniquement les sections pertinentes pour l'intent de génération
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
*
* @param systemName Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD").
* @param systemDescription Description courte du système (nullable).
* @param sections Sections de règles pertinentes, indexées par titre H2.
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
*/
public record GameSystemContext(
String systemName,
String systemDescription,
Map<String, String> sections) {
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List; import java.util.List;
/** /**
@@ -10,19 +7,16 @@ import java.util.List;
* pour remplir une Page à partir d'un Template. * pour remplir une Page à partir d'un Template.
* <p> * <p>
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py). * Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
* Entité pure du domaine : aucune dépendance technique. * Record Java : pur domaine, aucune dépendance technique.
* <p> *
* Immuable via @Value (Lombok) : pas de setters, tous les champs final. * @param templateFields Champs à générer (clés attendues dans la réponse).
* C'est un DTO de domaine entrant dans le port AiProvider. * @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
*/ */
@Value public record GenerationContext(
@Builder String loreName,
public class GenerationContext { String loreDescription,
String folderName,
String loreName; String templateName,
String loreDescription; List<String> templateFields,
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux") String pageTitle) {
String templateName;
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
String pageTitle;
} }

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -16,15 +12,14 @@ import java.util.Map;
* <p> * <p>
* La map `folders` est indexée par nom de dossier et mappe vers la liste * La map `folders` est indexée par nom de dossier et mappe vers la liste
* des pages qu'il contient (liste vide autorisée pour les dossiers vides). * des pages qu'il contient (liste vide autorisée pour les dossiers vides).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*/ */
@Value public record LoreStructuralContext(
@Builder String loreName,
public class LoreStructuralContext { String loreDescription,
Map<String, List<PageSummary>> folders,
String loreName; List<String> tags) {
String loreDescription;
Map<String, List<PageSummary>> folders;
@Singular List<String> tags;
/** /**
* Résumé projeté d'une page pour l'IA. * Résumé projeté d'une page pour l'IA.
@@ -40,13 +35,11 @@ public class LoreStructuralContext {
* uniquement ce qui est partageable en narration — les secrets MJ * uniquement ce qui est partageable en narration — les secrets MJ
* restent confinés à leur page d'édition). * restent confinés à leur page d'édition).
*/ */
@Value public record PageSummary(
@Builder String title,
public static class PageSummary { String templateName,
String title; Map<String, String> values,
String templateName; List<String> tags,
Map<String, String> values; List<String> relatedPageTitles) {
List<String> tags;
List<String> relatedPageTitles;
} }
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map; import java.util.Map;
/** /**
@@ -17,13 +14,11 @@ import java.util.Map;
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration") * `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une * à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé). * LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
*
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
*/ */
@Value public record NarrativeEntityContext(
@Builder String entityType,
public class NarrativeEntityContext { String title,
Map<String, String> fields) {
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
String entityType;
String title;
Map<String, String> fields;
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -14,14 +11,11 @@ import java.util.Map;
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder * à l'IA de focaliser ses suggestions sur les bons champs sans déborder
* sur d'autres pages/templates. * sur d'autres pages/templates.
* <p> * <p>
* Object de valeur immuable, pur domaine aucune dépendance technique. * Record Java : immuable, pur domaine, aucune dépendance technique.
*/ */
@Value public record PageContext(
@Builder String title,
public class PageContext { String templateName,
List<String> templateFields,
String title; Map<String, String> values) {
String templateName;
List<String> templateFields;
Map<String, String> values;
} }

View File

@@ -0,0 +1,58 @@
package com.loremind.domain.generationcontext;
import java.time.LocalDateTime;
import java.util.List;
/**
* Contexte structurel d'une Session de jeu — injecté dans le system prompt
* de l'IA pour qu'elle ait conscience de la partie en cours et de son journal.
*
* <p>Pendant qu'une session se joue, l'IA reçoit en plus du Lore/Campagne/GameSystem :
* le nom de la session, son statut, un résumé chronologique du journal,
* et — depuis l'ajout du mode Hub — l'état des quêtes ouvertes de la campagne et
* les flags actifs.</p>
*
* <p>Value Object du Generation Context — record Java immutable.</p>
*
* @param sessionName Nom de la session courante telle qu'affichée au MJ.
* @param active True si la session est en cours, false si terminée.
* @param startedAt Horodatage de démarrage de la session courante.
* @param entries Entrées du journal de la session courante (cap côté builder).
* @param previousEvents Évènements marquants des sessions précédentes (continuité narrative).
* @param availableQuests Quêtes Hub actuellement débloquées et non démarrées.
* @param inProgressQuests Quêtes Hub en cours.
* @param lockedQuestTitles Titres des quêtes Hub verrouillées — uniquement le titre
* pour signaler leur existence sans spoiler.
* @param activeFlags Noms des flags de campagne à true.
*/
public record SessionContext(
String sessionName,
boolean active,
LocalDateTime startedAt,
List<JournalEntrySummary> entries,
List<JournalEntrySummary> previousEvents,
List<QuestSummary> availableQuests,
List<QuestSummary> inProgressQuests,
List<String> lockedQuestTitles,
List<String> activeFlags) {
/**
* Résumé d'une entrée de journal — type + contenu + horodatage + (optionnel) session source.
* {@code sourceSessionName} renseigné uniquement pour les évènements issus de sessions
* précédentes, pour aider l'IA à les ancrer temporellement.
*/
public record JournalEntrySummary(
String type,
String content,
LocalDateTime occurredAt,
String sourceSessionName) {}
/**
* Résumé d'une quête (= Chapter dans un Arc HUB) telle qu'exposée à l'IA.
* On omet volontairement les notes MJ : pas de fuite côté prompt.
*/
public record QuestSummary(
String name,
String arcName,
String description) {}
}

View File

@@ -1,6 +1,7 @@
package com.loremind.domain.generationcontext.ports; package com.loremind.domain.generationcontext.ports;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -26,6 +27,10 @@ public interface AiChatProvider {
* HTTP côté controller SSE). * HTTP côté controller SSE).
* *
* @param request messages + contexte Lore * @param request messages + contexte Lore
* @param onUsage invoqué une fois au début du stream avec le bilan
* d'occupation de la fenêtre de contexte (tokens system /
* history / current / max). Peut ne jamais être invoqué
* si le provider ne supporte pas le comptage.
* @param onToken invoqué à chaque token reçu du LLM (peut être appelé * @param onToken invoqué à chaque token reçu du LLM (peut être appelé
* de nombreuses fois) * de nombreuses fois)
* @param onComplete invoqué une fois le stream terminé avec succès * @param onComplete invoqué une fois le stream terminé avec succès
@@ -34,6 +39,7 @@ public interface AiChatProvider {
*/ */
void streamChat( void streamChat(
ChatRequest request, ChatRequest request,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Runnable onComplete, Runnable onComplete,
Consumer<Throwable> onError Consumer<Throwable> onError

View File

@@ -0,0 +1,48 @@
package com.loremind.domain.licensing;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
/**
* Licence Patreon installee dans cette instance LoreMind.
* <p>
* Singleton (une seule licence par instance, identifiee logiquement par
* {@code id = "current"}). Contient le JWT brut emis par le relais OAuth
* + les claims extraits a la verification, plus l'etat operationnel
* (derniere tentative de refresh, succes/echec).
* <p>
* <b>Note securite :</b> {@link #rawJwt} est stocke tel quel ; sa signature
* Ed25519 est verifiee a chaque lecture. Pas besoin de chiffrement au repos
* supplementaire — un attaquant qui a acces a la base a deja l'instance,
* et le JWT ne donne aucun pouvoir au-dela du canal beta de cette instance.
*/
@Data
@Builder
public class License {
private String id;
private String rawJwt;
private String patreonUserId;
private String tierId;
private String instanceId;
private Instant issuedAt;
private Instant expiresAt;
private Instant lastRefreshAttemptAt;
private boolean lastRefreshSucceeded;
private boolean betaChannelEnabled;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Claims extraits d'un JWT licence apres verification de signature.
* Immuable.
*/
public record LicenseClaims(
String subject,
String tierId,
String instanceId,
Instant issuedAt,
Instant expiresAt
) {}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Vue immuable de la licence pour exposition vers les couches superieures.
* Decouple le domaine du DTO web et permet de calculer le {@link LicenseStatus}
* a un instant donne sans muter l'entite.
*/
public record LicenseSnapshot(
LicenseStatus status,
String patreonUserId,
String tierId,
String instanceId,
Instant expiresAt,
Instant lastRefreshAttemptAt,
boolean lastRefreshSucceeded,
boolean betaChannelEnabled
) {
public static LicenseSnapshot none() {
return new LicenseSnapshot(LicenseStatus.NONE, null, null, null, null, null, false, false);
}
}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.licensing;
/**
* Etat operationnel de la licence vis-a-vis de l'acces beta.
* <p>
* Calcule a partir de la presence de licence + son JWT exp + grace period.
* <ul>
* <li>{@link #NONE} : aucune licence installee</li>
* <li>{@link #VALID} : JWT non expire, acces beta autorise</li>
* <li>{@link #GRACE} : JWT expire mais dans la periode de tolerance ;
* acces beta toujours autorise, l'UI doit avertir</li>
* <li>{@link #EXPIRED} : au-dela de la grace period, acces beta refuse</li>
* <li>{@link #UNVERIFIABLE} : JWT impossible a verifier (cle publique manquante,
* signature invalide, claims malformes) — traite comme NONE pour la securite</li>
* </ul>
*/
public enum LicenseStatus {
NONE,
VALID,
GRACE,
EXPIRED,
UNVERIFIABLE
}

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Credentials de pull pour un registry Docker, distribues par le relais
* apres verification d'un JWT licence valide.
* <p>
* {@code expiresAt} peut etre {@code null} si le credential est statique
* (cas du PAT GHCR partage en MVP) ; sinon, l'instance doit re-demander
* de nouveaux credentials avant cette date.
*/
public record RegistryCredentials(
String registry,
String username,
String password,
Instant expiresAt
) {}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.RegistryCredentials;
import java.io.IOException;
/**
* Port de sortie : ecriture du docker config.json partage avec Watchtower.
* <p>
* Le fichier sert a Watchtower pour s'authentifier au registry prive (GHCR)
* lors du pull des images du canal beta. Volume Docker {@code docker-config}
* monte sur Core (en ecriture) et sur Watchtower (en lecture, via la variable
* {@code DOCKER_CONFIG}).
*/
public interface DockerConfigWriter {
/**
* Ecrit ou met a jour les credentials pour le registry indique.
* Cree le fichier s'il n'existe pas, conserve les autres registries deja
* presents (en theorie : aucun, mais defensif).
*/
void writeCredentials(RegistryCredentials credentials) throws IOException;
/**
* Supprime le fichier de credentials. Appele quand la licence est invalidee
* ou que le toggle beta passe a OFF.
*/
void clear() throws IOException;
/**
* @return true si le fichier de creds existe actuellement.
*/
boolean isPresent();
}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.LicenseClaims;
/**
* Port de sortie : verification de signature et extraction des claims
* d'un JWT emis par le relais.
* <p>
* Implemente cote infrastructure avec la cle publique Ed25519 embarquee
* (SPKI PEM via configuration {@code licensing.jwt.public-key}).
*/
public interface JwtVerifier {
/**
* Verifie la signature, l'issuer, l'audience et l'expiration du JWT.
* @throws JwtVerificationException si la signature est invalide ou les claims malformes
*/
LicenseClaims verify(String rawJwt) throws JwtVerificationException;
/**
* @return true si la cle publique est configuree et utilisable.
* Permet a l'application de masquer la feature licensing si pas configuree.
*/
boolean isConfigured();
class JwtVerificationException extends Exception {
public JwtVerificationException(String message) {
super(message);
}
public JwtVerificationException(String message, Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -0,0 +1,59 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.RegistryCredentials;
/**
* Port de sortie vers le service relais OAuth Patreon.
* Encapsule les appels HTTP : refresh JWT et fetch registry credentials.
*/
public interface LicenseRelay {
/**
* Demande au relais l'URL OAuth a ouvrir pour connecter le compte Patreon.
*/
String buildConnectUrl(String instanceId);
/**
* Demande au relais de renouveler un JWT existant. Le relais re-verifie
* le tier Patreon de l'utilisateur ; renvoie un nouveau JWT si toujours
* actif, ou leve {@link RelayException} sinon.
*/
String refreshToken(String currentJwt) throws RelayException;
/**
* Demande au relais les credentials de pull du registry beta.
*/
RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException;
/**
* Erreurs distinctes emises par le relais. Permet au service application
* de differencier "tier expire" (action utilisateur) de "relais down"
* (action transitoire, garde la grace period).
*/
class RelayException extends Exception {
private final RelayErrorKind kind;
public RelayException(RelayErrorKind kind, String message) {
super(message);
this.kind = kind;
}
public RelayException(RelayErrorKind kind, String message, Throwable cause) {
super(message, cause);
this.kind = kind;
}
public RelayErrorKind getKind() {
return kind;
}
}
enum RelayErrorKind {
/** Le relais est joignable mais refuse : tier non actif, JWT trop ancien, etc. */
REJECTED,
/** Le relais a renvoye un JWT mais il est invalide / non parsable. */
BAD_RESPONSE,
/** Le relais est injoignable / 5xx / timeout. */
TRANSIENT
}
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.License;
import java.util.Optional;
/**
* Port de sortie pour la persistance de la licence installee.
* <p>
* Une seule licence par instance ({@code id = "current"} par convention).
*/
public interface LicenseRepository {
Optional<License> findCurrent();
License save(License license);
void deleteCurrent();
}

View File

@@ -1,15 +0,0 @@
package com.loremind.domain.lorecontext;
/**
* Type d'un champ dynamique d'un Template.
* <p>
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
* (stockee dans Page.imageValues : Map<String, List<String>>)
* <p>
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
*/
public enum FieldType {
TEXT,
IMAGE
}

View File

@@ -1,5 +1,7 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.lorecontext;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,39 +0,0 @@
package com.loremind.domain.lorecontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Value Object d'un champ de Template.
* <p>
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible).
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
* <p>
* Evolution de `List<String> fields` vers `List<TemplateField> fields` :
* refactor propre (DDD Value Object polymorphism) permettant d'ajouter
* facilement d'autres types de champs (DATE, NUMBER, RICH_TEXT...) sans
* casser le contrat.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TemplateField {
/** 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. */
private FieldType type;
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT);
}
/** Raccourci : construit un champ de type IMAGE. */
public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE);
}
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.playcontext;
/**
* Type d'entrée du journal de session.
* Permet à l'UI de catégoriser visuellement la timeline (icône, couleur).
*/
public enum EntryType {
/** Note libre du MJ (défaut). */
NOTE,
/** Moment marquant du scénario (combat gagné, décision majeure...). */
EVENT,
/** Jet de dés / test de caractéristique. */
DICE_ROLL,
/** Action déclarée par un joueur. */
PLAYER_ACTION
}

View File

@@ -0,0 +1,36 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Instance jouée d'une Campagne par une table donnée.
*
* <p>Sépare clairement le SCÉNARIO (Campaign : arcs, chapitres, prérequis) de
* l'ÉTAT DE JEU d'une table précise (progression des quêtes, flags narratifs,
* sessions tenues, PJ). Permet à plusieurs tables de jouer la même campagne
* indépendamment.</p>
*
* <p>Fait partie du Play Context. Référence la Campagne par weak reference
* (campaignId) pour respecter les Bounded Contexts.</p>
*/
@Data
@Builder
public class Playthrough {
private String id;
/** Weak reference vers la Campagne (le scénario joué). */
private String campaignId;
/** Nom donné par le MJ à cette partie (ex. : "Table du vendredi"). */
private String name;
/** Notes libres sur la partie / la table — facultatif. */
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,29 @@
package com.loremind.domain.playcontext;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import lombok.Builder;
import lombok.Data;
/**
* État de progression d'une quête (Chapter) pour un Playthrough donné.
*
* <p>Remplace l'ancien champ {@code Chapter.progressionStatus} qui mélangeait
* 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
* Bounded Contexts. Le type {@link ProgressionStatus} reste défini dans
* Campaign Context (c'est un Value Object générique, partageable).</p>
*
* <p>Sémantique : l'absence de ligne dans le repo équivaut à NOT_STARTED.
* On ne persiste donc que les transitions explicites IN_PROGRESS / COMPLETED.</p>
*/
@Data
@Builder
public class QuestProgression {
private String id;
private String playthroughId;
private String chapterId;
private ProgressionStatus status;
}

View File

@@ -0,0 +1,40 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entité de domaine représentant une Session de jeu (une soirée).
*
* <p>Une Session appartient à un {@link Playthrough} (une instance jouée d'une
* campagne par une table). Un Playthrough a typiquement plusieurs sessions
* dans le temps ; la progression et les flags persistent entre elles via le
* Playthrough parent.</p>
*
* <p>{@code endedAt == null} signifie que la session est en cours.
* Une seule session peut être en cours dans l'application à la fois.</p>
*/
@Data
@Builder
public class Session {
private String id;
private String name;
/** Weak reference vers le Playthrough parent. */
private String playthroughId;
private LocalDateTime startedAt;
/** Null = session en cours ; renseigné = session terminée. */
private LocalDateTime endedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public boolean isActive() {
return this.endedAt == null;
}
}

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entrée du journal d'une Session.
* Représente un évènement horodaté capturé pendant ou après une partie :
* note libre du MJ, évènement marquant, jet de dés, action de joueur.
*
* <p>Fait partie du Play Context. Référence la Session par weak reference
* (sessionId) — l'orchestration en cascade est gérée par le service applicatif.</p>
*/
@Data
@Builder
public class SessionEntry {
private String id;
/** Weak reference vers Session (intra-contexte mais reste découplée). */
private String sessionId;
private EntryType type;
/** Contenu texte brut saisi par le MJ. */
private String content;
/**
* Horodatage métier de l'évènement.
* Distinct de {@code createdAt} : utile si le MJ rédige a posteriori
* une note rétroactive sur quelque chose qui s'est passé plus tôt.
*/
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.playcontext.ports;
import java.util.Map;
/**
* Port de sortie pour les flags narratifs d'un Playthrough.
*
* <p>Anciennement {@code CampaignFlagRepository} : les flags suivent maintenant
* une partie (Playthrough), pas un scénario (Campaign).</p>
*/
public interface PlaythroughFlagRepository {
Map<String, Boolean> findByPlaythroughId(String playthroughId);
void setFlag(String playthroughId, String name, boolean value);
void deleteFlag(String playthroughId, String name);
void deleteAllByPlaythroughId(String playthroughId);
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.Playthrough;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Playthroughs (parties jouées).
*/
public interface PlaythroughRepository {
Playthrough save(Playthrough playthrough);
Optional<Playthrough> findById(String id);
List<Playthrough> findByCampaignId(String campaignId);
List<Playthrough> findAll();
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,31 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.playcontext.QuestProgression;
import java.util.List;
import java.util.Set;
/**
* Port de sortie pour la persistance des progressions de quêtes d'un Playthrough.
*
* <p>Modèle "absence = NOT_STARTED" : on ne stocke que les transitions
* explicites IN_PROGRESS / COMPLETED.</p>
*/
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);
/**
* 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);
/** Supprime toutes les progressions d'un Playthrough (cascade applicative). */
void deleteAllByPlaythroughId(String playthroughId);
}

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.SessionEntry;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des entrées de journal de session.
*/
public interface SessionEntryRepository {
SessionEntry save(SessionEntry entry);
Optional<SessionEntry> findById(String id);
/** Renvoie les entrées d'une session, triées par occurredAt croissant (chronologique). */
List<SessionEntry> findBySessionId(String sessionId);
void deleteById(String id);
/** Supprime toutes les entrées d'une session — utilisé pour la cascade à la suppression. */
void deleteBySessionId(String sessionId);
boolean existsById(String id);
}

View File

@@ -0,0 +1,30 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.Session;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Sessions.
*/
public interface SessionRepository {
Session save(Session session);
Optional<Session> findById(String id);
List<Session> findAll();
List<Session> findByPlaythroughId(String playthroughId);
/** Retourne UNE session active dans l'app (sémantique « legacy » — multi-actives possibles). */
Optional<Session> findActive();
/** Retourne la session active du Playthrough donné, s'il y en a une. */
Optional<Session> findActiveByPlaythroughId(String playthroughId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.shared.template;
/**
* Type d'un champ dynamique de template (kernel partage).
* <p>
* - TEXT : valeur textuelle libre (Map<String, String>)
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
* (Map<String, Map<String, String>> : fieldName -> label -> value).
* Usage : stat blocks, listes de competences, traits.
* <p>
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
*/
public enum FieldType {
TEXT,
IMAGE,
NUMBER,
KEY_VALUE_LIST
}

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.shared.template;
/**
* Variante de rendu pour un champ de type IMAGE.
* <p>
* - GALLERY : grille de vignettes (defaut, comportement historique)
* - HERO : premiere image en banniere pleine largeur, suivantes en petit
* - MASONRY : mosaique hauteurs variables facon Pinterest
* - CAROUSEL : defilement horizontal
* <p>
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon.
*/
public enum ImageLayout {
GALLERY,
HERO,
MASONRY,
CAROUSEL
}

View File

@@ -0,0 +1,73 @@
package com.loremind.domain.shared.template;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* Value Object d'un champ de Template (kernel partage).
* <p>
* Un champ a un nom (affiche dans l'UI) et un type. Le type pilote
* le rendu cote front et la logique metier (seuls les champs TEXT sont
* envoyes a l'IA pour generation).
* <p>
* 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.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TemplateField {
/** 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. */
private FieldType type;
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
/**
* Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif).
* Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques".
* Null/vide pour les autres types.
*/
private List<String> labels;
/** Constructeur de retrocompat : type seul, layout/labels=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null, null);
}
/** Constructeur de retrocompat : type + layout, labels=null. */
public TemplateField(String name, FieldType type, ImageLayout layout) {
this(name, type, layout, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT, null, null);
}
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY, null);
}
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
public static TemplateField image(String name, ImageLayout layout) {
return new TemplateField(name, FieldType.IMAGE, layout, null);
}
/** Raccourci : construit un champ de type NUMBER. */
public static TemplateField number(String name) {
return new TemplateField(name, FieldType.NUMBER, null, null);
}
/** Raccourci : construit un champ KEY_VALUE_LIST avec labels predefinis. */
public static TemplateField keyValueList(String name, List<String> labels) {
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
}
}

View File

@@ -1,16 +1,7 @@
package com.loremind.infrastructure.ai; package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException; import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -22,25 +13,21 @@ import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import java.time.Duration; import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors;
/** /**
* Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider * Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider
* en appelant le Brain Python via WebClient + SSE (Server-Sent Events). * en appelant le Brain Python via WebClient + SSE (Server-Sent Events).
* <p> * <p>
* Responsabilités : * Responsabilités (après extraction) :
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream. * 1. Transport HTTP + consommation du flux SSE.
* Sérialise lore_context, page_context, campaign_context et * 2. Dispatch des évènements SSE (data / done / error / usage).
* narrative_entity de façon conditionnelle selon le scénario d'appel * 3. Traduction des erreurs techniques en AiProviderException.
* (chat Lore / chat Lore focalisé page / chat Campagne / chat Campagne * <p>
* focalisé arc-chapter-scene). * Les responsabilités auxiliaires sont déléguées :
* 2. Consommer le flux SSE token par token. * - Construction du payload JSON : {@link BrainChatPayloadBuilder}.
* 3. Invoquer onToken / onComplete / onError au bon moment. * - Parsing des payloads SSE : {@link BrainSseParser}.
* 4. Traduire toute erreur technique en AiProviderException.
* <p> * <p>
* Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL. * Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL.
*/ */
@@ -52,21 +39,28 @@ public class BrainAiChatClient implements AiChatProvider {
new ParameterizedTypeReference<>() {}; new ParameterizedTypeReference<>() {};
private final WebClient webClient; private final WebClient webClient;
private final BrainChatPayloadBuilder payloadBuilder;
private final BrainSseParser sseParser;
public BrainAiChatClient( public BrainAiChatClient(
WebClient.Builder builder, WebClient.Builder builder,
@Value("${brain.base-url}") String baseUrl) { @Value("${brain.base-url}") String baseUrl,
BrainChatPayloadBuilder payloadBuilder,
BrainSseParser sseParser) {
this.webClient = builder.baseUrl(baseUrl).build(); this.webClient = builder.baseUrl(baseUrl).build();
this.payloadBuilder = payloadBuilder;
this.sseParser = sseParser;
} }
@Override @Override
public void streamChat( public void streamChat(
ChatRequest request, ChatRequest request,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Runnable onComplete, Runnable onComplete,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
Map<String, Object> payload = toPayload(request); Map<String, Object> payload = payloadBuilder.build(request);
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(CHAT_STREAM_PATH) .uri(CHAT_STREAM_PATH)
@@ -81,7 +75,7 @@ public class BrainAiChatClient implements AiChatProvider {
// au contrat synchrone du port. L'appelant choisit le thread. // au contrat synchrone du port. L'appelant choisit le thread.
flux flux
.timeout(Duration.ofSeconds(120)) .timeout(Duration.ofSeconds(120))
.doOnNext(sse -> handleEvent(sse, onToken, onError)) .doOnNext(sse -> handleEvent(sse, onUsage, onToken, onError))
.blockLast(); .blockLast();
onComplete.run(); onComplete.run();
} catch (Exception e) { } catch (Exception e) {
@@ -90,12 +84,13 @@ public class BrainAiChatClient implements AiChatProvider {
} }
} }
/** Dispatch selon le type d'événement SSE (data par défaut, done, error). */ /** Dispatch selon le type d'évènement SSE (data par défaut, done, error, usage). */
private void handleEvent( private void handleEvent(
ServerSentEvent<String> sse, ServerSentEvent<String> sse,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut String event = sse.event(); // null si pas d'event: xxx -> data par défaut
String data = sse.data(); String data = sse.data();
if ("error".equals(event)) { if ("error".equals(event)) {
@@ -104,197 +99,17 @@ public class BrainAiChatClient implements AiChatProvider {
return; return;
} }
if ("done".equals(event)) { if ("done".equals(event)) {
return; // la fin est gérée par blockLast + onComplete return; // fin gérée par blockLast + onComplete
} }
// Défaut : événement data avec JSON {"token":"..."}. if ("usage".equals(event)) {
String token = extractToken(data); ChatUsage usage = sseParser.parseUsage(data);
if (usage != null) onUsage.accept(usage);
return;
}
// Défaut : évènement data avec JSON {"token":"..."}.
String token = sseParser.parseToken(data);
if (token != null && !token.isEmpty()) { if (token != null && !token.isEmpty()) {
onToken.accept(token); onToken.accept(token);
} }
} }
/**
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
* Si le format se complexifie, on remplacera par un DTO Jackson.
*/
private String extractToken(String json) {
if (json == null) return null;
int idx = json.indexOf("\"token\"");
if (idx < 0) return null;
int colon = json.indexOf(':', idx);
int firstQuote = json.indexOf('"', colon + 1);
int lastQuote = json.lastIndexOf('"');
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
return json.substring(firstQuote + 1, lastQuote)
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\");
}
// --- Construction du payload JSON vers le Brain -------------------------
/**
* Construit le payload JSON. Chaque contexte optionnel est omis s'il est
* null, pour s'aligner sur le schéma Pydantic côté Brain (champs
* Optional qui restent absents du dict transmis au LLM).
*/
private Map<String, Object> toPayload(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
if (request.getLoreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext()));
}
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
}
if (request.getCampaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
}
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
}
return root;
}
private Map<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("role", m.role());
map.put("content", m.content());
return map;
}
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName());
map.put("lore_description", ctx.getLoreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.getTags());
return map;
}
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.getTitle());
map.put("template_name", ps.getTemplateName());
// values/tags/related_page_titles ne sont sérialisés que s'ils contiennent
// de l'info — payload réseau plus léger quand la page est vierge.
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
map.put("values", ps.getValues());
}
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
map.put("tags", ps.getTags());
}
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles());
}
return map;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
return map;
}
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.getCampaignName());
map.put("campaign_description", ctx.getCampaignDescription());
map.put("arcs", ctx.getArcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
return map;
}
/**
* Helper generic pour serialiser les entites structurelles (Arc/Chapter/Scene)
* avec name, description et illustration_count conditionnel.
*/
private <T> Map<String, Object> structuralSummaryToMap(
T entity,
java.util.function.Function<T, String> nameExtractor,
java.util.function.Function<T, String> descriptionExtractor,
java.util.function.Function<T, Integer> illustrationCountExtractor,
java.util.function.BiConsumer<Map<String, Object>, T> childSerializer) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", nameExtractor.apply(entity));
map.put("description", descriptionExtractor.apply(entity));
// Envoye au Python pour enrichir le prompt ("N illustrations attachees").
// Serialise uniquement si > 0 pour economiser le payload sur les entites sans images.
if (illustrationCountExtractor.apply(entity) > 0) {
map.put("illustration_count", illustrationCountExtractor.apply(entity));
}
childSerializer.accept(map, entity);
return map;
}
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap(
a,
ArcSummary::getName,
ArcSummary::getDescription,
ArcSummary::getIllustrationCount,
(map, arc) -> map.put("chapters", arc.getChapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap(
c,
ChapterSummary::getName,
ChapterSummary::getDescription,
ChapterSummary::getIllustrationCount,
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap(
s,
SceneSummary::getName,
SceneSummary::getDescription,
SceneSummary::getIllustrationCount,
(map, scene) -> {
// Branches narratives : serialise uniquement si presentes, pour garder
// un payload leger sur les scenes lineaires classiques.
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
map.put("branches", s.getBranches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
});
}
private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.getLabel());
map.put("target_scene_name", b.getTargetSceneName());
if (b.getCondition() != null && !b.getCondition().isBlank()) {
map.put("condition", b.getCondition());
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.getEntityType());
map.put("title", ne.getTitle());
map.put("fields", ne.getFields());
return map;
}
} }

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