Compare commits

...

10 Commits

Author SHA1 Message Date
ad3ea0e9e6 CI : build web cassé par la sortie de npm 12 (npm@latest exige Node >= 22)
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 24s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 3m14s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Failing after 1m1s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 45s
E2E Tests / e2e (push) Failing after 4m6s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Successful in 1m12s
Build & Push Images / build (brain) (push) Successful in 2m14s
Build & Push Images / build (web) (push) Successful in 1m54s
Build & Push Images / build (core) (push) Successful in 3m41s
Build & Push Images / build-switcher (push) Successful in 48s
Suppression du « npm install -g npm@latest » du Dockerfile web : cible
mouvante inutile — le npm embarqué dans node:20 suffit pour npm ci
(lockfileVersion 3) et le build Angular.
2026-07-10 09:31:58 +02:00
0c44e42a22 Quêtes libres : fin des conteneurs fantômes (cascade, impact annoncé, export filtré, migration V24)
Some checks failed
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 52s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Successful in 2m36s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m11s
Build & Push Images / build (web) (push) Failing after 35s
Build & Push Images / build (brain) (push) Successful in 1m18s
Build & Push Images / build-switcher (push) Successful in 42s
Build & Push Images / build (core) (push) Successful in 3m8s
Sidebar Lore : le panneau Templates ne peut plus écraser l'arbre

Plafonné à 45 % de la sidebar avec ascenseur interne sur la liste —
l'en-tête, le repli et le bouton + restent toujours visibles, l'arbre
garde son propre scroll (remonté par un utilisateur avec ~30 templates)

