Compare commits
10 Commits
f72161e41e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ad3ea0e9e6 | |||
| 0c44e42a22 | |||
| 1c8b24c5dd | |||
| 177967af55 | |||
| 42c4b53ced | |||
| 3e9e828225 | |||
| 51eb907078 | |||
| 9f0359d8c0 | |||
| 587e0fe097 | |||
| 5c3c58265f |
@@ -82,9 +82,11 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
||||||
|
|
||||||
|
# v3 obligatoire : l'API artifacts v4 n'est pas supportée par Gitea
|
||||||
|
# (GHESNotSupportedError — constaté sur le job MegaLinter).
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: web/playwright-report/
|
path: web/playwright-report/
|
||||||
|
|||||||
104
.gitea/workflows/quality.yml
Normal file
104
.gitea/workflows/quality.yml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
name: Qualité & Sécurité
|
||||||
|
|
||||||
|
# Analyse statique (MegaLinter, config racine .mega-linter.yml) + CVE des
|
||||||
|
# dépendances (Trivy). Workflow SÉPARÉ de ci.yml : un rouge qualité ne bloque
|
||||||
|
# pas la chaîne tests → release pendant la phase de rodage. Une fois la base
|
||||||
|
# assainie, on pourra l'ajouter aux checks requis de la branch protection.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# beta inclus : c'est la branche de dev, le feedback qualité doit y vivre
|
||||||
|
# (ci.yml/e2e.yml, eux, restent volontairement sur main + PR).
|
||||||
|
branches: [main, beta]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
megalinter:
|
||||||
|
name: MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Historique complet : requis par gitleaks (scan de l'historique)
|
||||||
|
# et par le mode "fichiers modifiés seulement" en PR.
|
||||||
|
fetch-depth: 0
|
||||||
|
# Flavor "cupcake" : image allégée couvrant les langages courants
|
||||||
|
# (Java/Python/TS inclus). Si un linter activé manquait à la flavor,
|
||||||
|
# MegaLinter échoue en l'indiquant → remplacer par oxsecurity/megalinter@v9
|
||||||
|
# (image complète, plus lourde).
|
||||||
|
- name: MegaLinter
|
||||||
|
uses: oxsecurity/megalinter/flavors/cupcake@v9
|
||||||
|
env:
|
||||||
|
# main → scan complet du dépôt ; beta et PR → seulement les fichiers
|
||||||
|
# modifiés par rapport à main (rapide, feedback ciblé).
|
||||||
|
VALIDATE_ALL_CODEBASE: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
# v3 obligatoire : l'API artifacts v4 (@actions/artifact 2.x) n'est pas
|
||||||
|
# supportée par Gitea (GHESNotSupportedError constaté avec v4).
|
||||||
|
- name: Publier les rapports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: megalinter-reports
|
||||||
|
path: megalinter-reports/
|
||||||
|
|
||||||
|
web-lint:
|
||||||
|
name: Web (ESLint · angular-eslint + sonarjs)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- name: npm ci
|
||||||
|
working-directory: web
|
||||||
|
run: npm ci --no-audit --no-fund
|
||||||
|
# Lint via la toolchain du projet (et non via MegaLinter : ESLint a
|
||||||
|
# besoin des plugins de web/node_modules et du contexte Angular).
|
||||||
|
# Config + règles : web/eslint.config.js (sonarjs = règles "à la Sonar").
|
||||||
|
- name: ng lint
|
||||||
|
working-directory: web
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
trivy:
|
||||||
|
name: Trivy (CVE dépendances Maven / pip / npm)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# Pour résoudre pom.xml (versions héritées du parent Spring Boot), Trivy
|
||||||
|
# télécharge les BOMs depuis Maven Central — qui finit par rate-limiter
|
||||||
|
# l'IP du runner (429). Parade officielle : peupler ~/.m2 AVANT le scan,
|
||||||
|
# Trivy lit le cache local en priorité. Le cache setup-java (clé pom.xml)
|
||||||
|
# rend l'étape quasi gratuite d'un run à l'autre.
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
cache: maven
|
||||||
|
- name: Précharger le cache Maven (~/.m2)
|
||||||
|
working-directory: core
|
||||||
|
run: |
|
||||||
|
chmod +x ./mvnw
|
||||||
|
./mvnw -B -q dependency:go-offline
|
||||||
|
- name: Installer Trivy
|
||||||
|
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||||
|
# Scanne les manifestes du dépôt (pom.xml, requirements*.txt,
|
||||||
|
# package-lock.json) contre les bases CVE. --ignore-unfixed : on ne
|
||||||
|
# bloque que sur les vulnérabilités qui ONT un correctif publié.
|
||||||
|
# --offline-scan : AUCUNE requête vers Maven Central pendant la
|
||||||
|
# résolution — Trivy allait y chercher les POMs parents même avec le
|
||||||
|
# cache rempli, et l'IP du runner finissait rate-limitée (429, 30 min).
|
||||||
|
# Tout est déjà dans ~/.m2 grâce au dependency:go-offline ci-dessus ;
|
||||||
|
# contrepartie : une dépendance absente du cache serait ignorée en
|
||||||
|
# silence (impossible ici, go-offline échouerait d'abord).
|
||||||
|
- name: Scan des dépendances (HIGH/CRITICAL bloquants)
|
||||||
|
run: |
|
||||||
|
trivy fs . \
|
||||||
|
--scanners vuln \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--offline-scan \
|
||||||
|
--skip-dirs node_modules \
|
||||||
|
--skip-dirs docusaurus \
|
||||||
|
--exit-code 1
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -129,3 +129,5 @@ web/coverage/
|
|||||||
brain/htmlcov/
|
brain/htmlcov/
|
||||||
brain/.coverage
|
brain/.coverage
|
||||||
foundry-module/
|
foundry-module/
|
||||||
|
plan-promotion-loremind.md
|
||||||
|
post-reddit-foundryvtt.md
|
||||||
|
|||||||
43
.mega-linter.yml
Normal file
43
.mega-linter.yml
Normal 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
|
||||||
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="1.0.0",
|
version="1.0.3",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
15
core/pom.xml
15
core/pom.xml
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>1.0.0</version>
|
<version>1.0.3</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
@@ -24,6 +24,19 @@
|
|||||||
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
|
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
|
||||||
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
|
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
|
||||||
<commons-lang3.version>3.20.0</commons-lang3.version>
|
<commons-lang3.version>3.20.0</commons-lang3.version>
|
||||||
|
<!-- Overrides CVE (detectes par Trivy en CI — job quality.yml) :
|
||||||
|
proprietes reconnues par le BOM Spring Boot, comme ci-dessus. -->
|
||||||
|
<!-- >= 2.21.4 : CVE-2026-54512 / CVE-2026-54513 (execution de code
|
||||||
|
arbitraire via contournement du PolymorphicTypeValidator). -->
|
||||||
|
<jackson-bom.version>2.21.4</jackson-bom.version>
|
||||||
|
<!-- >= 4.1.135 : lot de CVE netty (DoS codec/handler, bypass de
|
||||||
|
verification hostname CVE-2026-50010, DNS CVE-2026-45674/47691). -->
|
||||||
|
<netty.version>4.1.135.Final</netty.version>
|
||||||
|
<!-- >= 10.1.55 : 3 CRITICAL Tomcat (CVE-2026-41293 headers HTTP/2 non
|
||||||
|
valides, CVE-2026-43512 bypass auth digest, CVE-2026-43515). -->
|
||||||
|
<tomcat.version>10.1.55</tomcat.version>
|
||||||
|
<!-- >= 42.7.11 : CVE-2026-42198 (DoS client via SCRAM-SHA-256). -->
|
||||||
|
<postgresql.version>42.7.11</postgresql.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.structure.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.FieldProposal;
|
import com.loremind.domain.campaigncontext.generation.FieldProposal;
|
||||||
import com.loremind.domain.campaigncontext.Quest;
|
import com.loremind.domain.campaigncontext.quest.Quest;
|
||||||
import com.loremind.domain.shared.ReorderSupport;
|
import com.loremind.domain.shared.ReorderSupport;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
@@ -169,8 +169,8 @@ public class ArcService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void reorderArcs(List<String> orderedIds) {
|
public void reorderArcs(List<String> orderedIds) {
|
||||||
ReorderSupport.reorder(orderedIds,
|
ReorderSupport.reorder(orderedIds,
|
||||||
id -> arcRepository.findById(id).orElse(null),
|
arcRepository::findById,
|
||||||
(arc, i) -> arc.setOrder(i),
|
Arc::setOrder,
|
||||||
arcRepository::save);
|
arcRepository::save);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ public class CampaignBriefBuilder {
|
|||||||
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
|
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
|
||||||
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
|
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
|
||||||
|
|
||||||
|
appendStructure(sb, cc);
|
||||||
|
appendNpcs(sb, cc);
|
||||||
|
|
||||||
|
if (campaign.isLinkedToLore()) {
|
||||||
|
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendStructure(StringBuilder sb, CampaignStructuralContext cc) {
|
||||||
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
|
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
|
||||||
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
|
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
|
||||||
.append("un arc LINEAR contient des chapitres en séquence._\n");
|
.append("un arc LINEAR contient des chapitres en séquence._\n");
|
||||||
@@ -48,11 +58,21 @@ public class CampaignBriefBuilder {
|
|||||||
sb.append("_(aucun arc pour le moment)_\n");
|
sb.append("_(aucun arc pour le moment)_\n");
|
||||||
}
|
}
|
||||||
for (ArcSummary arc : cc.arcs()) {
|
for (ArcSummary arc : cc.arcs()) {
|
||||||
|
appendArc(sb, arc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendArc(StringBuilder sb, ArcSummary arc) {
|
||||||
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
|
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
|
||||||
if (notBlank(arc.description())) sb.append(" — ").append(arc.description());
|
if (notBlank(arc.description())) sb.append(" — ").append(arc.description());
|
||||||
sb.append("\n");
|
sb.append("\n");
|
||||||
for (ChapterSummary ch : arc.chapters()) {
|
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());
|
if (notBlank(ch.description())) sb.append(" — ").append(ch.description());
|
||||||
sb.append("\n");
|
sb.append("\n");
|
||||||
for (SceneSummary sc : ch.scenes()) {
|
for (SceneSummary sc : ch.scenes()) {
|
||||||
@@ -61,9 +81,9 @@ public class CampaignBriefBuilder {
|
|||||||
sb.append("\n");
|
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");
|
sb.append("\n## PNJ existants\n");
|
||||||
for (NpcSummary n : cc.npcs()) {
|
for (NpcSummary n : cc.npcs()) {
|
||||||
sb.append("- ").append(n.name());
|
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) {
|
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
|
||||||
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
|
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
|
||||||
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");
|
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.structure.Arc;
|
||||||
import com.loremind.domain.campaigncontext.ArcType;
|
import com.loremind.domain.campaigncontext.structure.ArcType;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProgress;
|
import com.loremind.domain.campaigncontext.generation.CampaignImportProgress;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal;
|
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
|
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ArcProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
|
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ChapterProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.NpcProposal;
|
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.NpcProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
|
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.RoomProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
|
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.SceneProposal;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Room;
|
import com.loremind.domain.campaigncontext.structure.Room;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.structure.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
|
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -83,14 +83,32 @@ public class CampaignImportService {
|
|||||||
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
|
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0;
|
int arcsCreated = 0;
|
||||||
|
int chaptersCreated = 0;
|
||||||
|
int scenesCreated = 0;
|
||||||
|
|
||||||
// Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés
|
// Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés
|
||||||
// via leur existingId dans l'arbre fusionné venu de la revue).
|
// via leur existingId dans l'arbre fusionné venu de la revue).
|
||||||
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
|
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
|
||||||
for (ArcProposal arcP : proposal.arcs()) {
|
for (ArcProposal arcP : proposal.arcs()) {
|
||||||
if (isBlank(arcP.name())) continue;
|
if (isBlank(arcP.name())) continue;
|
||||||
|
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;
|
String arcId;
|
||||||
|
int arcsCreated = 0;
|
||||||
if (!isBlank(arcP.existingId())) {
|
if (!isBlank(arcP.existingId())) {
|
||||||
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
|
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
|
||||||
} else {
|
} else {
|
||||||
@@ -103,13 +121,28 @@ public class CampaignImportService {
|
|||||||
.type(parseArcType(arcP.type()))
|
.type(parseArcType(arcP.type()))
|
||||||
.build());
|
.build());
|
||||||
arcId = arc.getId();
|
arcId = arc.getId();
|
||||||
arcsCreated++;
|
arcsCreated = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
|
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
|
||||||
|
int chaptersCreated = 0;
|
||||||
|
int scenesCreated = 0;
|
||||||
for (ChapterProposal chapP : safe(arcP.chapters())) {
|
for (ChapterProposal chapP : safe(arcP.chapters())) {
|
||||||
if (isBlank(chapP.name())) continue;
|
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;
|
String chapId;
|
||||||
|
int chaptersCreated = 0;
|
||||||
if (!isBlank(chapP.existingId())) {
|
if (!isBlank(chapP.existingId())) {
|
||||||
chapId = chapP.existingId();
|
chapId = chapP.existingId();
|
||||||
} else {
|
} else {
|
||||||
@@ -117,13 +150,13 @@ public class CampaignImportService {
|
|||||||
Chapter chapter = chapterService.createChapter(
|
Chapter chapter = chapterService.createChapter(
|
||||||
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
|
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
|
||||||
chapId = chapter.getId();
|
chapId = chapter.getId();
|
||||||
chaptersCreated++;
|
chaptersCreated = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
|
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
|
||||||
|
int scenesCreated = 0;
|
||||||
for (SceneProposal sceneP : safe(chapP.scenes())) {
|
for (SceneProposal sceneP : safe(chapP.scenes())) {
|
||||||
if (isBlank(sceneP.name())) continue;
|
if (isBlank(sceneP.name()) || !isBlank(sceneP.existingId())) continue; // vide ou déjà présente
|
||||||
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
|
|
||||||
sceneOrder++;
|
sceneOrder++;
|
||||||
sceneService.createScene(Scene.builder()
|
sceneService.createScene(Scene.builder()
|
||||||
.name(sceneP.name().trim())
|
.name(sceneP.name().trim())
|
||||||
@@ -136,11 +169,7 @@ public class CampaignImportService {
|
|||||||
.build());
|
.build());
|
||||||
scenesCreated++;
|
scenesCreated++;
|
||||||
}
|
}
|
||||||
}
|
return new ChapterOutcome(chapterOrder, chaptersCreated, scenesCreated);
|
||||||
}
|
|
||||||
|
|
||||||
int npcsCreated = createNpcs(campaignId, proposal.npcs());
|
|
||||||
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,7 +211,8 @@ public class CampaignImportService {
|
|||||||
|
|
||||||
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
|
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
|
||||||
private static ArcType parseArcType(String type) {
|
private static ArcType parseArcType(String type) {
|
||||||
return "HUB".equalsIgnoreCase(type == null ? "" : type.trim()) ? ArcType.HUB : ArcType.LINEAR;
|
String normalized = type == null ? "" : type.trim();
|
||||||
|
return "HUB".equalsIgnoreCase(normalized) ? ArcType.HUB : ArcType.LINEAR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */
|
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.ReadinessStatus;
|
import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.structure.Arc;
|
||||||
import com.loremind.domain.campaigncontext.ArcType;
|
import com.loremind.domain.campaigncontext.structure.ArcType;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Enemy;
|
import com.loremind.domain.campaigncontext.bestiary.Enemy;
|
||||||
import com.loremind.domain.campaigncontext.NodeType;
|
import com.loremind.domain.campaigncontext.quest.NodeType;
|
||||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
import com.loremind.domain.campaigncontext.quest.Prerequisite;
|
||||||
import com.loremind.domain.campaigncontext.Quest;
|
import com.loremind.domain.campaigncontext.quest.Quest;
|
||||||
import com.loremind.domain.campaigncontext.ReadinessEntityType;
|
import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
|
||||||
import com.loremind.domain.campaigncontext.ReadinessSeverity;
|
import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
|
||||||
import com.loremind.domain.campaigncontext.ReadinessStatus;
|
import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
|
||||||
import com.loremind.domain.campaigncontext.Room;
|
import com.loremind.domain.campaigncontext.structure.Room;
|
||||||
import com.loremind.domain.campaigncontext.RoomBranch;
|
import com.loremind.domain.campaigncontext.structure.Scene;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.structure.SceneBranch;
|
||||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
@@ -55,6 +54,8 @@ import java.util.stream.Collectors;
|
|||||||
@Service
|
@Service
|
||||||
public class CampaignReadinessService {
|
public class CampaignReadinessService {
|
||||||
|
|
||||||
|
private static final String SCENE_FALLBACK_NAME = "Scène";
|
||||||
|
|
||||||
private final CampaignRepository campaignRepository;
|
private final CampaignRepository campaignRepository;
|
||||||
private final ArcRepository arcRepository;
|
private final ArcRepository arcRepository;
|
||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
@@ -88,11 +89,6 @@ public class CampaignReadinessService {
|
|||||||
Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream()
|
Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream()
|
||||||
.map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet());
|
.map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet());
|
||||||
|
|
||||||
// Index global chapitres / scènes (cibles possibles des nœuds de quête).
|
|
||||||
Set<String> allChapterIds = new HashSet<>();
|
|
||||||
Set<String> allSceneIds = new HashSet<>();
|
|
||||||
int totalScenes = 0;
|
|
||||||
|
|
||||||
List<Quest> quests = questRepository.findByCampaignId(campaignId);
|
List<Quest> quests = questRepository.findByCampaignId(campaignId);
|
||||||
// Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides
|
// Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides
|
||||||
// (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres).
|
// (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres).
|
||||||
@@ -102,20 +98,52 @@ public class CampaignReadinessService {
|
|||||||
|
|
||||||
List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId));
|
List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId));
|
||||||
arcs.sort(Comparator.comparingInt(Arc::getOrder));
|
arcs.sort(Comparator.comparingInt(Arc::getOrder));
|
||||||
|
|
||||||
|
// Index global chapitres / scènes (cibles possibles des nœuds de quête).
|
||||||
|
Set<String> allChapterIds = new HashSet<>();
|
||||||
|
Set<String> allSceneIds = new HashSet<>();
|
||||||
|
int totalScenes = 0;
|
||||||
for (Arc arc : arcs) {
|
for (Arc arc : arcs) {
|
||||||
|
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());
|
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
|
||||||
// Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la
|
// Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la
|
||||||
// plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs).
|
// plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs).
|
||||||
boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId());
|
boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId());
|
||||||
if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) {
|
if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) {
|
||||||
String msg = arc.getType() == ArcType.HUB
|
gaps.add(emptyArcGap(arc));
|
||||||
? "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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> chapterIds = new HashSet<>();
|
||||||
|
Set<String> sceneIds = new HashSet<>();
|
||||||
|
int sceneCount = 0;
|
||||||
for (Chapter chapter : chapters) {
|
for (Chapter chapter : chapters) {
|
||||||
allChapterIds.add(chapter.getId());
|
chapterIds.add(chapter.getId());
|
||||||
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
|
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
|
||||||
if (scenes.isEmpty()) {
|
if (scenes.isEmpty()) {
|
||||||
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
|
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
|
||||||
@@ -125,59 +153,66 @@ public class CampaignReadinessService {
|
|||||||
}
|
}
|
||||||
Set<String> chapterSceneIds = scenes.stream()
|
Set<String> chapterSceneIds = scenes.stream()
|
||||||
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||||
totalScenes += scenes.size();
|
sceneCount += scenes.size();
|
||||||
for (Scene scene : scenes) {
|
for (Scene scene : scenes) {
|
||||||
allSceneIds.add(scene.getId());
|
sceneIds.add(scene.getId());
|
||||||
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
|
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return new ArcScan(chapterIds, sceneIds, sceneCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<String> questIds = quests.stream()
|
private ReadinessGap emptyArcGap(Arc arc) {
|
||||||
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
String msg = arc.getType() == ArcType.HUB
|
||||||
|
? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le."
|
||||||
// Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat).
|
: "Arc vide : ajoutez un chapitre, ou supprimez-le.";
|
||||||
boolean anyQuestWithNodes = quests.stream()
|
return new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
|
||||||
.anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
|
"ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null);
|
||||||
if (totalScenes == 0 && !anyQuestWithNodes) {
|
|
||||||
gaps.add(new ReadinessGap(ReadinessEntityType.CAMPAIGN, campaignId, campaignName,
|
|
||||||
"CAMP-001-NO-CONTENT",
|
|
||||||
"Campagne vide : ajoutez un arc avec une scène, ou créez une quête, pour commencer à jouer.",
|
|
||||||
ReadinessSeverity.BLOCKING, null, null));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Quest quest : quests) {
|
private static boolean anyQuestHasNodes(List<Quest> quests) {
|
||||||
checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps);
|
return quests.stream().anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
|
||||||
}
|
|
||||||
|
|
||||||
return aggregate(campaignId, gaps);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkScene(Scene scene, String arcId, String chapterId,
|
private void checkScene(Scene scene, String arcId, String chapterId,
|
||||||
Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) {
|
Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) {
|
||||||
String name = labelOr(scene.getName(), "Scène");
|
checkSceneName(scene, arcId, chapterId, gaps);
|
||||||
|
checkSceneBranches(scene, arcId, chapterId, chapterSceneIds, gaps);
|
||||||
|
checkSceneCombat(scene, arcId, chapterId, enemyIds, gaps);
|
||||||
|
checkSceneEnemyRefs(scene, arcId, chapterId, enemyIds, gaps);
|
||||||
|
checkSceneRooms(scene, arcId, chapterId, enemyIds, gaps);
|
||||||
|
}
|
||||||
|
|
||||||
// SCENE-001 — scène sans titre.
|
/** SCENE-001 — scène sans titre. */
|
||||||
|
private void checkSceneName(Scene scene, String arcId, String chapterId, List<ReadinessGap> gaps) {
|
||||||
if (isBlank(scene.getName())) {
|
if (isBlank(scene.getName())) {
|
||||||
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME",
|
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME",
|
||||||
"Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.",
|
"Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.",
|
||||||
ReadinessSeverity.BLOCKING));
|
ReadinessSeverity.BLOCKING));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence).
|
/** SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence). */
|
||||||
|
private void checkSceneBranches(Scene scene, String arcId, String chapterId,
|
||||||
|
Set<String> chapterSceneIds, List<ReadinessGap> gaps) {
|
||||||
List<SceneBranch> branches = scene.getBranches();
|
List<SceneBranch> branches = scene.getBranches();
|
||||||
if (branches != null && branches.stream().anyMatch(b ->
|
if (branches == null) return;
|
||||||
|
boolean invalid = branches.stream().anyMatch(b ->
|
||||||
isBlank(b.targetSceneId())
|
isBlank(b.targetSceneId())
|
||||||
|| b.targetSceneId().equals(scene.getId())
|
|| b.targetSceneId().equals(scene.getId())
|
||||||
|| !chapterSceneIds.contains(b.targetSceneId()))) {
|
|| !chapterSceneIds.contains(b.targetSceneId()));
|
||||||
|
if (invalid) {
|
||||||
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID",
|
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID",
|
||||||
"Branche cassée : une sortie de « " + name
|
"Branche cassée : une sortie de « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
|
||||||
+ " » pointe dans le vide, hors du chapitre, ou sur elle-même.",
|
+ " » pointe dans le vide, hors du chapitre, ou sur elle-même.",
|
||||||
ReadinessSeverity.BLOCKING));
|
ReadinessSeverity.BLOCKING));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SCENE-011 — combat annoncé sans adversaire (règle produit clé).
|
/** SCENE-011 — combat annoncé sans adversaire (règle produit clé). */
|
||||||
if (!isBlank(scene.getCombatDifficulty())) {
|
private void checkSceneCombat(Scene scene, String arcId, String chapterId,
|
||||||
|
Set<String> enemyIds, List<ReadinessGap> gaps) {
|
||||||
|
if (isBlank(scene.getCombatDifficulty())) return;
|
||||||
boolean hasEnemyText = !isBlank(scene.getEnemies());
|
boolean hasEnemyText = !isBlank(scene.getEnemies());
|
||||||
boolean hasResolvedEnemy = scene.getEnemyIds() != null
|
boolean hasResolvedEnemy = scene.getEnemyIds() != null
|
||||||
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id));
|
&& 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).
|
/** SCENE-012 — référence d'ennemi cassée (fiche supprimée). */
|
||||||
if (scene.getEnemyIds() != null
|
private void checkSceneEnemyRefs(Scene scene, String arcId, String chapterId,
|
||||||
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id))) {
|
Set<String> enemyIds, List<ReadinessGap> gaps) {
|
||||||
|
if (scene.getEnemyIds() == null) return;
|
||||||
|
boolean broken = scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
|
||||||
|
if (broken) {
|
||||||
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN",
|
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN",
|
||||||
"Ennemi introuvable : « " + name
|
"Ennemi introuvable : « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
|
||||||
+ " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.",
|
+ " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.",
|
||||||
ReadinessSeverity.RECOMMENDED));
|
ReadinessSeverity.RECOMMENDED));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes.
|
/** SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes. */
|
||||||
|
private void checkSceneRooms(Scene scene, String arcId, String chapterId,
|
||||||
|
Set<String> enemyIds, List<ReadinessGap> gaps) {
|
||||||
List<Room> rooms = scene.getRooms();
|
List<Room> rooms = scene.getRooms();
|
||||||
if (rooms != null && !rooms.isEmpty()) {
|
if (rooms == null || rooms.isEmpty()) return;
|
||||||
|
String name = labelOr(scene.getName(), SCENE_FALLBACK_NAME);
|
||||||
Set<String> roomIds = rooms.stream()
|
Set<String> roomIds = rooms.stream()
|
||||||
.map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
.map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||||
boolean roomBranchInvalid = false;
|
|
||||||
boolean roomEnemyBroken = false;
|
if (rooms.stream().anyMatch(room -> hasInvalidBranch(room, roomIds))) {
|
||||||
for (Room room : rooms) {
|
|
||||||
if (room.getBranches() != null) {
|
|
||||||
for (RoomBranch rb : room.getBranches()) {
|
|
||||||
if (isBlank(rb.targetRoomId())
|
|
||||||
|| rb.targetRoomId().equals(room.getId())
|
|
||||||
|| !roomIds.contains(rb.targetRoomId())) {
|
|
||||||
roomBranchInvalid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (room.getEnemyIds() != null) {
|
|
||||||
for (String id : room.getEnemyIds()) {
|
|
||||||
if (!isBlank(id) && !enemyIds.contains(id)) {
|
|
||||||
roomEnemyBroken = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (roomBranchInvalid) {
|
|
||||||
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID",
|
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID",
|
||||||
"Porte cassée : dans « " + name
|
"Porte cassée : dans « " + name
|
||||||
+ " », une sortie de pièce pointe hors de la scène ou dans le vide.",
|
+ " », une sortie de pièce pointe hors de la scène ou dans le vide.",
|
||||||
ReadinessSeverity.BLOCKING));
|
ReadinessSeverity.BLOCKING));
|
||||||
}
|
}
|
||||||
if (roomEnemyBroken) {
|
if (rooms.stream().anyMatch(room -> hasBrokenEnemyRef(room, enemyIds))) {
|
||||||
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN",
|
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN",
|
||||||
"Ennemi introuvable dans une pièce de « " + name
|
"Ennemi introuvable dans une pièce de « " + name
|
||||||
+ " » : la référence pointe vers une fiche supprimée.",
|
+ " » : la référence pointe vers une fiche supprimée.",
|
||||||
ReadinessSeverity.RECOMMENDED));
|
ReadinessSeverity.RECOMMENDED));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean hasInvalidBranch(Room room, Set<String> roomIds) {
|
||||||
|
if (room.getBranches() == null) return false;
|
||||||
|
return room.getBranches().stream().anyMatch(rb ->
|
||||||
|
isBlank(rb.targetRoomId())
|
||||||
|
|| rb.targetRoomId().equals(room.getId())
|
||||||
|
|| !roomIds.contains(rb.targetRoomId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasBrokenEnemyRef(Room room, Set<String> enemyIds) {
|
||||||
|
if (room.getEnemyIds() == null) return false;
|
||||||
|
return room.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkQuest(Quest quest, Set<String> allChapterIds, Set<String> allSceneIds,
|
private void checkQuest(Quest quest, Set<String> allChapterIds, Set<String> allSceneIds,
|
||||||
@@ -244,8 +279,7 @@ public class CampaignReadinessService {
|
|||||||
// QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide.
|
// QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide.
|
||||||
if (quest.getNodes() == null || quest.getNodes().isEmpty()) {
|
if (quest.getNodes() == null || quest.getNodes().isEmpty()) {
|
||||||
gaps.add(questGap(quest, "QUEST-001-NO-NODES",
|
gaps.add(questGap(quest, "QUEST-001-NO-NODES",
|
||||||
"Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " ».",
|
"Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " »."));
|
||||||
ReadinessSeverity.BLOCKING));
|
|
||||||
} else if (quest.getNodes().stream().anyMatch(n ->
|
} else if (quest.getNodes().stream().anyMatch(n ->
|
||||||
n.nodeType() == null
|
n.nodeType() == null
|
||||||
|| isBlank(n.nodeId())
|
|| isBlank(n.nodeId())
|
||||||
@@ -253,8 +287,7 @@ public class CampaignReadinessService {
|
|||||||
|| (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) {
|
|| (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) {
|
||||||
gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN",
|
gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN",
|
||||||
"Nœud de quête cassé : dans « " + name
|
"Nœud de quête cassé : dans « " + name
|
||||||
+ " », un chapitre ou une scène référencé n'existe plus.",
|
+ " », un chapitre ou une scène référencé n'existe plus."));
|
||||||
ReadinessSeverity.BLOCKING));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CAMP-010 — prérequis QuestCompleted pointant une quête disparue.
|
// CAMP-010 — prérequis QuestCompleted pointant une quête disparue.
|
||||||
@@ -264,8 +297,7 @@ public class CampaignReadinessService {
|
|||||||
.anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) {
|
.anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) {
|
||||||
gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ",
|
gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ",
|
||||||
"Prérequis cassé : « " + name
|
"Prérequis cassé : « " + name
|
||||||
+ " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage.",
|
+ " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage."));
|
||||||
ReadinessSeverity.BLOCKING));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,12 +326,12 @@ public class CampaignReadinessService {
|
|||||||
private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId,
|
private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId,
|
||||||
String ruleId, String message, ReadinessSeverity severity) {
|
String ruleId, String message, ReadinessSeverity severity) {
|
||||||
return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(),
|
return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(),
|
||||||
labelOr(scene.getName(), "Scène"), ruleId, message, severity, arcId, chapterId);
|
labelOr(scene.getName(), SCENE_FALLBACK_NAME), ruleId, message, severity, arcId, chapterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ReadinessGap questGap(Quest quest, String ruleId, String message, ReadinessSeverity severity) {
|
private ReadinessGap questGap(Quest quest, String ruleId, String message) {
|
||||||
return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(),
|
return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(),
|
||||||
labelOr(quest.getName(), "Quête"), ruleId, message, severity, null, null);
|
labelOr(quest.getName(), "Quête"), ruleId, message, ReadinessSeverity.BLOCKING, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int severityRank(ReadinessSeverity severity) {
|
private static int severityRank(ReadinessSeverity severity) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
import com.loremind.domain.campaigncontext.quest.Prerequisite;
|
||||||
import com.loremind.domain.campaigncontext.Quest;
|
import com.loremind.domain.campaigncontext.quest.Quest;
|
||||||
import com.loremind.domain.campaigncontext.ports.QuestRepository;
|
import com.loremind.domain.campaigncontext.ports.QuestRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.application.playcontext.PlaythroughService;
|
import com.loremind.application.playcontext.PlaythroughService;
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.structure.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.FieldProposal;
|
import com.loremind.domain.campaigncontext.generation.FieldProposal;
|
||||||
import com.loremind.domain.shared.ReorderSupport;
|
import com.loremind.domain.shared.ReorderSupport;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
@@ -136,7 +136,7 @@ public class ChapterService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void reorderChapters(String arcId, List<String> orderedIds) {
|
public void reorderChapters(String arcId, List<String> orderedIds) {
|
||||||
ReorderSupport.reorder(orderedIds,
|
ReorderSupport.reorder(orderedIds,
|
||||||
id -> chapterRepository.findById(id).orElse(null),
|
chapterRepository::findById,
|
||||||
(chapter, i) -> {
|
(chapter, i) -> {
|
||||||
if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId);
|
if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId);
|
||||||
chapter.setOrder(i);
|
chapter.setOrder(i);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.bestiary.Character;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -41,9 +41,9 @@ public class CharacterService {
|
|||||||
.name(data.name())
|
.name(data.name())
|
||||||
.portraitImageId(data.portraitImageId())
|
.portraitImageId(data.portraitImageId())
|
||||||
.headerImageId(data.headerImageId())
|
.headerImageId(data.headerImageId())
|
||||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
.values(copyStringMap(data.values()))
|
||||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
.imageValues(copyStringListMap(data.imageValues()))
|
||||||
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
.keyValueValues(copyStringMapMap(data.keyValueValues()))
|
||||||
.playthroughId(data.playthroughId())
|
.playthroughId(data.playthroughId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -64,9 +64,9 @@ public class CharacterService {
|
|||||||
existing.setName(data.name());
|
existing.setName(data.name());
|
||||||
existing.setPortraitImageId(data.portraitImageId());
|
existing.setPortraitImageId(data.portraitImageId());
|
||||||
existing.setHeaderImageId(data.headerImageId());
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
existing.setValues(copyStringMap(data.values()));
|
||||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
existing.setImageValues(copyStringListMap(data.imageValues()));
|
||||||
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
existing.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
@@ -89,4 +89,16 @@ public class CharacterService {
|
|||||||
.max()
|
.max()
|
||||||
.orElse(-1) + 1;
|
.orElse(-1) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> copyStringMap(Map<String, String> map) {
|
||||||
|
return map != null ? new HashMap<>(map) : new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, List<String>> copyStringListMap(Map<String, List<String>> map) {
|
||||||
|
return map != null ? new HashMap<>(map) : new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, String>> copyStringMapMap(Map<String, Map<String, String>> map) {
|
||||||
|
return map != null ? new HashMap<>(map) : new HashMap<>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Enemy;
|
import com.loremind.domain.campaigncontext.bestiary.Enemy;
|
||||||
import com.loremind.domain.shared.ReorderSupport;
|
import com.loremind.domain.shared.ReorderSupport;
|
||||||
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||||
import com.loremind.domain.images.Image;
|
import com.loremind.domain.images.Image;
|
||||||
@@ -10,11 +10,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.util.Base64;
|
import java.util.*;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
|
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
|
||||||
@@ -48,18 +44,10 @@ public class EnemyService {
|
|||||||
|
|
||||||
public Enemy createEnemy(EnemyData data) {
|
public Enemy createEnemy(EnemyData data) {
|
||||||
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
|
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
|
||||||
Enemy enemy = Enemy.builder()
|
Enemy enemy = Enemy.builder().build();
|
||||||
.name(data.name())
|
applyCommonEnemyFields(enemy, data);
|
||||||
.level(normalize(data.level()))
|
enemy.setCampaignId(data.campaignId());
|
||||||
.folder(normalize(data.folder()))
|
enemy.setOrder(order);
|
||||||
.portraitImageId(data.portraitImageId())
|
|
||||||
.headerImageId(data.headerImageId())
|
|
||||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
|
||||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
|
||||||
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
|
||||||
.campaignId(data.campaignId())
|
|
||||||
.order(order)
|
|
||||||
.build();
|
|
||||||
return enemyRepository.save(enemy);
|
return enemyRepository.save(enemy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,17 +62,11 @@ public class EnemyService {
|
|||||||
public Enemy updateEnemy(String id, EnemyData data) {
|
public Enemy updateEnemy(String id, EnemyData data) {
|
||||||
Enemy existing = enemyRepository.findById(id)
|
Enemy existing = enemyRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
|
||||||
existing.setName(data.name());
|
applyCommonEnemyFields(existing, data);
|
||||||
existing.setLevel(normalize(data.level()));
|
|
||||||
existing.setFolder(normalize(data.folder()));
|
|
||||||
existing.setPortraitImageId(data.portraitImageId());
|
|
||||||
existing.setHeaderImageId(data.headerImageId());
|
|
||||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
|
||||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
|
||||||
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
|
// campaignId immuable après création.
|
||||||
return enemyRepository.save(existing);
|
return enemyRepository.save(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +82,7 @@ public class EnemyService {
|
|||||||
public void reorderEnemies(String folder, List<String> orderedIds) {
|
public void reorderEnemies(String folder, List<String> orderedIds) {
|
||||||
String f = normalize(folder);
|
String f = normalize(folder);
|
||||||
ReorderSupport.reorder(orderedIds,
|
ReorderSupport.reorder(orderedIds,
|
||||||
id -> enemyRepository.findById(id).orElse(null),
|
enemyRepository::findById,
|
||||||
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
|
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
|
||||||
enemyRepository::save);
|
enemyRepository::save);
|
||||||
}
|
}
|
||||||
@@ -141,9 +123,7 @@ public class EnemyService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (bytes.length == 0) return null;
|
if (bytes.length == 0) return null;
|
||||||
String ext = contentType.contains("png") ? "png"
|
String ext = pickExtension(contentType);
|
||||||
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
|
|
||||||
: contentType.contains("gif") ? "gif" : "webp";
|
|
||||||
// Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non
|
// Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non
|
||||||
// standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg").
|
// standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg").
|
||||||
String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext;
|
String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext;
|
||||||
@@ -158,6 +138,14 @@ public class EnemyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extension déduite du content-type ; "webp" par défaut si non reconnu. */
|
||||||
|
private static String pickExtension(String contentType) {
|
||||||
|
if (contentType.contains("png")) return "png";
|
||||||
|
if (contentType.contains("jpeg") || contentType.contains("jpg")) return "jpg";
|
||||||
|
if (contentType.contains("gif")) return "gif";
|
||||||
|
return "webp";
|
||||||
|
}
|
||||||
|
|
||||||
public record MonsterImportResult(int created, int updated) {}
|
public record MonsterImportResult(int created, int updated) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,44 +162,49 @@ public class EnemyService {
|
|||||||
}
|
}
|
||||||
int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1;
|
int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1;
|
||||||
|
|
||||||
int created = 0, updated = 0;
|
int created = 0;
|
||||||
|
int updated = 0;
|
||||||
for (MonsterImport m : monsters) {
|
for (MonsterImport m : monsters) {
|
||||||
if (m.foundryRef() == null || m.foundryRef().isBlank()
|
if (isBlank(m.foundryRef()) || isBlank(m.name())) continue;
|
||||||
|| m.name() == null || m.name().isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
|
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
|
||||||
String folder = foundryFolder(m.folder());
|
|
||||||
Enemy ex = byRef.get(m.foundryRef());
|
Enemy ex = byRef.get(m.foundryRef());
|
||||||
if (ex != null) {
|
if (ex != null) {
|
||||||
|
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.setName(m.name());
|
||||||
ex.setFoundryStats(stats); // rafraîchit le snapshot
|
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).
|
// Portrait : seulement s'il manque (pas de ré-upload à chaque import).
|
||||||
if (ex.getPortraitImageId() == null) {
|
if (ex.getPortraitImageId() == null) {
|
||||||
String portrait = storePortrait(m.imgData(), m.name());
|
String portrait = storePortrait(m.imgData(), m.name());
|
||||||
if (portrait != null) ex.setPortraitImageId(portrait);
|
if (portrait != null) ex.setPortraitImageId(portrait);
|
||||||
}
|
}
|
||||||
enemyRepository.save(ex);
|
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())
|
.name(m.name())
|
||||||
.foundryRef(m.foundryRef())
|
.foundryRef(m.foundryRef())
|
||||||
.foundryStats(stats)
|
.foundryStats(stats)
|
||||||
.folder(folder)
|
.folder(foundryFolder(m.folder()))
|
||||||
.portraitImageId(storePortrait(m.imgData(), m.name()))
|
.portraitImageId(storePortrait(m.imgData(), m.name()))
|
||||||
.campaignId(campaignId)
|
.campaignId(campaignId)
|
||||||
.order(order++)
|
.order(order)
|
||||||
.values(new HashMap<>())
|
.values(new HashMap<>())
|
||||||
.imageValues(new HashMap<>())
|
.imageValues(new HashMap<>())
|
||||||
.keyValueValues(new HashMap<>())
|
.keyValueValues(new HashMap<>())
|
||||||
.build());
|
.build());
|
||||||
byRef.put(m.foundryRef(), saved);
|
|
||||||
created++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new MonsterImportResult(created, updated);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Enemy> searchEnemies(String query) {
|
public List<Enemy> searchEnemies(String query) {
|
||||||
@@ -226,10 +219,37 @@ public class EnemyService {
|
|||||||
return trimmed.isEmpty() ? null : trimmed;
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String s) {
|
||||||
|
return s == null || s.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
private int nextOrderFor(String campaignId) {
|
private int nextOrderFor(String campaignId) {
|
||||||
return enemyRepository.findByCampaignId(campaignId).stream()
|
return enemyRepository.findByCampaignId(campaignId).stream()
|
||||||
.mapToInt(Enemy::getOrder)
|
.mapToInt(Enemy::getOrder)
|
||||||
.max()
|
.max()
|
||||||
.orElse(-1) + 1;
|
.orElse(-1) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> copyStringMap(Map<String, String> map) {
|
||||||
|
return map != null ? new HashMap<>(map) : new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, List<String>> copyStringListMap(Map<String, List<String>> map) {
|
||||||
|
return map != null ? new HashMap<>(map) : new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, String>> copyStringMapMap(Map<String, Map<String, String>> map) {
|
||||||
|
return map != null ? new HashMap<>(map) : new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyCommonEnemyFields(Enemy enemy, EnemyData data) {
|
||||||
|
enemy.setName(data.name());
|
||||||
|
enemy.setLevel(normalize(data.level()));
|
||||||
|
enemy.setFolder(normalize(data.folder()));
|
||||||
|
enemy.setPortraitImageId(data.portraitImageId());
|
||||||
|
enemy.setHeaderImageId(data.headerImageId());
|
||||||
|
enemy.setValues(copyStringMap(data.values()));
|
||||||
|
enemy.setImageValues(copyStringListMap(data.imageValues()));
|
||||||
|
enemy.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.itemcatalog.CatalogItem;
|
||||||
import com.loremind.domain.campaigncontext.CatalogItem;
|
import com.loremind.domain.campaigncontext.itemcatalog.ItemCatalog;
|
||||||
import com.loremind.domain.campaigncontext.ItemCatalog;
|
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
|
||||||
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
|
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
|
||||||
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
|
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -21,18 +18,15 @@ public class ItemCatalogService {
|
|||||||
|
|
||||||
private final ItemCatalogRepository repository;
|
private final ItemCatalogRepository repository;
|
||||||
private final ItemCatalogGenerator generator;
|
private final ItemCatalogGenerator generator;
|
||||||
private final CampaignRepository campaignRepository;
|
private final CampaignContextFormatter campaignContextFormatter;
|
||||||
private final GameSystemRepository gameSystemRepository;
|
|
||||||
|
|
||||||
public ItemCatalogService(
|
public ItemCatalogService(
|
||||||
ItemCatalogRepository repository,
|
ItemCatalogRepository repository,
|
||||||
ItemCatalogGenerator generator,
|
ItemCatalogGenerator generator,
|
||||||
CampaignRepository campaignRepository,
|
CampaignContextFormatter campaignContextFormatter) {
|
||||||
GameSystemRepository gameSystemRepository) {
|
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.generator = generator;
|
this.generator = generator;
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignContextFormatter = campaignContextFormatter;
|
||||||
this.gameSystemRepository = gameSystemRepository;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CatalogData(
|
public record CatalogData(
|
||||||
@@ -89,7 +83,8 @@ public class ItemCatalogService {
|
|||||||
|
|
||||||
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
|
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
|
||||||
public ItemCatalog generateProposal(String campaignId, String description) {
|
public ItemCatalog generateProposal(String campaignId, String description) {
|
||||||
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));
|
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(
|
||||||
|
description, campaignContextFormatter.format(campaignId));
|
||||||
return ItemCatalog.builder()
|
return ItemCatalog.builder()
|
||||||
.name(g.name())
|
.name(g.name())
|
||||||
.description(g.description())
|
.description(g.description())
|
||||||
@@ -102,22 +97,6 @@ public class ItemCatalogService {
|
|||||||
return items != null ? new ArrayList<>(items) : new ArrayList<>();
|
return items != null ? new ArrayList<>(items) : new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildContext(String campaignId) {
|
|
||||||
if (campaignId == null) return "";
|
|
||||||
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
|
|
||||||
if (campaign == null) return "";
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("Campagne : ").append(campaign.getName());
|
|
||||||
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
|
|
||||||
sb.append(" — ").append(campaign.getDescription().trim());
|
|
||||||
}
|
|
||||||
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
|
|
||||||
gameSystemRepository.findById(campaign.getGameSystemId())
|
|
||||||
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private int nextOrderFor(String campaignId) {
|
private int nextOrderFor(String campaignId) {
|
||||||
return repository.findByCampaignId(campaignId).stream()
|
return repository.findByCampaignId(campaignId).stream()
|
||||||
.mapToInt(ItemCatalog::getOrder)
|
.mapToInt(ItemCatalog::getOrder)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Notebook;
|
import com.loremind.domain.campaigncontext.notebook.Notebook;
|
||||||
import com.loremind.domain.campaigncontext.NotebookMessage;
|
import com.loremind.domain.campaigncontext.notebook.NotebookMessage;
|
||||||
import com.loremind.domain.campaigncontext.NotebookSource;
|
import com.loremind.domain.campaigncontext.notebook.NotebookSource;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
|
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
|
||||||
import com.loremind.domain.campaigncontext.ports.NotebookRepository;
|
import com.loremind.domain.campaigncontext.ports.NotebookRepository;
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Npc;
|
import com.loremind.domain.campaigncontext.bestiary.Npc;
|
||||||
import com.loremind.domain.shared.ReorderSupport;
|
import com.loremind.domain.shared.ReorderSupport;
|
||||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.*;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service d'application pour les fiches de PNJ (campagne).
|
* Service d'application pour les fiches de PNJ (campagne).
|
||||||
@@ -92,7 +88,7 @@ public class NpcService {
|
|||||||
public void reorderNpcs(String folder, List<String> orderedIds) {
|
public void reorderNpcs(String folder, List<String> orderedIds) {
|
||||||
String f = normalizeFolder(folder);
|
String f = normalizeFolder(folder);
|
||||||
ReorderSupport.reorder(orderedIds,
|
ReorderSupport.reorder(orderedIds,
|
||||||
id -> npcRepository.findById(id).orElse(null),
|
npcRepository::findById,
|
||||||
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
|
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
|
||||||
npcRepository::save);
|
npcRepository::save);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.structure.Arc;
|
||||||
import com.loremind.domain.campaigncontext.ArcType;
|
import com.loremind.domain.campaigncontext.structure.ArcType;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.NodeType;
|
import com.loremind.domain.campaigncontext.quest.NodeType;
|
||||||
import com.loremind.domain.campaigncontext.Quest;
|
import com.loremind.domain.campaigncontext.quest.Quest;
|
||||||
import com.loremind.domain.campaigncontext.QuestNodeRef;
|
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.QuestRepository;
|
import com.loremind.domain.campaigncontext.ports.QuestRepository;
|
||||||
@@ -106,6 +106,11 @@ public class QuestService {
|
|||||||
/** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */
|
/** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */
|
||||||
private boolean isContainerOf(Quest quest, Chapter chapter) {
|
private boolean isContainerOf(Quest quest, Chapter chapter) {
|
||||||
if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true;
|
if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true;
|
||||||
|
return inSystemArc(chapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le chapitre vit-il dans l'arc technique SYSTEM (masqué partout dans l'appli) ? */
|
||||||
|
private boolean inSystemArc(Chapter chapter) {
|
||||||
return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId())
|
return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId())
|
||||||
.map(a -> a.getType() == ArcType.SYSTEM)
|
.map(a -> a.getType() == ArcType.SYSTEM)
|
||||||
.orElse(false);
|
.orElse(false);
|
||||||
@@ -119,15 +124,6 @@ public class QuestService {
|
|||||||
return questRepository.findByCampaignId(campaignId);
|
return questRepository.findByCampaignId(campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quêtes rattachées à un arc HUB. */
|
|
||||||
public List<Quest> getQuestsByArcId(String arcId) {
|
|
||||||
return questRepository.findByArcId(arcId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Quest> getAllQuests() {
|
|
||||||
return questRepository.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */
|
/** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */
|
||||||
@Transactional
|
@Transactional
|
||||||
public Quest updateQuest(String id, Quest updated) {
|
public Quest updateQuest(String id, Quest updated) {
|
||||||
@@ -165,9 +161,22 @@ public class QuestService {
|
|||||||
/**
|
/**
|
||||||
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
|
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
|
||||||
*
|
*
|
||||||
* <p>TODO (Phase 5) : signaler/nettoyer les {@code Prerequisite.QuestCompleted} pendants
|
* <p>Nettoyage du CONTENEUR (chapitre jumeau, jamais un chapitre simplement LIÉ —
|
||||||
* d'autres quêtes qui pointaient celle-ci. Échec sûr aujourd'hui : un prérequis vers une
|
* isContainerOf exclut les liens transversaux) :
|
||||||
* quête supprimée n'est jamais satisfait → la quête dépendante reste LOCKED (pas de corruption).</p>
|
* <ul>
|
||||||
|
* <li>jumeau de HUB non vide : GARDÉ — il redevient un chapitre visible de l'arc,
|
||||||
|
* aucune perte de contenu ;</li>
|
||||||
|
* <li>conteneur d'arc SYSTEM (quête libre) : supprimé AVEC ses scènes — une fois la
|
||||||
|
* quête partie il est invisible partout dans l'appli et pourrirait en fantôme
|
||||||
|
* (réapparitions dans les exports). L'impact est annoncé au préalable par
|
||||||
|
* {@link #getDeletionImpact} (dialogue de confirmation côté front) ;</li>
|
||||||
|
* <li>conteneur encore référencé par une autre quête : jamais touché.</li>
|
||||||
|
* </ul></p>
|
||||||
|
*
|
||||||
|
* <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
|
||||||
|
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
|
||||||
|
* nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
|
||||||
|
* satisfait → la quête dépendante reste LOCKED (pas de corruption).</p>
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteQuest(String id) {
|
public void deleteQuest(String id) {
|
||||||
@@ -175,41 +184,56 @@ public class QuestService {
|
|||||||
progressionRepository.deleteByQuestId(id);
|
progressionRepository.deleteByQuestId(id);
|
||||||
questRepository.deleteById(id);
|
questRepository.deleteById(id);
|
||||||
if (quest == null) return;
|
if (quest == null) return;
|
||||||
// Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE
|
|
||||||
// (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître
|
|
||||||
// comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune
|
|
||||||
// perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du
|
|
||||||
// contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut.
|
|
||||||
List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId());
|
List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId());
|
||||||
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
|
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
|
||||||
if (node.nodeType() != NodeType.CHAPTER) continue;
|
if (node.nodeType() != NodeType.CHAPTER) continue;
|
||||||
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
|
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
|
||||||
boolean container = isContainerOf(quest, ch);
|
boolean container = isContainerOf(quest, ch);
|
||||||
boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty();
|
|
||||||
boolean referencedElsewhere = remaining.stream()
|
boolean referencedElsewhere = remaining.stream()
|
||||||
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
|
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
|
||||||
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
|
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
|
||||||
&& ch.getId().equals(n.nodeId())));
|
&& ch.getId().equals(n.nodeId())));
|
||||||
if (container && empty && !referencedElsewhere) {
|
if (!container || referencedElsewhere) return;
|
||||||
|
var scenes = sceneRepository.findByChapterId(ch.getId());
|
||||||
|
if (!scenes.isEmpty() && !inSystemArc(ch)) return; // jumeau de hub : reste visible
|
||||||
|
for (var scene : scenes) sceneRepository.deleteById(scene.getId());
|
||||||
chapterRepository.deleteById(ch.getId());
|
chapterRepository.deleteById(ch.getId());
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Scènes qui tomberont avec la quête (conteneurs d'arc SYSTEM exclusifs à cette quête). */
|
||||||
|
public record DeletionImpact(int scenes) {}
|
||||||
|
|
||||||
|
public DeletionImpact getDeletionImpact(String questId) {
|
||||||
|
Quest quest = questRepository.findById(questId).orElse(null);
|
||||||
|
if (quest == null) return new DeletionImpact(0);
|
||||||
|
List<Quest> others = questRepository.findByCampaignId(quest.getCampaignId()).stream()
|
||||||
|
.filter(q -> !questId.equals(q.getId()))
|
||||||
|
.toList();
|
||||||
|
int scenes = 0;
|
||||||
|
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
|
||||||
|
if (node.nodeType() != NodeType.CHAPTER) continue;
|
||||||
|
Chapter ch = chapterRepository.findById(node.nodeId()).orElse(null);
|
||||||
|
if (ch == null || !isContainerOf(quest, ch) || !inSystemArc(ch)) continue;
|
||||||
|
boolean referencedElsewhere = others.stream()
|
||||||
|
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
|
||||||
|
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
|
||||||
|
&& ch.getId().equals(n.nodeId())));
|
||||||
|
if (!referencedElsewhere) scenes += sceneRepository.findByChapterId(ch.getId()).size();
|
||||||
|
}
|
||||||
|
return new DeletionImpact(scenes);
|
||||||
|
}
|
||||||
|
|
||||||
private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
|
private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
|
||||||
return nodes != null ? nodes : List.of();
|
return nodes != null ? nodes : List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean questExists(String id) {
|
|
||||||
return questRepository.existsById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */
|
/** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void reorderQuests(String campaignId, List<String> orderedIds) {
|
public void reorderQuests(String campaignId, List<String> orderedIds) {
|
||||||
ReorderSupport.reorder(orderedIds,
|
ReorderSupport.reorder(orderedIds,
|
||||||
id -> questRepository.findById(id).orElse(null),
|
questRepository::findById,
|
||||||
(quest, i) -> {
|
(quest, i) -> {
|
||||||
if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId);
|
if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId);
|
||||||
quest.setOrder(i);
|
quest.setOrder(i);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
|
import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
|
||||||
import com.loremind.domain.campaigncontext.ProgressionStatus;
|
import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
|
||||||
import com.loremind.domain.campaigncontext.Quest;
|
import com.loremind.domain.campaigncontext.quest.Quest;
|
||||||
import com.loremind.domain.campaigncontext.QuestStatus;
|
import com.loremind.domain.campaigncontext.quest.QuestStatus;
|
||||||
import com.loremind.domain.playcontext.QuestProgression;
|
import com.loremind.domain.playcontext.QuestProgression;
|
||||||
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
|
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
|
||||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.randomtable.RandomTable;
|
||||||
import com.loremind.domain.campaigncontext.RandomTable;
|
|
||||||
import com.loremind.domain.shared.ReorderSupport;
|
import com.loremind.domain.shared.ReorderSupport;
|
||||||
import com.loremind.domain.campaigncontext.RandomTableEntry;
|
import com.loremind.domain.campaigncontext.randomtable.RandomTableEntry;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
|
||||||
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
|
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
|
||||||
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
|
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -22,18 +19,15 @@ public class RandomTableService {
|
|||||||
|
|
||||||
private final RandomTableRepository repository;
|
private final RandomTableRepository repository;
|
||||||
private final RandomTableGenerator generator;
|
private final RandomTableGenerator generator;
|
||||||
private final CampaignRepository campaignRepository;
|
private final CampaignContextFormatter campaignContextFormatter;
|
||||||
private final GameSystemRepository gameSystemRepository;
|
|
||||||
|
|
||||||
public RandomTableService(
|
public RandomTableService(
|
||||||
RandomTableRepository repository,
|
RandomTableRepository repository,
|
||||||
RandomTableGenerator generator,
|
RandomTableGenerator generator,
|
||||||
CampaignRepository campaignRepository,
|
CampaignContextFormatter campaignContextFormatter) {
|
||||||
GameSystemRepository gameSystemRepository) {
|
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.generator = generator;
|
this.generator = generator;
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignContextFormatter = campaignContextFormatter;
|
||||||
this.gameSystemRepository = gameSystemRepository;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TableData(
|
public record TableData(
|
||||||
@@ -90,8 +84,8 @@ public class RandomTableService {
|
|||||||
@org.springframework.transaction.annotation.Transactional
|
@org.springframework.transaction.annotation.Transactional
|
||||||
public void reorderTables(List<String> orderedIds) {
|
public void reorderTables(List<String> orderedIds) {
|
||||||
ReorderSupport.reorder(orderedIds,
|
ReorderSupport.reorder(orderedIds,
|
||||||
id -> repository.findById(id).orElse(null),
|
repository::findById,
|
||||||
(table, i) -> table.setOrder(i),
|
RandomTable::setOrder,
|
||||||
repository::save);
|
repository::save);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +97,8 @@ public class RandomTableService {
|
|||||||
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
|
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
|
||||||
public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
|
public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
|
||||||
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
|
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
|
||||||
RandomTableGenerator.GeneratedTable g = generator.generate(description, formula, buildContext(campaignId));
|
RandomTableGenerator.GeneratedTable g = generator.generate(
|
||||||
|
description, formula, campaignContextFormatter.format(campaignId));
|
||||||
return RandomTable.builder()
|
return RandomTable.builder()
|
||||||
.name(g.name())
|
.name(g.name())
|
||||||
.description(g.description())
|
.description(g.description())
|
||||||
@@ -115,24 +110,7 @@ public class RandomTableService {
|
|||||||
|
|
||||||
/** Brode un court récit IA sur un résultat tiré (pour la partie). */
|
/** Brode un court récit IA sur un résultat tiré (pour la partie). */
|
||||||
public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) {
|
public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) {
|
||||||
return generator.improvise(tableName, resultLabel, resultDetail, buildContext(campaignId));
|
return generator.improvise(tableName, resultLabel, resultDetail, campaignContextFormatter.format(campaignId));
|
||||||
}
|
|
||||||
|
|
||||||
/** Contexte libre (nom de campagne + description + système) pour orienter l'IA. */
|
|
||||||
private String buildContext(String campaignId) {
|
|
||||||
if (campaignId == null) return "";
|
|
||||||
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
|
|
||||||
if (campaign == null) return "";
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("Campagne : ").append(campaign.getName());
|
|
||||||
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
|
|
||||||
sb.append(" — ").append(campaign.getDescription().trim());
|
|
||||||
}
|
|
||||||
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
|
|
||||||
gameSystemRepository.findById(campaign.getGameSystemId())
|
|
||||||
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {
|
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.ReadinessEntityType;
|
import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
|
||||||
import com.loremind.domain.campaigncontext.ReadinessSeverity;
|
import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B).
|
* Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B).
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.FieldProposal;
|
import com.loremind.domain.campaigncontext.generation.FieldProposal;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.structure.Scene;
|
||||||
import com.loremind.domain.campaigncontext.SceneDraft;
|
import com.loremind.domain.campaigncontext.generation.SceneDraft;
|
||||||
import com.loremind.domain.shared.ReorderSupport;
|
import com.loremind.domain.shared.ReorderSupport;
|
||||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
import com.loremind.domain.campaigncontext.structure.SceneBranch;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -171,7 +171,7 @@ public class SceneService {
|
|||||||
if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue;
|
if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue;
|
||||||
List<SceneBranch> kept = branches.stream()
|
List<SceneBranch> kept = branches.stream()
|
||||||
.filter(b -> !id.equals(b.targetSceneId()))
|
.filter(b -> !id.equals(b.targetSceneId()))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
if (kept.size() != branches.size()) {
|
if (kept.size() != branches.size()) {
|
||||||
sibling.setBranches(kept);
|
sibling.setBranches(kept);
|
||||||
sceneRepository.save(sibling);
|
sceneRepository.save(sibling);
|
||||||
@@ -193,7 +193,7 @@ public class SceneService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void reorderScenes(String chapterId, List<String> orderedIds) {
|
public void reorderScenes(String chapterId, List<String> orderedIds) {
|
||||||
ReorderSupport.reorder(orderedIds,
|
ReorderSupport.reorder(orderedIds,
|
||||||
id -> sceneRepository.findById(id).orElse(null),
|
sceneRepository::findById,
|
||||||
(scene, i) -> {
|
(scene, i) -> {
|
||||||
if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) {
|
if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) {
|
||||||
scene.setChapterId(chapterId);
|
scene.setChapterId(chapterId);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -24,18 +25,18 @@ import java.util.Set;
|
|||||||
@Service
|
@Service
|
||||||
public class StoredFileService {
|
public class StoredFileService {
|
||||||
|
|
||||||
|
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
||||||
|
|
||||||
/** Types MIME explicitement autorises (en plus de tout {@code image/*} et {@code video/*}). */
|
/** Types MIME explicitement autorises (en plus de tout {@code image/*} et {@code video/*}). */
|
||||||
private static final Set<String> ALLOWED_EXACT_MIME = Set.of(
|
private static final Set<String> ALLOWED_EXACT_MIME = Set.of(
|
||||||
"application/json",
|
"application/json",
|
||||||
"application/octet-stream",
|
DEFAULT_CONTENT_TYPE,
|
||||||
"text/plain"
|
"text/plain"
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Coherent avec spring.servlet.multipart.max-file-size (application.properties). */
|
/** Coherent avec spring.servlet.multipart.max-file-size (application.properties). */
|
||||||
private static final long MAX_SIZE_BYTES = 128L * 1024 * 1024; // 128 Mo
|
private static final long MAX_SIZE_BYTES = 128L * 1024 * 1024; // 128 Mo
|
||||||
|
|
||||||
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
||||||
|
|
||||||
private final StoredFileRepository repository;
|
private final StoredFileRepository repository;
|
||||||
private final FileStorage storage;
|
private final FileStorage storage;
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ public class StoredFileService {
|
|||||||
.contentType(resolvedType)
|
.contentType(resolvedType)
|
||||||
.sizeBytes(sizeBytes)
|
.sizeBytes(sizeBytes)
|
||||||
.storageKey(storageKey)
|
.storageKey(storageKey)
|
||||||
.uploadedAt(LocalDateTime.now())
|
.uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
|
||||||
.build();
|
.build();
|
||||||
return repository.save(file);
|
return repository.save(file);
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ import java.util.regex.Pattern;
|
|||||||
@Service
|
@Service
|
||||||
public class GameSystemContextBuilder {
|
public class GameSystemContextBuilder {
|
||||||
|
|
||||||
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
|
/**
|
||||||
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
|
* Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1.
|
||||||
|
* Le titre est forcé à commencer par un caractère non-blanc ({@code \S.*}) : la
|
||||||
|
* frontière entre les espaces de tête et le titre devient non ambiguë (classes de
|
||||||
|
* caractères disjointes), ce qui élimine tout risque de backtracking polynomial.
|
||||||
|
*/
|
||||||
|
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(\\S.*)$");
|
||||||
|
|
||||||
private final GameSystemRepository gameSystemRepository;
|
private final GameSystemRepository gameSystemRepository;
|
||||||
|
|
||||||
|
|||||||
@@ -119,27 +119,29 @@ public class GameSystemService {
|
|||||||
|
|
||||||
List<TemplateField> template = new ArrayList<>();
|
List<TemplateField> template = new ArrayList<>();
|
||||||
Set<String> usedNames = new HashSet<>();
|
Set<String> usedNames = new HashSet<>();
|
||||||
for (FoundryStructField f : fields == null ? List.<FoundryStructField>of() : fields) {
|
for (FoundryStructField f : fields != null ? fields : List.<FoundryStructField>of()) {
|
||||||
if (f.path() == null || f.path().isBlank()) continue;
|
TemplateField field = toTemplateField(f, usedNames);
|
||||||
String label = (f.label() != null && !f.label().isBlank()) ? f.label().trim() : f.path();
|
if (field != null) template.add(field);
|
||||||
// Nom unique : libellé si libre, sinon le chemin (toujours unique).
|
|
||||||
String name = usedNames.contains(label.toLowerCase()) ? f.path() : label;
|
|
||||||
if (usedNames.contains(name.toLowerCase())) continue;
|
|
||||||
usedNames.add(name.toLowerCase());
|
|
||||||
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
|
|
||||||
template.add(new TemplateField(name, type, null, null, f.path()));
|
|
||||||
}
|
}
|
||||||
gs.replaceEnemyTemplate(template);
|
gs.replaceEnemyTemplate(template);
|
||||||
gs.setFoundryActorType(normalize(actorType));
|
gs.setFoundryActorType(normalize(actorType));
|
||||||
return gameSystemRepository.save(gs);
|
return gameSystemRepository.save(gs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteGameSystem(String id) {
|
/** Convertit un champ Foundry en TemplateField, ou null si son chemin est vide ou son nom déjà pris. */
|
||||||
gameSystemRepository.deleteById(id);
|
private static TemplateField toTemplateField(FoundryStructField f, Set<String> usedNames) {
|
||||||
|
if (f.path() == null || f.path().isBlank()) return null;
|
||||||
|
String label = (f.label() != null && !f.label().isBlank()) ? f.label().trim() : f.path();
|
||||||
|
// Nom unique : libellé si libre, sinon le chemin (toujours unique).
|
||||||
|
String name = usedNames.contains(label.toLowerCase()) ? f.path() : label;
|
||||||
|
if (usedNames.contains(name.toLowerCase())) return null;
|
||||||
|
usedNames.add(name.toLowerCase());
|
||||||
|
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
|
||||||
|
return new TemplateField(name, type, null, null, f.path());
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean gameSystemExists(String id) {
|
public void deleteGameSystem(String id) {
|
||||||
return gameSystemRepository.existsById(id);
|
gameSystemRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<GameSystem> searchGameSystems(String query) {
|
public List<GameSystem> searchGameSystems(String query) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package com.loremind.application.generationcontext;
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.structure.Arc;
|
||||||
import com.loremind.domain.campaigncontext.ArcType;
|
import com.loremind.domain.campaigncontext.structure.ArcType;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.bestiary.Character;
|
||||||
import com.loremind.domain.campaigncontext.Npc;
|
import com.loremind.domain.campaigncontext.bestiary.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.structure.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
@@ -93,26 +93,26 @@ public class CampaignStructuralContextBuilder {
|
|||||||
// les enemyIds des pièces sans N+1 sur le repo.
|
// les enemyIds des pièces sans N+1 sur le repo.
|
||||||
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
|
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
|
||||||
.collect(Collectors.toMap(
|
.collect(Collectors.toMap(
|
||||||
com.loremind.domain.campaigncontext.Enemy::getId,
|
com.loremind.domain.campaigncontext.bestiary.Enemy::getId,
|
||||||
CampaignStructuralContextBuilder::enemyLabel,
|
CampaignStructuralContextBuilder::enemyLabel,
|
||||||
(a, b) -> a));
|
(a, b) -> a));
|
||||||
|
|
||||||
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
|
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
|
||||||
.sorted(Comparator.comparingInt(Arc::getOrder))
|
.sorted(Comparator.comparingInt(Arc::getOrder))
|
||||||
.map(arc -> toArcSummary(arc, enemyLabelById))
|
.map(arc -> toArcSummary(arc, enemyLabelById))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
|
|
||||||
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
|
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
|
||||||
? List.of()
|
? List.of()
|
||||||
: characterRepository.findByPlaythroughId(playthroughId).stream()
|
: characterRepository.findByPlaythroughId(playthroughId).stream()
|
||||||
.sorted(Comparator.comparingInt(Character::getOrder))
|
.sorted(Comparator.comparingInt(Character::getOrder))
|
||||||
.map(this::toCharacterSummary)
|
.map(this::toCharacterSummary)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
|
|
||||||
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
|
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
|
||||||
.sorted(Comparator.comparingInt(Npc::getOrder))
|
.sorted(Comparator.comparingInt(Npc::getOrder))
|
||||||
.map(this::toNpcSummary)
|
.map(this::toNpcSummary)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
|
|
||||||
return new CampaignStructuralContext(
|
return new CampaignStructuralContext(
|
||||||
campaign.getName(),
|
campaign.getName(),
|
||||||
@@ -140,27 +140,36 @@ public class CampaignStructuralContextBuilder {
|
|||||||
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
|
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
|
||||||
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
|
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
|
||||||
*/
|
*/
|
||||||
private static String extractSnippet(java.util.Map<String, String> values) {
|
private static String extractSnippet(Map<String, String> values) {
|
||||||
if (values == null || values.isEmpty()) return "";
|
if (values == null || values.isEmpty()) return "";
|
||||||
for (String value : values.values()) {
|
return values.values().stream()
|
||||||
if (value == null || value.isBlank()) continue;
|
.filter(v -> v != null && !v.isBlank())
|
||||||
String firstLine = value.lines()
|
.map(CampaignStructuralContextBuilder::firstMeaningfulLine)
|
||||||
|
.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)
|
.map(String::strip)
|
||||||
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse("");
|
.orElse("");
|
||||||
if (firstLine.isEmpty()) continue;
|
}
|
||||||
|
|
||||||
|
private static String truncateSnippet(String firstLine) {
|
||||||
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
||||||
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
||||||
}
|
}
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
|
private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
|
||||||
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
||||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||||
.map(chapter -> toChapterSummary(chapter, enemyLabelById))
|
.map(chapter -> toChapterSummary(chapter, enemyLabelById))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
return new ArcSummary(
|
return new ArcSummary(
|
||||||
arc.getName(),
|
arc.getName(),
|
||||||
arc.getDescription(),
|
arc.getDescription(),
|
||||||
@@ -181,7 +190,7 @@ public class CampaignStructuralContextBuilder {
|
|||||||
|
|
||||||
List<SceneSummary> summaries = scenes.stream()
|
List<SceneSummary> summaries = scenes.stream()
|
||||||
.map(s -> toSceneSummary(s, nameById, enemyLabelById))
|
.map(s -> toSceneSummary(s, nameById, enemyLabelById))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
|
|
||||||
return new ChapterSummary(
|
return new ChapterSummary(
|
||||||
chapter.getName(),
|
chapter.getName(),
|
||||||
@@ -199,7 +208,7 @@ public class CampaignStructuralContextBuilder {
|
|||||||
b.label(),
|
b.label(),
|
||||||
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
|
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
|
||||||
b.condition()))
|
b.condition()))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
|
|
||||||
List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
|
List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
|
||||||
|
|
||||||
@@ -221,8 +230,8 @@ public class CampaignStructuralContextBuilder {
|
|||||||
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
|
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
|
||||||
Map<String, String> nameById = scene.getRooms().stream()
|
Map<String, String> nameById = scene.getRooms().stream()
|
||||||
.collect(Collectors.toMap(
|
.collect(Collectors.toMap(
|
||||||
com.loremind.domain.campaigncontext.Room::getId,
|
com.loremind.domain.campaigncontext.structure.Room::getId,
|
||||||
com.loremind.domain.campaigncontext.Room::getName,
|
com.loremind.domain.campaigncontext.structure.Room::getName,
|
||||||
(a, b) -> a));
|
(a, b) -> a));
|
||||||
return scene.getRooms().stream()
|
return scene.getRooms().stream()
|
||||||
.map(r -> {
|
.map(r -> {
|
||||||
@@ -233,12 +242,12 @@ public class CampaignStructuralContextBuilder {
|
|||||||
b.label(),
|
b.label(),
|
||||||
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
|
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
|
||||||
b.condition()))
|
b.condition()))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
return new RoomSummary(
|
return new RoomSummary(
|
||||||
r.getName(), r.getFloor(), r.getDescription(),
|
r.getName(), r.getFloor(), r.getDescription(),
|
||||||
roomEnemiesText(r, enemyLabelById), hints);
|
roomEnemiesText(r, enemyLabelById), hints);
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -247,7 +256,7 @@ public class CampaignStructuralContextBuilder {
|
|||||||
* libre. L'un ou l'autre peut être vide.
|
* libre. L'un ou l'autre peut être vide.
|
||||||
*/
|
*/
|
||||||
private static String roomEnemiesText(
|
private static String roomEnemiesText(
|
||||||
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) {
|
com.loremind.domain.campaigncontext.structure.Room room, Map<String, String> enemyLabelById) {
|
||||||
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
|
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
|
||||||
.map(enemyLabelById::get)
|
.map(enemyLabelById::get)
|
||||||
.filter(l -> l != null && !l.isBlank())
|
.filter(l -> l != null && !l.isBlank())
|
||||||
@@ -259,7 +268,7 @@ public class CampaignStructuralContextBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
|
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
|
||||||
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) {
|
private static String enemyLabel(com.loremind.domain.campaigncontext.bestiary.Enemy enemy) {
|
||||||
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
|
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
|
||||||
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
|
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
package com.loremind.application.generationcontext;
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.application.campaigncontext.CampaignContextFormatter;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.SceneDraft;
|
import com.loremind.domain.campaigncontext.structure.Scene;
|
||||||
import com.loremind.domain.campaigncontext.SceneDraftProposal;
|
import com.loremind.domain.campaigncontext.generation.SceneDraft;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.generation.SceneDraftProposal;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
|
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -30,19 +29,16 @@ public class DraftScenesUseCase {
|
|||||||
|
|
||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
private final CampaignRepository campaignRepository;
|
private final CampaignContextFormatter campaignContextFormatter;
|
||||||
private final GameSystemRepository gameSystemRepository;
|
|
||||||
private final SceneDraftAssistant assistant;
|
private final SceneDraftAssistant assistant;
|
||||||
|
|
||||||
public DraftScenesUseCase(ChapterRepository chapterRepository,
|
public DraftScenesUseCase(ChapterRepository chapterRepository,
|
||||||
SceneRepository sceneRepository,
|
SceneRepository sceneRepository,
|
||||||
CampaignRepository campaignRepository,
|
CampaignContextFormatter campaignContextFormatter,
|
||||||
GameSystemRepository gameSystemRepository,
|
|
||||||
SceneDraftAssistant assistant) {
|
SceneDraftAssistant assistant) {
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
this.sceneRepository = sceneRepository;
|
this.sceneRepository = sceneRepository;
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignContextFormatter = campaignContextFormatter;
|
||||||
this.gameSystemRepository = gameSystemRepository;
|
|
||||||
this.assistant = assistant;
|
this.assistant = assistant;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +51,13 @@ public class DraftScenesUseCase {
|
|||||||
List<SceneDraft> drafts = assistant.draftScenes(context, instruction, n).stream()
|
List<SceneDraft> drafts = assistant.draftScenes(context, instruction, n).stream()
|
||||||
.filter(d -> d != null && d.name() != null && !d.name().isBlank())
|
.filter(d -> d != null && d.name() != null && !d.name().isBlank())
|
||||||
.limit(n)
|
.limit(n)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
return new SceneDraftProposal(chapterId, drafts);
|
return new SceneDraftProposal(chapterId, drafts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildContext(Chapter chapter, String campaignId) {
|
private String buildContext(Chapter chapter, String campaignId) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("Chapitre : ").append(blankToLabel(chapter.getName(), "(sans titre)")).append("\n");
|
sb.append("Chapitre : ").append(blankToLabel(chapter.getName())).append("\n");
|
||||||
appendIf(sb, "Synopsis", chapter.getDescription());
|
appendIf(sb, "Synopsis", chapter.getDescription());
|
||||||
appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives());
|
appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives());
|
||||||
appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes());
|
appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes());
|
||||||
@@ -78,17 +74,10 @@ public class DraftScenesUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (campaignId != null && !campaignId.isBlank()) {
|
if (campaignId != null && !campaignId.isBlank()) {
|
||||||
campaignRepository.findById(campaignId).ifPresent(c -> {
|
String campaignBlock = campaignContextFormatter.format(campaignId);
|
||||||
sb.append("Campagne : ").append(c.getName());
|
if (!campaignBlock.isBlank()) {
|
||||||
if (c.getDescription() != null && !c.getDescription().isBlank()) {
|
sb.append(campaignBlock).append("\n");
|
||||||
sb.append(" — ").append(c.getDescription().trim());
|
|
||||||
}
|
}
|
||||||
sb.append("\n");
|
|
||||||
if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) {
|
|
||||||
gameSystemRepository.findById(c.getGameSystemId())
|
|
||||||
.ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).append("\n"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
@@ -99,7 +88,7 @@ public class DraftScenesUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String blankToLabel(String value, String fallback) {
|
private static String blankToLabel(String value) {
|
||||||
return value == null || value.isBlank() ? fallback : value;
|
return value == null || value.isBlank() ? "(sans titre)" : value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import java.util.Map;
|
|||||||
@Service
|
@Service
|
||||||
public class GeneratePageValuesUseCase {
|
public class GeneratePageValuesUseCase {
|
||||||
|
|
||||||
|
private static final String FOR_PAGE = ") pour la page ";
|
||||||
|
|
||||||
private final PageRepository pageRepository;
|
private final PageRepository pageRepository;
|
||||||
private final TemplateRepository templateRepository;
|
private final TemplateRepository templateRepository;
|
||||||
private final LoreRepository loreRepository;
|
private final LoreRepository loreRepository;
|
||||||
@@ -96,22 +98,19 @@ public class GeneratePageValuesUseCase {
|
|||||||
}
|
}
|
||||||
return templateRepository.findById(templateId)
|
return templateRepository.findById(templateId)
|
||||||
.orElseThrow(() -> new IllegalStateException(
|
.orElseThrow(() -> new IllegalStateException(
|
||||||
"Template introuvable (id=" + templateId
|
"Template introuvable (id=" + templateId + FOR_PAGE + pageId));
|
||||||
+ ") pour la page " + pageId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Lore loadLore(String loreId, String pageId) {
|
private Lore loadLore(String loreId, String pageId) {
|
||||||
return loreRepository.findById(loreId)
|
return loreRepository.findById(loreId)
|
||||||
.orElseThrow(() -> new IllegalStateException(
|
.orElseThrow(() -> new IllegalStateException(
|
||||||
"Lore introuvable (id=" + loreId
|
"Lore introuvable (id=" + loreId + FOR_PAGE + pageId));
|
||||||
+ ") pour la page " + pageId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoreNode loadFolder(String nodeId, String pageId) {
|
private LoreNode loadFolder(String nodeId, String pageId) {
|
||||||
return loreNodeRepository.findById(nodeId)
|
return loreNodeRepository.findById(nodeId)
|
||||||
.orElseThrow(() -> new IllegalStateException(
|
.orElseThrow(() -> new IllegalStateException(
|
||||||
"Dossier parent introuvable (id=" + nodeId
|
"Dossier parent introuvable (id=" + nodeId + FOR_PAGE + pageId));
|
||||||
+ ") pour la page " + pageId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireNonEmptyFields(Template template) {
|
private void requireNonEmptyFields(Template template) {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ public class LoreStructuralContextBuilder {
|
|||||||
return allPages.stream()
|
return allPages.stream()
|
||||||
.filter(p -> nodeId.equals(p.getNodeId()))
|
.filter(p -> nodeId.equals(p.getNodeId()))
|
||||||
.map(p -> toPageSummary(p, templateNameById, pageTitleById))
|
.map(p -> toPageSummary(p, templateNameById, pageTitleById))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private PageSummary toPageSummary(
|
private PageSummary toPageSummary(
|
||||||
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilder {
|
|||||||
return relatedIds.stream()
|
return relatedIds.stream()
|
||||||
.map(pageTitleById::get)
|
.map(pageTitleById::get)
|
||||||
.filter(title -> title != null && !title.isBlank())
|
.filter(title -> title != null && !title.isBlank())
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> extractUniqueTags(List<Page> pages) {
|
private List<String> extractUniqueTags(List<Page> pages) {
|
||||||
@@ -168,6 +168,6 @@ public class LoreStructuralContextBuilder {
|
|||||||
.filter(p -> p.getTags() != null)
|
.filter(p -> p.getTags() != null)
|
||||||
.flatMap(p -> p.getTags().stream())
|
.flatMap(p -> p.getTags().stream())
|
||||||
.distinct()
|
.distinct()
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package com.loremind.application.generationcontext;
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.EntityFieldPatchProposal;
|
import com.loremind.application.campaigncontext.CampaignContextFormatter;
|
||||||
import com.loremind.domain.campaigncontext.FieldProposal;
|
import com.loremind.domain.campaigncontext.generation.EntityFieldPatchProposal;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.generation.FieldProposal;
|
||||||
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
|
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -26,18 +25,15 @@ import java.util.stream.Collectors;
|
|||||||
public class NarrativeAssistFieldsUseCase {
|
public class NarrativeAssistFieldsUseCase {
|
||||||
|
|
||||||
private final NarrativeFieldCatalog catalog;
|
private final NarrativeFieldCatalog catalog;
|
||||||
private final CampaignRepository campaignRepository;
|
private final CampaignContextFormatter campaignContextFormatter;
|
||||||
private final GameSystemRepository gameSystemRepository;
|
|
||||||
private final NarrativeFieldAssistant assistant;
|
private final NarrativeFieldAssistant assistant;
|
||||||
|
|
||||||
public NarrativeAssistFieldsUseCase(
|
public NarrativeAssistFieldsUseCase(
|
||||||
NarrativeFieldCatalog catalog,
|
NarrativeFieldCatalog catalog,
|
||||||
CampaignRepository campaignRepository,
|
CampaignContextFormatter campaignContextFormatter,
|
||||||
GameSystemRepository gameSystemRepository,
|
|
||||||
NarrativeFieldAssistant assistant) {
|
NarrativeFieldAssistant assistant) {
|
||||||
this.catalog = catalog;
|
this.catalog = catalog;
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignContextFormatter = campaignContextFormatter;
|
||||||
this.gameSystemRepository = gameSystemRepository;
|
|
||||||
this.assistant = assistant;
|
this.assistant = assistant;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +42,7 @@ public class NarrativeAssistFieldsUseCase {
|
|||||||
|
|
||||||
List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream()
|
List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream()
|
||||||
.map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label()))
|
.map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label()))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
Set<String> allowed = snap.defs().stream()
|
Set<String> allowed = snap.defs().stream()
|
||||||
.map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet());
|
.map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet());
|
||||||
|
|
||||||
@@ -56,19 +52,26 @@ public class NarrativeAssistFieldsUseCase {
|
|||||||
|
|
||||||
List<FieldProposal> fields = new ArrayList<>();
|
List<FieldProposal> fields = new ArrayList<>();
|
||||||
for (NarrativeFieldAssistant.ProposedField pf : proposed) {
|
for (NarrativeFieldAssistant.ProposedField pf : proposed) {
|
||||||
if (pf.key() == null || !allowed.contains(pf.key())) continue;
|
FieldProposal proposal = toFieldProposal(pf, allowed, snap);
|
||||||
if (pf.value() == null || pf.value().isBlank()) continue;
|
if (proposal != null) fields.add(proposal);
|
||||||
String current = snap.current().getOrDefault(pf.key(), "");
|
|
||||||
fields.add(new FieldProposal(pf.key(), current, pf.value()));
|
|
||||||
}
|
}
|
||||||
return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields);
|
return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convertit un champ proposé par l'IA en FieldProposal, ou null si sa clé n'est pas autorisée ou sa valeur vide. */
|
||||||
|
private static FieldProposal toFieldProposal(
|
||||||
|
NarrativeFieldAssistant.ProposedField pf, Set<String> allowed, NarrativeFieldCatalog.Snapshot snap) {
|
||||||
|
if (pf.key() == null || !allowed.contains(pf.key())) return null;
|
||||||
|
if (pf.value() == null || pf.value().isBlank()) return null;
|
||||||
|
String current = snap.current().getOrDefault(pf.key(), "");
|
||||||
|
return new FieldProposal(pf.key(), current, pf.value());
|
||||||
|
}
|
||||||
|
|
||||||
/** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */
|
/** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */
|
||||||
private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) {
|
private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(entityLabel(snap.entityType())).append(" : ")
|
sb.append(entityLabel(snap.entityType())).append(" : ")
|
||||||
.append(blankToLabel(snap.title(), "(sans titre)")).append("\n");
|
.append(blankToLabel(snap.title())).append("\n");
|
||||||
sb.append("État actuel des champs :\n");
|
sb.append("État actuel des champs :\n");
|
||||||
boolean any = false;
|
boolean any = false;
|
||||||
for (Map.Entry<String, String> e : snap.current().entrySet()) {
|
for (Map.Entry<String, String> e : snap.current().entrySet()) {
|
||||||
@@ -82,17 +85,10 @@ public class NarrativeAssistFieldsUseCase {
|
|||||||
sb.append("- (tous les champs sont vides — à créer de zéro)\n");
|
sb.append("- (tous les champs sont vides — à créer de zéro)\n");
|
||||||
}
|
}
|
||||||
if (campaignId != null && !campaignId.isBlank()) {
|
if (campaignId != null && !campaignId.isBlank()) {
|
||||||
campaignRepository.findById(campaignId).ifPresent(c -> {
|
String campaignBlock = campaignContextFormatter.format(campaignId);
|
||||||
sb.append("Campagne : ").append(c.getName());
|
if (!campaignBlock.isBlank()) {
|
||||||
if (c.getDescription() != null && !c.getDescription().isBlank()) {
|
sb.append(campaignBlock).append("\n");
|
||||||
sb.append(" — ").append(c.getDescription().trim());
|
|
||||||
}
|
}
|
||||||
sb.append("\n");
|
|
||||||
if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) {
|
|
||||||
gameSystemRepository.findById(c.getGameSystemId())
|
|
||||||
.ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).append("\n"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
@@ -106,7 +102,7 @@ public class NarrativeAssistFieldsUseCase {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String blankToLabel(String value, String fallback) {
|
private static String blankToLabel(String value) {
|
||||||
return value == null || value.isBlank() ? fallback : value;
|
return value == null || value.isBlank() ? "(sans titre)" : value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.loremind.application.generationcontext;
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.structure.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.bestiary.Character;
|
||||||
import com.loremind.domain.campaigncontext.Npc;
|
import com.loremind.domain.campaigncontext.bestiary.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.structure.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
@@ -28,6 +28,9 @@ import java.util.Map;
|
|||||||
@Component
|
@Component
|
||||||
public class NarrativeEntityContextBuilder {
|
public class NarrativeEntityContextBuilder {
|
||||||
|
|
||||||
|
/** Longueur max d'une valeur de stat dans le résumé d'ennemi lié (contexte focus compact). */
|
||||||
|
private static final int STAT_VALUE_MAX_LEN = 100;
|
||||||
|
|
||||||
private final ArcRepository arcRepository;
|
private final ArcRepository arcRepository;
|
||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
@@ -144,14 +147,14 @@ public class NarrativeEntityContextBuilder {
|
|||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
for (String enemyId : s.getEnemyIds()) {
|
for (String enemyId : s.getEnemyIds()) {
|
||||||
enemyRepository.findById(enemyId).ifPresent(e -> {
|
enemyRepository.findById(enemyId).ifPresent(e -> {
|
||||||
if (sb.length() > 0) sb.append("\n");
|
if (!sb.isEmpty()) sb.append("\n");
|
||||||
sb.append("- ").append(e.getName());
|
sb.append("- ").append(e.getName());
|
||||||
if (e.getLevel() != null && !e.getLevel().isBlank()) {
|
if (e.getLevel() != null && !e.getLevel().isBlank()) {
|
||||||
sb.append(" (").append(e.getLevel().trim()).append(")");
|
sb.append(" (").append(e.getLevel().trim()).append(")");
|
||||||
}
|
}
|
||||||
String stats = e.getValues().entrySet().stream()
|
String stats = e.getValues().entrySet().stream()
|
||||||
.filter(en -> en.getValue() != null && !en.getValue().isBlank())
|
.filter(en -> en.getValue() != null && !en.getValue().isBlank())
|
||||||
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100))
|
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim()))
|
||||||
.collect(java.util.stream.Collectors.joining(" ; "));
|
.collect(java.util.stream.Collectors.joining(" ; "));
|
||||||
if (!stats.isEmpty()) sb.append(" — ").append(stats);
|
if (!stats.isEmpty()) sb.append(" — ").append(stats);
|
||||||
});
|
});
|
||||||
@@ -159,8 +162,8 @@ public class NarrativeEntityContextBuilder {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String truncate(String value, int maxLen) {
|
private static String truncate(String value) {
|
||||||
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "…";
|
return value.length() <= STAT_VALUE_MAX_LEN ? value : value.substring(0, STAT_VALUE_MAX_LEN - 1).stripTrailing() + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromCharacter(Character c) {
|
private NarrativeEntityContext fromCharacter(Character c) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.loremind.application.generationcontext;
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.structure.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.structure.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
@@ -29,22 +29,25 @@ public class NarrativeFieldCatalog {
|
|||||||
public record Snapshot(String entityType, String title,
|
public record Snapshot(String entityType, String title,
|
||||||
LinkedHashMap<String, String> current, List<FieldDef> defs) {}
|
LinkedHashMap<String, String> current, List<FieldDef> defs) {}
|
||||||
|
|
||||||
|
private static final String FIELD_DESCRIPTION = "description";
|
||||||
|
private static final String FIELD_GM_NOTES = "gmNotes";
|
||||||
|
|
||||||
private static final List<FieldDef> ARC_DEFS = List.of(
|
private static final List<FieldDef> ARC_DEFS = List.of(
|
||||||
new FieldDef("description", "description / synopsis de l'arc"),
|
new FieldDef(FIELD_DESCRIPTION, "description / synopsis de l'arc"),
|
||||||
new FieldDef("themes", "thèmes explorés"),
|
new FieldDef("themes", "thèmes explorés"),
|
||||||
new FieldDef("stakes", "enjeux globaux pour les personnages"),
|
new FieldDef("stakes", "enjeux globaux pour les personnages"),
|
||||||
new FieldDef("rewards", "récompenses et progression"),
|
new FieldDef("rewards", "récompenses et progression"),
|
||||||
new FieldDef("resolution", "dénouement prévu"),
|
new FieldDef("resolution", "dénouement prévu"),
|
||||||
new FieldDef("gmNotes", "notes privées du MJ"));
|
new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
|
||||||
|
|
||||||
private static final List<FieldDef> CHAPTER_DEFS = List.of(
|
private static final List<FieldDef> CHAPTER_DEFS = List.of(
|
||||||
new FieldDef("description", "synopsis du chapitre"),
|
new FieldDef(FIELD_DESCRIPTION, "synopsis du chapitre"),
|
||||||
new FieldDef("playerObjectives", "objectifs des joueurs"),
|
new FieldDef("playerObjectives", "objectifs des joueurs"),
|
||||||
new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"),
|
new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"),
|
||||||
new FieldDef("gmNotes", "notes privées du MJ"));
|
new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
|
||||||
|
|
||||||
private static final List<FieldDef> SCENE_DEFS = List.of(
|
private static final List<FieldDef> SCENE_DEFS = List.of(
|
||||||
new FieldDef("description", "description courte de la scène"),
|
new FieldDef(FIELD_DESCRIPTION, "description courte de la scène"),
|
||||||
new FieldDef("location", "lieu où se déroule la scène"),
|
new FieldDef("location", "lieu où se déroule la scène"),
|
||||||
new FieldDef("timing", "moment / temporalité"),
|
new FieldDef("timing", "moment / temporalité"),
|
||||||
new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"),
|
new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"),
|
||||||
@@ -80,12 +83,12 @@ public class NarrativeFieldCatalog {
|
|||||||
Arc a = arcRepository.findById(id)
|
Arc a = arcRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
|
||||||
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
|
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
|
||||||
cur.put("description", nz(a.getDescription()));
|
cur.put(FIELD_DESCRIPTION, nz(a.getDescription()));
|
||||||
cur.put("themes", nz(a.getThemes()));
|
cur.put("themes", nz(a.getThemes()));
|
||||||
cur.put("stakes", nz(a.getStakes()));
|
cur.put("stakes", nz(a.getStakes()));
|
||||||
cur.put("rewards", nz(a.getRewards()));
|
cur.put("rewards", nz(a.getRewards()));
|
||||||
cur.put("resolution", nz(a.getResolution()));
|
cur.put("resolution", nz(a.getResolution()));
|
||||||
cur.put("gmNotes", nz(a.getGmNotes()));
|
cur.put(FIELD_GM_NOTES, nz(a.getGmNotes()));
|
||||||
return new Snapshot("arc", a.getName(), cur, ARC_DEFS);
|
return new Snapshot("arc", a.getName(), cur, ARC_DEFS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,10 +96,10 @@ public class NarrativeFieldCatalog {
|
|||||||
Chapter c = chapterRepository.findById(id)
|
Chapter c = chapterRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
|
||||||
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
|
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
|
||||||
cur.put("description", nz(c.getDescription()));
|
cur.put(FIELD_DESCRIPTION, nz(c.getDescription()));
|
||||||
cur.put("playerObjectives", nz(c.getPlayerObjectives()));
|
cur.put("playerObjectives", nz(c.getPlayerObjectives()));
|
||||||
cur.put("narrativeStakes", nz(c.getNarrativeStakes()));
|
cur.put("narrativeStakes", nz(c.getNarrativeStakes()));
|
||||||
cur.put("gmNotes", nz(c.getGmNotes()));
|
cur.put(FIELD_GM_NOTES, nz(c.getGmNotes()));
|
||||||
return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS);
|
return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +107,7 @@ public class NarrativeFieldCatalog {
|
|||||||
Scene s = sceneRepository.findById(id)
|
Scene s = sceneRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
|
||||||
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
|
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
|
||||||
cur.put("description", nz(s.getDescription()));
|
cur.put(FIELD_DESCRIPTION, nz(s.getDescription()));
|
||||||
cur.put("location", nz(s.getLocation()));
|
cur.put("location", nz(s.getLocation()));
|
||||||
cur.put("timing", nz(s.getTiming()));
|
cur.put("timing", nz(s.getTiming()));
|
||||||
cur.put("atmosphere", nz(s.getAtmosphere()));
|
cur.put("atmosphere", nz(s.getAtmosphere()));
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.application.generationcontext;
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
|
import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
|
||||||
import com.loremind.domain.campaigncontext.ProgressionStatus;
|
import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
|
||||||
import com.loremind.domain.campaigncontext.Quest;
|
import com.loremind.domain.campaigncontext.quest.Quest;
|
||||||
import com.loremind.domain.campaigncontext.QuestStatus;
|
import com.loremind.domain.campaigncontext.quest.QuestStatus;
|
||||||
import com.loremind.domain.campaigncontext.ports.QuestRepository;
|
import com.loremind.domain.campaigncontext.ports.QuestRepository;
|
||||||
import com.loremind.domain.generationcontext.SessionContext;
|
import com.loremind.domain.generationcontext.SessionContext;
|
||||||
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
|
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
|
||||||
@@ -26,7 +26,6 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
|
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
|
||||||
@@ -62,10 +61,6 @@ public class SessionStructuralContextBuilder {
|
|||||||
this.questProgressionRepository = questProgressionRepository;
|
this.questProgressionRepository = questProgressionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<SessionContext> buildOptional(String sessionId) {
|
|
||||||
return sessionRepository.findById(sessionId).map(this::toContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
public SessionContext build(String sessionId) {
|
public SessionContext build(String sessionId) {
|
||||||
Session session = sessionRepository.findById(sessionId)
|
Session session = sessionRepository.findById(sessionId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
||||||
@@ -97,7 +92,7 @@ public class SessionStructuralContextBuilder {
|
|||||||
|
|
||||||
return kept.stream()
|
return kept.stream()
|
||||||
.map(e -> toSummary(e, null))
|
.map(e -> toSummary(e, null))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,6 +204,6 @@ public class SessionStructuralContextBuilder {
|
|||||||
.filter(Map.Entry::getValue)
|
.filter(Map.Entry::getValue)
|
||||||
.map(Map.Entry::getKey)
|
.map(Map.Entry::getKey)
|
||||||
.sorted()
|
.sorted()
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import com.loremind.domain.gamesystemcontext.GenerationIntent;
|
|||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatRequest;
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
import com.loremind.domain.generationcontext.ChatUsage;
|
import com.loremind.domain.generationcontext.ChatStreamCallbacks;
|
||||||
import com.loremind.domain.generationcontext.GameSystemContext;
|
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
@@ -15,7 +15,6 @@ import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
|
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
|
||||||
@@ -72,10 +71,7 @@ public class StreamChatForCampaignUseCase {
|
|||||||
String entityType,
|
String entityType,
|
||||||
String entityId,
|
String entityId,
|
||||||
List<ChatMessage> messages,
|
List<ChatMessage> messages,
|
||||||
Consumer<ChatUsage> onUsage,
|
ChatStreamCallbacks callbacks) {
|
||||||
Consumer<String> onToken,
|
|
||||||
Runnable onComplete,
|
|
||||||
Consumer<Throwable> onError) {
|
|
||||||
|
|
||||||
Campaign campaign = campaignRepository.findById(campaignId)
|
Campaign campaign = campaignRepository.findById(campaignId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException(
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
@@ -94,7 +90,7 @@ public class StreamChatForCampaignUseCase {
|
|||||||
.gameSystemContext(gameSystemContext)
|
.gameSystemContext(gameSystemContext)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
|
aiChatProvider.streamChat(request, callbacks);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.loremind.application.generationcontext;
|
|||||||
|
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatRequest;
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
import com.loremind.domain.generationcontext.ChatUsage;
|
import com.loremind.domain.generationcontext.ChatStreamCallbacks;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.PageContext;
|
import com.loremind.domain.generationcontext.PageContext;
|
||||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Service;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
|
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
|
||||||
@@ -61,10 +60,7 @@ public class StreamChatForLoreUseCase {
|
|||||||
String loreId,
|
String loreId,
|
||||||
String pageId,
|
String pageId,
|
||||||
List<ChatMessage> messages,
|
List<ChatMessage> messages,
|
||||||
Consumer<ChatUsage> onUsage,
|
ChatStreamCallbacks callbacks) {
|
||||||
Consumer<String> onToken,
|
|
||||||
Runnable onComplete,
|
|
||||||
Consumer<Throwable> onError) {
|
|
||||||
|
|
||||||
LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
|
LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
|
||||||
PageContext pageContext = (pageId == null || pageId.isBlank())
|
PageContext pageContext = (pageId == null || pageId.isBlank())
|
||||||
@@ -77,7 +73,7 @@ public class StreamChatForLoreUseCase {
|
|||||||
.pageContext(pageContext)
|
.pageContext(pageContext)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
|
aiChatProvider.streamChat(request, callbacks);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import com.loremind.domain.gamesystemcontext.GenerationIntent;
|
|||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatRequest;
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
import com.loremind.domain.generationcontext.ChatUsage;
|
import com.loremind.domain.generationcontext.ChatStreamCallbacks;
|
||||||
import com.loremind.domain.generationcontext.GameSystemContext;
|
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.SessionContext;
|
import com.loremind.domain.generationcontext.SessionContext;
|
||||||
@@ -19,7 +19,6 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use case applicatif : chat IA pendant une Session de jeu.
|
* Use case applicatif : chat IA pendant une Session de jeu.
|
||||||
@@ -71,10 +70,7 @@ public class StreamChatForSessionUseCase {
|
|||||||
public void execute(
|
public void execute(
|
||||||
String sessionId,
|
String sessionId,
|
||||||
List<ChatMessage> messages,
|
List<ChatMessage> messages,
|
||||||
Consumer<ChatUsage> onUsage,
|
ChatStreamCallbacks callbacks) {
|
||||||
Consumer<String> onToken,
|
|
||||||
Runnable onComplete,
|
|
||||||
Consumer<Throwable> onError) {
|
|
||||||
|
|
||||||
Session session = sessionRepository.findById(sessionId)
|
Session session = sessionRepository.findById(sessionId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
||||||
@@ -102,7 +98,7 @@ public class StreamChatForSessionUseCase {
|
|||||||
.sessionContext(sessionContext)
|
.sessionContext(sessionContext)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
|
aiChatProvider.streamChat(request, callbacks);
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
|
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -60,7 +61,7 @@ public class ImageService {
|
|||||||
.contentType(contentType)
|
.contentType(contentType)
|
||||||
.sizeBytes(sizeBytes)
|
.sizeBytes(sizeBytes)
|
||||||
.storageKey(storageKey)
|
.storageKey(storageKey)
|
||||||
.uploadedAt(LocalDateTime.now())
|
.uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
|
||||||
.build();
|
.build();
|
||||||
return imageRepository.save(image);
|
return imageRepository.save(image);
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ public class LicenseService {
|
|||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.jwtVerifier = jwtVerifier;
|
this.jwtVerifier = jwtVerifier;
|
||||||
this.relay = relay;
|
this.relay = relay;
|
||||||
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
|
this.gracePeriodSeconds = gracePeriodDays * 86_400L;
|
||||||
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
|
this.refreshBeforeExpirySeconds = refreshBeforeExpiryDays * 86_400L;
|
||||||
this.licensingEnabled = licensingEnabled;
|
this.licensingEnabled = licensingEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,9 +134,9 @@ public class LicenseService {
|
|||||||
* Etat courant de la licence pour exposition UI / decision technique.
|
* Etat courant de la licence pour exposition UI / decision technique.
|
||||||
*/
|
*/
|
||||||
public LicenseSnapshot getCurrentSnapshot() {
|
public LicenseSnapshot getCurrentSnapshot() {
|
||||||
Optional<License> opt = repository.findCurrent();
|
return repository.findCurrent()
|
||||||
if (opt.isEmpty()) return LicenseSnapshot.none();
|
.map(l -> snapshotOf(l, Instant.now()))
|
||||||
return snapshotOf(opt.get(), Instant.now());
|
.orElseGet(LicenseSnapshot::none);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,31 +161,32 @@ public class LicenseService {
|
|||||||
/**
|
/**
|
||||||
* Tente un refresh si la licence est proche de l'expiration. Idempotent.
|
* Tente un refresh si la licence est proche de l'expiration. Idempotent.
|
||||||
* Appele par le daemon planifie + manuellement via l'UI ("Reessayer").
|
* Appele par le daemon planifie + manuellement via l'UI ("Reessayer").
|
||||||
*
|
|
||||||
* @return true si un refresh a ete tente (avec ou sans succes)
|
|
||||||
*/
|
*/
|
||||||
public boolean refreshIfNeeded() {
|
public void refreshIfNeeded() {
|
||||||
Optional<License> opt = repository.findCurrent();
|
Optional<License> opt = repository.findCurrent();
|
||||||
if (opt.isEmpty()) return false;
|
if (opt.isEmpty()) return;
|
||||||
License current = opt.get();
|
License current = opt.get();
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
|
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
|
||||||
if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
|
if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
return doRefresh(current, now);
|
doRefresh(current, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force un refresh immediat (bouton UI "Reessayer maintenant").
|
* Force un refresh immediat (bouton UI "Reessayer maintenant").
|
||||||
*/
|
*/
|
||||||
public boolean forceRefresh() {
|
public void forceRefresh() {
|
||||||
return repository.findCurrent()
|
repository.findCurrent().ifPresent(license -> doRefresh(license, Instant.now()));
|
||||||
.map(license -> doRefresh(license, Instant.now()))
|
|
||||||
.orElse(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean doRefresh(License current, Instant now) {
|
/**
|
||||||
|
* Tente le refresh aupres du relais et persiste le resultat (succes ou echec) sur
|
||||||
|
* la licence. Le succes/echec est lu depuis {@code lastRefreshSucceeded}, pas
|
||||||
|
* depuis un retour de methode : le tentative est deja actee cote persistance.
|
||||||
|
*/
|
||||||
|
private void doRefresh(License current, Instant now) {
|
||||||
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
|
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
|
||||||
try {
|
try {
|
||||||
String newJwt = relay.refreshToken(current.getRawJwt());
|
String newJwt = relay.refreshToken(current.getRawJwt());
|
||||||
@@ -199,7 +200,6 @@ public class LicenseService {
|
|||||||
current.setLastRefreshSucceeded(true);
|
current.setLastRefreshSucceeded(true);
|
||||||
repository.save(current);
|
repository.save(current);
|
||||||
log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
|
log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
|
||||||
return true;
|
|
||||||
} catch (LicenseRelay.RelayException e) {
|
} catch (LicenseRelay.RelayException e) {
|
||||||
current.setLastRefreshAttemptAt(now);
|
current.setLastRefreshAttemptAt(now);
|
||||||
current.setLastRefreshSucceeded(false);
|
current.setLastRefreshSucceeded(false);
|
||||||
@@ -209,13 +209,11 @@ public class LicenseService {
|
|||||||
} else {
|
} else {
|
||||||
log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
|
log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
} catch (JwtVerifier.JwtVerificationException e) {
|
} catch (JwtVerifier.JwtVerificationException e) {
|
||||||
current.setLastRefreshAttemptAt(now);
|
current.setLastRefreshAttemptAt(now);
|
||||||
current.setLastRefreshSucceeded(false);
|
current.setLastRefreshSucceeded(false);
|
||||||
repository.save(current);
|
repository.save(current);
|
||||||
log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
|
log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class LoreNodeService {
|
|||||||
public void reorderNodes(String parentId, List<String> orderedIds) {
|
public void reorderNodes(String parentId, List<String> orderedIds) {
|
||||||
String parent = (parentId == null || parentId.isBlank()) ? null : parentId;
|
String parent = (parentId == null || parentId.isBlank()) ? null : parentId;
|
||||||
ReorderSupport.reorder(orderedIds,
|
ReorderSupport.reorder(orderedIds,
|
||||||
id -> loreNodeRepository.findById(id).orElse(null),
|
loreNodeRepository::findById,
|
||||||
(node, i) -> {
|
(node, i) -> {
|
||||||
if (parent == null || !isSelfOrDescendant(parent, node.getId())) {
|
if (parent == null || !isSelfOrDescendant(parent, node.getId())) {
|
||||||
node.setParentId(parent);
|
node.setParentId(parent);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service d'application pour le contexte Lore.
|
* Service d'application pour le contexte Lore.
|
||||||
@@ -72,7 +71,7 @@ public class LoreService {
|
|||||||
public List<Lore> getAllLores() {
|
public List<Lore> getAllLores() {
|
||||||
return loreRepository.findAll().stream()
|
return loreRepository.findAll().stream()
|
||||||
.map(this::withCounts)
|
.map(this::withCounts)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import com.loremind.domain.lorecontext.Page;
|
|||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.loremind.domain.shared.CollectionUtils;
|
|
||||||
import com.loremind.domain.shared.ReorderSupport;
|
import com.loremind.domain.shared.ReorderSupport;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -84,14 +83,7 @@ public class PageService {
|
|||||||
|
|
||||||
existing.setTitle(changes.getTitle());
|
existing.setTitle(changes.getTitle());
|
||||||
existing.setNodeId(changes.getNodeId());
|
existing.setNodeId(changes.getNodeId());
|
||||||
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
|
existing.applyEditableContent(changes);
|
||||||
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
|
|
||||||
existing.setImageFraming(CollectionUtils.copyMap(changes.getImageFraming()));
|
|
||||||
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
|
|
||||||
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
|
|
||||||
existing.setNotes(changes.getNotes());
|
|
||||||
existing.setTags(CollectionUtils.copyList(changes.getTags()));
|
|
||||||
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
|
|
||||||
return pageRepository.save(existing);
|
return pageRepository.save(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +98,7 @@ public class PageService {
|
|||||||
@org.springframework.transaction.annotation.Transactional
|
@org.springframework.transaction.annotation.Transactional
|
||||||
public void reorderPages(String nodeId, List<String> orderedIds) {
|
public void reorderPages(String nodeId, List<String> orderedIds) {
|
||||||
ReorderSupport.reorder(orderedIds,
|
ReorderSupport.reorder(orderedIds,
|
||||||
id -> pageRepository.findById(id).orElse(null),
|
pageRepository::findById,
|
||||||
(page, i) -> {
|
(page, i) -> {
|
||||||
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
|
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
|
||||||
page.setOrder(i);
|
page.setOrder(i);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ public class SessionEntryService {
|
|||||||
}
|
}
|
||||||
validateContent(data.content());
|
validateContent(data.content());
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
SessionEntry entry = SessionEntry.builder()
|
SessionEntry entry = SessionEntry.builder()
|
||||||
.sessionId(sessionId)
|
.sessionId(sessionId)
|
||||||
.type(data.type() != null ? data.type() : EntryType.NOTE)
|
.type(data.type() != null ? data.type() : EntryType.NOTE)
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import com.loremind.application.campaigncontext.CampaignReadinessAssessment;
|
|||||||
import com.loremind.application.campaigncontext.CampaignReadinessService;
|
import com.loremind.application.campaigncontext.CampaignReadinessService;
|
||||||
import com.loremind.application.campaigncontext.QuestStatusEnricher;
|
import com.loremind.application.campaigncontext.QuestStatusEnricher;
|
||||||
import com.loremind.application.campaigncontext.ReadinessGap;
|
import com.loremind.application.campaigncontext.ReadinessGap;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Quest;
|
import com.loremind.domain.campaigncontext.quest.Quest;
|
||||||
import com.loremind.domain.campaigncontext.QuestNodeRef;
|
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
|
||||||
import com.loremind.domain.campaigncontext.QuestStatus;
|
import com.loremind.domain.campaigncontext.quest.QuestStatus;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.QuestRepository;
|
import com.loremind.domain.campaigncontext.ports.QuestRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
@@ -20,7 +20,6 @@ import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
|||||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
@@ -111,7 +110,7 @@ public class SessionPrepService {
|
|||||||
} else {
|
} else {
|
||||||
focused = assessment.gaps().stream()
|
focused = assessment.gaps().stream()
|
||||||
.filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds))
|
.filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
int otherGapCount = assessment.gaps().size() - focused.size();
|
int otherGapCount = assessment.gaps().size() - focused.size();
|
||||||
|
|
||||||
@@ -122,7 +121,7 @@ public class SessionPrepService {
|
|||||||
.filter(c -> c.getFilled() > 0)
|
.filter(c -> c.getFilled() > 0)
|
||||||
.map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(),
|
.map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(),
|
||||||
c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null))
|
c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
|
|
||||||
return new SessionPrepReport(
|
return new SessionPrepReport(
|
||||||
playthroughId,
|
playthroughId,
|
||||||
@@ -173,7 +172,7 @@ public class SessionPrepService {
|
|||||||
List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId);
|
List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId);
|
||||||
return sessions.stream()
|
return sessions.stream()
|
||||||
.max(Comparator.comparing(Session::getStartedAt,
|
.max(Comparator.comparing(Session::getStartedAt,
|
||||||
Comparator.nullsFirst(Comparator.<LocalDateTime>naturalOrder())))
|
Comparator.nullsFirst(Comparator.naturalOrder())))
|
||||||
.map(s -> new SessionPrepReport.LastSessionInfo(
|
.map(s -> new SessionPrepReport.LastSessionInfo(
|
||||||
s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive()))
|
s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive()))
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
@@ -183,13 +182,13 @@ public class SessionPrepService {
|
|||||||
return quests.stream()
|
return quests.stream()
|
||||||
.filter(q -> statusById.get(q.getId()) == wanted)
|
.filter(q -> statusById.get(q.getId()) == wanted)
|
||||||
.sorted(Comparator.comparingInt(Quest::getOrder))
|
.sorted(Comparator.comparingInt(Quest::getOrder))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) {
|
private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) {
|
||||||
return quests.stream()
|
return quests.stream()
|
||||||
.map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon()))
|
.map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon()))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Quest> concat(List<Quest> a, List<Quest> b) {
|
private static List<Quest> concat(List<Quest> a, List<Quest> b) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -23,6 +24,7 @@ import java.util.Optional;
|
|||||||
public class SessionService {
|
public class SessionService {
|
||||||
|
|
||||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
|
private static final String SESSION_NOT_FOUND = "Session introuvable : ";
|
||||||
|
|
||||||
private final SessionRepository sessionRepository;
|
private final SessionRepository sessionRepository;
|
||||||
private final SessionEntryRepository entryRepository;
|
private final SessionEntryRepository entryRepository;
|
||||||
@@ -57,7 +59,7 @@ public class SessionService {
|
|||||||
"). Termine-la avant d'en lancer une nouvelle.");
|
"). Termine-la avant d'en lancer une nouvelle.");
|
||||||
});
|
});
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
Session session = Session.builder()
|
Session session = Session.builder()
|
||||||
.name(generateDefaultName(now))
|
.name(generateDefaultName(now))
|
||||||
.playthroughId(playthroughId)
|
.playthroughId(playthroughId)
|
||||||
@@ -68,11 +70,11 @@ public class SessionService {
|
|||||||
|
|
||||||
public Session endSession(String id) {
|
public Session endSession(String id) {
|
||||||
Session session = sessionRepository.findById(id)
|
Session session = sessionRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
|
.orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
|
||||||
if (!session.isActive()) {
|
if (!session.isActive()) {
|
||||||
throw new IllegalStateException("Cette session est déjà terminée.");
|
throw new IllegalStateException("Cette session est déjà terminée.");
|
||||||
}
|
}
|
||||||
session.setEndedAt(LocalDateTime.now());
|
session.setEndedAt(LocalDateTime.now(ZoneId.systemDefault()));
|
||||||
Session saved = sessionRepository.save(session);
|
Session saved = sessionRepository.save(session);
|
||||||
// Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie.
|
// Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie.
|
||||||
clockService.onSessionEnded(saved.getPlaythroughId());
|
clockService.onSessionEnded(saved.getPlaythroughId());
|
||||||
@@ -86,7 +88,7 @@ public class SessionService {
|
|||||||
*/
|
*/
|
||||||
public Session setCurrentScene(String id, String sceneId) {
|
public Session setCurrentScene(String id, String sceneId) {
|
||||||
Session session = sessionRepository.findById(id)
|
Session session = sessionRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
|
.orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
|
||||||
session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null);
|
session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null);
|
||||||
return sessionRepository.save(session);
|
return sessionRepository.save(session);
|
||||||
}
|
}
|
||||||
@@ -96,7 +98,7 @@ public class SessionService {
|
|||||||
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
|
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
|
||||||
}
|
}
|
||||||
Session session = sessionRepository.findById(id)
|
Session session = sessionRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
|
.orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
|
||||||
session.setName(newName.trim());
|
session.setName(newName.trim());
|
||||||
return sessionRepository.save(session);
|
return sessionRepository.save(session);
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ public class SessionService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void deleteSession(String id) {
|
public void deleteSession(String id) {
|
||||||
if (!sessionRepository.existsById(id)) {
|
if (!sessionRepository.existsById(id)) {
|
||||||
throw new IllegalArgumentException("Session introuvable : " + id);
|
throw new IllegalArgumentException(SESSION_NOT_FOUND + id);
|
||||||
}
|
}
|
||||||
entryRepository.deleteBySessionId(id);
|
entryRepository.deleteBySessionId(id);
|
||||||
sessionRepository.deleteById(id);
|
sessionRepository.deleteById(id);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.bestiary;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.bestiary;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.bestiary;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.generation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.
|
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.generation;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -11,12 +11,13 @@ import java.util.List;
|
|||||||
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
|
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
|
* {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply).
|
||||||
* campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le
|
* <p>
|
||||||
* recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer.
|
* {@code existingId} (nullable, porté aussi par Chapter/SceneProposal) : 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 = à créer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
|
|
||||||
public record ArcProposal(
|
public record ArcProposal(
|
||||||
String name, String description, String type,
|
String name, String description, String type,
|
||||||
List<ChapterProposal> chapters, String existingId) {
|
List<ChapterProposal> chapters, String existingId) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.generation;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.generation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proposition IA pour UN champ textuel d'une entité (Pilier A — co-création).
|
* Proposition IA pour UN champ textuel d'une entité (Pilier A — co-création).
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.generation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ébauche de scène proposée par l'IA (Pilier A — capacité « create »). Value Object
|
* Ébauche de scène proposée par l'IA (Pilier A — capacité « create »). Value Object
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.generation;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.itemcatalog;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.itemcatalog;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.notebook;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.notebook;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.notebook;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.structure.Arc;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProgress;
|
import com.loremind.domain.campaigncontext.generation.CampaignImportProgress;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal;
|
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal;
|
||||||
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.structure.Chapter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.bestiary.Character;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Enemy;
|
import com.loremind.domain.campaigncontext.bestiary.Enemy;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.CatalogItem;
|
import com.loremind.domain.campaigncontext.itemcatalog.CatalogItem;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.ItemCatalog;
|
import com.loremind.domain.campaigncontext.itemcatalog.ItemCatalog;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|||||||
@@ -16,11 +16,22 @@ public interface NotebookChatStreamer {
|
|||||||
record Progress(int current, int total) {}
|
record Progress(int current, int total) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil
|
* Callbacks du stream, invoqués au fil de l'eau : {@code onSourcesJson} UNE fois
|
||||||
* de l'eau : {@code onSourcesJson} UNE fois avant le premier token (JSON brut des
|
* avant le premier token (JSON brut des passages utilisés — transparence UI),
|
||||||
* passages utilisés — transparence UI), {@code onToken} par fragment,
|
* {@code onToken} par fragment, {@code onProgress} (mode approfondi uniquement)
|
||||||
* {@code onProgress} (mode approfondi uniquement) pendant la lecture du document,
|
* pendant la lecture du document, {@code onDone} à la fin, {@code onError} en
|
||||||
* {@code onDone} à la fin, {@code onError} en cas d'échec.
|
* cas d'échec. Regroupés en un seul objet (Argument Object) : ils voyagent
|
||||||
|
* toujours ensemble entre ce port, son adaptateur et le controller SSE.
|
||||||
|
*/
|
||||||
|
record Callbacks(
|
||||||
|
Consumer<String> onSourcesJson,
|
||||||
|
Consumer<String> onToken,
|
||||||
|
Consumer<Progress> onProgress,
|
||||||
|
Runnable onDone,
|
||||||
|
Consumer<Throwable> onError) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streame la réponse ancrée sur les sources.
|
||||||
*
|
*
|
||||||
* @param deep true = analyse approfondie (map-reduce sur tout le document) ;
|
* @param deep true = analyse approfondie (map-reduce sur tout le document) ;
|
||||||
* false = chat RAG (top-k).
|
* false = chat RAG (top-k).
|
||||||
@@ -30,9 +41,5 @@ public interface NotebookChatStreamer {
|
|||||||
List<Msg> messages,
|
List<Msg> messages,
|
||||||
String context,
|
String context,
|
||||||
boolean deep,
|
boolean deep,
|
||||||
Consumer<String> onSourcesJson,
|
Callbacks callbacks);
|
||||||
Consumer<String> onToken,
|
|
||||||
Consumer<Progress> onProgress,
|
|
||||||
Runnable onDone,
|
|
||||||
Consumer<Throwable> onError);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Notebook;
|
import com.loremind.domain.campaigncontext.notebook.Notebook;
|
||||||
import com.loremind.domain.campaigncontext.NotebookMessage;
|
import com.loremind.domain.campaigncontext.notebook.NotebookMessage;
|
||||||
import com.loremind.domain.campaigncontext.NotebookSource;
|
import com.loremind.domain.campaigncontext.notebook.NotebookSource;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Npc;
|
import com.loremind.domain.campaigncontext.bestiary.Npc;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Quest;
|
import com.loremind.domain.campaigncontext.quest.Quest;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.RandomTableEntry;
|
import com.loremind.domain.campaigncontext.randomtable.RandomTableEntry;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.RandomTable;
|
import com.loremind.domain.campaigncontext.randomtable.RandomTable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.SceneDraft;
|
import com.loremind.domain.campaigncontext.generation.SceneDraft;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.structure.Scene;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports.exceptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible,
|
* Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports.exceptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Échec de génération IA d'un catalogue d'objets (Brain injoignable, erreur du
|
* Échec de génération IA d'un catalogue d'objets (Brain injoignable, erreur du
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports.exceptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erreur de génération lors de l'étoffage IA d'une entité narrative (Brain injoignable,
|
* Erreur de génération lors de l'étoffage IA d'une entité narrative (Brain injoignable,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports.exceptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle…).
|
* Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle…).
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext.ports;
|
package com.loremind.domain.campaigncontext.ports.exceptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du
|
* Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.quest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}.
|
* Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}.
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.quest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
|
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
|
||||||
*
|
|
||||||
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
|
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
|
||||||
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
|
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
|
||||||
* {@link PrerequisiteEvaluator}.
|
* {@link PrerequisiteEvaluator}.
|
||||||
*
|
|
||||||
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
|
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
|
||||||
* (pas de OR pour le moment).
|
* (pas de OR pour le moment).
|
||||||
*/
|
*/
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.quest;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -7,7 +7,6 @@ import java.util.Set;
|
|||||||
/**
|
/**
|
||||||
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
|
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
|
||||||
* et en dérive le {@link QuestStatus} effectif.
|
* et en dérive le {@link QuestStatus} effectif.
|
||||||
*
|
|
||||||
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
|
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
|
||||||
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
|
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
|
||||||
* compile-time est compensée par le throw final qui fait crasher tout nouvel
|
* compile-time est compensée par le throw final qui fait crasher tout nouvel
|
||||||
@@ -51,15 +50,12 @@ public final class PrerequisiteEvaluator {
|
|||||||
List<Prerequisite> prerequisites,
|
List<Prerequisite> prerequisites,
|
||||||
EvaluationContext ctx
|
EvaluationContext ctx
|
||||||
) {
|
) {
|
||||||
switch (progression) {
|
return switch (progression) {
|
||||||
case COMPLETED: return QuestStatus.COMPLETED;
|
case COMPLETED -> QuestStatus.COMPLETED;
|
||||||
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
|
case IN_PROGRESS -> QuestStatus.IN_PROGRESS;
|
||||||
case NOT_STARTED:
|
case NOT_STARTED -> areAllSatisfied(prerequisites, ctx)
|
||||||
return areAllSatisfied(prerequisites, ctx)
|
|
||||||
? QuestStatus.AVAILABLE
|
? QuestStatus.AVAILABLE
|
||||||
: QuestStatus.LOCKED;
|
: QuestStatus.LOCKED;
|
||||||
default:
|
};
|
||||||
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.quest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
|
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
|
||||||
*
|
|
||||||
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
|
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
|
||||||
* selon les prérequis — voir {@link QuestStatus}.
|
* selon les prérequis — voir {@link QuestStatus}.
|
||||||
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
|
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
|
||||||
* COMPLETED : marquée terminée par le MJ.
|
* COMPLETED : marquée terminée par le MJ.
|
||||||
*
|
|
||||||
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
|
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
|
||||||
*/
|
*/
|
||||||
public enum ProgressionStatus {
|
public enum ProgressionStatus {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.quest;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.quest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value Object : lien faible d'une {@link Quest} vers un nœud narratif
|
* Value Object : lien faible d'une {@link Quest} vers un nœud narratif
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.quest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
|
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
|
||||||
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
|
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
|
||||||
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
|
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
|
||||||
*
|
|
||||||
* Table de vérité :
|
* Table de vérité :
|
||||||
* NOT_STARTED + prérequis non remplis -> LOCKED
|
* NOT_STARTED + prérequis non remplis -> LOCKED
|
||||||
* NOT_STARTED + prérequis remplis -> AVAILABLE
|
* NOT_STARTED + prérequis remplis -> AVAILABLE
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.randomtable;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.randomtable;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.readiness;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type de l'entité de scénario ciblée par un manque de readiness. Le front s'en
|
* Type de l'entité de scénario ciblée par un manque de readiness. Le front s'en
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.readiness;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gravité d'un manque de préparation détecté par le guidage (Pilier B).
|
* Gravité d'un manque de préparation détecté par le guidage (Pilier B).
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.readiness;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »).
|
* Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »).
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.structure;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.structure;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type structurel d'un Arc.
|
* Type structurel d'un Arc.
|
||||||
@@ -9,7 +9,6 @@ package com.loremind.domain.campaigncontext;
|
|||||||
* narration dans l'arbre (ses quêtes s'affichent sous « Quêtes ») ; dans
|
* narration dans l'arbre (ses quêtes s'affichent sous « Quêtes ») ; dans
|
||||||
* les exports (PDF / Foundry / backup) il apparaît sous son nom — ses
|
* les exports (PDF / Foundry / backup) il apparaît sous son nom — ses
|
||||||
* scènes sont du vrai contenu jouable.
|
* scènes sont du vrai contenu jouable.
|
||||||
*
|
|
||||||
* Value Object du domaine (Bounded Context : Campaign).
|
* Value Object du domaine (Bounded Context : Campaign).
|
||||||
*/
|
*/
|
||||||
public enum ArcType {
|
public enum ArcType {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.structure;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.structure;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type d'un lien narratif entre nœuds ({@link SceneBranch}) — Niveau 2.
|
* Type d'un lien narratif entre nœuds ({@link SceneBranch}) — Niveau 2.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.structure;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.structure;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sortie d'une pièce vers une autre pièce de la même Scene explorable.
|
* Sortie d'une pièce vers une autre pièce de la même Scene explorable.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
package com.loremind.domain.campaigncontext.structure;
|
||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user