Compare commits

..

11 Commits

Author SHA1 Message Date
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
f72161e41e Correction sur l'export PDF : problème d'échappement de caractères qui faisait péter un test unitaire
Some checks failed
Build & Push Images / build (core) (push) Successful in 3m45s
Build & Push Images / build (web) (push) Successful in 1m53s
E2E Tests / e2e (push) Failing after 9m2s
Build & Push Images / build-switcher (push) Successful in 42s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m18s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 18s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / build (brain) (push) Successful in 1m13s
2026-07-04 11:42:18 +02:00
4d05aa5118 Passage à la version 1.0.0
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 22s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 2m54s
Build & Push Images / build (brain) (push) Successful in 1m21s
Build & Push Images / build (core) (push) Successful in 3m37s
Build & Push Images / build (web) (push) Successful in 2m1s
E2E Tests / e2e (push) Failing after 9m37s
Build & Push Images / build-switcher (push) Successful in 42s
2026-07-04 11:21:46 +02:00
beafbc2fa9 Livret PDF v2, graphe des liens côté campagne, et refactor de l'import
Livret PDF (export campagne) :
- refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs
  en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat,
  ligne de contexte des scènes, en-tête courant, tables zébrées
- reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub
  fusionnées dans leur chapitre, partie « Quêtes » sans doublon
- contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces
  explorables, partie « Tables aléatoires »
- polices DejaVu embarquées (couverture Unicode, licence incluse) et harness
  visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres)

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

Divers :
- ImportService découpé en classes dédiées (parser d'archive, remapper d'ids,
  inserters campagne/lore/état de jeu, conversion legacy des quêtes)
- fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans
- garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs),
  budget SCSS anyComponentStyle relevé à 12 ko
2026-07-04 11:15:16 +02:00
470 changed files with 11405 additions and 5404 deletions

View File