Passage v1.0.3
2026-07-10 09:19:26 +02:00
1c8b24c5dd CI : Trivy en résolution 100 % hors-ligne (--offline-scan)
All checks were successful
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 55s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Successful in 32s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 42s
Build & Push Images / build (brain) (push) Successful in 1m26s
Build & Push Images / build (web) (push) Successful in 1m59s
Build & Push Images / build (core) (push) Successful in 3m16s
Build & Push Images / build-switcher (push) Successful in 16s
Le préchargement du cache Maven ne suffisait pas : Trivy va chercher les
POMs parents (ex. netty-parent) sur Maven Central même quand ~/.m2 est
rempli, et l'IP du runner finit rate-limitée (429, blocage 30 min).
--offline-scan supprime toute requête réseau pendant la résolution — sans
perte : dependency:go-offline garantit juste avant que tout est en cache.
2026-07-07 12:09:04 +02:00
177967af55 Sécurité : purge des 22 CVE remontées par Trivy (premier run du gate)
Some checks failed
Build & Push Images / build (web) (push) Successful in 1m58s
Build & Push Images / build (core) (push) Successful in 3m13s
Build & Push Images / build-switcher (push) Successful in 22s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 43s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m0s
Build & Push Images / build (brain) (push) Successful in 1m12s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m35s
- core : overrides BOM Spring Boot — tomcat-embed 10.1.55 (3 CRITICAL dont
  bypass d'auth digest), jackson-databind 2.21.4 (exécution de code arbitraire),
  netty 4.1.135.Final (lot DoS/bypass hostname/DNS), postgresql 42.7.11
- web : Angular 21.2.16 → 21.2.17 (HttpTransferCache, DoS formatDate,
  DOM clobbering hydration) — package-lock régénéré (peer-deps exactes
  entre paquets @angular : montée impossible paquet par paquet)

Vérifié : test-compile core, build + tests web, re-scan Trivy local propr
2026-07-07 01:22:54 +02:00
42c4b53ced Stack qualité CI (MegaLinter, Trivy, ESLint) et purge du backlog lint web
Some checks failed
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m9s
Build & Push Images / build (brain) (push) Successful in 1m10s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m36s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (core) (push) Successful in 3m9s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 38s
CI (Gitea Actions) :
- Nouveau workflow quality.yml (séparé de ci.yml, non bloquant pour la release) :
  MegaLinter v9 (PMD, Ruff, Bandit, gitleaks, hadolint), ng lint (web) et
  Trivy (CVE des dépendances Maven/pip/npm), sur main + beta + PR
- Trivy : préchargement du cache Maven avant le scan — sans ça Maven Central
  rate-limite l'IP du runner (429) en résolvant les BOMs du parent Spring Boot
- upload-artifact v4 → v3 : l'API artifacts v4 n'est pas supportée par Gitea
  (corrige aussi l'upload du rapport Playwright de e2e.yml, cassé depuis toujours)
- Ruleset PMD projet (java-pmd-ruleset.xml, auto-détecté par MegaLinter) orienté
  bugs réels, sans le style Lombok-hostile du défaut : 11 337 → 65 findings ;
  PMD en mode rapport (JAVA_PMD_DISABLE_ERRORS) le temps de purger ce backlog

Web :
- angular-eslint 21 + eslint-plugin-sonarjs : les règles Sonar vivent désormais
  dans ESLint (config web/eslint.config.js, réglages justifiés en commentaire)
- tsconfig : skipLibCheck + types:[] + node_modules ré-exclu — répare les builds
  desktop Windows/Linux cassés par le conflit @types/eslint-scope vs ESLint 9
- Purge du backlog lint : 114 erreurs → 0 sur 66 fichiers (ngOnDestroy vides
  supprimés, any typés avec les vrais DTOs, outputs (close) → (closed), regex
  super-linéaires désamorcées, ternaires/fonctions imbriqués dépliés, intention
  documentée sur les catch/error volontairement vides)
- Bug latent corrigé au passage : license.service.disconnect() émettait
  undefined au lieu de true en cas de succès (masqué par un double cast)
2026-07-07 01:05:00 +02:00
3e9e828225 Mise en place de la qualité sur la branche beta également
Some checks failed
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 1m5s
Build & Push Images / build (brain) (push) Successful in 2m5s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Failing after 14m12s
Build & Push Images / build (web) (push) Failing after 2m7s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (core) (push) Successful in 3m27s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Failing after 8m26s
2026-07-07 00:16:23 +02:00
51eb907078 Mise en place de la version 1.0.2-bêta
Some checks failed
Build & Push Images / build (core) (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-07-07 00:12:40 +02:00
9f0359d8c0 Dernière fournée de corrections Sonar
Mise en place de MegaLinter ; web-lint pour la partie code quality sur la CI, Trivy pour la partie CVE
2026-07-07 00:11:52 +02:00
587e0fe097 passage 1.0.1-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m58s
Build & Push Images / build (core) (push) Successful in 3m39s
Build & Push Images / build-switcher (push) Successful in 20s
2026-07-06 15:25:48 +02:00
5c3c58265f Première fournée correction dette technique.
Refactoring du code pour améliorer la lisibilité de ce dernier coté core
Correction sur l'export / import : les images n'étaient pas bien liées à la campagne lors de l'export / import et provoquait un bug d'affichage des images
lorsqu'on voulait ouvrir une campagne importée.
2026-07-06 14:39:55 +02:00
445 changed files with 8774 additions and 4516 deletions

View File

@@ -82,9 +82,11 @@ jobs:
if: failure() if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
# v3 obligatoire : l'API artifacts v4 n'est pas supportée par Gitea
# (GHESNotSupportedError — constaté sur le job MegaLinter).
- name: Upload Playwright report - name: Upload Playwright report
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: playwright-report name: playwright-report
path: web/playwright-report/ path: web/playwright-report/

View File

@@ -0,0 +1,104 @@
name: Qualité & Sécurité
# Analyse statique (MegaLinter, config racine .mega-linter.yml) + CVE des
# dépendances (Trivy). Workflow SÉPARÉ de ci.yml : un rouge qualité ne bloque
# pas la chaîne tests → release pendant la phase de rodage. Une fois la base
# assainie, on pourra l'ajouter aux checks requis de la branch protection.
on:
push:
# beta inclus : c'est la branche de dev, le feedback qualité doit y vivre
# (ci.yml/e2e.yml, eux, restent volontairement sur main + PR).
branches: [main, beta]
pull_request:
branches: [main]
jobs:
megalinter:
name: MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Historique complet : requis par gitleaks (scan de l'historique)
# et par le mode "fichiers modifiés seulement" en PR.
fetch-depth: 0
# Flavor "cupcake" : image allégée couvrant les langages courants
# (Java/Python/TS inclus). Si un linter activé manquait à la flavor,
# MegaLinter échoue en l'indiquant → remplacer par oxsecurity/megalinter@v9
# (image complète, plus lourde).
- name: MegaLinter
uses: oxsecurity/megalinter/flavors/cupcake@v9
env:
# main → scan complet du dépôt ; beta et PR → seulement les fichiers
# modifiés par rapport à main (rapide, feedback ciblé).
VALIDATE_ALL_CODEBASE: ${{ github.ref == 'refs/heads/main' }}
# v3 obligatoire : l'API artifacts v4 (@actions/artifact 2.x) n'est pas
# supportée par Gitea (GHESNotSupportedError constaté avec v4).
- name: Publier les rapports
if: always()
uses: actions/upload-artifact@v3
with:
name: megalinter-reports
path: megalinter-reports/
web-lint:
name: Web (ESLint · angular-eslint + sonarjs)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: npm ci
working-directory: web
run: npm ci --no-audit --no-fund
# Lint via la toolchain du projet (et non via MegaLinter : ESLint a
# besoin des plugins de web/node_modules et du contexte Angular).
# Config + règles : web/eslint.config.js (sonarjs = règles "à la Sonar").
- name: ng lint
working-directory: web
run: npm run lint
trivy:
name: Trivy (CVE dépendances Maven / pip / npm)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Pour résoudre pom.xml (versions héritées du parent Spring Boot), Trivy
# télécharge les BOMs depuis Maven Central — qui finit par rate-limiter
# l'IP du runner (429). Parade officielle : peupler ~/.m2 AVANT le scan,
# Trivy lit le cache local en priorité. Le cache setup-java (clé pom.xml)
# rend l'étape quasi gratuite d'un run à l'autre.
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Précharger le cache Maven (~/.m2)
working-directory: core
run: |
chmod +x ./mvnw
./mvnw -B -q dependency:go-offline
- name: Installer Trivy
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Scanne les manifestes du dépôt (pom.xml, requirements*.txt,
# package-lock.json) contre les bases CVE. --ignore-unfixed : on ne
# bloque que sur les vulnérabilités qui ONT un correctif publié.
# --offline-scan : AUCUNE requête vers Maven Central pendant la
# résolution — Trivy allait y chercher les POMs parents même avec le
# cache rempli, et l'IP du runner finissait rate-limitée (429, 30 min).
# Tout est déjà dans ~/.m2 grâce au dependency:go-offline ci-dessus ;
# contrepartie : une dépendance absente du cache serait ignorée en
# silence (impossible ici, go-offline échouerait d'abord).
- name: Scan des dépendances (HIGH/CRITICAL bloquants)
run: |
trivy fs . \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--offline-scan \
--skip-dirs node_modules \
--skip-dirs docusaurus \
--exit-code 1

2
.gitignore vendored
View File

@@ -129,3 +129,5 @@ web/coverage/
brain/htmlcov/ brain/htmlcov/
brain/.coverage brain/.coverage
foundry-module/ foundry-module/
plan-promotion-loremind.md
post-reddit-foundryvtt.md

43
.mega-linter.yml Normal file
View File

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

View File

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

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>1.0.0</version> <version>1.0.3</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -24,6 +24,19 @@
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass). >= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
Propriete reconnue par le BOM Spring Boot → s'applique partout. --> Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
<commons-lang3.version>3.20.0</commons-lang3.version> <commons-lang3.version>3.20.0</commons-lang3.version>
<!-- Overrides CVE (detectes par Trivy en CI — job quality.yml) :
proprietes reconnues par le BOM Spring Boot, comme ci-dessus. -->
<!-- >= 2.21.4 : CVE-2026-54512 / CVE-2026-54513 (execution de code
arbitraire via contournement du PolymorphicTypeValidator). -->
<jackson-bom.version>2.21.4</jackson-bom.version>
<!-- >= 4.1.135 : lot de CVE netty (DoS codec/handler, bypass de
verification hostname CVE-2026-50010, DNS CVE-2026-45674/47691). -->
<netty.version>4.1.135.Final</netty.version>
<!-- >= 10.1.55 : 3 CRITICAL Tomcat (CVE-2026-41293 headers HTTP/2 non
valides, CVE-2026-43512 bypass auth digest, CVE-2026-43515). -->
<tomcat.version>10.1.55</tomcat.version>
<!-- >= 42.7.11 : CVE-2026-42198 (DoS client via SCRAM-SHA-256). -->
<postgresql.version>42.7.11</postgresql.version>
</properties> </properties>
<dependencies> <dependencies>

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -169,8 +169,8 @@ public class ArcService {
@Transactional @Transactional
public void reorderArcs(List<String> orderedIds) { public void reorderArcs(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> arcRepository.findById(id).orElse(null), arcRepository::findById,
(arc, i) -> arc.setOrder(i), Arc::setOrder,
arcRepository::save); arcRepository::save);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.ReadinessStatus; import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;

View File

@@ -1,20 +1,19 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Enemy; import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.campaigncontext.NodeType; import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.quest.Prerequisite;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.ReadinessEntityType; import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.ReadinessSeverity; import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
import com.loremind.domain.campaigncontext.ReadinessStatus; import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
import com.loremind.domain.campaigncontext.Room; import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.RoomBranch; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.SceneBranch;
import com.loremind.domain.campaigncontext.SceneBranch;
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;
@@ -55,6 +54,8 @@ import java.util.stream.Collectors;
@Service @Service
public class CampaignReadinessService { public class CampaignReadinessService {
private static final String SCENE_FALLBACK_NAME = "Scène";
private final CampaignRepository campaignRepository; private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
@@ -88,11 +89,6 @@ public class CampaignReadinessService {
Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream() Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream()
.map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet()); .map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet());
// Index global chapitres / scènes (cibles possibles des nœuds de quête).
Set<String> allChapterIds = new HashSet<>();
Set<String> allSceneIds = new HashSet<>();
int totalScenes = 0;
List<Quest> quests = questRepository.findByCampaignId(campaignId); List<Quest> quests = questRepository.findByCampaignId(campaignId);
// Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides // Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides
// (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres). // (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres).
@@ -102,50 +98,28 @@ public class CampaignReadinessService {
List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId)); List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId));
arcs.sort(Comparator.comparingInt(Arc::getOrder)); arcs.sort(Comparator.comparingInt(Arc::getOrder));
// Index global chapitres / scènes (cibles possibles des nœuds de quête).
Set<String> allChapterIds = new HashSet<>();
Set<String> allSceneIds = new HashSet<>();
int totalScenes = 0;
for (Arc arc : arcs) { for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId()); ArcScan scan = checkArc(arc, arcsWithQuests, enemyIds, gaps);
// Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la allChapterIds.addAll(scan.chapterIds());
// plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs). allSceneIds.addAll(scan.sceneIds());
boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId()); totalScenes += scan.sceneCount();
if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) {
String msg = arc.getType() == ArcType.HUB
? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le."
: "Arc vide : ajoutez un chapitre, ou supprimez-le.";
gaps.add(new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
"ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null));
}
for (Chapter chapter : chapters) {
allChapterIds.add(chapter.getId());
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
if (scenes.isEmpty()) {
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
labelOr(chapter.getName(), "Chapitre"), "CHAP-001-NO-SCENE",
"Chapitre vide : ajoutez au moins une scène pour pouvoir le jouer.",
ReadinessSeverity.BLOCKING, arc.getId(), chapter.getId()));
}
Set<String> chapterSceneIds = scenes.stream()
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
totalScenes += scenes.size();
for (Scene scene : scenes) {
allSceneIds.add(scene.getId());
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
}
}
} }
Set<String> questIds = quests.stream()
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet());
// Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat). // Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat).
boolean anyQuestWithNodes = quests.stream() if (totalScenes == 0 && !anyQuestHasNodes(quests)) {
.anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
if (totalScenes == 0 && !anyQuestWithNodes) {
gaps.add(new ReadinessGap(ReadinessEntityType.CAMPAIGN, campaignId, campaignName, gaps.add(new ReadinessGap(ReadinessEntityType.CAMPAIGN, campaignId, campaignName,
"CAMP-001-NO-CONTENT", "CAMP-001-NO-CONTENT",
"Campagne vide : ajoutez un arc avec une scène, ou créez une quête, pour commencer à jouer.", "Campagne vide : ajoutez un arc avec une scène, ou créez une quête, pour commencer à jouer.",
ReadinessSeverity.BLOCKING, null, null)); ReadinessSeverity.BLOCKING, null, null));
} }
Set<String> questIds = quests.stream()
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet());
for (Quest quest : quests) { for (Quest quest : quests) {
checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps); checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps);
} }
@@ -153,88 +127,149 @@ public class CampaignReadinessService {
return aggregate(campaignId, gaps); return aggregate(campaignId, gaps);
} }
/** Résultat du scan d'un arc : chapitres/scènes indexés (cibles des nœuds de quête) + total scènes. */
private record ArcScan(Set<String> chapterIds, Set<String> sceneIds, int sceneCount) {}
private ArcScan checkArc(Arc arc, Set<String> arcsWithQuests, Set<String> enemyIds, List<ReadinessGap> gaps) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
// Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la
// plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs).
boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId());
if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) {
gaps.add(emptyArcGap(arc));
}
Set<String> chapterIds = new HashSet<>();
Set<String> sceneIds = new HashSet<>();
int sceneCount = 0;
for (Chapter chapter : chapters) {
chapterIds.add(chapter.getId());
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
if (scenes.isEmpty()) {
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
labelOr(chapter.getName(), "Chapitre"), "CHAP-001-NO-SCENE",
"Chapitre vide : ajoutez au moins une scène pour pouvoir le jouer.",
ReadinessSeverity.BLOCKING, arc.getId(), chapter.getId()));
}
Set<String> chapterSceneIds = scenes.stream()
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
sceneCount += scenes.size();
for (Scene scene : scenes) {
sceneIds.add(scene.getId());
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
}
}
return new ArcScan(chapterIds, sceneIds, sceneCount);
}
private ReadinessGap emptyArcGap(Arc arc) {
String msg = arc.getType() == ArcType.HUB
? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le."
: "Arc vide : ajoutez un chapitre, ou supprimez-le.";
return new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
"ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null);
}
private static boolean anyQuestHasNodes(List<Quest> quests) {
return quests.stream().anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
}
private void checkScene(Scene scene, String arcId, String chapterId, private void checkScene(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) { Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) {
String name = labelOr(scene.getName(), "Scène"); checkSceneName(scene, arcId, chapterId, gaps);
checkSceneBranches(scene, arcId, chapterId, chapterSceneIds, gaps);
checkSceneCombat(scene, arcId, chapterId, enemyIds, gaps);
checkSceneEnemyRefs(scene, arcId, chapterId, enemyIds, gaps);
checkSceneRooms(scene, arcId, chapterId, enemyIds, gaps);
}
// SCENE-001 — scène sans titre. /** SCENE-001 — scène sans titre. */
private void checkSceneName(Scene scene, String arcId, String chapterId, List<ReadinessGap> gaps) {
if (isBlank(scene.getName())) { if (isBlank(scene.getName())) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME", gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME",
"Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.", "Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.",
ReadinessSeverity.BLOCKING)); ReadinessSeverity.BLOCKING));
} }
}
// SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence). /** SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence). */
private void checkSceneBranches(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, List<ReadinessGap> gaps) {
List<SceneBranch> branches = scene.getBranches(); List<SceneBranch> branches = scene.getBranches();
if (branches != null && branches.stream().anyMatch(b -> if (branches == null) return;
boolean invalid = branches.stream().anyMatch(b ->
isBlank(b.targetSceneId()) isBlank(b.targetSceneId())
|| b.targetSceneId().equals(scene.getId()) || b.targetSceneId().equals(scene.getId())
|| !chapterSceneIds.contains(b.targetSceneId()))) { || !chapterSceneIds.contains(b.targetSceneId()));
if (invalid) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID", gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID",
"Branche cassée : une sortie de « " + name "Branche cassée : une sortie de « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
+ " » pointe dans le vide, hors du chapitre, ou sur elle-même.", + " » pointe dans le vide, hors du chapitre, ou sur elle-même.",
ReadinessSeverity.BLOCKING)); ReadinessSeverity.BLOCKING));
} }
}
// SCENE-011 — combat annoncé sans adversaire (règle produit clé). /** SCENE-011 — combat annoncé sans adversaire (règle produit clé). */
if (!isBlank(scene.getCombatDifficulty())) { private void checkSceneCombat(Scene scene, String arcId, String chapterId,
boolean hasEnemyText = !isBlank(scene.getEnemies()); Set<String> enemyIds, List<ReadinessGap> gaps) {
boolean hasResolvedEnemy = scene.getEnemyIds() != null if (isBlank(scene.getCombatDifficulty())) return;
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id)); boolean hasEnemyText = !isBlank(scene.getEnemies());
if (!hasEnemyText && !hasResolvedEnemy) { boolean hasResolvedEnemy = scene.getEnemyIds() != null
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-011-COMBAT-NO-ENEMY", && scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id));
"Combat annoncé sans adversaire : ajoutez une fiche du bestiaire ou décrivez les ennemis.", if (!hasEnemyText && !hasResolvedEnemy) {
ReadinessSeverity.RECOMMENDED)); gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-011-COMBAT-NO-ENEMY",
} "Combat annoncé sans adversaire : ajoutez une fiche du bestiaire ou décrivez les ennemis.",
ReadinessSeverity.RECOMMENDED));
} }
}
// SCENE-012 — référence d'ennemi cassée (fiche supprimée). /** SCENE-012 — référence d'ennemi cassée (fiche supprimée). */
if (scene.getEnemyIds() != null private void checkSceneEnemyRefs(Scene scene, String arcId, String chapterId,
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id))) { Set<String> enemyIds, List<ReadinessGap> gaps) {
if (scene.getEnemyIds() == null) return;
boolean broken = scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
if (broken) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN", gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN",
"Ennemi introuvable : « " + name "Ennemi introuvable : « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
+ " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.", + " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.",
ReadinessSeverity.RECOMMENDED)); ReadinessSeverity.RECOMMENDED));
} }
}
// SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes. /** SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes. */
private void checkSceneRooms(Scene scene, String arcId, String chapterId,
Set<String> enemyIds, List<ReadinessGap> gaps) {
List<Room> rooms = scene.getRooms(); List<Room> rooms = scene.getRooms();
if (rooms != null && !rooms.isEmpty()) { if (rooms == null || rooms.isEmpty()) return;
Set<String> roomIds = rooms.stream() String name = labelOr(scene.getName(), SCENE_FALLBACK_NAME);
.map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet()); Set<String> roomIds = rooms.stream()
boolean roomBranchInvalid = false; .map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet());
boolean roomEnemyBroken = false;
for (Room room : rooms) { if (rooms.stream().anyMatch(room -> hasInvalidBranch(room, roomIds))) {
if (room.getBranches() != null) { gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID",
for (RoomBranch rb : room.getBranches()) { "Porte cassée : dans « " + name
if (isBlank(rb.targetRoomId()) + " », une sortie de pièce pointe hors de la scène ou dans le vide.",
|| rb.targetRoomId().equals(room.getId()) ReadinessSeverity.BLOCKING));
|| !roomIds.contains(rb.targetRoomId())) {
roomBranchInvalid = true;
}
}
}
if (room.getEnemyIds() != null) {
for (String id : room.getEnemyIds()) {
if (!isBlank(id) && !enemyIds.contains(id)) {
roomEnemyBroken = true;
}
}
}
}
if (roomBranchInvalid) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID",
"Porte cassée : dans « " + name
+ " », une sortie de pièce pointe hors de la scène ou dans le vide.",
ReadinessSeverity.BLOCKING));
}
if (roomEnemyBroken) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN",
"Ennemi introuvable dans une pièce de « " + name
+ " » : la référence pointe vers une fiche supprimée.",
ReadinessSeverity.RECOMMENDED));
}
} }
if (rooms.stream().anyMatch(room -> hasBrokenEnemyRef(room, enemyIds))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN",
"Ennemi introuvable dans une pièce de « " + name
+ " » : la référence pointe vers une fiche supprimée.",
ReadinessSeverity.RECOMMENDED));
}
}
private static boolean hasInvalidBranch(Room room, Set<String> roomIds) {
if (room.getBranches() == null) return false;
return room.getBranches().stream().anyMatch(rb ->
isBlank(rb.targetRoomId())
|| rb.targetRoomId().equals(room.getId())
|| !roomIds.contains(rb.targetRoomId()));
}
private static boolean hasBrokenEnemyRef(Room room, Set<String> enemyIds) {
if (room.getEnemyIds() == null) return false;
return room.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
} }
private void checkQuest(Quest quest, Set<String> allChapterIds, Set<String> allSceneIds, private void checkQuest(Quest quest, Set<String> allChapterIds, Set<String> allSceneIds,
@@ -244,8 +279,7 @@ public class CampaignReadinessService {
// QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide. // QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide.
if (quest.getNodes() == null || quest.getNodes().isEmpty()) { if (quest.getNodes() == null || quest.getNodes().isEmpty()) {
gaps.add(questGap(quest, "QUEST-001-NO-NODES", gaps.add(questGap(quest, "QUEST-001-NO-NODES",
"Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " ».", "Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " »."));
ReadinessSeverity.BLOCKING));
} else if (quest.getNodes().stream().anyMatch(n -> } else if (quest.getNodes().stream().anyMatch(n ->
n.nodeType() == null n.nodeType() == null
|| isBlank(n.nodeId()) || isBlank(n.nodeId())
@@ -253,8 +287,7 @@ public class CampaignReadinessService {
|| (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) { || (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) {
gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN", gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN",
"Nœud de quête cassé : dans « " + name "Nœud de quête cassé : dans « " + name
+ " », un chapitre ou une scène référencé n'existe plus.", + " », un chapitre ou une scène référencé n'existe plus."));
ReadinessSeverity.BLOCKING));
} }
// CAMP-010 — prérequis QuestCompleted pointant une quête disparue. // CAMP-010 — prérequis QuestCompleted pointant une quête disparue.
@@ -264,8 +297,7 @@ public class CampaignReadinessService {
.anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) { .anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) {
gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ", gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ",
"Prérequis cassé : « " + name "Prérequis cassé : « " + name
+ " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage.", + " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage."));
ReadinessSeverity.BLOCKING));
} }
} }
@@ -294,12 +326,12 @@ public class CampaignReadinessService {
private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId, private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId,
String ruleId, String message, ReadinessSeverity severity) { String ruleId, String message, ReadinessSeverity severity) {
return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(), return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(),
labelOr(scene.getName(), "Scène"), ruleId, message, severity, arcId, chapterId); labelOr(scene.getName(), SCENE_FALLBACK_NAME), ruleId, message, severity, arcId, chapterId);
} }
private ReadinessGap questGap(Quest quest, String ruleId, String message, ReadinessSeverity severity) { private ReadinessGap questGap(Quest quest, String ruleId, String message) {
return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(), return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(),
labelOr(quest.getName(), "Quête"), ruleId, message, severity, null, null); labelOr(quest.getName(), "Quête"), ruleId, message, ReadinessSeverity.BLOCKING, null, null);
} }
private static int severityRank(ReadinessSeverity severity) { private static int severityRank(ReadinessSeverity severity) {

View File

@@ -1,7 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.quest.Prerequisite;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.QuestRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.application.playcontext.PlaythroughService; import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;

View File

@@ -1,7 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -136,7 +136,7 @@ public class ChapterService {
@Transactional @Transactional
public void reorderChapters(String arcId, List<String> orderedIds) { public void reorderChapters(String arcId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> chapterRepository.findById(id).orElse(null), chapterRepository::findById,
(chapter, i) -> { (chapter, i) -> {
if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId); if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId);
chapter.setOrder(i); chapter.setOrder(i);

View File

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

View File

@@ -1,6 +1,6 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy; import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.EnemyRepository; import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.images.Image; import com.loremind.domain.images.Image;
@@ -10,11 +10,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.util.Base64; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** /**
* Service d'application pour les fiches d'ennemis (bestiaire de campagne). * Service d'application pour les fiches d'ennemis (bestiaire de campagne).
@@ -48,18 +44,10 @@ public class EnemyService {
public Enemy createEnemy(EnemyData data) { public Enemy createEnemy(EnemyData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId()); int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
Enemy enemy = Enemy.builder() Enemy enemy = Enemy.builder().build();
.name(data.name()) applyCommonEnemyFields(enemy, data);
.level(normalize(data.level())) enemy.setCampaignId(data.campaignId());
.folder(normalize(data.folder())) enemy.setOrder(order);
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
return enemyRepository.save(enemy); return enemyRepository.save(enemy);
} }
@@ -74,17 +62,11 @@ public class EnemyService {
public Enemy updateEnemy(String id, EnemyData data) { public Enemy updateEnemy(String id, EnemyData data) {
Enemy existing = enemyRepository.findById(id) Enemy existing = enemyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
existing.setName(data.name()); applyCommonEnemyFields(existing, data);
existing.setLevel(normalize(data.level()));
existing.setFolder(normalize(data.folder()));
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }
// campaignId immuable après création.
return enemyRepository.save(existing); return enemyRepository.save(existing);
} }
@@ -100,7 +82,7 @@ public class EnemyService {
public void reorderEnemies(String folder, List<String> orderedIds) { public void reorderEnemies(String folder, List<String> orderedIds) {
String f = normalize(folder); String f = normalize(folder);
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> enemyRepository.findById(id).orElse(null), enemyRepository::findById,
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); }, (enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
enemyRepository::save); enemyRepository::save);
} }
@@ -141,9 +123,7 @@ public class EnemyService {
return null; return null;
} }
if (bytes.length == 0) return null; if (bytes.length == 0) return null;
String ext = contentType.contains("png") ? "png" String ext = pickExtension(contentType);
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
: contentType.contains("gif") ? "gif" : "webp";
// Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non // Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non
// standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg"). // standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg").
String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext; String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext;
@@ -158,6 +138,14 @@ public class EnemyService {
} }
} }
/** Extension déduite du content-type ; "webp" par défaut si non reconnu. */
private static String pickExtension(String contentType) {
if (contentType.contains("png")) return "png";
if (contentType.contains("jpeg") || contentType.contains("jpg")) return "jpg";
if (contentType.contains("gif")) return "gif";
return "webp";
}
public record MonsterImportResult(int created, int updated) {} public record MonsterImportResult(int created, int updated) {}
/** /**
@@ -174,46 +162,51 @@ public class EnemyService {
} }
int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1; int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1;
int created = 0, updated = 0; int created = 0;
int updated = 0;
for (MonsterImport m : monsters) { for (MonsterImport m : monsters) {
if (m.foundryRef() == null || m.foundryRef().isBlank() if (isBlank(m.foundryRef()) || isBlank(m.name())) continue;
|| m.name() == null || m.name().isBlank()) {
continue;
}
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>(); Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
String folder = foundryFolder(m.folder());
Enemy ex = byRef.get(m.foundryRef()); Enemy ex = byRef.get(m.foundryRef());
if (ex != null) { if (ex != null) {
ex.setName(m.name()); updateMonster(ex, m, stats);
ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(folder); // ré-aligne sur l'arborescence Foundry
// Portrait : seulement s'il manque (pas de ré-upload à chaque import).
if (ex.getPortraitImageId() == null) {
String portrait = storePortrait(m.imgData(), m.name());
if (portrait != null) ex.setPortraitImageId(portrait);
}
enemyRepository.save(ex);
updated++; updated++;
} else { } else {
Enemy saved = enemyRepository.save(Enemy.builder() byRef.put(m.foundryRef(), createMonster(campaignId, m, stats, order++));
.name(m.name())
.foundryRef(m.foundryRef())
.foundryStats(stats)
.folder(folder)
.portraitImageId(storePortrait(m.imgData(), m.name()))
.campaignId(campaignId)
.order(order++)
.values(new HashMap<>())
.imageValues(new HashMap<>())
.keyValueValues(new HashMap<>())
.build());
byRef.put(m.foundryRef(), saved);
created++; created++;
} }
} }
return new MonsterImportResult(created, updated); return new MonsterImportResult(created, updated);
} }
/** Met à jour un monstre déjà importé : nom, snapshot de stats, dossier, portrait manquant. */
private void updateMonster(Enemy ex, MonsterImport m, Map<String, String> stats) {
ex.setName(m.name());
ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(foundryFolder(m.folder())); // ré-aligne sur l'arborescence Foundry
// Portrait : seulement s'il manque (pas de ré-upload à chaque import).
if (ex.getPortraitImageId() == null) {
String portrait = storePortrait(m.imgData(), m.name());
if (portrait != null) ex.setPortraitImageId(portrait);
}
enemyRepository.save(ex);
}
private Enemy createMonster(String campaignId, MonsterImport m, Map<String, String> stats, int order) {
return enemyRepository.save(Enemy.builder()
.name(m.name())
.foundryRef(m.foundryRef())
.foundryStats(stats)
.folder(foundryFolder(m.folder()))
.portraitImageId(storePortrait(m.imgData(), m.name()))
.campaignId(campaignId)
.order(order)
.values(new HashMap<>())
.imageValues(new HashMap<>())
.keyValueValues(new HashMap<>())
.build());
}
public List<Enemy> searchEnemies(String query) { public List<Enemy> searchEnemies(String query) {
if (query == null || query.isBlank()) return List.of(); if (query == null || query.isBlank()) return List.of();
return enemyRepository.searchByName(query.trim()); return enemyRepository.searchByName(query.trim());
@@ -226,10 +219,37 @@ public class EnemyService {
return trimmed.isEmpty() ? null : trimmed; return trimmed.isEmpty() ? null : trimmed;
} }
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private int nextOrderFor(String campaignId) { private int nextOrderFor(String campaignId) {
return enemyRepository.findByCampaignId(campaignId).stream() return enemyRepository.findByCampaignId(campaignId).stream()
.mapToInt(Enemy::getOrder) .mapToInt(Enemy::getOrder)
.max() .max()
.orElse(-1) + 1; .orElse(-1) + 1;
} }
private static Map<String, String> copyStringMap(Map<String, String> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, List<String>> copyStringListMap(Map<String, List<String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, Map<String, String>> copyStringMapMap(Map<String, Map<String, String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static void applyCommonEnemyFields(Enemy enemy, EnemyData data) {
enemy.setName(data.name());
enemy.setLevel(normalize(data.level()));
enemy.setFolder(normalize(data.folder()));
enemy.setPortraitImageId(data.portraitImageId());
enemy.setHeaderImageId(data.headerImageId());
enemy.setValues(copyStringMap(data.values()));
enemy.setImageValues(copyStringListMap(data.imageValues()));
enemy.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
}
} }

View File

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

View File

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

View File

@@ -1,15 +1,11 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** /**
* Service d'application pour les fiches de PNJ (campagne). * Service d'application pour les fiches de PNJ (campagne).
@@ -92,7 +88,7 @@ public class NpcService {
public void reorderNpcs(String folder, List<String> orderedIds) { public void reorderNpcs(String folder, List<String> orderedIds) {
String f = normalizeFolder(folder); String f = normalizeFolder(folder);
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> npcRepository.findById(id).orElse(null), npcRepository::findById,
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); }, (npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
npcRepository::save); npcRepository::save);
} }

View File

@@ -1,11 +1,11 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.NodeType; import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef; import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.QuestRepository;
@@ -106,6 +106,11 @@ public class QuestService {
/** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */ /** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */
private boolean isContainerOf(Quest quest, Chapter chapter) { private boolean isContainerOf(Quest quest, Chapter chapter) {
if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true; if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true;
return inSystemArc(chapter);
}
/** Le chapitre vit-il dans l'arc technique SYSTEM (masqué partout dans l'appli) ? */
private boolean inSystemArc(Chapter chapter) {
return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId()) return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId())
.map(a -> a.getType() == ArcType.SYSTEM) .map(a -> a.getType() == ArcType.SYSTEM)
.orElse(false); .orElse(false);
@@ -119,15 +124,6 @@ public class QuestService {
return questRepository.findByCampaignId(campaignId); return questRepository.findByCampaignId(campaignId);
} }
/** Quêtes rattachées à un arc HUB. */
public List<Quest> getQuestsByArcId(String arcId) {
return questRepository.findByArcId(arcId);
}
public List<Quest> getAllQuests() {
return questRepository.findAll();
}
/** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */ /** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */
@Transactional @Transactional
public Quest updateQuest(String id, Quest updated) { public Quest updateQuest(String id, Quest updated) {
@@ -165,9 +161,22 @@ public class QuestService {
/** /**
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties. * Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
* *
* <p>TODO (Phase 5) : signaler/nettoyer les {@code Prerequisite.QuestCompleted} pendants * <p>Nettoyage du CONTENEUR (chapitre jumeau, jamais un chapitre simplement LIÉ —
* d'autres quêtes qui pointaient celle-ci. Échec sûr aujourd'hui : un prérequis vers une * isContainerOf exclut les liens transversaux) :
* quête supprimée n'est jamais satisfait → la quête dépendante reste LOCKED (pas de corruption).</p> * <ul>
* <li>jumeau de HUB non vide : GARDÉ — il redevient un chapitre visible de l'arc,
* aucune perte de contenu ;</li>
* <li>conteneur d'arc SYSTEM (quête libre) : supprimé AVEC ses scènes — une fois la
* quête partie il est invisible partout dans l'appli et pourrirait en fantôme
* (réapparitions dans les exports). L'impact est annoncé au préalable par
* {@link #getDeletionImpact} (dialogue de confirmation côté front) ;</li>
* <li>conteneur encore référencé par une autre quête : jamais touché.</li>
* </ul></p>
*
* <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
* nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
* satisfait → la quête dépendante reste LOCKED (pas de corruption).</p>
*/ */
@Transactional @Transactional
public void deleteQuest(String id) { public void deleteQuest(String id) {
@@ -175,41 +184,56 @@ public class QuestService {
progressionRepository.deleteByQuestId(id); progressionRepository.deleteByQuestId(id);
questRepository.deleteById(id); questRepository.deleteById(id);
if (quest == null) return; if (quest == null) return;
// Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE
// (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître
// comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune
// perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du
// contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut.
List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId()); List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId());
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) { for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue; if (node.nodeType() != NodeType.CHAPTER) continue;
chapterRepository.findById(node.nodeId()).ifPresent(ch -> { chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
boolean container = isContainerOf(quest, ch); boolean container = isContainerOf(quest, ch);
boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty();
boolean referencedElsewhere = remaining.stream() boolean referencedElsewhere = remaining.stream()
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream() .anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER .anyMatch(n -> n.nodeType() == NodeType.CHAPTER
&& ch.getId().equals(n.nodeId()))); && ch.getId().equals(n.nodeId())));
if (container && empty && !referencedElsewhere) { if (!container || referencedElsewhere) return;
chapterRepository.deleteById(ch.getId()); var scenes = sceneRepository.findByChapterId(ch.getId());
} if (!scenes.isEmpty() && !inSystemArc(ch)) return; // jumeau de hub : reste visible
for (var scene : scenes) sceneRepository.deleteById(scene.getId());
chapterRepository.deleteById(ch.getId());
}); });
} }
} }
/** Scènes qui tomberont avec la quête (conteneurs d'arc SYSTEM exclusifs à cette quête). */
public record DeletionImpact(int scenes) {}
public DeletionImpact getDeletionImpact(String questId) {
Quest quest = questRepository.findById(questId).orElse(null);
if (quest == null) return new DeletionImpact(0);
List<Quest> others = questRepository.findByCampaignId(quest.getCampaignId()).stream()
.filter(q -> !questId.equals(q.getId()))
.toList();
int scenes = 0;
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue;
Chapter ch = chapterRepository.findById(node.nodeId()).orElse(null);
if (ch == null || !isContainerOf(quest, ch) || !inSystemArc(ch)) continue;
boolean referencedElsewhere = others.stream()
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
&& ch.getId().equals(n.nodeId())));
if (!referencedElsewhere) scenes += sceneRepository.findByChapterId(ch.getId()).size();
}
return new DeletionImpact(scenes);
}
private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) { private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
return nodes != null ? nodes : List.of(); return nodes != null ? nodes : List.of();
} }
public boolean questExists(String id) {
return questRepository.existsById(id);
}
/** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */ /** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */
@Transactional @Transactional
public void reorderQuests(String campaignId, List<String> orderedIds) { public void reorderQuests(String campaignId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> questRepository.findById(id).orElse(null), questRepository::findById,
(quest, i) -> { (quest, i) -> {
if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId); if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId);
quest.setOrder(i); quest.setOrder(i);

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator; import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus; import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.QuestStatus; import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.playcontext.QuestProgression; import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository; import com.loremind.domain.playcontext.ports.PlaythroughRepository;

View File

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

View File

@@ -1,7 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.ReadinessEntityType; import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.ReadinessSeverity; import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
/** /**
* Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B). * Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B).

View File

@@ -1,10 +1,10 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.SceneDraft; import com.loremind.domain.campaigncontext.generation.SceneDraft;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.structure.SceneBranch;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -171,7 +171,7 @@ public class SceneService {
if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue; if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue;
List<SceneBranch> kept = branches.stream() List<SceneBranch> kept = branches.stream()
.filter(b -> !id.equals(b.targetSceneId())) .filter(b -> !id.equals(b.targetSceneId()))
.collect(Collectors.toList()); .toList();
if (kept.size() != branches.size()) { if (kept.size() != branches.size()) {
sibling.setBranches(kept); sibling.setBranches(kept);
sceneRepository.save(sibling); sceneRepository.save(sibling);
@@ -193,7 +193,7 @@ public class SceneService {
@Transactional @Transactional
public void reorderScenes(String chapterId, List<String> orderedIds) { public void reorderScenes(String chapterId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> sceneRepository.findById(id).orElse(null), sceneRepository::findById,
(scene, i) -> { (scene, i) -> {
if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) { if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) {
scene.setChapterId(chapterId); scene.setChapterId(chapterId);

View File

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import java.io.InputStream; import java.io.InputStream;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -24,18 +25,18 @@ import java.util.Set;
@Service @Service
public class StoredFileService { public class StoredFileService {
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
/** Types MIME explicitement autorises (en plus de tout {@code image/*} et {@code video/*}). */ /** Types MIME explicitement autorises (en plus de tout {@code image/*} et {@code video/*}). */
private static final Set<String> ALLOWED_EXACT_MIME = Set.of( private static final Set<String> ALLOWED_EXACT_MIME = Set.of(
"application/json", "application/json",
"application/octet-stream", DEFAULT_CONTENT_TYPE,
"text/plain" "text/plain"
); );
/** Coherent avec spring.servlet.multipart.max-file-size (application.properties). */ /** Coherent avec spring.servlet.multipart.max-file-size (application.properties). */
private static final long MAX_SIZE_BYTES = 128L * 1024 * 1024; // 128 Mo private static final long MAX_SIZE_BYTES = 128L * 1024 * 1024; // 128 Mo
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
private final StoredFileRepository repository; private final StoredFileRepository repository;
private final FileStorage storage; private final FileStorage storage;
@@ -60,7 +61,7 @@ public class StoredFileService {
.contentType(resolvedType) .contentType(resolvedType)
.sizeBytes(sizeBytes) .sizeBytes(sizeBytes)
.storageKey(storageKey) .storageKey(storageKey)
.uploadedAt(LocalDateTime.now()) .uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
.build(); .build();
return repository.save(file); return repository.save(file);
} catch (RuntimeException ex) { } catch (RuntimeException ex) {

View File

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

View File

@@ -119,27 +119,29 @@ public class GameSystemService {
List<TemplateField> template = new ArrayList<>(); List<TemplateField> template = new ArrayList<>();
Set<String> usedNames = new HashSet<>(); Set<String> usedNames = new HashSet<>();
for (FoundryStructField f : fields == null ? List.<FoundryStructField>of() : fields) { for (FoundryStructField f : fields != null ? fields : List.<FoundryStructField>of()) {
if (f.path() == null || f.path().isBlank()) continue; TemplateField field = toTemplateField(f, usedNames);
String label = (f.label() != null && !f.label().isBlank()) ? f.label().trim() : f.path(); if (field != null) template.add(field);
// Nom unique : libellé si libre, sinon le chemin (toujours unique).
String name = usedNames.contains(label.toLowerCase()) ? f.path() : label;
if (usedNames.contains(name.toLowerCase())) continue;
usedNames.add(name.toLowerCase());
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
template.add(new TemplateField(name, type, null, null, f.path()));
} }
gs.replaceEnemyTemplate(template); gs.replaceEnemyTemplate(template);
gs.setFoundryActorType(normalize(actorType)); gs.setFoundryActorType(normalize(actorType));
return gameSystemRepository.save(gs); return gameSystemRepository.save(gs);
} }
public void deleteGameSystem(String id) { /** Convertit un champ Foundry en TemplateField, ou null si son chemin est vide ou son nom déjà pris. */
gameSystemRepository.deleteById(id); private static TemplateField toTemplateField(FoundryStructField f, Set<String> usedNames) {
if (f.path() == null || f.path().isBlank()) return null;
String label = (f.label() != null && !f.label().isBlank()) ? f.label().trim() : f.path();
// Nom unique : libellé si libre, sinon le chemin (toujours unique).
String name = usedNames.contains(label.toLowerCase()) ? f.path() : label;
if (usedNames.contains(name.toLowerCase())) return null;
usedNames.add(name.toLowerCase());
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
return new TemplateField(name, type, null, null, f.path());
} }
public boolean gameSystemExists(String id) { public void deleteGameSystem(String id) {
return gameSystemRepository.existsById(id); gameSystemRepository.deleteById(id);
} }
public List<GameSystem> searchGameSystems(String query) { public List<GameSystem> searchGameSystems(String query) {

View File

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

View File

@@ -1,14 +1,13 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.application.campaigncontext.CampaignContextFormatter;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.SceneDraft; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.SceneDraftProposal; import com.loremind.domain.campaigncontext.generation.SceneDraft;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.generation.SceneDraftProposal;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant; import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -30,19 +29,16 @@ public class DraftScenesUseCase {
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CampaignRepository campaignRepository; private final CampaignContextFormatter campaignContextFormatter;
private final GameSystemRepository gameSystemRepository;
private final SceneDraftAssistant assistant; private final SceneDraftAssistant assistant;
public DraftScenesUseCase(ChapterRepository chapterRepository, public DraftScenesUseCase(ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CampaignRepository campaignRepository, CampaignContextFormatter campaignContextFormatter,
GameSystemRepository gameSystemRepository,
SceneDraftAssistant assistant) { SceneDraftAssistant assistant) {
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.campaignRepository = campaignRepository; this.campaignContextFormatter = campaignContextFormatter;
this.gameSystemRepository = gameSystemRepository;
this.assistant = assistant; this.assistant = assistant;
} }
@@ -55,13 +51,13 @@ public class DraftScenesUseCase {
List<SceneDraft> drafts = assistant.draftScenes(context, instruction, n).stream() List<SceneDraft> drafts = assistant.draftScenes(context, instruction, n).stream()
.filter(d -> d != null && d.name() != null && !d.name().isBlank()) .filter(d -> d != null && d.name() != null && !d.name().isBlank())
.limit(n) .limit(n)
.collect(Collectors.toList()); .toList();
return new SceneDraftProposal(chapterId, drafts); return new SceneDraftProposal(chapterId, drafts);
} }
private String buildContext(Chapter chapter, String campaignId) { private String buildContext(Chapter chapter, String campaignId) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("Chapitre : ").append(blankToLabel(chapter.getName(), "(sans titre)")).append("\n"); sb.append("Chapitre : ").append(blankToLabel(chapter.getName())).append("\n");
appendIf(sb, "Synopsis", chapter.getDescription()); appendIf(sb, "Synopsis", chapter.getDescription());
appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives()); appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives());
appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes()); appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes());
@@ -78,17 +74,10 @@ public class DraftScenesUseCase {
} }
if (campaignId != null && !campaignId.isBlank()) { if (campaignId != null && !campaignId.isBlank()) {
campaignRepository.findById(campaignId).ifPresent(c -> { String campaignBlock = campaignContextFormatter.format(campaignId);
sb.append("Campagne : ").append(c.getName()); if (!campaignBlock.isBlank()) {
if (c.getDescription() != null && !c.getDescription().isBlank()) { sb.append(campaignBlock).append("\n");
sb.append("").append(c.getDescription().trim()); }
}
sb.append("\n");
if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) {
gameSystemRepository.findById(c.getGameSystemId())
.ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).append("\n"));
}
});
} }
return sb.toString(); return sb.toString();
} }
@@ -99,7 +88,7 @@ public class DraftScenesUseCase {
} }
} }
private static String blankToLabel(String value, String fallback) { private static String blankToLabel(String value) {
return value == null || value.isBlank() ? fallback : value; return value == null || value.isBlank() ? "(sans titre)" : value;
} }
} }

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.EntityFieldPatchProposal; import com.loremind.application.campaigncontext.CampaignContextFormatter;
import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.generation.EntityFieldPatchProposal;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant; import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@@ -26,18 +25,15 @@ import java.util.stream.Collectors;
public class NarrativeAssistFieldsUseCase { public class NarrativeAssistFieldsUseCase {
private final NarrativeFieldCatalog catalog; private final NarrativeFieldCatalog catalog;
private final CampaignRepository campaignRepository; private final CampaignContextFormatter campaignContextFormatter;
private final GameSystemRepository gameSystemRepository;
private final NarrativeFieldAssistant assistant; private final NarrativeFieldAssistant assistant;
public NarrativeAssistFieldsUseCase( public NarrativeAssistFieldsUseCase(
NarrativeFieldCatalog catalog, NarrativeFieldCatalog catalog,
CampaignRepository campaignRepository, CampaignContextFormatter campaignContextFormatter,
GameSystemRepository gameSystemRepository,
NarrativeFieldAssistant assistant) { NarrativeFieldAssistant assistant) {
this.catalog = catalog; this.catalog = catalog;
this.campaignRepository = campaignRepository; this.campaignContextFormatter = campaignContextFormatter;
this.gameSystemRepository = gameSystemRepository;
this.assistant = assistant; this.assistant = assistant;
} }
@@ -46,7 +42,7 @@ public class NarrativeAssistFieldsUseCase {
List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream() List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream()
.map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label())) .map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label()))
.collect(Collectors.toList()); .toList();
Set<String> allowed = snap.defs().stream() Set<String> allowed = snap.defs().stream()
.map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet()); .map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet());
@@ -56,19 +52,26 @@ public class NarrativeAssistFieldsUseCase {
List<FieldProposal> fields = new ArrayList<>(); List<FieldProposal> fields = new ArrayList<>();
for (NarrativeFieldAssistant.ProposedField pf : proposed) { for (NarrativeFieldAssistant.ProposedField pf : proposed) {
if (pf.key() == null || !allowed.contains(pf.key())) continue; FieldProposal proposal = toFieldProposal(pf, allowed, snap);
if (pf.value() == null || pf.value().isBlank()) continue; if (proposal != null) fields.add(proposal);
String current = snap.current().getOrDefault(pf.key(), "");
fields.add(new FieldProposal(pf.key(), current, pf.value()));
} }
return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields); return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields);
} }
/** Convertit un champ proposé par l'IA en FieldProposal, ou null si sa clé n'est pas autorisée ou sa valeur vide. */
private static FieldProposal toFieldProposal(
NarrativeFieldAssistant.ProposedField pf, Set<String> allowed, NarrativeFieldCatalog.Snapshot snap) {
if (pf.key() == null || !allowed.contains(pf.key())) return null;
if (pf.value() == null || pf.value().isBlank()) return null;
String current = snap.current().getOrDefault(pf.key(), "");
return new FieldProposal(pf.key(), current, pf.value());
}
/** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */ /** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */
private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) { private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(entityLabel(snap.entityType())).append(" : ") sb.append(entityLabel(snap.entityType())).append(" : ")
.append(blankToLabel(snap.title(), "(sans titre)")).append("\n"); .append(blankToLabel(snap.title())).append("\n");
sb.append("État actuel des champs :\n"); sb.append("État actuel des champs :\n");
boolean any = false; boolean any = false;
for (Map.Entry<String, String> e : snap.current().entrySet()) { for (Map.Entry<String, String> e : snap.current().entrySet()) {
@@ -82,17 +85,10 @@ public class NarrativeAssistFieldsUseCase {
sb.append("- (tous les champs sont vides — à créer de zéro)\n"); sb.append("- (tous les champs sont vides — à créer de zéro)\n");
} }
if (campaignId != null && !campaignId.isBlank()) { if (campaignId != null && !campaignId.isBlank()) {
campaignRepository.findById(campaignId).ifPresent(c -> { String campaignBlock = campaignContextFormatter.format(campaignId);
sb.append("Campagne : ").append(c.getName()); if (!campaignBlock.isBlank()) {
if (c.getDescription() != null && !c.getDescription().isBlank()) { sb.append(campaignBlock).append("\n");
sb.append("").append(c.getDescription().trim()); }
}
sb.append("\n");
if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) {
gameSystemRepository.findById(c.getGameSystemId())
.ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).append("\n"));
}
});
} }
return sb.toString(); return sb.toString();
} }
@@ -106,7 +102,7 @@ public class NarrativeAssistFieldsUseCase {
}; };
} }
private static String blankToLabel(String value, String fallback) { private static String blankToLabel(String value) {
return value == null || value.isBlank() ? fallback : value; return value == null || value.isBlank() ? "(sans titre)" : value;
} }
} }

