diff --git a/brain/app/api/routers/generation.py b/brain/app/api/routers/generation.py
index d5df287..c09bc95 100644
--- a/brain/app/api/routers/generation.py
+++ b/brain/app/api/routers/generation.py
@@ -6,7 +6,12 @@ from pydantic import BaseModel, Field
from app.api.deps import get_generate_page_use_case, get_llm_provider
from app.application.generate_page import GeneratePageUseCase
+from app.application.llm_json import load_json_object
+from app.application.llm_retry import generate_with_retry
from app.application.prompts import conversation_title as title_prompts
+from app.application.prompts import narrative_fields as narrative_fields_prompts
+from app.application.prompts import scene_drafts as scene_drafts_prompts
+from app.application.prompts import session_recap as session_recap_prompts
from app.core.config import Settings, get_settings
from app.core.language import get_user_language
from app.domain.models import PageGenerationContext
@@ -131,3 +136,157 @@ async def summarize_conversation_title(
if not title:
title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"])
return SummarizeTitleResponseDTO(title=title)
+
+
+# --- Étoffer une entité narrative (Pilier A : co-MJ propose → l'humain valide) ----------
+
+
+class NarrativeFieldSpecDTO(BaseModel):
+ """Un champ autorisé : clé technique + libellé lisible (fourni par le Core)."""
+
+ key: str
+ label: str = Field(default="")
+
+
+class NarrativeFieldsRequestDTO(BaseModel):
+ """Contexte envoyé par le Core pour proposer des valeurs de champs (arc/chapitre/scène)."""
+
+ entity_type: str = Field(default="")
+ context: str = Field(default="")
+ instruction: str = Field(default="")
+ # Whitelist (clé + libellé) fournie par le Core, source de vérité.
+ fields: list[NarrativeFieldSpecDTO] = Field(default_factory=list)
+
+
+class NarrativeFieldsResponseDTO(BaseModel):
+ """Retour : une valeur proposée par clé (uniquement des clés autorisées, non vides)."""
+
+ fields: dict[str, str]
+
+
+@router.post("/generate/narrative-fields", response_model=NarrativeFieldsResponseDTO)
+async def generate_narrative_fields(
+ body: NarrativeFieldsRequestDTO,
+ llm: Annotated[LLMProvider, Depends(get_llm_provider)],
+ language: Annotated[str, Depends(get_user_language)],
+) -> NarrativeFieldsResponseDTO:
+ """Propose des valeurs pour ÉTOFFER une entité narrative (patch champ par champ, non appliqué).
+
+ Whitelist stricte : on ne retient que les clés autorisées, non vides. Un objet vide
+ est une réponse VALIDE (le modèle n'a rien de pertinent à proposer — l'entité est
+ peut-être déjà complète) ; seule une sortie non-JSON est une erreur.
+ """
+ allowed = {f.key for f in body.fields if f.key}
+ prompt = narrative_fields_prompts.narrative_fields_prompt(
+ body.entity_type, body.context, body.instruction,
+ [{"key": f.key, "label": f.label} for f in body.fields], language)
+ try:
+ raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
+ except LLMProviderError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ parsed, _ = load_json_object(raw)
+ if not isinstance(parsed, dict):
+ raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de champs exploitables.")
+
+ out: dict[str, str] = {}
+ raw_fields = parsed.get("fields")
+ if isinstance(raw_fields, dict):
+ for key, value in raw_fields.items():
+ if key not in allowed:
+ continue
+ if not isinstance(value, (str, int, float)):
+ continue
+ text = str(value).strip()
+ if text:
+ out[str(key)] = text
+ return NarrativeFieldsResponseDTO(fields=out)
+
+
+# --- Peupler un chapitre en scènes (Pilier A : capacité « create ») ----------
+
+
+class SceneDraftsRequestDTO(BaseModel):
+ """Contexte envoyé par le Core pour ébaucher des scènes d'un chapitre."""
+
+ context: str = Field(default="")
+ instruction: str = Field(default="")
+ count: int = Field(default=4)
+
+
+class SceneDraftDTO(BaseModel):
+ name: str
+ description: str = Field(default="")
+ playerNarration: str = Field(default="")
+
+
+class SceneDraftsResponseDTO(BaseModel):
+ scenes: list[SceneDraftDTO]
+
+
+@router.post("/generate/scene-drafts", response_model=SceneDraftsResponseDTO)
+async def generate_scene_drafts(
+ body: SceneDraftsRequestDTO,
+ llm: Annotated[LLMProvider, Depends(get_llm_provider)],
+ language: Annotated[str, Depends(get_user_language)],
+) -> SceneDraftsResponseDTO:
+ """Propose des ébauches de scènes pour un chapitre (non créées). Un titre par scène
+ est obligatoire ; on borne le nombre. Seule une sortie non-JSON est une erreur."""
+ n = max(1, min(8, body.count))
+ prompt = scene_drafts_prompts.scene_drafts_prompt(body.context, body.instruction, n, language)
+ try:
+ raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.8)
+ except LLMProviderError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ parsed, _ = load_json_object(raw)
+ if not isinstance(parsed, dict):
+ raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de scènes exploitables.")
+
+ scenes: list[SceneDraftDTO] = []
+ for s in (parsed.get("scenes") or [])[:n]:
+ if not isinstance(s, dict):
+ continue
+ name = str(s.get("name") or "").strip()
+ if not name:
+ continue
+ scenes.append(SceneDraftDTO(
+ name=name[:200],
+ description=str(s.get("description") or "").strip(),
+ playerNarration=str(s.get("playerNarration") or "").strip(),
+ ))
+ return SceneDraftsResponseDTO(scenes=scenes)
+
+
+# --- Récap « précédemment… » d'une séance (mode cockpit) ---------------------
+
+
+class SessionRecapRequestDTO(BaseModel):
+ """Journal chronologique de la séance précédente + méta courte."""
+
+ transcript: str
+ context: str = Field(default="")
+
+
+class SessionRecapResponseDTO(BaseModel):
+ recap: str
+
+
+@router.post("/generate/session-recap", response_model=SessionRecapResponseDTO)
+async def generate_session_recap(
+ body: SessionRecapRequestDTO,
+ llm: Annotated[LLMProvider, Depends(get_llm_provider)],
+ language: Annotated[str, Depends(get_user_language)],
+) -> SessionRecapResponseDTO:
+ """Rédige le récap « Précédemment… » à lire aux joueurs (texte libre, pas de JSON)."""
+ if not body.transcript.strip():
+ raise HTTPException(status_code=422, detail="Journal vide : rien à résumer.")
+ prompt = session_recap_prompts.session_recap_prompt(body.transcript, body.context, language)
+ try:
+ raw = await generate_with_retry(llm, prompt, temperature=0.7)
+ except LLMProviderError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ recap = raw.strip()
+ if not recap:
+ raise HTTPException(status_code=502, detail="Le modèle n'a renvoyé aucun récit.")
+ return SessionRecapResponseDTO(recap=recap)
diff --git a/brain/app/application/prompts/narrative_fields.py b/brain/app/application/prompts/narrative_fields.py
new file mode 100644
index 0000000..eb5cf63
--- /dev/null
+++ b/brain/app/application/prompts/narrative_fields.py
@@ -0,0 +1,50 @@
+"""Prompt d'étoffage des champs d'une entité narrative (arc / chapitre / scène) — Pilier A.
+
+Générique : le Core (Java) est la SOURCE DE VÉRITÉ des champs (clé + libellé) et les
+passe en entrée ; ce module ne fait que formuler le prompt. Le modèle ne renvoie que les
+clés fournies et OMET celles pour lesquelles il n'a rien de pertinent (pas de remplissage forcé).
+"""
+from app.core.language import language_name
+
+# Étiquette lisible du type d'entité, pour la formulation du prompt.
+ENTITY_LABEL: dict[str, str] = {
+ "arc": "cet arc narratif",
+ "chapter": "ce chapitre",
+ "scene": "cette scène",
+}
+
+
+def narrative_fields_prompt(entity_type: str, entity_context: str, instruction: str,
+ fields: list[dict], language: str) -> str:
+ """Construit le prompt d'étoffage. `fields` = [{key, label}] (whitelist du Core)."""
+ label = ENTITY_LABEL.get(entity_type or "", "cette entité narrative")
+ lines = []
+ for f in fields or []:
+ key = str(f.get("key") or "").strip()
+ if not key:
+ continue
+ flabel = str(f.get("label") or key).strip()
+ lines.append(f'- "{key}" : {flabel}')
+ fields_list = "\n".join(lines)
+ instruction_block = (
+ f"\nConsigne particulière du MJ : {instruction.strip()}\n"
+ if instruction and instruction.strip() else ""
+ )
+ return (
+ f"Tu es un co-Maître de Jeu. On te donne l'état ACTUEL d'{label} de jeu de rôle. "
+ "Propose des valeurs pour l'ÉTOFFER, cohérentes avec ce qui existe déjà.\n\n"
+ f"{entity_context.strip()}\n"
+ f"{instruction_block}\n"
+ "Champs que tu peux remplir (n'utilise QUE ces clés) :\n"
+ f"{fields_list}\n\n"
+ "Règles IMPÉRATIVES :\n"
+ "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
+ '- Format exact : {"fields": {"cle": "valeur proposée", ...}}\n'
+ "- N'inclus QUE des clés de la liste ci-dessus. N'invente AUCUNE autre clé.\n"
+ "- Si un champ est déjà bien rempli ou si tu n'as rien de pertinent, OMETS-le "
+ "(ne le renvoie pas) plutôt que de le remplir de force.\n"
+ "- Reste cohérent avec le contexte : n'invente pas d'élément qui contredit "
+ "l'entité ou la campagne.\n"
+ f"- Rédige les valeurs en {language_name(language)}.\n"
+ "Renvoie maintenant le JSON."
+ )
diff --git a/brain/app/application/prompts/scene_drafts.py b/brain/app/application/prompts/scene_drafts.py
new file mode 100644
index 0000000..773b8fa
--- /dev/null
+++ b/brain/app/application/prompts/scene_drafts.py
@@ -0,0 +1,30 @@
+"""Prompt d'ébauche de scènes pour un chapitre (Pilier A — capacité « create »).
+
+Le co-MJ propose plusieurs scènes cohérentes pour PEUPLER un chapitre vide (ou en manque).
+JSON structuré, une liste de scènes ; l'humain révise et ne crée que celles qu'il retient.
+"""
+from app.core.language import language_name
+
+
+def scene_drafts_prompt(context: str, instruction: str, count: int, language: str) -> str:
+ instruction_block = (
+ f"\nConsigne particulière du MJ : {instruction.strip()}\n"
+ if instruction and instruction.strip() else ""
+ )
+ return (
+ f"Tu es un co-Maître de Jeu. Propose {count} SCÈNES de jeu de rôle pour PEUPLER ce "
+ "chapitre, cohérentes entre elles et avec le contexte.\n\n"
+ f"{context.strip()}\n"
+ f"{instruction_block}\n"
+ "Règles IMPÉRATIVES :\n"
+ "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
+ '- Format exact : {"scenes": [{"name": "...", "description": "...", "playerNarration": "..."}]}\n'
+ f"- Propose AU PLUS {count} scènes, distinctes et complémentaires (une progression du chapitre).\n"
+ "- 'name' : titre court et évocateur (OBLIGATOIRE).\n"
+ "- 'description' : un résumé bref (une phrase).\n"
+ "- 'playerNarration' : 2-3 phrases de mise en scène lues aux joueurs.\n"
+ "- Ne DUPLIQUE pas les scènes déjà présentes ; reste cohérent avec le chapitre et la campagne "
+ "(n'invente pas d'élément qui les contredit).\n"
+ f"- Rédige en {language_name(language)}.\n"
+ "Renvoie maintenant le JSON."
+ )
diff --git a/brain/app/application/prompts/session_recap.py b/brain/app/application/prompts/session_recap.py
new file mode 100644
index 0000000..48c22f8
--- /dev/null
+++ b/brain/app/application/prompts/session_recap.py
@@ -0,0 +1,25 @@
+"""Prompt du récap « précédemment dans… » (mode séance).
+
+Le Core envoie le journal chronologique de la séance PRÉCÉDENTE ; le modèle rédige un
+récapitulatif court, à lire aux joueurs à l'ouverture de la séance suivante. Texte libre
+(pas de JSON) : c'est de la narration.
+"""
+from app.core.language import language_name
+
+
+def session_recap_prompt(transcript: str, context: str, language: str) -> str:
+ context_block = f"\n{context.strip()}\n" if context and context.strip() else ""
+ return (
+ "Tu es le Maître du Jeu. Voici le journal de la SÉANCE PRÉCÉDENTE de ta table "
+ "(entrées chronologiques : notes, évènements, jets de dés, actions des joueurs).\n"
+ f"{context_block}\n"
+ "Journal :\n"
+ f"{transcript.strip()}\n\n"
+ "Rédige un récapitulatif « Précédemment… » à LIRE AUX JOUEURS pour ouvrir la "
+ "nouvelle séance :\n"
+ "- 4 à 8 phrases, ton narratif et vivant, au passé.\n"
+ "- Uniquement ce qui s'est réellement passé dans le journal — n'invente RIEN, "
+ "ne révèle aucun secret du MJ.\n"
+ "- Termine sur la situation où les joueurs se sont arrêtés (le « cliffhanger »).\n"
+ f"- Rédige en {language_name(language)}. Pas de préambule ni de méta : juste le récit."
+ )
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java
index 75b3354..5152b80 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java
@@ -2,9 +2,12 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
+import com.loremind.domain.campaigncontext.FieldProposal;
+import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
+import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@@ -24,13 +27,16 @@ public class ArcService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
+ private final QuestRepository questRepository;
public ArcService(ArcRepository arcRepository,
ChapterRepository chapterRepository,
- SceneRepository sceneRepository) {
+ SceneRepository sceneRepository,
+ QuestRepository questRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
+ this.questRepository = questRepository;
}
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
@@ -87,6 +93,37 @@ public class ArcService {
return arcRepository.save(arc);
}
+ /**
+ * Patch CIBLÉ champ-par-champ d'un arc (Pilier A — co-création). Applique UNIQUEMENT
+ * les {@link FieldProposal} reçus ; les autres champs restent INTACTS (contraste voulu
+ * avec {@link #updateArc} qui écrase tout via BeanUtils).
+ */
+ @Transactional
+ public Arc patchArc(String id, List Déterministe, sans IA, sans persistance (aucune colonne readiness — recalcul
+ * à chaque appel, comme le statut de quête). Orthogonalité stricte : n'injecte
+ * AUCUN repository du Play Context ; le readiness ne dépend jamais d'un Playthrough,
+ * d'un flag ou d'une progression. Purement indicatif : aucune sévérité ne bloque une action. Chargement en une passe façon {@code CampaignStructuralContextBuilder} : arcs →
+ * chapitres (par arc) → scènes (par chapitre), + quêtes et bestiaire de la campagne
+ * chargés une seule fois, indexés par id pour résoudre les références faibles sans N+1. Périmètre MVP : le noyau BLOQUANT (vides, branches/portes cassées, quête sans nœud
+ * ou à nœud mort, prérequis cassé) + le trio « combat » RECOMMANDÉ (combat annoncé sans
+ * ennemi, réf d'ennemi cassée en scène et en pièce). Les règles narratives/ambiance et
+ * les orphelins (état que l'UI empêche) sont hors MVP. Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés
* globalement. Un fait existe dès qu'au moins une quête le référence dans ses
* prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans
- * la Partie, autocomplete dans l'éditeur de prérequis).
Niveau 1 : lit désormais les quêtes (entité de première classe), plus les + * chapitres HUB.
*/ @Service public class CampaignReferencedFlagsService { - private final ArcRepository arcRepository; - private final ChapterRepository chapterRepository; + private final QuestRepository questRepository; - public CampaignReferencedFlagsService(ArcRepository arcRepository, - ChapterRepository chapterRepository) { - this.arcRepository = arcRepository; - this.chapterRepository = chapterRepository; + public CampaignReferencedFlagsService(QuestRepository questRepository) { + this.questRepository = questRepository; } /** Retourne la liste triée alphabétiquement des noms de faits référencés. */ public ListDepuis l'introduction de Playthrough : la progression et les flags vivent au niveau - * de la Partie, plus de la Campagne. L'enrichissement nécessite donc un playthroughId.
- */ -@Service -public class ChapterStatusEnricher { - - private final PlaythroughRepository playthroughRepository; - private final QuestProgressionRepository progressionRepository; - private final PlaythroughFlagRepository flagRepository; - private final SessionRepository sessionRepository; - private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator(); - - public ChapterStatusEnricher(PlaythroughRepository playthroughRepository, - QuestProgressionRepository progressionRepository, - PlaythroughFlagRepository flagRepository, - SessionRepository sessionRepository) { - this.playthroughRepository = playthroughRepository; - this.progressionRepository = progressionRepository; - this.flagRepository = flagRepository; - this.sessionRepository = sessionRepository; - } - - /** Contexte d'évaluation + map chapterId -> ProgressionStatus pour ce Playthrough. */ - public record PlaythroughEvalSnapshot( - PrerequisiteEvaluator.EvaluationContext ctx, - MapTOUTE quête créée sans nœud reçoit son CONTENEUR de scènes (chapitre jumeau, + * même nom, masqué dans l'arbre par la fusion quête/jumeau) : une quête est un espace + * jouable où le MJ crée ses scènes à la volée — qu'elle vive dans un arc HUB (le + * conteneur y est rangé) ou LIBRE (le conteneur va dans l'arc technique {@code SYSTEM} + * de la campagne, invisible et non exporté). Lier des nœuds existants à la création + * (quête « transversale ») court-circuite le provisioning.
+ */ + @Transactional + public Quest createQuest(Quest input) { + input.setId(null); + if (nullSafeNodes(input.getNodes()).isEmpty()) { + provisionContainer(input); + } + return questRepository.save(input); + } + + /** + * Provisionne le conteneur de scènes d'une quête sans nœud et le référence + * (mutation de {@code quest.nodes} — la sauvegarde reste à la charge de l'appelant). + */ + private void provisionContainer(Quest quest) { + String containerArcId = quest.getArcId() != null && !quest.getArcId().isBlank() + ? quest.getArcId() + : systemArcIdFor(quest.getCampaignId()); + int order = chapterRepository.findByArcId(containerArcId).stream() + .mapToInt(Chapter::getOrder).max().orElse(-1) + 1; + Chapter container = chapterRepository.save(Chapter.builder() + .name(quest.getName()) + .description("") // le narratif vit sur la quête, pas sur le conteneur + .arcId(containerArcId) + .order(order) + .build()); + quest.setNodes(new ArrayList<>(List.of( + new QuestNodeRef(NodeType.CHAPTER, container.getId(), 0)))); + } + + /** Arc technique (SYSTEM) de la campagne — créé au premier besoin. */ + private String systemArcIdFor(String campaignId) { + return arcRepository.findByCampaignId(campaignId).stream() + .filter(a -> a.getType() == ArcType.SYSTEM) + .map(Arc::getId) + .findFirst() + .orElseGet(() -> arcRepository.save(Arc.builder() + .name(SYSTEM_ARC_NAME) + .description("") + .campaignId(campaignId) + .type(ArcType.SYSTEM) + .order(9999) + .build()).getId()); + } + + /** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */ + private boolean isContainerOf(Quest quest, Chapter chapter) { + if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true; + return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId()) + .map(a -> a.getType() == ArcType.SYSTEM) + .orElse(false); + } + + public OptionalTODO (Phase 5) : signaler/nettoyer les {@code Prerequisite.QuestCompleted} pendants + * d'autres quêtes qui pointaient celle-ci. Échec sûr aujourd'hui : un prérequis vers une + * quête supprimée n'est jamais satisfait → la quête dépendante reste LOCKED (pas de corruption).
+ */ + @Transactional + public void deleteQuest(String id) { + Quest quest = questRepository.findById(id).orElse(null); + progressionRepository.deleteByQuestId(id); + questRepository.deleteById(id); + if (quest == null) return; + // Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE + // (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître + // comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune + // perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du + // contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut. + ListDepuis l'introduction de Playthrough, la progression et les flags vivent au niveau + * de la Partie. L'enrichissement nécessite donc un playthroughId ; sans lui (ou s'il est + * inconnu), le snapshot est vide et tout est NOT_STARTED / AVAILABLE.
+ */ +@Service +public class QuestStatusEnricher { + + private final PlaythroughRepository playthroughRepository; + private final QuestProgressionRepository progressionRepository; + private final PlaythroughFlagRepository flagRepository; + private final SessionRepository sessionRepository; + private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator(); + + public QuestStatusEnricher(PlaythroughRepository playthroughRepository, + QuestProgressionRepository progressionRepository, + PlaythroughFlagRepository flagRepository, + SessionRepository sessionRepository) { + this.playthroughRepository = playthroughRepository; + this.progressionRepository = progressionRepository; + this.flagRepository = flagRepository; + this.sessionRepository = sessionRepository; + } + + /** Contexte d'évaluation + map questId -> ProgressionStatus pour ce Playthrough. */ + public record PlaythroughEvalSnapshot( + PrerequisiteEvaluator.EvaluationContext ctx, + MapLe message est déjà rédigé (orienté action, bienveillant) côté back ; le front + * l'affiche tel quel. {@code arcId}/{@code chapterId} sont le CONTEXTE de navigation + * (nullable selon l'entité) : le front construit le lien profond vers l'éditeur à + * partir de {@code entityType}, {@code entityId} et de ces ancêtres — aucune route + * Angular n'est codée côté back.
+ * + * @param entityType type de l'entité concernée + * @param entityId id de l'entité concernée + * @param entityName libellé lisible de l'entité (peut être {@code null} si sans nom) + * @param ruleId identifiant stable de la règle (ex. {@code SCENE-011-COMBAT-NO-ENEMY}) + * @param message message utilisateur prêt à afficher + * @param severity gravité du manque + * @param arcId arc parent (navigation), ou {@code null} + * @param chapterId chapitre parent (navigation), ou {@code null} + */ +public record ReadinessGap( + ReadinessEntityType entityType, + String entityId, + String entityName, + String ruleId, + String message, + ReadinessSeverity severity, + String arcId, + String chapterId +) { +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java index 39f4968..aac895b 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java @@ -1,6 +1,8 @@ package com.loremind.application.campaigncontext; +import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneDraft; import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.ports.SceneRepository; @@ -86,7 +88,96 @@ public class SceneService { return sceneRepository.save(scene); } + /** + * Patch CIBLÉ champ-par-champ d'une scène (Pilier A — co-création). Applique + * UNIQUEMENT les {@link FieldProposal} reçus (valeurs acceptées par l'utilisateur) sur + * les champs correspondants ; tous les autres champs restent INTACTS. + * + *Contraste volontaire avec {@link #updateScene} : ce dernier fait un + * {@code BeanUtils.copyProperties} qui écrase MÊME avec des null — inadapté ici où l'on + * ne veut toucher que les champs proposés. Les branches ne sont pas modifiées (pas de + * revalidation du graphe nécessaire).
+ */ + @Transactional + public Scene patchScene(String id, ListContexte compact : le chapitre (objectifs/enjeux) + les scènes DÉJÀ présentes (pour + * éviter les doublons) + méta campagne. Zéro écriture — la création est un second appel.
+ */ +@Service +public class DraftScenesUseCase { + + private static final int MIN_COUNT = 1; + private static final int MAX_COUNT = 8; + + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + private final CampaignRepository campaignRepository; + private final GameSystemRepository gameSystemRepository; + private final SceneDraftAssistant assistant; + + public DraftScenesUseCase(ChapterRepository chapterRepository, + SceneRepository sceneRepository, + CampaignRepository campaignRepository, + GameSystemRepository gameSystemRepository, + SceneDraftAssistant assistant) { + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + this.campaignRepository = campaignRepository; + this.gameSystemRepository = gameSystemRepository; + this.assistant = assistant; + } + + public SceneDraftProposal execute(String chapterId, String campaignId, String instruction, int count) { + Chapter chapter = chapterRepository.findById(chapterId) + .orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + chapterId)); + int n = Math.max(MIN_COUNT, Math.min(MAX_COUNT, count)); + + String context = buildContext(chapter, campaignId); + ListContexte volontairement COMPACT (entité + méta campagne). Zéro écriture — l'application + * est un second appel explicite (human-in-the-loop).
+ */ +@Service +public class NarrativeAssistFieldsUseCase { + + private final NarrativeFieldCatalog catalog; + private final CampaignRepository campaignRepository; + private final GameSystemRepository gameSystemRepository; + private final NarrativeFieldAssistant assistant; + + public NarrativeAssistFieldsUseCase( + NarrativeFieldCatalog catalog, + CampaignRepository campaignRepository, + GameSystemRepository gameSystemRepository, + NarrativeFieldAssistant assistant) { + this.catalog = catalog; + this.campaignRepository = campaignRepository; + this.gameSystemRepository = gameSystemRepository; + this.assistant = assistant; + } + + public EntityFieldPatchProposal execute(String entityType, String entityId, String campaignId, String instruction) { + NarrativeFieldCatalog.Snapshot snap = catalog.read(entityType, entityId); + + ListLes clés correspondent aux setters des services (patch) et aux contrôles de + * formulaire côté front. Le champ {@code name}/{@code type} n'est jamais étoffable + * (identité / enum). La liaison clé → setter reste dans chaque service (persistance).
+ */ +@Component +public class NarrativeFieldCatalog { + + /** Définition d'un champ : clé technique + libellé (guide le prompt du Brain). */ + public record FieldDef(String key, String label) {} + + /** Instantané d'une entité pour l'étoffage : titre + valeurs actuelles + champs. */ + public record Snapshot(String entityType, String title, + LinkedHashMapDéterministe, sans IA, zéro persistance. Si la campagne n'utilise pas de quêtes, la + * notion de « contenu probable » n'existe pas : on renvoie alors TOUS les manques (toute + * la campagne est potentiellement la prochaine séance).
+ */ +@Service +public class SessionPrepService { + + private final PlaythroughRepository playthroughRepository; + private final SessionRepository sessionRepository; + private final ClockRepository clockRepository; + private final FrontRepository frontRepository; + private final QuestRepository questRepository; + private final ChapterRepository chapterRepository; + private final SceneRepository sceneRepository; + private final QuestStatusEnricher statusEnricher; + private final CampaignReadinessService readinessService; + + public SessionPrepService(PlaythroughRepository playthroughRepository, + SessionRepository sessionRepository, + ClockRepository clockRepository, + FrontRepository frontRepository, + QuestRepository questRepository, + ChapterRepository chapterRepository, + SceneRepository sceneRepository, + QuestStatusEnricher statusEnricher, + CampaignReadinessService readinessService) { + this.playthroughRepository = playthroughRepository; + this.sessionRepository = sessionRepository; + this.clockRepository = clockRepository; + this.frontRepository = frontRepository; + this.questRepository = questRepository; + this.chapterRepository = chapterRepository; + this.sceneRepository = sceneRepository; + this.statusEnricher = statusEnricher; + this.readinessService = readinessService; + } + + public SessionPrepReport prepare(String playthroughId) { + Playthrough playthrough = playthroughRepository.findById(playthroughId) + .orElseThrow(() -> new IllegalArgumentException("Partie non trouvée: " + playthroughId)); + String campaignId = playthrough.getCampaignId(); + + // 1) Position : quêtes actives + dernière séance. + ListLe front affiche chaque {@link FieldProposal} (diff avant/après), l'utilisateur + * accepte/rejette champ par champ, puis renvoie CE MÊME record filtré aux seuls champs + * acceptés au endpoint d'application. Aucun statut d'acceptation n'est porté ici : le grain + * d'acceptation vit côté UI, l'apply ne reçoit que les champs retenus.
+ * + * @param target type d'entité ciblée : {@code "scene"} (tranche 1), à terme aussi + * {@code "arc"|"chapter"|"npc"} + * @param targetId id de l'entité DÉJÀ persistée à patcher + * @param type {@code "patch"} (tranche 1) ou {@code "create"} (extension future) + * @param fields un {@link FieldProposal} par champ proposé/accepté + */ +public record EntityFieldPatchProposal(String target, String targetId, String type, ListValue Object immuable, non persisté (éphémère, comme les records {@code *Proposal} + * de l'import). La {@code key} est alignée sur les champs exposés par + * {@code NarrativeEntityContextBuilder.fromScene()} : c'est la source de vérité qui relie + * la génération, l'affichage (diff avant/après) et l'application (patch ciblé).
+ * + * @param key clé du champ (ex. {@code "playerNarration"}, {@code "atmosphere"}) + * @param currentValue valeur actuelle du champ (echo pour la diff UI ; ignorée à l'apply) + * @param proposedValue valeur proposée par l'IA pour ce champ + */ +public record FieldProposal(String key, String currentValue, String proposedValue) { +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/LinkType.java b/core/src/main/java/com/loremind/domain/campaigncontext/LinkType.java new file mode 100644 index 0000000..dd84f62 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/LinkType.java @@ -0,0 +1,17 @@ +package com.loremind.domain.campaigncontext; + +/** + * Type d'un lien narratif entre nœuds ({@link SceneBranch}) — Niveau 2. + * + *{@code EXIT} = sortie / choix narratif : c'est la sémantique historique des + * branches, et la valeur par défaut (branches existantes / bundles antérieurs). + * {@code CLUE} et {@code LEAD} enrichissent le graphe façon « Three Clue Rule ».
+ */ +public enum LinkType { + /** Sortie / choix narratif (défaut, comportement historique). */ + EXIT, + /** Indice menant à une information. */ + CLUE, + /** Piste vers un autre nœud. */ + LEAD +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/NodeType.java b/core/src/main/java/com/loremind/domain/campaigncontext/NodeType.java new file mode 100644 index 0000000..95ef33b --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/NodeType.java @@ -0,0 +1,14 @@ +package com.loremind.domain.campaigncontext; + +/** + * Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}. + * + *Une quête est ORTHOGONALE à l'arbre Arc→Chapitre→Scène : elle peut pointer + * un chapitre entier (CHAPTER) ou une scène précise (SCENE), et traverser + * plusieurs nœuds. Le schéma supporte les deux dès le départ (décision D3) ; + * l'UI démarre sur les chapitres.
+ */ +public enum NodeType { + CHAPTER, + SCENE +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Quest.java b/core/src/main/java/com/loremind/domain/campaigncontext/Quest.java new file mode 100644 index 0000000..f878774 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Quest.java @@ -0,0 +1,67 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Entité de domaine (Aggregate Root) représentant une Quête. + * + *Niveau 1 : la Quête est une entité de PREMIÈRE CLASSE, ORTHOGONALE à + * l'arbre Arc→Chapitre→Scène. Elle est rattachée à la Campagne (décision D2) et + * référence des nœuds narratifs arbitraires via {@link QuestNodeRef}. Elle porte + * ses propres conditions de déblocage (réutilise le sealed {@link Prerequisite}).
+ * + *Elle remplace le double rôle historique du {@code Chapter} en mode HUB : + * {@code Prerequisite.QuestCompleted.questId} et {@code QuestProgression.questId} + * pointent désormais une {@code Quest.id}. Entité pure, sans dépendance technique.
+ */ +@Data +@Builder +public class Quest { + + private String id; + private String campaignId; // Rattachement campagne (orthogonalité, décision D2) + /** + * Arc de rattachement (weak ref, NULLABLE). Non nul ⇒ quête d'un ARC HUB (affichée + * sous cet arc). Null ⇒ quête TRANSVERSE (peut couvrir plusieurs arcs ; visible dans + * la liste « Quêtes »). Rétro-compat : les quêtes existantes ont {@code arcId=null}. + */ + private String arcId; + private String name; + private String description; // Synopsis de la quête + private String icon; // Clé d'icône (cf. CAMPAIGN_ICON_OPTIONS côté front) + private int order; // Ordre d'affichage dans la campagne + + /** + * Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE. + * Réutilise le sealed {@link Prerequisite} (migré depuis Chapter en mode HUB). + * Donnée de SCÉNARIO — l'état réel par Partie vit dans QuestProgression (Play Context). + */ + @Builder.Default + private ListRéférence faible (pas de FK dure cross-aggregate, cohérent avec + * {@code relatedPageIds}) : une quête peut couvrir plusieurs nœuds et un nœud + * peut servir plusieurs quêtes (N‑N). Immuable (record), comme + * {@link SceneBranch} / {@link RoomBranch}.
+ * + * @param nodeType type du nœud cible (CHAPTER|SCENE) + * @param nodeId id du Chapter ou de la Scene (weak ref) + * @param order ordre d'affichage du nœud dans la quête + */ +public record QuestNodeRef(NodeType nodeType, String nodeId, int order) { +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessEntityType.java b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessEntityType.java new file mode 100644 index 0000000..7c69598 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessEntityType.java @@ -0,0 +1,16 @@ +package com.loremind.domain.campaigncontext; + +/** + * Type de l'entité de scénario ciblée par un manque de readiness. Le front s'en + * sert (avec {@code arcId}/{@code chapterId} du gap) pour construire le lien profond + * vers l'éditeur concerné. + */ +public enum ReadinessEntityType { + CAMPAIGN, + ARC, + CHAPTER, + SCENE, + QUEST, + NPC, + ENEMY +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessSeverity.java b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessSeverity.java new file mode 100644 index 0000000..a5cd93c --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessSeverity.java @@ -0,0 +1,19 @@ +package com.loremind.domain.campaigncontext; + +/** + * Gravité d'un manque de préparation détecté par le guidage (Pilier B). + * + *Purement indicatif : aucun niveau ne BLOQUE une action. Le guidage conseille, + * il n'interdit rien (un MJ qui improvise n'est jamais empêché).
+ */ +public enum ReadinessSeverity { + + /** Empêche de jouer proprement : structure cassée, entité vide, référence morte. */ + BLOCKING, + + /** Fortement conseillé pour la cohérence / le confort de jeu (ex. combat sans ennemi). */ + RECOMMENDED, + + /** Finition (ambiance, illustrations…). Masqué par défaut dans l'UI. */ + OPTIONAL +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessStatus.java new file mode 100644 index 0000000..0321b61 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ReadinessStatus.java @@ -0,0 +1,22 @@ +package com.loremind.domain.campaigncontext; + +/** + * Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »). + * + *À NE PAS confondre avec la progression EN PARTIE ({@link ProgressionStatus} / + * {@link QuestStatus}, Play Context). Le readiness décrit « ce scénario est-il prêt + * à jouer ? » et se calcule uniquement depuis les champs du Campaign Context — + * jamais depuis un Playthrough. Ordre implicite DRAFT < PLAYABLE < POLISHED, + * agrégé vers le haut en MIN() (le maillon faible tire l'ensemble vers le bas).
+ */ +public enum ReadinessStatus { + + /** Il reste au moins un manque BLOQUANT (structure cassée, vide, référence morte). */ + DRAFT, + + /** Plus aucun manque bloquant : jouable, mais il reste des manques recommandés. */ + PLAYABLE, + + /** Plus aucun manque bloquant ni recommandé : prêt « clé en main ». */ + POLISHED +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java index 9b9419d..6740078 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java @@ -24,6 +24,14 @@ public class Scene { /** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */ private String icon; + /** + * Type narratif du nœud (Niveau 2 — graphe de nœuds typés). Défaut + * {@link SceneType#GENERIC} (scène non typée). Métadonnée : n'altère pas + * le comportement existant des scènes. + */ + @Builder.Default + private SceneType type = SceneType.GENERIC; + // === Contexte et ambiance === private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or) private String timing; // Moment (ex: Soir, à la tombée de la nuit) @@ -65,16 +73,23 @@ public class Scene { private List
+ * Record Java : immuable, sans dépendance technique (même pattern que
+ * {@link SceneBranch}) — Jackson le (dé)sérialise nativement via le constructeur
+ * canonique pour le stockage JSON en base.
+ *
+ * @param label Libellé de la variante (ex : "Jour", "Nuit"). Jamais null (normalisé "").
+ * @param mediaFileId ID du fichier media image/video ({@link com.loremind.domain.files.StoredFile}).
+ * Null = carte sans fond (sidecar seul).
+ * @param dataFileId ID du fichier sidecar Universal VTT (.json/.dd2vtt). Null si absent.
+ */
+public record SceneBattlemap(String label, String mediaFileId, String dataFileId) {
+
+ /** Normalise un libellé absent en chaîne vide (une seule représentation du « sans nom »). */
+ public SceneBattlemap {
+ if (label == null) label = "";
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java
index 186751c..ff61d45 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java
@@ -15,8 +15,19 @@ package com.loremind.domain.campaigncontext;
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
+ * @param kind Type de lien (Niveau 2). {@code null} normalisé en {@link LinkType#EXIT}.
*/
-public record SceneBranch(String label, String targetSceneId, String condition) {
+public record SceneBranch(String label, String targetSceneId, String condition, LinkType kind) {
+
+ /** Normalise un {@code kind} absent (branches / bundles antérieurs au Niveau 2) vers EXIT. */
+ public SceneBranch {
+ if (kind == null) kind = LinkType.EXIT;
+ }
+
+ /** Constructeur 3-args rétro-compatible : {@code kind} par défaut {@link LinkType#EXIT}. */
+ public SceneBranch(String label, String targetSceneId, String condition) {
+ this(label, targetSceneId, condition, LinkType.EXIT);
+ }
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
public static SceneBranch of(String label, String targetSceneId) {
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraft.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraft.java
new file mode 100644
index 0000000..97731a5
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraft.java
@@ -0,0 +1,13 @@
+package com.loremind.domain.campaigncontext;
+
+/**
+ * Ébauche de scène proposée par l'IA (Pilier A — capacité « create »). Value Object
+ * immuable et non persisté : l'utilisateur révise la liste, puis les ébauches ACCEPTÉES
+ * sont créées comme vraies {@link Scene} dans le chapitre ciblé.
+ *
+ * @param name titre court de la scène (obligatoire à la création)
+ * @param description résumé bref (facultatif)
+ * @param playerNarration texte de mise en scène lu aux joueurs (facultatif)
+ */
+public record SceneDraft(String name, String description, String playerNarration) {
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraftProposal.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraftProposal.java
new file mode 100644
index 0000000..77ca4ec
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneDraftProposal.java
@@ -0,0 +1,14 @@
+package com.loremind.domain.campaigncontext;
+
+import java.util.List;
+
+/**
+ * Proposition de PEUPLEMENT d'un chapitre en scènes (Pilier A — capacité « create »).
+ * Éphémère : le front affiche les ébauches, l'utilisateur accepte/rejette, puis renvoie
+ * ce record filtré aux ébauches retenues au endpoint d'application (qui crée les scènes).
+ *
+ * @param chapterId chapitre cible où créer les scènes
+ * @param scenes ébauches proposées / retenues
+ */
+public record SceneDraftProposal(String chapterId, List Métadonnée qui oriente le rendu et l'édition dans la vue graphe ; elle
+ * n'altère pas le comportement existant des scènes. {@code GENERIC} = scène non
+ * typée (valeur par défaut, et celle des scènes antérieures au Niveau 2). Générique par {@code entityType} : le Core est la SOURCE DE VÉRITÉ des champs
+ * (clé + libellé), le Brain ne fait que rédiger. Double garde-fou : whitelist stricte
+ * de clés côté Core ET côté adapter. Appartient à un {@link Playthrough}. Compteur à {@code segments} segments dont
+ * {@code filled} sont remplis ; pleine ({@code filled == segments}) → un effet narratif
+ * se déclenche. Orthogonale à l'arbre Arc/Chapitre/Scène, comme {@code Session} /
+ * {@code QuestProgression} : c'est de l'état de Partie, pas du scénario.
Référence le Chapter par weak reference (chapterId) pour respecter les + *
Référence la Quest par weak reference (questId) pour respecter les * Bounded Contexts. Le type {@link ProgressionStatus} reste défini dans * Campaign Context (c'est un Value Object générique, partageable).
* @@ -24,6 +24,6 @@ public class QuestProgression { private String id; private String playthroughId; - private String chapterId; + private String questId; private ProgressionStatus status; } diff --git a/core/src/main/java/com/loremind/domain/playcontext/Session.java b/core/src/main/java/com/loremind/domain/playcontext/Session.java index 2391fd1..f83a937 100644 --- a/core/src/main/java/com/loremind/domain/playcontext/Session.java +++ b/core/src/main/java/com/loremind/domain/playcontext/Session.java @@ -31,6 +31,12 @@ public class Session { /** Null = session en cours ; renseigné = session terminée. */ private LocalDateTime endedAt; + /** + * Scène courante épinglée pendant la séance (mode cockpit). Weak ref nullable vers + * une Scene du scénario ; null = rien d'épinglé. Sert de repère « on en est là ». + */ + private String currentSceneId; + private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/ClockRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/ClockRepository.java new file mode 100644 index 0000000..85934da --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/ports/ClockRepository.java @@ -0,0 +1,23 @@ +package com.loremind.domain.playcontext.ports; + +import com.loremind.domain.playcontext.Clock; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des Horloges de progression (Clocks). + */ +public interface ClockRepository { + + Clock save(Clock clock); + + Optional