@@ -82,9 +82,11 @@ jobs:
if: failure()
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
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: 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

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(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="1.0.0-beta",
version="1.0.2-beta",
)
logger = logging.getLogger(__name__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>1.0.0-beta</version>
<version>1.0.2-beta</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -24,6 +24,19 @@
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
<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>
<dependencies>

View File

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

View File

@@ -41,6 +41,16 @@ public class CampaignBriefBuilder {
sb.append("# Campagne : ").append(cc.campaignName()).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("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
.append("un arc LINEAR contient des chapitres en séquence._\n");
@@ -48,11 +58,21 @@ public class CampaignBriefBuilder {
sb.append("_(aucun arc pour le moment)_\n");
}
for (ArcSummary arc : cc.arcs()) {
appendArc(sb, arc);
}
}
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()) {
sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name());
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()) {
@@ -61,9 +81,9 @@ public class CampaignBriefBuilder {
sb.append("\n");
}
}
}
if (!cc.npcs().isEmpty()) {
private void appendNpcs(StringBuilder sb, CampaignStructuralContext cc) {
if (cc.npcs().isEmpty()) return;
sb.append("\n## PNJ existants\n");
for (NpcSummary n : cc.npcs()) {
sb.append("- ").append(n.name());
@@ -72,12 +92,6 @@ public class CampaignBriefBuilder {
}
}
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");

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;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.NpcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.generation.CampaignImportProgress;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.NpcProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -83,14 +83,32 @@ public class CampaignImportService {
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
// via leur existingId dans l'arbre fusionné venu de la revue).
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
for (ArcProposal arcP : proposal.arcs()) {
if (isBlank(arcP.name())) continue;
ArcOutcome outcome = applyArc(campaignId, arcP, arcOrder);
arcOrder = outcome.nextOrder();
arcsCreated += outcome.arcsCreated();
chaptersCreated += outcome.chaptersCreated();
scenesCreated += outcome.scenesCreated();
}
int npcsCreated = createNpcs(campaignId, proposal.npcs());
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 {
@@ -103,13 +121,28 @@ public class CampaignImportService {
.type(parseArcType(arcP.type()))
.build());
arcId = arc.getId();
arcsCreated++;
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 {
@@ -117,13 +150,13 @@ public class CampaignImportService {
Chapter chapter = chapterService.createChapter(
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
chapId = chapter.getId();
chaptersCreated++;
chaptersCreated = 1;
}
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
int scenesCreated = 0;
for (SceneProposal sceneP : safe(chapP.scenes())) {
if (isBlank(sceneP.name())) continue;
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
if (isBlank(sceneP.name()) || !isBlank(sceneP.existingId())) continue; // vide ou déjà présente
sceneOrder++;
sceneService.createScene(Scene.builder()
.name(sceneP.name().trim())
@@ -136,11 +169,7 @@ public class CampaignImportService {
.build());
scenesCreated++;
}
}
}
int npcsCreated = createNpcs(campaignId, proposal.npcs());
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
return new ChapterOutcome(chapterOrder, chaptersCreated, scenesCreated);
}
/**
@@ -182,7 +211,8 @@ public class CampaignImportService {
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
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). */

View File

@@ -1,6 +1,6 @@
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.Map;

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext;
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.Chapter;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -92,6 +92,22 @@ public class CampaignService {
return campaignRepository.save(campaign);
}
/**
* Sauvegarde les positions du graphe de campagne (JSON opaque, état de
* présentation possédé par le front). Null/vide = retour à la disposition
* automatique. Taille bornée : ce champ ne doit pas devenir un fourre-tout.
*/
public void updateGraphPositions(String id, String positionsJson) {
Campaign campaign = campaignRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Campaign non trouvé avec l'ID: " + id));
String value = (positionsJson == null || positionsJson.isBlank()) ? null : positionsJson;
if (value != null && value.length() > 200_000) {
throw new IllegalArgumentException("Positions de graphe trop volumineuses");
}
campaign.setGraphPositions(value);
campaignRepository.save(campaign);
}
private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id;
}

View File

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

View File

@@ -1,6 +1,6 @@
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 org.springframework.stereotype.Service;
@@ -41,9 +41,9 @@ public class CharacterService {
.name(data.name())
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.values(copyStringMap(data.values()))
.imageValues(copyStringListMap(data.imageValues()))
.keyValueValues(copyStringMapMap(data.keyValueValues()))
.playthroughId(data.playthroughId())
.order(order)
.build();
@@ -64,9 +64,9 @@ public class CharacterService {
existing.setName(data.name());
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
existing.setValues(copyStringMap(data.values()));
existing.setImageValues(copyStringListMap(data.imageValues()));
existing.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
if (data.order() != null) {
existing.setOrder(data.order());
}
@@ -89,4 +89,16 @@ public class CharacterService {
.max()
.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;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.images.Image;
@@ -10,11 +10,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.*;
/**
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
@@ -48,18 +44,10 @@ public class EnemyService {
public Enemy createEnemy(EnemyData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
Enemy enemy = Enemy.builder()
.name(data.name())
.level(normalize(data.level()))
.folder(normalize(data.folder()))
.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();
Enemy enemy = Enemy.builder().build();
applyCommonEnemyFields(enemy, data);
enemy.setCampaignId(data.campaignId());
enemy.setOrder(order);
return enemyRepository.save(enemy);
}
@@ -74,17 +62,11 @@ public class EnemyService {
public Enemy updateEnemy(String id, EnemyData data) {
Enemy existing = enemyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
existing.setName(data.name());
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<>());
applyCommonEnemyFields(existing, data);
if (data.order() != null) {
existing.setOrder(data.order());
}
// campaignId immuable après création.
return enemyRepository.save(existing);
}
@@ -100,7 +82,7 @@ public class EnemyService {
public void reorderEnemies(String folder, List<String> orderedIds) {
String f = normalize(folder);
ReorderSupport.reorder(orderedIds,
id -> enemyRepository.findById(id).orElse(null),
enemyRepository::findById,
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
enemyRepository::save);
}
@@ -141,9 +123,7 @@ public class EnemyService {
return null;
}
if (bytes.length == 0) return null;
String ext = contentType.contains("png") ? "png"
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
: contentType.contains("gif") ? "gif" : "webp";
String ext = pickExtension(contentType);
// Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non
// standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg").
String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext;
@@ -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) {}
/**
@@ -174,44 +162,49 @@ public class EnemyService {
}
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) {
if (m.foundryRef() == null || m.foundryRef().isBlank()
|| m.name() == null || m.name().isBlank()) {
continue;
}
if (isBlank(m.foundryRef()) || isBlank(m.name())) continue;
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
String folder = foundryFolder(m.folder());
Enemy ex = byRef.get(m.foundryRef());
if (ex != null) {
updateMonster(ex, m, stats);
updated++;
} else {
byRef.put(m.foundryRef(), createMonster(campaignId, m, stats, order++));
created++;
}
}
return new MonsterImportResult(created, updated);
}
/** Met à jour un monstre déjà importé : nom, snapshot de stats, dossier, portrait manquant. */
private void updateMonster(Enemy ex, MonsterImport m, Map<String, String> stats) {
ex.setName(m.name());
ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(folder); // ré-aligne sur l'arborescence Foundry
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);
updated++;
} else {
Enemy saved = enemyRepository.save(Enemy.builder()
}
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(folder)
.folder(foundryFolder(m.folder()))
.portraitImageId(storePortrait(m.imgData(), m.name()))
.campaignId(campaignId)
.order(order++)
.order(order)
.values(new HashMap<>())
.imageValues(new HashMap<>())
.keyValueValues(new HashMap<>())
.build());
byRef.put(m.foundryRef(), saved);
created++;
}
}
return new MonsterImportResult(created, updated);
}
public List<Enemy> searchEnemies(String query) {
@@ -226,10 +219,37 @@ public class EnemyService {
return trimmed.isEmpty() ? null : trimmed;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private int nextOrderFor(String campaignId) {
return enemyRepository.findByCampaignId(campaignId).stream()
.mapToInt(Enemy::getOrder)
.max()
.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;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.itemcatalog.CatalogItem;
import com.loremind.domain.campaigncontext.itemcatalog.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -21,18 +18,15 @@ public class ItemCatalogService {
private final ItemCatalogRepository repository;
private final ItemCatalogGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
private final CampaignContextFormatter campaignContextFormatter;
public ItemCatalogService(
ItemCatalogRepository repository,
ItemCatalogGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
CampaignContextFormatter campaignContextFormatter) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
this.campaignContextFormatter = campaignContextFormatter;
}
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. */
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()
.name(g.name())
.description(g.description())
@@ -102,22 +97,6 @@ public class ItemCatalogService {
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) {
return repository.findByCampaignId(campaignId).stream()
.mapToInt(ItemCatalog::getOrder)

View File

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

View File

@@ -1,17 +1,11 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.*;
/**
* Service d'application pour les fiches de PNJ (campagne).
@@ -20,11 +14,9 @@ import java.util.Optional;
public class NpcService {
private final NpcRepository npcRepository;
private final CampaignRepository campaignRepository;
public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) {
public NpcService(NpcRepository npcRepository) {
this.npcRepository = npcRepository;
this.campaignRepository = campaignRepository;
}
public record NpcData(
@@ -67,21 +59,6 @@ public class NpcService {
return npcRepository.findByCampaignId(campaignId);
}
/**
* PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}).
* Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent.
* Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé.
*/
public List<Npc> getNpcsByLoreId(String loreId) {
List<Npc> out = new ArrayList<>();
for (Campaign campaign : campaignRepository.findAll()) {
if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) {
out.addAll(npcRepository.findByCampaignId(campaign.getId()));
}
}
return out;
}
public Npc updateNpc(String id, NpcData data) {
Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
@@ -111,7 +88,7 @@ public class NpcService {
public void reorderNpcs(String folder, List<String> orderedIds) {
String f = normalizeFolder(folder);
ReorderSupport.reorder(orderedIds,
id -> npcRepository.findById(id).orElse(null),
npcRepository::findById,
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
npcRepository::save);
}

View File

@@ -1,11 +1,11 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
@@ -119,15 +119,6 @@ public class QuestService {
return questRepository.findByCampaignId(campaignId);
}
/** Quêtes rattachées à un arc HUB. */
public List<Quest> getQuestsByArcId(String arcId) {
return questRepository.findByArcId(arcId);
}
public List<Quest> getAllQuests() {
return questRepository.findAll();
}
/** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */
@Transactional
public Quest updateQuest(String id, Quest updated) {
@@ -165,9 +156,10 @@ public class QuestService {
/**
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
*
* <p>TODO (Phase 5) : signaler/nettoyer les {@code Prerequisite.QuestCompleted} pendants
* d'autres quêtes qui pointaient celle-ci. Échec sûr aujourd'hui : un prérequis vers une
* quête supprimée n'est jamais satisfait → la quête dépendante reste LOCKED (pas de corruption).</p>
* <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
* nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
* satisfait → la quête dépendante reste LOCKED (pas de corruption).</p>
*/
@Transactional
public void deleteQuest(String id) {
@@ -201,15 +193,11 @@ public class QuestService {
return nodes != null ? nodes : List.of();
}
public boolean questExists(String id) {
return questRepository.existsById(id);
}
/** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */
@Transactional
public void reorderQuests(String campaignId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> questRepository.findById(id).orElse(null),
questRepository::findById,
(quest, i) -> {
if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId);
quest.setOrder(i);

View File

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

View File

@@ -1,13 +1,10 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.randomtable.RandomTable;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.randomtable.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -22,18 +19,15 @@ public class RandomTableService {
private final RandomTableRepository repository;
private final RandomTableGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
private final CampaignContextFormatter campaignContextFormatter;
public RandomTableService(
RandomTableRepository repository,
RandomTableGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
CampaignContextFormatter campaignContextFormatter) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
this.campaignContextFormatter = campaignContextFormatter;
}
public record TableData(
@@ -90,8 +84,8 @@ public class RandomTableService {
@org.springframework.transaction.annotation.Transactional
public void reorderTables(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> repository.findById(id).orElse(null),
(table, i) -> table.setOrder(i),
repository::findById,
RandomTable::setOrder,
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. */
public RandomTable generateProposal(String campaignId, String description, String 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()
.name(g.name())
.description(g.description())
@@ -115,24 +110,7 @@ public class RandomTableService {
/** 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) {
return generator.improvise(tableName, resultLabel, resultDetail, buildContext(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();
return generator.improvise(tableName, resultLabel, resultDetail, campaignContextFormatter.format(campaignId));
}
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {

View File

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

View File

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

View File

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

View File

@@ -27,8 +27,13 @@ import java.util.regex.Pattern;
@Service
public class GameSystemContextBuilder {
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
/**
* 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
@@ -28,6 +28,9 @@ import java.util.Map;
@Component
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 ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
@@ -144,14 +147,14 @@ public class NarrativeEntityContextBuilder {
StringBuilder sb = new StringBuilder();
for (String enemyId : s.getEnemyIds()) {
enemyRepository.findById(enemyId).ifPresent(e -> {
if (sb.length() > 0) sb.append("\n");
if (!sb.isEmpty()) sb.append("\n");
sb.append("- ").append(e.getName());
if (e.getLevel() != null && !e.getLevel().isBlank()) {
sb.append(" (").append(e.getLevel().trim()).append(")");
}
String stats = e.getValues().entrySet().stream()
.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(" ; "));
if (!stats.isEmpty()) sb.append("").append(stats);
});
@@ -159,8 +162,8 @@ public class NarrativeEntityContextBuilder {
return sb.toString();
}
private static String truncate(String value, int maxLen) {
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "";
private static String truncate(String value) {
return value.length() <= STAT_VALUE_MAX_LEN ? value : value.substring(0, STAT_VALUE_MAX_LEN - 1).stripTrailing() + "";
}
private NarrativeEntityContext fromCharacter(Character c) {

View File

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

View File

@@ -1,9 +1,9 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
@@ -26,7 +26,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
@@ -62,10 +61,6 @@ public class SessionStructuralContextBuilder {
this.questProgressionRepository = questProgressionRepository;
}
public Optional<SessionContext> buildOptional(String sessionId) {
return sessionRepository.findById(sessionId).map(this::toContext);
}
public SessionContext build(String sessionId) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -97,7 +92,7 @@ public class SessionStructuralContextBuilder {
return kept.stream()
.map(e -> toSummary(e, null))
.collect(Collectors.toList());
.toList();
}
/**
@@ -209,6 +204,6 @@ public class SessionStructuralContextBuilder {
.filter(Map.Entry::getValue)
.map(Map.Entry::getKey)
.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.ChatMessage;
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.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -15,7 +15,6 @@ import com.loremind.domain.generationcontext.ports.AiChatProvider;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
@@ -72,10 +71,7 @@ public class StreamChatForCampaignUseCase {
String entityType,
String entityId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
ChatStreamCallbacks callbacks) {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException(
@@ -94,7 +90,7 @@ public class StreamChatForCampaignUseCase {
.gameSystemContext(gameSystemContext)
.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.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
@@ -61,10 +60,7 @@ public class StreamChatForLoreUseCase {
String loreId,
String pageId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
ChatStreamCallbacks callbacks) {
LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
PageContext pageContext = (pageId == null || pageId.isBlank())
@@ -77,7 +73,7 @@ public class StreamChatForLoreUseCase {
.pageContext(pageContext)
.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.ChatMessage;
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.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext;
@@ -19,7 +19,6 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat IA pendant une Session de jeu.
@@ -71,10 +70,7 @@ public class StreamChatForSessionUseCase {
public void execute(
String sessionId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
ChatStreamCallbacks callbacks) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -102,7 +98,7 @@ public class StreamChatForSessionUseCase {
.sessionContext(sessionContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
aiChatProvider.streamChat(request, callbacks);
}
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Service d'application pour le contexte Lore.
@@ -72,7 +71,7 @@ public class LoreService {
public List<Lore> getAllLores() {
return loreRepository.findAll().stream()
.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 org.springframework.stereotype.Service;
import com.loremind.domain.shared.CollectionUtils;
import com.loremind.domain.shared.ReorderSupport;
import java.util.ArrayList;
@@ -84,14 +83,7 @@ public class PageService {
existing.setTitle(changes.getTitle());
existing.setNodeId(changes.getNodeId());
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
existing.setImageFraming(CollectionUtils.copyMap(changes.getImageFraming()));
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
existing.setNotes(changes.getNotes());
existing.setTags(CollectionUtils.copyList(changes.getTags()));
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
existing.applyEditableContent(changes);
return pageRepository.save(existing);
}
@@ -106,7 +98,7 @@ public class PageService {
@org.springframework.transaction.annotation.Transactional
public void reorderPages(String nodeId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> pageRepository.findById(id).orElse(null),
pageRepository::findById,
(page, i) -> {
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
page.setOrder(i);

View File

@@ -7,6 +7,7 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
@@ -38,7 +39,7 @@ public class SessionEntryService {
}
validateContent(data.content());
LocalDateTime now = LocalDateTime.now();
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
SessionEntry entry = SessionEntry.builder()
.sessionId(sessionId)
.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.QuestStatusEnricher;
import com.loremind.application.campaigncontext.ReadinessGap;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -20,7 +20,6 @@ import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
@@ -111,7 +110,7 @@ public class SessionPrepService {
} else {
focused = assessment.gaps().stream()
.filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds))
.collect(Collectors.toList());
.toList();
}
int otherGapCount = assessment.gaps().size() - focused.size();
@@ -122,7 +121,7 @@ public class SessionPrepService {
.filter(c -> c.getFilled() > 0)
.map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(),
c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null))
.collect(Collectors.toList());
.toList();
return new SessionPrepReport(
playthroughId,
@@ -173,7 +172,7 @@ public class SessionPrepService {
List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId);
return sessions.stream()
.max(Comparator.comparing(Session::getStartedAt,
Comparator.nullsFirst(Comparator.<LocalDateTime>naturalOrder())))
Comparator.nullsFirst(Comparator.naturalOrder())))
.map(s -> new SessionPrepReport.LastSessionInfo(
s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive()))
.orElse(null);
@@ -183,13 +182,13 @@ public class SessionPrepService {
return quests.stream()
.filter(q -> statusById.get(q.getId()) == wanted)
.sorted(Comparator.comparingInt(Quest::getOrder))
.collect(Collectors.toList());
.toList();
}
private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) {
return quests.stream()
.map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon()))
.collect(Collectors.toList());
.toList();
}
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 java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
@@ -23,6 +24,7 @@ import java.util.Optional;
public class SessionService {
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 SessionEntryRepository entryRepository;
@@ -57,7 +59,7 @@ public class SessionService {
"). Termine-la avant d'en lancer une nouvelle.");
});
LocalDateTime now = LocalDateTime.now();
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
Session session = Session.builder()
.name(generateDefaultName(now))
.playthroughId(playthroughId)
@@ -68,11 +70,11 @@ public class SessionService {
public Session endSession(String id) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
.orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
if (!session.isActive()) {
throw new IllegalStateException("Cette session est déjà terminée.");
}
session.setEndedAt(LocalDateTime.now());
session.setEndedAt(LocalDateTime.now(ZoneId.systemDefault()));
Session saved = sessionRepository.save(session);
// Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie.
clockService.onSessionEnded(saved.getPlaythroughId());
@@ -86,7 +88,7 @@ public class SessionService {
*/
public Session setCurrentScene(String id, String sceneId) {
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);
return sessionRepository.save(session);
}
@@ -96,7 +98,7 @@ public class SessionService {
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
}
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
.orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
session.setName(newName.trim());
return sessionRepository.save(session);
}
@@ -124,7 +126,7 @@ public class SessionService {
@Transactional
public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) {
throw new IllegalArgumentException("Session introuvable : " + id);
throw new IllegalArgumentException(SESSION_NOT_FOUND + id);
}
entryRepository.deleteBySessionId(id);
sessionRepository.deleteById(id);

View File

@@ -38,6 +38,12 @@ public class Campaign {
*/
private String gameSystemId;
/**
* Positions des nœuds du graphe de campagne (JSON opaque, état de
* présentation possédé par le front). Null = disposition automatique.
*/
private String graphPositions;
public boolean isLinkedToLore() {
return this.loreId != null && !this.loreId.isBlank();
}

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder;
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.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder;
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.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.generation;
import java.util.List;
@@ -11,12 +11,13 @@ import java.util.List;
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
/**
* {@code existingId} (nullable) : si présent, 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 = à cer.
* {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply).
* <p>
* {@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(
String name, String description, String type,
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;

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).

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.generation;
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.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.itemcatalog;
import lombok.Builder;
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.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.notebook;
import lombok.Builder;
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.Data;

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

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

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

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

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

@@ -16,11 +16,22 @@ public interface NotebookChatStreamer {
record Progress(int current, int total) {}
/**
* Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil
* de l'eau : {@code onSourcesJson} UNE fois avant le premier token (JSON brut des
* passages utilisés — transparence UI), {@code onToken} par fragment,
* {@code onProgress} (mode approfondi uniquement) pendant la lecture du document,
* {@code onDone} à la fin, {@code onError} en cas d'échec.
* Callbacks du stream, invoqués au fil de l'eau : {@code onSourcesJson} UNE fois
* avant le premier token (JSON brut des passages utilisés — transparence UI),
* {@code onToken} par fragment, {@code onProgress} (mode approfondi uniquement)
* pendant la lecture du document, {@code onDone} à la fin, {@code onError} en
* 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) ;
* false = chat RAG (top-k).
@@ -30,9 +41,5 @@ public interface NotebookChatStreamer {
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onSourcesJson,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError);
Callbacks callbacks);
}

View File

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

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

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

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

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

View File

@@ -1,6 +1,6 @@
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.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,

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

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,

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).

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

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}.

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).
*
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
* {@link PrerequisiteEvaluator}.
*
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
* (pas de OR pour le moment).
*/

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.quest;
import java.util.List;
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
* et en dérive le {@link QuestStatus} effectif.
*
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
* compile-time est compensée par le throw final qui fait crasher tout nouvel
@@ -51,15 +50,12 @@ public final class PrerequisiteEvaluator {
List<Prerequisite> prerequisites,
EvaluationContext ctx
) {
switch (progression) {
case COMPLETED: return QuestStatus.COMPLETED;
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
case NOT_STARTED:
return areAllSatisfied(prerequisites, ctx)
return switch (progression) {
case COMPLETED -> QuestStatus.COMPLETED;
case IN_PROGRESS -> QuestStatus.IN_PROGRESS;
case NOT_STARTED -> areAllSatisfied(prerequisites, ctx)
? QuestStatus.AVAILABLE
: 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.
*
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
* selon les prérequis voir {@link QuestStatus}.
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
* COMPLETED : marquée terminée par le MJ.
*
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
*/
public enum ProgressionStatus {

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.quest;
import lombok.Builder;
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

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.
* DÉRIVÉ jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
*
* Table de vérité :
* NOT_STARTED + prérequis non remplis -> LOCKED
* NOT_STARTED + prérequis remplis -> AVAILABLE

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.randomtable;
import lombok.Builder;
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.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

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).

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 »).

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.structure;
import lombok.Builder;
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.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.structure;
import lombok.AllArgsConstructor;
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.

View File

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

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