View File

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

View File

@@ -1,8 +1,8 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -29,22 +29,25 @@ public class NarrativeFieldCatalog {
public record Snapshot(String entityType, String title, public record Snapshot(String entityType, String title,
LinkedHashMap<String, String> current, List<FieldDef> defs) {} LinkedHashMap<String, String> current, List<FieldDef> defs) {}
private static final String FIELD_DESCRIPTION = "description";
private static final String FIELD_GM_NOTES = "gmNotes";
private static final List<FieldDef> ARC_DEFS = List.of( private static final List<FieldDef> ARC_DEFS = List.of(
new FieldDef("description", "description / synopsis de l'arc"), new FieldDef(FIELD_DESCRIPTION, "description / synopsis de l'arc"),
new FieldDef("themes", "thèmes explorés"), new FieldDef("themes", "thèmes explorés"),
new FieldDef("stakes", "enjeux globaux pour les personnages"), new FieldDef("stakes", "enjeux globaux pour les personnages"),
new FieldDef("rewards", "récompenses et progression"), new FieldDef("rewards", "récompenses et progression"),
new FieldDef("resolution", "dénouement prévu"), new FieldDef("resolution", "dénouement prévu"),
new FieldDef("gmNotes", "notes privées du MJ")); new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
private static final List<FieldDef> CHAPTER_DEFS = List.of( private static final List<FieldDef> CHAPTER_DEFS = List.of(
new FieldDef("description", "synopsis du chapitre"), new FieldDef(FIELD_DESCRIPTION, "synopsis du chapitre"),
new FieldDef("playerObjectives", "objectifs des joueurs"), new FieldDef("playerObjectives", "objectifs des joueurs"),
new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"), new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"),
new FieldDef("gmNotes", "notes privées du MJ")); new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
private static final List<FieldDef> SCENE_DEFS = List.of( private static final List<FieldDef> SCENE_DEFS = List.of(
new FieldDef("description", "description courte de la scène"), new FieldDef(FIELD_DESCRIPTION, "description courte de la scène"),
new FieldDef("location", "lieu où se déroule la scène"), new FieldDef("location", "lieu où se déroule la scène"),
new FieldDef("timing", "moment / temporalité"), new FieldDef("timing", "moment / temporalité"),
new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"), new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"),
@@ -80,12 +83,12 @@ public class NarrativeFieldCatalog {
Arc a = arcRepository.findById(id) Arc a = arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id)); .orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>(); LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put("description", nz(a.getDescription())); cur.put(FIELD_DESCRIPTION, nz(a.getDescription()));
cur.put("themes", nz(a.getThemes())); cur.put("themes", nz(a.getThemes()));
cur.put("stakes", nz(a.getStakes())); cur.put("stakes", nz(a.getStakes()));
cur.put("rewards", nz(a.getRewards())); cur.put("rewards", nz(a.getRewards()));
cur.put("resolution", nz(a.getResolution())); cur.put("resolution", nz(a.getResolution()));
cur.put("gmNotes", nz(a.getGmNotes())); cur.put(FIELD_GM_NOTES, nz(a.getGmNotes()));
return new Snapshot("arc", a.getName(), cur, ARC_DEFS); return new Snapshot("arc", a.getName(), cur, ARC_DEFS);
} }
@@ -93,10 +96,10 @@ public class NarrativeFieldCatalog {
Chapter c = chapterRepository.findById(id) Chapter c = chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id)); .orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>(); LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put("description", nz(c.getDescription())); cur.put(FIELD_DESCRIPTION, nz(c.getDescription()));
cur.put("playerObjectives", nz(c.getPlayerObjectives())); cur.put("playerObjectives", nz(c.getPlayerObjectives()));
cur.put("narrativeStakes", nz(c.getNarrativeStakes())); cur.put("narrativeStakes", nz(c.getNarrativeStakes()));
cur.put("gmNotes", nz(c.getGmNotes())); cur.put(FIELD_GM_NOTES, nz(c.getGmNotes()));
return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS); return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS);
} }
@@ -104,7 +107,7 @@ public class NarrativeFieldCatalog {
Scene s = sceneRepository.findById(id) Scene s = sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id)); .orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>(); LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put("description", nz(s.getDescription())); cur.put(FIELD_DESCRIPTION, nz(s.getDescription()));
cur.put("location", nz(s.getLocation())); cur.put("location", nz(s.getLocation()));
cur.put("timing", nz(s.getTiming())); cur.put("timing", nz(s.getTiming()));
cur.put("atmosphere", nz(s.getAtmosphere())); cur.put("atmosphere", nz(s.getAtmosphere()));

View File

@@ -1,9 +1,9 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator; import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus; import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.QuestStatus; import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.generationcontext.SessionContext; import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary; import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
@@ -26,7 +26,6 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Construit le SessionContext injecté dans le prompt IA pendant une partie. * Construit le SessionContext injecté dans le prompt IA pendant une partie.
@@ -62,10 +61,6 @@ public class SessionStructuralContextBuilder {
this.questProgressionRepository = questProgressionRepository; this.questProgressionRepository = questProgressionRepository;
} }
public Optional<SessionContext> buildOptional(String sessionId) {
return sessionRepository.findById(sessionId).map(this::toContext);
}
public SessionContext build(String sessionId) { public SessionContext build(String sessionId) {
Session session = sessionRepository.findById(sessionId) Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId)); .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -97,7 +92,7 @@ public class SessionStructuralContextBuilder {
return kept.stream() return kept.stream()
.map(e -> toSummary(e, null)) .map(e -> toSummary(e, null))
.collect(Collectors.toList()); .toList();
} }
/** /**
@@ -209,6 +204,6 @@ public class SessionStructuralContextBuilder {
.filter(Map.Entry::getValue) .filter(Map.Entry::getValue)
.map(Map.Entry::getKey) .map(Map.Entry::getKey)
.sorted() .sorted()
.collect(Collectors.toList()); .toList();
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,7 +77,7 @@ public class LoreNodeService {
public void reorderNodes(String parentId, List<String> orderedIds) { public void reorderNodes(String parentId, List<String> orderedIds) {
String parent = (parentId == null || parentId.isBlank()) ? null : parentId; String parent = (parentId == null || parentId.isBlank()) ? null : parentId;
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> loreNodeRepository.findById(id).orElse(null), loreNodeRepository::findById,
(node, i) -> { (node, i) -> {
if (parent == null || !isSelfOrDescendant(parent, node.getId())) { if (parent == null || !isSelfOrDescendant(parent, node.getId())) {
node.setParentId(parent); node.setParentId(parent);

View File

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

View File

@@ -4,7 +4,6 @@ import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.loremind.domain.shared.CollectionUtils;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import java.util.ArrayList; import java.util.ArrayList;
@@ -84,14 +83,7 @@ public class PageService {
existing.setTitle(changes.getTitle()); existing.setTitle(changes.getTitle());
existing.setNodeId(changes.getNodeId()); existing.setNodeId(changes.getNodeId());
existing.setValues(CollectionUtils.copyMap(changes.getValues())); existing.applyEditableContent(changes);
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
existing.setImageFraming(CollectionUtils.copyMap(changes.getImageFraming()));
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
existing.setNotes(changes.getNotes());
existing.setTags(CollectionUtils.copyList(changes.getTags()));
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
return pageRepository.save(existing); return pageRepository.save(existing);
} }
@@ -106,7 +98,7 @@ public class PageService {
@org.springframework.transaction.annotation.Transactional @org.springframework.transaction.annotation.Transactional
public void reorderPages(String nodeId, List<String> orderedIds) { public void reorderPages(String nodeId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> pageRepository.findById(id).orElse(null), pageRepository::findById,
(page, i) -> { (page, i) -> {
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId); if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
page.setOrder(i); page.setOrder(i);

View File

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

View File

@@ -4,10 +4,10 @@ import com.loremind.application.campaigncontext.CampaignReadinessAssessment;
import com.loremind.application.campaigncontext.CampaignReadinessService; import com.loremind.application.campaigncontext.CampaignReadinessService;
import com.loremind.application.campaigncontext.QuestStatusEnricher; import com.loremind.application.campaigncontext.QuestStatusEnricher;
import com.loremind.application.campaigncontext.ReadinessGap; import com.loremind.application.campaigncontext.ReadinessGap;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef; import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.QuestStatus; import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -20,7 +20,6 @@ import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository; import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
@@ -111,7 +110,7 @@ public class SessionPrepService {
} else { } else {
focused = assessment.gaps().stream() focused = assessment.gaps().stream()
.filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds)) .filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds))
.collect(Collectors.toList()); .toList();
} }
int otherGapCount = assessment.gaps().size() - focused.size(); int otherGapCount = assessment.gaps().size() - focused.size();
@@ -122,7 +121,7 @@ public class SessionPrepService {
.filter(c -> c.getFilled() > 0) .filter(c -> c.getFilled() > 0)
.map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(), .map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(),
c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null)) c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null))
.collect(Collectors.toList()); .toList();
return new SessionPrepReport( return new SessionPrepReport(
playthroughId, playthroughId,
@@ -173,7 +172,7 @@ public class SessionPrepService {
List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId); List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId);
return sessions.stream() return sessions.stream()
.max(Comparator.comparing(Session::getStartedAt, .max(Comparator.comparing(Session::getStartedAt,
Comparator.nullsFirst(Comparator.<LocalDateTime>naturalOrder()))) Comparator.nullsFirst(Comparator.naturalOrder())))
.map(s -> new SessionPrepReport.LastSessionInfo( .map(s -> new SessionPrepReport.LastSessionInfo(
s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive())) s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive()))
.orElse(null); .orElse(null);
@@ -183,13 +182,13 @@ public class SessionPrepService {
return quests.stream() return quests.stream()
.filter(q -> statusById.get(q.getId()) == wanted) .filter(q -> statusById.get(q.getId()) == wanted)
.sorted(Comparator.comparingInt(Quest::getOrder)) .sorted(Comparator.comparingInt(Quest::getOrder))
.collect(Collectors.toList()); .toList();
} }
private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) { private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) {
return quests.stream() return quests.stream()
.map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon())) .map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon()))
.collect(Collectors.toList()); .toList();
} }
private static List<Quest> concat(List<Quest> a, List<Quest> b) { private static List<Quest> concat(List<Quest> a, List<Quest> b) {

View File

@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -23,6 +24,7 @@ import java.util.Optional;
public class SessionService { public class SessionService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final String SESSION_NOT_FOUND = "Session introuvable : ";
private final SessionRepository sessionRepository; private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository; private final SessionEntryRepository entryRepository;
@@ -57,7 +59,7 @@ public class SessionService {
"). Termine-la avant d'en lancer une nouvelle."); "). Termine-la avant d'en lancer une nouvelle.");
}); });
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
Session session = Session.builder() Session session = Session.builder()
.name(generateDefaultName(now)) .name(generateDefaultName(now))
.playthroughId(playthroughId) .playthroughId(playthroughId)
@@ -68,11 +70,11 @@ public class SessionService {
public Session endSession(String id) { public Session endSession(String id) {
Session session = sessionRepository.findById(id) Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id)); .orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
if (!session.isActive()) { if (!session.isActive()) {
throw new IllegalStateException("Cette session est déjà terminée."); throw new IllegalStateException("Cette session est déjà terminée.");
} }
session.setEndedAt(LocalDateTime.now()); session.setEndedAt(LocalDateTime.now(ZoneId.systemDefault()));
Session saved = sessionRepository.save(session); Session saved = sessionRepository.save(session);
// Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie. // Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie.
clockService.onSessionEnded(saved.getPlaythroughId()); clockService.onSessionEnded(saved.getPlaythroughId());
@@ -86,7 +88,7 @@ public class SessionService {
*/ */
public Session setCurrentScene(String id, String sceneId) { public Session setCurrentScene(String id, String sceneId) {
Session session = sessionRepository.findById(id) Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id)); .orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null); session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null);
return sessionRepository.save(session); return sessionRepository.save(session);
} }
@@ -96,7 +98,7 @@ public class SessionService {
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide."); throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
} }
Session session = sessionRepository.findById(id) Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id)); .orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
session.setName(newName.trim()); session.setName(newName.trim());
return sessionRepository.save(session); return sessionRepository.save(session);
} }
@@ -124,7 +126,7 @@ public class SessionService {
@Transactional @Transactional
public void deleteSession(String id) { public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) { if (!sessionRepository.existsById(id)) {
throw new IllegalArgumentException("Session introuvable : " + id); throw new IllegalArgumentException(SESSION_NOT_FOUND + id);
} }
entryRepository.deleteBySessionId(id); entryRepository.deleteBySessionId(id);
sessionRepository.deleteById(id); sessionRepository.deleteById(id);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
/** /**
* Proposition IA pour UN champ textuel d'une entité (Pilier A co-création). * Proposition IA pour UN champ textuel d'une entité (Pilier A co-création).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
/** /**
* Ébauche de scène proposée par l'IA (Pilier A capacité « create »). Value Object * Ébauche de scène proposée par l'IA (Pilier A capacité « create »). Value Object

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
import java.util.List; import java.util.List;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Erreur de génération lors de l'étoffage IA d'une entité narrative (Brain injoignable, * Erreur de génération lors de l'étoffage IA d'une entité narrative (Brain injoignable,

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
/** /**
* Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}. * Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
/** /**
* Value Object : lien faible d'une {@link Quest} vers un nœud narratif * Value Object : lien faible d'une {@link Quest} vers un nœud narratif

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.readiness;
/** /**
* Type de l'entité de scénario ciblée par un manque de readiness. Le front s'en * Type de l'entité de scénario ciblée par un manque de readiness. Le front s'en

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.readiness;
/** /**
* Gravité d'un manque de préparation détecté par le guidage (Pilier B). * Gravité d'un manque de préparation détecté par le guidage (Pilier B).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.readiness;
/** /**
* Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »). * Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »).

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
/** /**
* Type structurel d'un Arc. * Type structurel d'un Arc.
@@ -9,7 +9,6 @@ package com.loremind.domain.campaigncontext;
* narration dans l'arbre (ses quêtes s'affichent sous « Quêtes ») ; dans * narration dans l'arbre (ses quêtes s'affichent sous « Quêtes ») ; dans
* les exports (PDF / Foundry / backup) il apparaît sous son nom ses * les exports (PDF / Foundry / backup) il apparaît sous son nom ses
* scènes sont du vrai contenu jouable. * scènes sont du vrai contenu jouable.
*
* Value Object du domaine (Bounded Context : Campaign). * Value Object du domaine (Bounded Context : Campaign).
*/ */
public enum ArcType { public enum ArcType {

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
/** /**
* Type d'un lien narratif entre nœuds ({@link SceneBranch}) Niveau 2. * Type d'un lien narratif entre nœuds ({@link SceneBranch}) Niveau 2.

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
/** /**
* Sortie d'une pièce vers une autre pièce de la même Scene explorable. * Sortie d'une pièce vers une autre pièce de la même Scene explorable.

View File

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

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