Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m53s
Build & Push Images / build (core) (push) Successful in 3m40s
Build & Push Images / build-switcher (push) Successful in 16s

mode séance, battlemaps multiples et export Foundry ciblé

- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
  ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
  Fusion quête/conteneur dans la sidebar, progression par partie (statuts
  disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
  automatiquement (arc technique « Quêtes libres », V21 : levée de la
  contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
  scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
  pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
  brouillons de scènes en propose→applique (brain : narrative-fields,
  scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
  épinglée (V19), récap « précédemment » (brain : session-recap), onglet
  « Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
  liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
  1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
  étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
  légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
  tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
  (respect du périmètre + une Scene Foundry par variante de carte,
  rétro-compatible anciens bundles).
This commit is contained in:
2026-07-03 15:29:23 +02:00
parent 268f4721ce
commit b2c3800bf8
278 changed files with 15570 additions and 1451 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<FieldProposal> fields) {
Arc arc = arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé avec l'ID: " + id));
if (fields != null) {
for (FieldProposal f : fields) {
if (f == null || f.key() == null) continue;
applyField(arc, f.key(), f.proposedValue());
}
}
return arcRepository.save(arc);
}
/** Whitelist STRICTE des champs étoffables d'un arc ; clé inconnue ignorée. */
private void applyField(Arc arc, String key, String value) {
switch (key) {
case "description" -> arc.setDescription(value);
case "themes" -> arc.setThemes(value);
case "stakes" -> arc.setStakes(value);
case "rewards" -> arc.setRewards(value);
case "resolution" -> arc.setResolution(value);
case "gmNotes" -> arc.setGmNotes(value);
default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ }
}
}
/**
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
* qui disparaîtront avec l'arc.
@@ -112,6 +149,12 @@ public class ArcService {
}
chapterRepository.deleteById(chapter.getId());
}
// Détache les quêtes rattachées (arc HUB) : elles deviennent TRANSVERSES plutôt
// que fantômes (arcId pointant un arc disparu). Weak ref, pas de FK cascade.
for (Quest quest : questRepository.findByArcId(id)) {
quest.setArcId(null);
questRepository.save(quest);
}
arcRepository.deleteById(id);
}

View File

@@ -0,0 +1,23 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.ReadinessStatus;
import java.util.List;
import java.util.Map;
/**
* Bilan de préparation d'une campagne (read-model, Pilier B « guidage »).
*
* @param campaignId campagne évaluée
* @param overallStatus statut agrégé (DRAFT si ≥1 gap bloquant, PLAYABLE si ≥1
* recommandé sans bloquant, POLISHED sinon)
* @param counts nombre de gaps par sévérité ({@code "BLOCKING"|"RECOMMENDED"|"OPTIONAL"})
* @param gaps liste des manques, triés par gravité décroissante
*/
public record CampaignReadinessAssessment(
String campaignId,
ReadinessStatus overallStatus,
Map<String, Integer> counts,
List<ReadinessGap> gaps
) {
}

View File

@@ -0,0 +1,320 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.ReadinessEntityType;
import com.loremind.domain.campaigncontext.ReadinessSeverity;
import com.loremind.domain.campaigncontext.ReadinessStatus;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.RoomBranch;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service applicatif du Pilier B (« guidage / readiness ») : calcule à la volée
* l'état de préparation d'une campagne et la liste des manques (« gaps ») à combler,
* chacun cliquable vers l'éditeur concerné côté front.
*
* <p><b>Déterministe, sans IA, sans persistance</b> (aucune colonne readiness — recalcul
* à chaque appel, comme le statut de quête). <b>Orthogonalité stricte</b> : 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.</p>
*
* <p>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>
*
* <p>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.</p>
*/
@Service
public class CampaignReadinessService {
private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final QuestRepository questRepository;
private final EnemyRepository enemyRepository;
public CampaignReadinessService(CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
QuestRepository questRepository,
EnemyRepository enemyRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.questRepository = questRepository;
this.enemyRepository = enemyRepository;
}
/** Évalue la préparation d'une campagne (arbre + quêtes) et agrège son statut. */
public CampaignReadinessAssessment assess(String campaignId) {
List<ReadinessGap> gaps = new ArrayList<>();
String campaignName = campaignRepository.findById(campaignId)
.map(Campaign::getName).orElse(null);
// Bestiaire de la campagne, indexé pour résoudre les weak refs sans N+1.
// On écarte les ids vides/blancs : ils ne doivent jamais « résoudre » une référence.
Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream()
.map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet());
// Index global chapitres / scènes (cibles possibles des nœuds de quête).
Set<String> allChapterIds = new HashSet<>();
Set<String> allSceneIds = new HashSet<>();
int totalScenes = 0;
List<Quest> quests = questRepository.findByCampaignId(campaignId);
// Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides
// (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres).
Set<String> arcsWithQuests = quests.stream()
.map(Quest::getArcId).filter(id -> id != null && !id.isBlank())
.collect(Collectors.toSet());
List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId));
arcs.sort(Comparator.comparingInt(Arc::getOrder));
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
// Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la
// plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs).
boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId());
if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) {
String msg = arc.getType() == ArcType.HUB
? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le."
: "Arc vide : ajoutez un chapitre, ou supprimez-le.";
gaps.add(new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
"ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null));
}
for (Chapter chapter : chapters) {
allChapterIds.add(chapter.getId());
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
if (scenes.isEmpty()) {
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
labelOr(chapter.getName(), "Chapitre"), "CHAP-001-NO-SCENE",
"Chapitre vide : ajoutez au moins une scène pour pouvoir le jouer.",
ReadinessSeverity.BLOCKING, arc.getId(), chapter.getId()));
}
Set<String> chapterSceneIds = scenes.stream()
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
totalScenes += scenes.size();
for (Scene scene : scenes) {
allSceneIds.add(scene.getId());
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
}
}
}
Set<String> questIds = quests.stream()
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet());
// Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat).
boolean anyQuestWithNodes = quests.stream()
.anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
if (totalScenes == 0 && !anyQuestWithNodes) {
gaps.add(new ReadinessGap(ReadinessEntityType.CAMPAIGN, campaignId, campaignName,
"CAMP-001-NO-CONTENT",
"Campagne vide : ajoutez un arc avec une scène, ou créez une quête, pour commencer à jouer.",
ReadinessSeverity.BLOCKING, null, null));
}
for (Quest quest : quests) {
checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps);
}
return aggregate(campaignId, gaps);
}
private void checkScene(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) {
String name = labelOr(scene.getName(), "Scène");
// SCENE-001 — scène sans titre.
if (isBlank(scene.getName())) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME",
"Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.",
ReadinessSeverity.BLOCKING));
}
// SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence).
List<SceneBranch> branches = scene.getBranches();
if (branches != null && branches.stream().anyMatch(b ->
isBlank(b.targetSceneId())
|| b.targetSceneId().equals(scene.getId())
|| !chapterSceneIds.contains(b.targetSceneId()))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID",
"Branche cassée : une sortie de « " + name
+ " » pointe dans le vide, hors du chapitre, ou sur elle-même.",
ReadinessSeverity.BLOCKING));
}
// SCENE-011 — combat annoncé sans adversaire (règle produit clé).
if (!isBlank(scene.getCombatDifficulty())) {
boolean hasEnemyText = !isBlank(scene.getEnemies());
boolean hasResolvedEnemy = scene.getEnemyIds() != null
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id));
if (!hasEnemyText && !hasResolvedEnemy) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-011-COMBAT-NO-ENEMY",
"Combat annoncé sans adversaire : ajoutez une fiche du bestiaire ou décrivez les ennemis.",
ReadinessSeverity.RECOMMENDED));
}
}
// SCENE-012 — référence d'ennemi cassée (fiche supprimée).
if (scene.getEnemyIds() != null
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN",
"Ennemi introuvable : « " + name
+ " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.",
ReadinessSeverity.RECOMMENDED));
}
// SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes.
List<Room> rooms = scene.getRooms();
if (rooms != null && !rooms.isEmpty()) {
Set<String> roomIds = rooms.stream()
.map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet());
boolean roomBranchInvalid = false;
boolean roomEnemyBroken = false;
for (Room room : rooms) {
if (room.getBranches() != null) {
for (RoomBranch rb : room.getBranches()) {
if (isBlank(rb.targetRoomId())
|| rb.targetRoomId().equals(room.getId())
|| !roomIds.contains(rb.targetRoomId())) {
roomBranchInvalid = true;
}
}
}
if (room.getEnemyIds() != null) {
for (String id : room.getEnemyIds()) {
if (!isBlank(id) && !enemyIds.contains(id)) {
roomEnemyBroken = true;
}
}
}
}
if (roomBranchInvalid) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID",
"Porte cassée : dans « " + name
+ " », une sortie de pièce pointe hors de la scène ou dans le vide.",
ReadinessSeverity.BLOCKING));
}
if (roomEnemyBroken) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN",
"Ennemi introuvable dans une pièce de « " + name
+ " » : la référence pointe vers une fiche supprimée.",
ReadinessSeverity.RECOMMENDED));
}
}
}
private void checkQuest(Quest quest, Set<String> allChapterIds, Set<String> allSceneIds,
Set<String> questIds, List<ReadinessGap> gaps) {
String name = labelOr(quest.getName(), "Quête");
// QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide.
if (quest.getNodes() == null || quest.getNodes().isEmpty()) {
gaps.add(questGap(quest, "QUEST-001-NO-NODES",
"Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " ».",
ReadinessSeverity.BLOCKING));
} else if (quest.getNodes().stream().anyMatch(n ->
n.nodeType() == null
|| isBlank(n.nodeId())
|| (n.nodeType() == NodeType.CHAPTER && !allChapterIds.contains(n.nodeId()))
|| (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) {
gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN",
"Nœud de quête cassé : dans « " + name
+ " », un chapitre ou une scène référencé n'existe plus.",
ReadinessSeverity.BLOCKING));
}
// CAMP-010 — prérequis QuestCompleted pointant une quête disparue.
if (quest.getPrerequisites() != null && quest.getPrerequisites().stream()
.filter(p -> p instanceof Prerequisite.QuestCompleted)
.map(p -> ((Prerequisite.QuestCompleted) p).questId())
.anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) {
gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ",
"Prérequis cassé : « " + name
+ " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage.",
ReadinessSeverity.BLOCKING));
}
}
private CampaignReadinessAssessment aggregate(String campaignId, List<ReadinessGap> gaps) {
Map<String, Integer> counts = new LinkedHashMap<>();
counts.put(ReadinessSeverity.BLOCKING.name(), 0);
counts.put(ReadinessSeverity.RECOMMENDED.name(), 0);
counts.put(ReadinessSeverity.OPTIONAL.name(), 0);
for (ReadinessGap g : gaps) {
counts.merge(g.severity().name(), 1, Integer::sum);
}
ReadinessStatus status;
if (counts.get(ReadinessSeverity.BLOCKING.name()) > 0) {
status = ReadinessStatus.DRAFT;
} else if (counts.get(ReadinessSeverity.RECOMMENDED.name()) > 0) {
status = ReadinessStatus.PLAYABLE;
} else {
status = ReadinessStatus.POLISHED;
}
gaps.sort(Comparator.comparingInt(g -> severityRank(g.severity())));
return new CampaignReadinessAssessment(campaignId, status, counts, gaps);
}
private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId,
String ruleId, String message, ReadinessSeverity severity) {
return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(),
labelOr(scene.getName(), "Scène"), ruleId, message, severity, arcId, chapterId);
}
private ReadinessGap questGap(Quest quest, String ruleId, String message, ReadinessSeverity severity) {
return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(),
labelOr(quest.getName(), "Quête"), ruleId, message, severity, null, null);
}
private static int severityRank(ReadinessSeverity severity) {
return switch (severity) {
case BLOCKING -> 0;
case RECOMMENDED -> 1;
case OPTIONAL -> 2;
};
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static String labelOr(String value, String fallback) {
return isBlank(value) ? fallback : value;
}
}

View File

@@ -1,10 +1,8 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -12,38 +10,36 @@ import java.util.TreeSet;
/**
* Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet})
* référencés par les chapitres d'une Campagne.
* référencés par les quêtes d'une Campagne.
*
* <p>Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés
* globalement. Un fait existe dès qu'au moins une quête le référence dans ses
* prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans
* la Partie, autocomplete dans l'éditeur de prérequis).</p>
* la Partie, autocomplete dans l'éditeur de prérequis de quête).</p>
*
* <p>Niveau 1 : lit désormais les quêtes (entité de première classe), plus les
* chapitres HUB.</p>
*/
@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 List<String> listForCampaign(String campaignId) {
TreeSet<String> unique = new TreeSet<>();
for (Arc arc : arcRepository.findByCampaignId(campaignId)) {
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
if (chapter.getPrerequisites() == null) continue;
for (Prerequisite p : chapter.getPrerequisites()) {
for (Quest quest : questRepository.findByCampaignId(campaignId)) {
if (quest.getPrerequisites() == null) continue;
for (Prerequisite p : quest.getPrerequisites()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
}
}
}
return List.copyOf(unique);
}
}

View File

@@ -1,6 +1,7 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.FieldProposal;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -81,6 +82,35 @@ public class ChapterService {
return chapterRepository.save(chapter);
}
/**
* Patch CIBLÉ champ-par-champ d'un chapitre (Pilier A — co-création). Applique
* UNIQUEMENT les {@link FieldProposal} reçus ; les autres champs restent INTACTS
* (contraste voulu avec {@link #updateChapter} qui écrase tout via BeanUtils).
*/
@Transactional
public Chapter patchChapter(String id, List<FieldProposal> fields) {
Chapter chapter = chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapter non trouvé avec l'ID: " + id));
if (fields != null) {
for (FieldProposal f : fields) {
if (f == null || f.key() == null) continue;
applyField(chapter, f.key(), f.proposedValue());
}
}
return chapterRepository.save(chapter);
}
/** Whitelist STRICTE des champs étoffables d'un chapitre ; clé inconnue ignorée. */
private void applyField(Chapter chapter, String key, String value) {
switch (key) {
case "description" -> chapter.setDescription(value);
case "gmNotes" -> chapter.setGmNotes(value);
case "playerObjectives" -> chapter.setPlayerObjectives(value);
case "narrativeStakes" -> chapter.setNarrativeStakes(value);
default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ }
}
}
/** Compte des scènes qui tomberont avec le chapitre. */
public DeletionImpact getDeletionImpact(String id) {
return new DeletionImpact(sceneRepository.findByChapterId(id).size());

View File

@@ -1,104 +0,0 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service applicatif : enrichit des ChapterDTO avec leur {@link QuestStatus} effectif,
* relatif à un Playthrough donné.
*
* <p>Depuis l'introduction de Playthrough : la progression et les flags vivent au niveau
* de la Partie, plus de la Campagne. L'enrichissement nécessite donc un playthroughId.</p>
*/
@Service
public class ChapterStatusEnricher {
private final PlaythroughRepository playthroughRepository;
private final QuestProgressionRepository progressionRepository;
private final PlaythroughFlagRepository flagRepository;
private final SessionRepository sessionRepository;
private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator();
public ChapterStatusEnricher(PlaythroughRepository playthroughRepository,
QuestProgressionRepository progressionRepository,
PlaythroughFlagRepository flagRepository,
SessionRepository sessionRepository) {
this.playthroughRepository = playthroughRepository;
this.progressionRepository = progressionRepository;
this.flagRepository = flagRepository;
this.sessionRepository = sessionRepository;
}
/** Contexte d'évaluation + map chapterId -> ProgressionStatus pour ce Playthrough. */
public record PlaythroughEvalSnapshot(
PrerequisiteEvaluator.EvaluationContext ctx,
Map<String, ProgressionStatus> progressionByChapterId
) {}
/** Construit le snapshot d'évaluation pour un Playthrough. */
public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) {
if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) {
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()),
Collections.emptyMap()
);
}
Map<String, Boolean> flags = flagRepository.findByPlaythroughId(playthroughId);
Set<String> completedQuestIds = progressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
Map<String, ProgressionStatus> progressionMap = new HashMap<>();
for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
progressionMap.put(qp.getChapterId(), qp.getStatus());
}
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
progressionMap
);
}
/** Calcule le statut effectif d'un seul chapitre relatif à un Playthrough. */
public QuestStatus computeFor(Chapter chapter, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
ProgressionStatus progression = snap.progressionByChapterId()
.getOrDefault(chapter.getId(), ProgressionStatus.NOT_STARTED);
return evaluator.computeStatus(progression, chapter.getPrerequisites(), snap.ctx());
}
/**
* Injecte le {@code effectiveStatus} et le {@code progressionStatus} dans une liste de DTOs.
* Un seul build du snapshot pour toute la liste (optimal pour les vues qui listent un arc).
*/
public void enrich(List<ChapterDTO> dtos, List<Chapter> domain, String playthroughId) {
if (dtos == null || dtos.isEmpty()) return;
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, Chapter> byId = domain.stream()
.collect(Collectors.toMap(Chapter::getId, c -> c));
for (ChapterDTO dto : dtos) {
Chapter c = byId.get(dto.getId());
if (c == null) continue;
ProgressionStatus progression = snap.progressionByChapterId()
.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = evaluator.computeStatus(progression, c.getPrerequisites(), snap.ctx());
dto.setProgressionStatus(progression.name());
dto.setEffectiveStatus(status.name());
}
}
}

View File

@@ -0,0 +1,219 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.shared.ReorderSupport;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Service d'application pour le contexte Quest (Niveau 1).
* Orchestre la logique métier via le Port {@code QuestRepository}.
*/
@Service
public class QuestService {
/** Nom de l'arc technique hébergeant les conteneurs des quêtes libres (invisible). */
static final String SYSTEM_ARC_NAME = "Quêtes libres";
private final QuestRepository questRepository;
private final QuestProgressionRepository progressionRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final ArcRepository arcRepository;
public QuestService(QuestRepository questRepository,
QuestProgressionRepository progressionRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
ArcRepository arcRepository) {
this.questRepository = questRepository;
this.progressionRepository = progressionRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.arcRepository = arcRepository;
}
/**
* Création à partir d'une Quest complète. L'id est forcé à null (généré par la DB).
*
* <p>TOUTE 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.</p>
*/
@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 Optional<Quest> getQuestById(String id) {
return questRepository.findById(id);
}
public List<Quest> getQuestsByCampaignId(String 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). */
@Transactional
public Quest updateQuest(String id, Quest updated) {
Optional<Quest> existing = questRepository.findById(id);
if (existing.isEmpty()) {
throw new IllegalArgumentException("Quest non trouvée avec l'ID: " + id);
}
Quest quest = existing.get();
String oldName = quest.getName();
BeanUtils.copyProperties(updated, quest, "id");
Quest saved = questRepository.save(quest);
// Le conteneur jumeau porte le même nom que la quête (fusion dans l'arbre) :
// il suit le renommage, sinon le guidage citerait encore l'ancien nom.
// Vaut pour les quêtes de hub COMME pour les quêtes libres (conteneur en arc SYSTEM).
if (oldName != null && !oldName.equals(saved.getName())) {
for (QuestNodeRef node : nullSafeNodes(saved.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue;
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
if (oldName.equals(ch.getName()) && isContainerOf(saved, ch)) {
ch.setName(saved.getName());
chapterRepository.save(ch);
}
});
}
}
// Auto-réparation : une quête historique restée sans nœud (créée avant le
// provisioning systématique) reçoit son espace de scènes à la première sauvegarde.
if (nullSafeNodes(saved.getNodes()).isEmpty()) {
provisionContainer(saved);
return questRepository.save(saved);
}
return saved;
}
/**
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
*
* <p>TODO (Phase 5) : signaler/nettoyer les {@code Prerequisite.QuestCompleted} pendants
* d'autres quêtes qui pointaient celle-ci. Échec sûr aujourd'hui : un prérequis vers une
* quête supprimée n'est jamais satisfait → la quête dépendante reste LOCKED (pas de corruption).</p>
*/
@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.
List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId());
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue;
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
boolean container = isContainerOf(quest, ch);
boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty();
boolean referencedElsewhere = remaining.stream()
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
&& ch.getId().equals(n.nodeId())));
if (container && empty && !referencedElsewhere) {
chapterRepository.deleteById(ch.getId());
}
});
}
}
private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
return nodes != null ? nodes : List.of();
}
public boolean questExists(String id) {
return questRepository.existsById(id);
}
/** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */
@Transactional
public void reorderQuests(String campaignId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> questRepository.findById(id).orElse(null),
(quest, i) -> {
if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId);
quest.setOrder(i);
},
questRepository::save);
}
}

View File

@@ -0,0 +1,122 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service applicatif : enrichit des {@link QuestDTO} avec leur {@link QuestStatus}
* effectif, relatif à un Playthrough donné.
*
* <p>Depuis l'introduction de Playthrough, la progression et les flags vivent au niveau
* de la Partie. L'enrichissement nécessite donc un playthroughId ; sans lui (ou s'il est
* inconnu), le snapshot est vide et tout est NOT_STARTED / AVAILABLE.</p>
*/
@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,
Map<String, ProgressionStatus> progressionByQuestId
) {}
/** Construit le snapshot d'évaluation pour un Playthrough (court-circuit si null / inconnu). */
public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) {
if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) {
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()),
Collections.emptyMap()
);
}
Map<String, Boolean> flags = flagRepository.findByPlaythroughId(playthroughId);
Set<String> completedQuestIds = progressionRepository.findCompletedQuestIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
Map<String, ProgressionStatus> progressionMap = new HashMap<>();
for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
progressionMap.put(qp.getQuestId(), qp.getStatus());
}
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
progressionMap
);
}
/** Calcule le statut effectif d'une seule quête relatif à un Playthrough. */
public QuestStatus computeFor(Quest quest, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
ProgressionStatus progression = snap.progressionByQuestId()
.getOrDefault(quest.getId(), ProgressionStatus.NOT_STARTED);
return evaluator.computeStatus(progression, quest.getPrerequisites(), snap.ctx());
}
/**
* Statut effectif de PLUSIEURS quêtes avec un seul build du snapshot (contrairement
* à {@link #computeFor} qui reconstruit le snapshot à chaque appel). Utilisé par les
* read-models qui balaient toutes les quêtes d'une campagne (préparation de séance).
*/
public Map<String, QuestStatus> computeAll(List<Quest> quests, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, QuestStatus> out = new HashMap<>();
for (Quest q : quests) {
if (q == null || q.getId() == null) continue;
ProgressionStatus progression = snap.progressionByQuestId()
.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
out.put(q.getId(), evaluator.computeStatus(progression, q.getPrerequisites(), snap.ctx()));
}
return out;
}
/**
* Injecte {@code progressionStatus} + {@code effectiveStatus} dans une liste de DTOs.
* Un seul build du snapshot pour toute la liste.
*/
public void enrich(List<QuestDTO> dtos, List<Quest> domain, String playthroughId) {
if (dtos == null || dtos.isEmpty()) return;
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, Quest> byId = domain.stream()
.collect(Collectors.toMap(Quest::getId, q -> q));
for (QuestDTO dto : dtos) {
Quest q = byId.get(dto.getId());
if (q == null) continue;
ProgressionStatus progression = snap.progressionByQuestId()
.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = evaluator.computeStatus(progression, q.getPrerequisites(), snap.ctx());
dto.setProgressionStatus(progression.name());
dto.setEffectiveStatus(status.name());
}
}
}

View File

@@ -0,0 +1,34 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.ReadinessEntityType;
import com.loremind.domain.campaigncontext.ReadinessSeverity;
/**
* Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B).
*
* <p>Le 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.</p>
*
* @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
) {
}

View File

@@ -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.
*
* <p>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).</p>
*/
@Transactional
public Scene patchScene(String id, List<FieldProposal> fields) {
Scene scene = sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scene non trouvée avec l'ID: " + id));
if (fields != null) {
for (FieldProposal f : fields) {
if (f == null || f.key() == null) continue;
applyField(scene, f.key(), f.proposedValue());
}
}
return sceneRepository.save(scene);
}
/**
* Applique une valeur sur le champ nommé. Whitelist STRICTE alignée sur
* {@code NarrativeEntityContextBuilder.fromScene()} : toute clé inconnue est ignorée
* (jamais d'écrasement hors de la liste connue).
*/
private void applyField(Scene scene, String key, String value) {
switch (key) {
case "description" -> scene.setDescription(value);
case "location" -> scene.setLocation(value);
case "timing" -> scene.setTiming(value);
case "atmosphere" -> scene.setAtmosphere(value);
case "playerNarration" -> scene.setPlayerNarration(value);
case "choicesConsequences" -> scene.setChoicesConsequences(value);
case "combatDifficulty" -> scene.setCombatDifficulty(value);
case "enemies" -> scene.setEnemies(value);
case "gmSecretNotes" -> scene.setGmSecretNotes(value);
default -> { /* clé inconnue → ignorée (garde-fou anti-écrasement) */ }
}
}
/**
* Crée en bloc des scènes à partir d'ébauches IA acceptées (Pilier A — capacité
* « create »). Les scènes sont AJOUTÉES à la fin du chapitre (ordre = suite des scènes
* existantes). Les ébauches sans titre sont ignorées. Transactionnel.
*/
@Transactional
public List<Scene> createDraftScenes(String chapterId, List<SceneDraft> drafts) {
if (drafts == null || drafts.isEmpty()) return List.of();
int order = sceneRepository.findByChapterId(chapterId).stream()
.mapToInt(Scene::getOrder).max().orElse(-1) + 1;
List<Scene> created = new ArrayList<>();
for (SceneDraft d : drafts) {
if (d == null || d.name() == null || d.name().isBlank()) continue;
Scene scene = Scene.builder()
.name(d.name().trim())
.description(d.description())
.playerNarration(d.playerNarration())
.chapterId(chapterId)
.order(order++)
.build();
created.add(createScene(scene)); // createScene(Scene) force id=null + rooms
}
return created;
}
/**
* Supprime la scène ET nettoie les branches des scènes sœurs qui pointaient vers elle
* (sinon elles deviennent des références mortes : invisibles dans le graphe — qui filtre
* les cibles inexistantes — mais signalées « branche cassée » par le guidage, ce qui est
* incompréhensible pour l'utilisateur). Les branches étant intra-chapitre, le nettoyage
* se limite aux sœurs du même chapitre. Transactionnel : atomique.
*/
@Transactional
public void deleteScene(String id) {
sceneRepository.findById(id).ifPresent(scene -> {
for (Scene sibling : sceneRepository.findByChapterId(scene.getChapterId())) {
List<SceneBranch> branches = sibling.getBranches();
if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue;
List<SceneBranch> kept = branches.stream()
.filter(b -> !id.equals(b.targetSceneId()))
.collect(Collectors.toList());
if (kept.size() != branches.size()) {
sibling.setBranches(kept);
sceneRepository.save(sibling);
}
}
});
sceneRepository.deleteById(id);
}

View File

@@ -0,0 +1,105 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneDraft;
import com.loremind.domain.campaigncontext.SceneDraftProposal;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* Use case (Pilier A — capacité « create ») : produit une PROPOSITION d'ébauches de scènes
* pour un chapitre (non persistée). Répond directement à la « page blanche » / au manque
* de guidage « Chapitre vide » (Pilier B).
*
* <p>Contexte 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.</p>
*/
@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);
List<SceneDraft> drafts = assistant.draftScenes(context, instruction, n).stream()
.filter(d -> d != null && d.name() != null && !d.name().isBlank())
.limit(n)
.collect(Collectors.toList());
return new SceneDraftProposal(chapterId, drafts);
}
private String buildContext(Chapter chapter, String campaignId) {
StringBuilder sb = new StringBuilder();
sb.append("Chapitre : ").append(blankToLabel(chapter.getName(), "(sans titre)")).append("\n");
appendIf(sb, "Synopsis", chapter.getDescription());
appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives());
appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes());
List<Scene> existing = sceneRepository.findByChapterId(chapter.getId());
if (!existing.isEmpty()) {
String names = existing.stream()
.map(Scene::getName)
.filter(nm -> nm != null && !nm.isBlank())
.collect(Collectors.joining(" ; "));
if (!names.isEmpty()) {
sb.append("Scènes DÉJÀ présentes (ne pas dupliquer) : ").append(names).append("\n");
}
}
if (campaignId != null && !campaignId.isBlank()) {
campaignRepository.findById(campaignId).ifPresent(c -> {
sb.append("Campagne : ").append(c.getName());
if (c.getDescription() != null && !c.getDescription().isBlank()) {
sb.append("").append(c.getDescription().trim());
}
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();
}
private static void appendIf(StringBuilder sb, String label, String value) {
if (value != null && !value.isBlank()) {
sb.append(label).append(" : ").append(value.trim()).append("\n");
}
}
private static String blankToLabel(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
}

View File

@@ -0,0 +1,112 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.EntityFieldPatchProposal;
import com.loremind.domain.campaigncontext.FieldProposal;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Use case (Pilier A — co-création) : produit une PROPOSITION d'étoffage des champs d'une
* entité narrative (arc / chapitre / scène), non persistée. Générique par {@code entityType}
* grâce à {@link NarrativeFieldCatalog} (source de vérité des champs) : la même mécanique
* couvre les trois types, seul le catalogue de champs change.
*
* <p>Contexte volontairement COMPACT (entité + méta campagne). Zéro écriture — l'application
* est un second appel explicite (human-in-the-loop).</p>
*/
@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);
List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream()
.map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label()))
.collect(Collectors.toList());
Set<String> allowed = snap.defs().stream()
.map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet());
String context = buildContext(campaignId, snap);
List<NarrativeFieldAssistant.ProposedField> proposed =
assistant.assist(snap.entityType(), context, instruction, specs);
List<FieldProposal> fields = new ArrayList<>();
for (NarrativeFieldAssistant.ProposedField pf : proposed) {
if (pf.key() == null || !allowed.contains(pf.key())) continue;
if (pf.value() == null || pf.value().isBlank()) continue;
String current = snap.current().getOrDefault(pf.key(), "");
fields.add(new FieldProposal(pf.key(), current, pf.value()));
}
return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields);
}
/** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */
private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) {
StringBuilder sb = new StringBuilder();
sb.append(entityLabel(snap.entityType())).append(" : ")
.append(blankToLabel(snap.title(), "(sans titre)")).append("\n");
sb.append("État actuel des champs :\n");
boolean any = false;
for (Map.Entry<String, String> e : snap.current().entrySet()) {
String v = e.getValue();
if (v != null && !v.isBlank()) {
sb.append("- ").append(e.getKey()).append(" : ").append(v.trim()).append("\n");
any = true;
}
}
if (!any) {
sb.append("- (tous les champs sont vides — à créer de zéro)\n");
}
if (campaignId != null && !campaignId.isBlank()) {
campaignRepository.findById(campaignId).ifPresent(c -> {
sb.append("Campagne : ").append(c.getName());
if (c.getDescription() != null && !c.getDescription().isBlank()) {
sb.append("").append(c.getDescription().trim());
}
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();
}
private static String entityLabel(String entityType) {
return switch (entityType == null ? "" : entityType) {
case "arc" -> "Arc";
case "chapter" -> "Chapitre";
case "scene" -> "Scène";
default -> "Entité";
};
}
private static String blankToLabel(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
}

View File

@@ -0,0 +1,126 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
/**
* Catalogue des champs ÉTOFFABLES par l'IA (Pilier A), par type d'entité narrative.
* SOURCE DE VÉRITÉ unique côté génération : ordre + clés + libellés + valeurs actuelles.
*
* <p>Les 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).</p>
*/
@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,
LinkedHashMap<String, String> current, List<FieldDef> defs) {}
private static final List<FieldDef> ARC_DEFS = List.of(
new FieldDef("description", "description / synopsis de l'arc"),
new FieldDef("themes", "thèmes explorés"),
new FieldDef("stakes", "enjeux globaux pour les personnages"),
new FieldDef("rewards", "récompenses et progression"),
new FieldDef("resolution", "dénouement prévu"),
new FieldDef("gmNotes", "notes privées du MJ"));
private static final List<FieldDef> CHAPTER_DEFS = List.of(
new FieldDef("description", "synopsis du chapitre"),
new FieldDef("playerObjectives", "objectifs des joueurs"),
new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"),
new FieldDef("gmNotes", "notes privées du MJ"));
private static final List<FieldDef> SCENE_DEFS = List.of(
new FieldDef("description", "description courte de la scène"),
new FieldDef("location", "lieu où se déroule la scène"),
new FieldDef("timing", "moment / temporalité"),
new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"),
new FieldDef("playerNarration", "texte de mise en scène lu aux joueurs"),
new FieldDef("choicesConsequences", "choix offerts aux joueurs et leurs conséquences"),
new FieldDef("combatDifficulty", "difficulté de combat estimée"),
new FieldDef("enemies", "ennemis / créatures présentes (texte libre)"),
new FieldDef("gmSecretNotes", "notes secrètes du MJ (cachées des joueurs)"));
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public NarrativeFieldCatalog(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/** Charge l'entité et son instantané d'étoffage. @throws IllegalArgumentException si type/entité inconnu. */
public Snapshot read(String entityType, String entityId) {
return switch (normalize(entityType)) {
case "arc" -> arcSnapshot(entityId);
case "chapter" -> chapterSnapshot(entityId);
case "scene" -> sceneSnapshot(entityId);
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
};
}
private Snapshot arcSnapshot(String id) {
Arc a = arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put("description", nz(a.getDescription()));
cur.put("themes", nz(a.getThemes()));
cur.put("stakes", nz(a.getStakes()));
cur.put("rewards", nz(a.getRewards()));
cur.put("resolution", nz(a.getResolution()));
cur.put("gmNotes", nz(a.getGmNotes()));
return new Snapshot("arc", a.getName(), cur, ARC_DEFS);
}
private Snapshot chapterSnapshot(String id) {
Chapter c = chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put("description", nz(c.getDescription()));
cur.put("playerObjectives", nz(c.getPlayerObjectives()));
cur.put("narrativeStakes", nz(c.getNarrativeStakes()));
cur.put("gmNotes", nz(c.getGmNotes()));
return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS);
}
private Snapshot sceneSnapshot(String id) {
Scene s = sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put("description", nz(s.getDescription()));
cur.put("location", nz(s.getLocation()));
cur.put("timing", nz(s.getTiming()));
cur.put("atmosphere", nz(s.getAtmosphere()));
cur.put("playerNarration", nz(s.getPlayerNarration()));
cur.put("choicesConsequences", nz(s.getChoicesConsequences()));
cur.put("combatDifficulty", nz(s.getCombatDifficulty()));
cur.put("enemies", nz(s.getEnemies()));
cur.put("gmSecretNotes", nz(s.getGmSecretNotes()));
return new Snapshot("scene", s.getName(), cur, SCENE_DEFS);
}
private static String normalize(String entityType) {
return entityType == null ? "" : entityType.trim().toLowerCase();
}
private static String nz(String v) {
return v == null ? "" : v;
}
}

View File

@@ -1,13 +1,10 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestStatus;
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.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
@@ -46,8 +43,7 @@ public class SessionStructuralContextBuilder {
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final QuestRepository questRepository;
private final PlaythroughFlagRepository playthroughFlagRepository;
private final QuestProgressionRepository questProgressionRepository;
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
@@ -55,15 +51,13 @@ public class SessionStructuralContextBuilder {
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
QuestRepository questRepository,
PlaythroughFlagRepository playthroughFlagRepository,
QuestProgressionRepository questProgressionRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.questRepository = questRepository;
this.playthroughFlagRepository = playthroughFlagRepository;
this.questProgressionRepository = questProgressionRepository;
}
@@ -169,23 +163,14 @@ public class SessionStructuralContextBuilder {
Map<String, Boolean> flags = playthroughFlagRepository.findByPlaythroughId(playthroughId);
List<String> activeFlags = buildActiveFlags(flags);
List<Arc> arcs = arcRepository.findByCampaignId(campaignId);
Map<String, Arc> arcsById = arcs.stream()
.filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a));
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
// Map questId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByQuest = new HashMap<>();
for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
progressionByChapter.put(qp.getChapterId(), qp.getStatus());
progressionByQuest.put(qp.getQuestId(), qp.getStatus());
}
// IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
// IDs des quêtes COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedQuestIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
PrerequisiteEvaluator.EvaluationContext ctx =
@@ -195,31 +180,26 @@ public class SessionStructuralContextBuilder {
List<QuestSummary> inProgress = new ArrayList<>();
List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
boolean isHub = arc.getType() == ArcType.HUB;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId());
String arcName = parent != null ? parent.getName() : null;
// Niveau 1 : les quêtes sont des entités orthogonales rattachées à la campagne
// (plus des chapitres HUB). arcName n'a plus de sens => null.
for (Quest q : questRepository.findByCampaignId(campaignId)) {
ProgressionStatus prog = progressionByQuest.getOrDefault(q.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, q.getPrerequisites(), ctx);
switch (status) {
case AVAILABLE:
available.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
available.add(new QuestSummary(q.getName(), null, q.getDescription()));
break;
case IN_PROGRESS:
inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
inProgress.add(new QuestSummary(q.getName(), null, q.getDescription()));
break;
case LOCKED:
lockedTitles.add(c.getName());
lockedTitles.add(q.getName());
break;
case COMPLETED:
// Omis (déjà dans le journal des EVENTs).
break;
}
}
}
return new HubStatus(available, inProgress, lockedTitles, activeFlags);
}

View File

@@ -0,0 +1,141 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Clock;
import com.loremind.domain.playcontext.ClockTrigger;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Service applicatif des Horloges de progression (Clocks) d'une Partie.
* CRUD + <b>avancer / reculer</b> d'un segment, borné à [0, segments].
*/
@Service
public class ClockService {
/** Garde-fou : taille maximale d'une horloge. */
private static final int MAX_SEGMENTS = 60;
private final ClockRepository clockRepository;
private final PlaythroughRepository playthroughRepository;
public ClockService(ClockRepository clockRepository, PlaythroughRepository playthroughRepository) {
this.clockRepository = clockRepository;
this.playthroughRepository = playthroughRepository;
}
public List<Clock> getByPlaythrough(String playthroughId) {
return clockRepository.findByPlaythroughId(playthroughId);
}
public Clock create(String playthroughId, String name, String description, int segments,
ClockTrigger triggerType, String triggerRef, String frontId) {
if (playthroughId == null || !playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Le nom de l'horloge est requis.");
}
int order = clockRepository.findByPlaythroughId(playthroughId).size();
ClockTrigger type = triggerType != null ? triggerType : ClockTrigger.NONE;
Clock clock = Clock.builder()
.playthroughId(playthroughId)
.name(name.trim())
.description(description)
.segments(clampSegments(segments))
.filled(0)
.order(order)
.triggerType(type)
.triggerRef(normalizeRef(type, triggerRef))
.frontId(blankToNull(frontId))
.build();
return clockRepository.save(clock);
}
public Clock update(String id, String name, String description, int segments,
ClockTrigger triggerType, String triggerRef, String frontId) {
Clock clock = require(id);
if (name != null && !name.isBlank()) clock.setName(name.trim());
clock.setDescription(description);
int seg = clampSegments(segments);
clock.setSegments(seg);
if (clock.getFilled() > seg) clock.setFilled(seg); // ré-borne si on réduit la taille
ClockTrigger type = triggerType != null ? triggerType : ClockTrigger.NONE;
clock.setTriggerType(type);
clock.setTriggerRef(normalizeRef(type, triggerRef));
clock.setFrontId(blankToNull(frontId));
return clockRepository.save(clock);
}
// ----- Avancement automatique (co-MJ) : le monde qui réagit -----
/** Un Fait vient de passer à vrai → avance les horloges liées (FLAG_SET + ce fait). */
public void onFlagRaised(String playthroughId, String flagName) {
advanceMatching(playthroughId, ClockTrigger.FLAG_SET, flagName);
}
/** Une quête vient de passer à COMPLETED → avance les horloges liées à cette quête. */
public void onQuestCompleted(String playthroughId, String questId) {
advanceMatching(playthroughId, ClockTrigger.QUEST_COMPLETED, questId);
}
/** Une séance vient de se clôturer → avance les horloges « fin de séance » de la Partie. */
public void onSessionEnded(String playthroughId) {
advanceMatching(playthroughId, ClockTrigger.SESSION_ENDED, null);
}
private void advanceMatching(String playthroughId, ClockTrigger type, String ref) {
if (playthroughId == null) return;
for (Clock c : clockRepository.findByPlaythroughId(playthroughId)) {
boolean match = c.getTriggerType() == type
&& (type == ClockTrigger.SESSION_ENDED || java.util.Objects.equals(c.getTriggerRef(), ref));
if (match && c.getFilled() < c.getSegments()) {
c.setFilled(c.getFilled() + 1);
clockRepository.save(c);
}
}
}
private String normalizeRef(ClockTrigger type, String ref) {
boolean needsRef = type == ClockTrigger.FLAG_SET || type == ClockTrigger.QUEST_COMPLETED;
if (!needsRef || ref == null || ref.isBlank()) return null;
return ref.trim();
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
/** +1 segment (sans dépasser {@code segments}). */
public Clock advance(String id) {
Clock clock = require(id);
if (clock.getFilled() < clock.getSegments()) clock.setFilled(clock.getFilled() + 1);
return clockRepository.save(clock);
}
/** 1 segment (sans descendre sous 0). */
public Clock regress(String id) {
Clock clock = require(id);
if (clock.getFilled() > 0) clock.setFilled(clock.getFilled() - 1);
return clockRepository.save(clock);
}
public void delete(String id) {
if (!clockRepository.existsById(id)) {
throw new IllegalArgumentException("Horloge introuvable : " + id);
}
clockRepository.deleteById(id);
}
private Clock require(String id) {
return clockRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Horloge introuvable : " + id));
}
private int clampSegments(int segments) {
if (segments < 1) return 1;
return Math.min(segments, MAX_SEGMENTS);
}
}

View File

@@ -0,0 +1,75 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Clock;
import com.loremind.domain.playcontext.Front;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.domain.playcontext.ports.FrontRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Service applicatif des Fronts (menaces regroupant des horloges) d'une Partie.
*/
@Service
public class FrontService {
private final FrontRepository frontRepository;
private final ClockRepository clockRepository;
private final PlaythroughRepository playthroughRepository;
public FrontService(FrontRepository frontRepository, ClockRepository clockRepository,
PlaythroughRepository playthroughRepository) {
this.frontRepository = frontRepository;
this.clockRepository = clockRepository;
this.playthroughRepository = playthroughRepository;
}
public List<Front> getByPlaythrough(String playthroughId) {
return frontRepository.findByPlaythroughId(playthroughId);
}
public Front create(String playthroughId, String name, String description) {
if (playthroughId == null || !playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Le nom du front est requis.");
}
int order = frontRepository.findByPlaythroughId(playthroughId).size();
Front front = Front.builder()
.playthroughId(playthroughId)
.name(name.trim())
.description(description)
.order(order)
.build();
return frontRepository.save(front);
}
public Front update(String id, String name, String description) {
Front front = require(id);
if (name != null && !name.isBlank()) front.setName(name.trim());
front.setDescription(description);
return frontRepository.save(front);
}
/** Supprime un front et ORPHELINE ses horloges (frontId -> null : on ne perd pas d'horloges). */
@Transactional
public void delete(String id) {
Front front = require(id);
for (Clock c : clockRepository.findByPlaythroughId(front.getPlaythroughId())) {
if (id.equals(c.getFrontId())) {
c.setFrontId(null);
clockRepository.save(c);
}
}
frontRepository.deleteById(id);
}
private Front require(String id) {
return frontRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Front introuvable : " + id));
}
}

View File

@@ -0,0 +1,52 @@
package com.loremind.application.playcontext;
import com.loremind.application.campaigncontext.ReadinessGap;
import java.time.LocalDateTime;
import java.util.List;
/**
* Bilan de PRÉPARATION DE SÉANCE d'une Partie (Phase 3 co-MJ — « PlanDeSéance »).
* Read-model pur, calculé à la volée : croise la position des joueurs (quêtes en cours /
* disponibles, dernière séance), le contenu probable (nœuds des quêtes actives), les
* manques de guidage CIBLÉS sur ce contenu, et les horloges en mouvement.
*
* @param playthroughId la Partie évaluée
* @param lastSession dernière séance (ou {@code null} si aucune)
* @param questsInProgress quêtes EN COURS (le « où en sont les joueurs »)
* @param questsAvailable quêtes DISPONIBLES (les prochaines pistes probables)
* @param questsCompleted quêtes TERMINÉES (rappel discret + possibilité de rouvrir)
* @param hotspots chapitres / scènes probables (nœuds des quêtes actives, dédupliqués)
* @param gaps manques de guidage restreints au contenu probable (à combler avant de jouer) ;
* si la campagne n'utilise pas de quêtes, tous les manques de la campagne
* @param otherGapCount manques ailleurs dans la campagne (information, non bloquant pour la séance)
* @param clocks horloges entamées ({@code filled > 0}), avec le nom de leur menace
*/
public record SessionPrepReport(
String playthroughId,
LastSessionInfo lastSession,
List<QuestInfo> questsInProgress,
List<QuestInfo> questsAvailable,
List<QuestInfo> questsCompleted,
List<NodeInfo> hotspots,
List<ReadinessGap> gaps,
int otherGapCount,
List<ClockInfo> clocks
) {
/** Dernière séance tenue (ou en cours) de la Partie. */
public record LastSessionInfo(String id, String name, LocalDateTime startedAt,
LocalDateTime endedAt, boolean active) {}
/** Quête résumée (statut implicite par la liste qui la porte). */
public record QuestInfo(String id, String name, String icon) {}
/**
* Nœud narratif probable. {@code nodeType} = "CHAPTER"|"SCENE" ; {@code arcId}/
* {@code chapterId} = contexte de navigation pour le lien profond côté front.
*/
public record NodeInfo(String nodeType, String id, String name, String arcId, String chapterId) {}
/** Horloge en mouvement (le front affiche « presque pleine » quand il reste ≤ 1 segment). */
public record ClockInfo(String id, String name, int segments, int filled, String frontName) {}
}

View File

@@ -0,0 +1,204 @@
package com.loremind.application.playcontext;
import com.loremind.application.campaigncontext.CampaignReadinessAssessment;
import com.loremind.application.campaigncontext.CampaignReadinessService;
import com.loremind.application.campaigncontext.QuestStatusEnricher;
import com.loremind.application.campaigncontext.ReadinessGap;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.Front;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.domain.playcontext.ports.FrontRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Read-model « Préparer la prochaine séance » (Phase 3 co-MJ). Croise, pour UNE Partie :
*
* <ol>
* <li><b>Position des joueurs</b> — quêtes EN COURS / DISPONIBLES (statut effectif via
* {@link QuestStatusEnricher}, un seul snapshot) + dernière séance ;</li>
* <li><b>Contenu probable</b> — les chapitres/scènes traversés par ces quêtes actives ;</li>
* <li><b>Manques ciblés</b> — les gaps du guidage ({@link CampaignReadinessService})
* restreints à ce contenu probable : « quoi combler AVANT la prochaine séance » ;</li>
* <li><b>Menaces en mouvement</b> — horloges entamées, avec leur front.</li>
* </ol>
*
* <p>Dé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).</p>
*/
@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.
List<Quest> quests = questRepository.findByCampaignId(campaignId);
Map<String, QuestStatus> statusById = statusEnricher.computeAll(quests, playthroughId);
List<Quest> inProgress = byStatus(quests, statusById, QuestStatus.IN_PROGRESS);
List<Quest> available = byStatus(quests, statusById, QuestStatus.AVAILABLE);
List<Quest> completed = byStatus(quests, statusById, QuestStatus.COMPLETED);
// 2) Contenu probable : nœuds des quêtes actives (en cours d'abord), dédupliqués.
LinkedHashSet<String> hotspotChapterIds = new LinkedHashSet<>();
LinkedHashSet<String> hotspotSceneIds = new LinkedHashSet<>();
List<SessionPrepReport.NodeInfo> hotspots = new ArrayList<>();
for (Quest quest : concat(inProgress, available)) {
for (QuestNodeRef node : nullSafe(quest.getNodes())) {
resolveNode(node, hotspotChapterIds, hotspotSceneIds, hotspots);
}
}
// 3) Manques ciblés sur ce contenu probable. Sans quête, tout est « probable ».
Set<String> hotspotQuestIds = concat(inProgress, available).stream()
.map(Quest::getId).collect(Collectors.toSet());
CampaignReadinessAssessment assessment = readinessService.assess(campaignId);
List<ReadinessGap> focused;
if (quests.isEmpty()) {
focused = assessment.gaps();
} else {
focused = assessment.gaps().stream()
.filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds))
.collect(Collectors.toList());
}
int otherGapCount = assessment.gaps().size() - focused.size();
// 4) Menaces en mouvement : horloges entamées, avec le nom de leur front.
Map<String, String> frontNames = frontRepository.findByPlaythroughId(playthroughId).stream()
.collect(Collectors.toMap(Front::getId, Front::getName, (a, b) -> a));
List<SessionPrepReport.ClockInfo> clocks = clockRepository.findByPlaythroughId(playthroughId).stream()
.filter(c -> c.getFilled() > 0)
.map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(),
c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null))
.collect(Collectors.toList());
return new SessionPrepReport(
playthroughId,
lastSessionOf(playthroughId),
toQuestInfos(inProgress),
toQuestInfos(available),
toQuestInfos(completed),
hotspots,
focused,
otherGapCount,
clocks);
}
/** Résout un nœud de quête vers son info navigable ; les refs mortes sont ignorées. */
private void resolveNode(QuestNodeRef node,
Set<String> chapterIds, Set<String> sceneIds,
List<SessionPrepReport.NodeInfo> out) {
if (node == null || node.nodeId() == null || node.nodeType() == null) return;
switch (node.nodeType()) {
case CHAPTER -> chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
if (chapterIds.add(ch.getId())) {
out.add(new SessionPrepReport.NodeInfo("CHAPTER", ch.getId(), ch.getName(), ch.getArcId(), ch.getId()));
}
});
case SCENE -> sceneRepository.findById(node.nodeId()).ifPresent(sc -> {
if (sceneIds.add(sc.getId())) {
String arcId = chapterRepository.findById(sc.getChapterId())
.map(Chapter::getArcId).orElse(null);
out.add(new SessionPrepReport.NodeInfo("SCENE", sc.getId(), sc.getName(), arcId, sc.getChapterId()));
}
});
}
}
/** Un gap est « ciblé » s'il touche une entité probable (ou une scène d'un chapitre probable). */
private boolean isFocused(ReadinessGap gap,
Set<String> chapterIds, Set<String> sceneIds, Set<String> questIds) {
return switch (gap.entityType()) {
case SCENE -> sceneIds.contains(gap.entityId())
|| (gap.chapterId() != null && chapterIds.contains(gap.chapterId()));
case CHAPTER -> chapterIds.contains(gap.entityId());
case QUEST -> questIds.contains(gap.entityId());
default -> false;
};
}
private SessionPrepReport.LastSessionInfo lastSessionOf(String playthroughId) {
List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId);
return sessions.stream()
.max(Comparator.comparing(Session::getStartedAt,
Comparator.nullsFirst(Comparator.<LocalDateTime>naturalOrder())))
.map(s -> new SessionPrepReport.LastSessionInfo(
s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive()))
.orElse(null);
}
private static List<Quest> byStatus(List<Quest> quests, Map<String, QuestStatus> statusById, QuestStatus wanted) {
return quests.stream()
.filter(q -> statusById.get(q.getId()) == wanted)
.sorted(Comparator.comparingInt(Quest::getOrder))
.collect(Collectors.toList());
}
private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) {
return quests.stream()
.map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon()))
.collect(Collectors.toList());
}
private static List<Quest> concat(List<Quest> a, List<Quest> b) {
List<Quest> out = new ArrayList<>(a);
out.addAll(b);
return out;
}
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
}

View File

@@ -0,0 +1,85 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRecapAssistant;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
/**
* Génère le récap « précédemment dans… » à lire à l'OUVERTURE d'une séance : résume le
* journal de la SÉANCE PRÉCÉDENTE (même Partie, la plus récente commencée avant celle-ci ;
* ou la dernière terminée si la courante est la plus récente). Zéro persistance : le MJ
* lit le récap, et peut choisir côté front de le consigner au journal.
*/
@Service
public class SessionRecapService {
/** Plafond du transcript envoyé au LLM (garde le budget de contexte local ~16k). */
private static final int MAX_TRANSCRIPT_CHARS = 12_000;
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final SessionRecapAssistant recapAssistant;
public SessionRecapService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
SessionRecapAssistant recapAssistant) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.recapAssistant = recapAssistant;
}
/** Récap généré + nom de la séance résumée (pour l'afficher au MJ). */
public record RecapResult(String previousSessionName, String recap) {}
public RecapResult recapPreviousSession(String sessionId) {
Session current = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
Session previous = findPreviousSession(current)
.orElseThrow(() -> new IllegalArgumentException(
"Aucune séance précédente à résumer pour cette Partie."));
List<SessionEntry> entries = entryRepository.findBySessionId(previous.getId());
if (entries.isEmpty()) {
throw new IllegalArgumentException(
"La séance précédente (« " + previous.getName() + " ») n'a aucune entrée de journal à résumer.");
}
String transcript = buildTranscript(entries);
String context = "Séance : " + previous.getName();
return new RecapResult(previous.getName(), recapAssistant.generateRecap(transcript, context));
}
/** Séance la plus récente de la même Partie commencée AVANT la courante. */
private Optional<Session> findPreviousSession(Session current) {
LocalDateTime pivot = current.getStartedAt();
return sessionRepository.findByPlaythroughId(current.getPlaythroughId()).stream()
.filter(s -> !s.getId().equals(current.getId()))
.filter(s -> s.getStartedAt() != null
&& (pivot == null || s.getStartedAt().isBefore(pivot)))
.max(Comparator.comparing(Session::getStartedAt));
}
/** Journal chronologique, une ligne par entrée, plafonné (on garde la FIN — le dénouement). */
private static String buildTranscript(List<SessionEntry> entries) {
StringBuilder sb = new StringBuilder();
for (SessionEntry e : entries) {
if (e.getContent() == null || e.getContent().isBlank()) continue;
sb.append("[").append(e.getType() != null ? e.getType().name() : "NOTE").append("] ")
.append(e.getContent().trim()).append("\n");
}
String transcript = sb.toString();
if (transcript.length() > MAX_TRANSCRIPT_CHARS) {
transcript = "\n" + transcript.substring(transcript.length() - MAX_TRANSCRIPT_CHARS);
}
return transcript;
}
}

View File

@@ -27,13 +27,16 @@ public class SessionService {
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ClockService clockService;
public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository) {
PlaythroughRepository playthroughRepository,
ClockService clockService) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.clockService = clockService;
}
/**
@@ -70,6 +73,21 @@ public class SessionService {
throw new IllegalStateException("Cette session est déjà terminée.");
}
session.setEndedAt(LocalDateTime.now());
Session saved = sessionRepository.save(session);
// Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie.
clockService.onSessionEnded(saved.getPlaythroughId());
return saved;
}
/**
* Épingle (ou dés-épingle avec {@code null}) la scène courante de la session — mode
* cockpit : « on en est là ». Weak ref : on ne valide pas l'existence de la scène
* (l'UI ne propose que des scènes réelles ; une scène supprimée rend l'épingle caduque).
*/
public Session setCurrentScene(String id, String sceneId) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null);
return sessionRepository.save(session);
}

View File

@@ -3,12 +3,17 @@ package com.loremind.domain.campaigncontext;
/**
* Type structurel d'un Arc.
* - LINEAR : narration séquentielle classique (chapitres joués dans l'ordre).
* - HUB : narration non linéaire ; les chapitres sont des "quêtes" satellites
* potentiellement parallèles, soumises à des prérequis pour être débloquées.
* - HUB : narration non linéaire ; contient des QUÊTES (entités Quest rattachées).
* - SYSTEM : arc TECHNIQUE (« Quêtes libres », un par campagne au besoin) qui héberge
* les conteneurs de scènes des quêtes LIBRES (hors arc). Masqué de la
* narration dans l'arbre (ses quêtes s'affichent sous « Quêtes ») ; dans
* les exports (PDF / Foundry / backup) il apparaît sous son nom — ses
* scènes sont du vrai contenu jouable.
*
* Value Object du domaine (Bounded Context : Campaign).
*/
public enum ArcType {
LINEAR,
HUB
HUB,
SYSTEM
}

View File

@@ -21,14 +21,6 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc
/**
* Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE.
* Pertinent surtout pour les chapitres d'un Arc HUB ; ignoré pour LINEAR.
* Donnée de SCÉNARIO — partagée par toutes les Parties de la campagne.
*/
@Builder.Default
private List<Prerequisite> prerequisites = new ArrayList<>();
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;

View File

@@ -0,0 +1,21 @@
package com.loremind.domain.campaigncontext;
import java.util.List;
/**
* Proposition de patch champ-par-champ d'une entité de scénario (Pilier A — co-création
* « propose → applique »). Éphémère et non persistée (comme {@code CampaignImportProposal}).
*
* <p>Le 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.</p>
*
* @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, List<FieldProposal> fields) {
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.campaigncontext;
/**
* Proposition IA pour UN champ textuel d'une entité (Pilier A — co-création).
*
* <p>Value 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é).</p>
*
* @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) {
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.campaigncontext;
/**
* Type d'un lien narratif entre nœuds ({@link SceneBranch}) — Niveau 2.
*
* <p>{@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 ».</p>
*/
public enum LinkType {
/** Sortie / choix narratif (défaut, comportement historique). */
EXIT,
/** Indice menant à une information. */
CLUE,
/** Piste vers un autre nœud. */
LEAD
}

View File

@@ -0,0 +1,14 @@
package com.loremind.domain.campaigncontext;
/**
* Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}.
*
* <p>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.</p>
*/
public enum NodeType {
CHAPTER,
SCENE
}

View File

@@ -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.
*
* <p>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}).</p>
*
* <p>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.</p>
*/
@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 List<Prerequisite> prerequisites = new ArrayList<>();
/**
* Nœuds narratifs (Chapitres / Scènes) traversés par la quête (NN, weak refs).
*/
@Builder.Default
private List<QuestNodeRef> nodes = new ArrayList<>();
// Champs narratifs (repris de l'usage HUB du Chapter)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs pour cette quête
private String narrativeStakes; // Enjeux narratifs dramatiques
/** IDs des pages du Lore associées (weak cross-context references). */
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant la quête. */
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.campaigncontext;
/**
* Value Object : lien faible d'une {@link Quest} vers un nœud narratif
* (Chapitre ou Scène) qu'elle traverse.
*
* <p>Ré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 (NN). Immuable (record), comme
* {@link SceneBranch} / {@link RoomBranch}.</p>
*
* @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) {
}

View File

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

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.campaigncontext;
/**
* Gravité d'un manque de préparation détecté par le guidage (Pilier B).
*
* <p>Purement indicatif : aucun niveau ne BLOQUE une action. Le guidage conseille,
* il n'interdit rien (un MJ qui improvise n'est jamais empêché).</p>
*/
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
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext;
/**
* Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »).
*
* <p>À 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 &lt; PLAYABLE &lt; POLISHED,
* agrégé vers le haut en MIN() (le maillon faible tire l'ensemble vers le bas).</p>
*/
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
}

View File

@@ -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<String> illustrationImageIds = new ArrayList<>();
/**
* "Battlemap" de la scene destinee a l'export Foundry : paire { media + sidecar }.
* Le media est un fichier image ou video ({@link com.loremind.domain.files.StoredFile}),
* le sidecar le .json/.dd2vtt Universal VTT (grille, murs, portes, lumieres) sorti
* de Dungeon Alchemist / Dungeondraft. Non affichee dans l'appli : passee telle quelle
* a l'export pour recreer la Scene cote Foundry. Null = scene sans carte.
* "Battlemaps" de la scene destinees a l'export Foundry : liste de paires
* { media + sidecar } etiquetees ({@link SceneBattlemap}). Plusieurs cartes =
* variantes de la meme scene (Jour/Nuit, etages, avant/apres). Le media est un
* fichier image ou video ({@link com.loremind.domain.files.StoredFile}), le sidecar
* le .json/.dd2vtt Universal VTT (grille, murs, portes, lumieres) sorti de
* Dungeon Alchemist / Dungeondraft. Non affichees dans l'appli : passees telles
* quelles a l'export pour recreer les Scenes cote Foundry. Vide = scene sans carte.
*/
private String battlemapMediaFileId;
@Builder.Default
private List<SceneBattlemap> battlemaps = new ArrayList<>();
/** ID du fichier sidecar Universal VTT (json) associe au media. Null si absent. */
private String battlemapDataFileId;
/**
* Position X/Y du nœud dans la vue graphe du chapitre (Niveau 2). {@code null} =
* pas encore positionné → le layout automatique (BFS) place le nœud à l'ouverture.
*/
private Double graphX;
private Double graphY;
/**
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre).

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext;
/**
* Value Object représentant UNE battlemap d'une Scene destinée à l'export Foundry :
* paire { media + sidecar Universal VTT } plus un libellé libre. Une scène peut en
* porter plusieurs (variantes « Jour » / « Nuit », étages, états avant/après…).
* <p>
* 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 = "";
}
}

View File

@@ -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) {

View File

@@ -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) {
}

View File

@@ -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<SceneDraft> scenes) {
}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext;
/**
* Type narratif d'un nœud (Scène) — Niveau 2 (graphe de nœuds typés).
*
* <p>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).</p>
*/
public enum SceneType {
/** Scène non typée (défaut). */
GENERIC,
/** Un lieu à explorer. */
LOCATION,
/** Une rencontre / un combat. */
ENCOUNTER,
/** Une situation centrée sur un PNJ. */
NPC,
/** Un événement scénarisé. */
EVENT,
/** Une révélation / un indice majeur. */
REVELATION
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Erreur de génération lors de l'étoffage IA d'une entité narrative (Brain injoignable,
* réponse inexploitable…). Traduite en HTTP 502 par le controller.
*/
public class NarrativeAssistException extends RuntimeException {
public NarrativeAssistException(String message) {
super(message);
}
public NarrativeAssistException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,32 @@
package com.loremind.domain.campaigncontext.ports;
import java.util.List;
/**
* Port de sortie (Pilier A — co-création) : demande à l'IA d'ÉTOFFER les champs d'une
* entité narrative (arc, chapitre ou scène). Implémenté par un client du Brain. One-shot.
*
* <p>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.</p>
*/
public interface NarrativeFieldAssistant {
/** Spécification d'un champ à proposer : clé technique + libellé lisible (pour le prompt). */
record FieldSpec(String key, String label) {}
/** Un champ proposé par l'IA (clé appartenant à la whitelist fournie). */
record ProposedField(String key, String value) {}
/**
* Génère des propositions de valeurs pour les champs d'une entité narrative.
*
* @param entityType {@code "arc"|"chapter"|"scene"} (pour la formulation du prompt)
* @param context contexte narratif compact (état actuel de l'entité + campagne)
* @param instruction consigne libre optionnelle du MJ (peut être vide/nulle)
* @param fields champs autorisés (whitelist) avec leur libellé
* @return champs proposés (uniquement des clés autorisées, valeurs non vides) ; vide
* si l'IA n'a rien de pertinent à proposer
*/
List<ProposedField> assist(String entityType, String context, String instruction, List<FieldSpec> fields);
}

View File

@@ -0,0 +1,27 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Quest;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Quests.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface QuestRepository {
Quest save(Quest quest);
Optional<Quest> findById(String id);
List<Quest> findByCampaignId(String campaignId);
/** Quêtes rattachées à un arc (HUB). Vide si aucune. */
List<Quest> findByArcId(String arcId);
List<Quest> findAll();
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.SceneDraft;
import java.util.List;
/**
* Port de sortie (Pilier A — capacité « create ») : demande à l'IA d'ÉBAUCHER des scènes
* pour un chapitre. Implémenté par un client du Brain. One-shot (pas de streaming).
*/
public interface SceneDraftAssistant {
/**
* Génère des ébauches de scènes cohérentes avec le contexte fourni.
*
* @param context contexte narratif compact (chapitre + campagne + scènes existantes)
* @param instruction consigne libre optionnelle du MJ (peut être vide/nulle)
* @param count nombre de scènes souhaité (indicatif)
* @return ébauches proposées (titres non vides) ; vide si rien de pertinent
*/
List<SceneDraft> draftScenes(String context, String instruction, int count);
}

View File

@@ -0,0 +1,55 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Horloge de progression (Clock, façon <i>Blades in the Dark</i>) — état dynamique de partie.
*
* <p>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.</p>
*/
@Data
@Builder
public class Clock {
private String id;
/** Weak reference vers le Playthrough parent. */
private String playthroughId;
private String name;
/** Ce qui se passe quand l'horloge est pleine (optionnel). */
private String description;
/** Nombre total de segments (≥ 1). */
private int segments;
/** Segments remplis (borné à [0, segments]). */
private int filled;
/** Ordre d'affichage parmi les horloges de la Partie. */
private int order;
/** Déclencheur d'avancement automatique (co-MJ). Défaut {@link ClockTrigger#NONE}. */
@Builder.Default
private ClockTrigger triggerType = ClockTrigger.NONE;
/** Cible du déclencheur : nom du fait (FLAG_SET) ou id de quête (QUEST_COMPLETED) ; sinon null. */
private String triggerRef;
/** Front (menace) auquel l'horloge appartient, ou null (horloge libre). Weak reference. */
private String frontId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public boolean isComplete() {
return filled >= segments;
}
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.playcontext;
/**
* Déclencheur d'avancement automatique d'une {@link Clock} (co-MJ) : l'horloge
* avance d'un segment quand l'événement lié survient dans la Partie. Le MJ garde
* la main (il peut reculer). {@code triggerRef} précise la cible selon le type.
*/
public enum ClockTrigger {
/** Aucun : l'horloge n'avance qu'à la main. */
NONE,
/** Quand un Fait (flag) passe à vrai — {@code triggerRef} = nom du fait. */
FLAG_SET,
/** Quand une quête passe à COMPLETED — {@code triggerRef} = id de la quête. */
QUEST_COMPLETED,
/** À la clôture d'une séance de la Partie — pas de {@code triggerRef}. */
SESSION_ENDED
}

View File

@@ -0,0 +1,31 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Front (menace) d'une Partie : regroupement nommé d'horloges de progression
* ({@link Clock}) sous une même menace (ex. « La montée du Culte »). État de
* Partie, comme les horloges — orthogonal à l'arbre Arc/Chapitre/Scène.
*/
@Data
@Builder
public class Front {
private String id;
/** Weak reference vers le Playthrough parent. */
private String playthroughId;
private String name;
private String description;
/** Ordre d'affichage parmi les fronts de la Partie. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -11,7 +11,7 @@ import lombok.Data;
* le scénario et l'état de jeu : ici, la progression est exclusivement
* propre à une instance jouée (Playthrough).</p>
*
* <p>Référence le Chapter par weak reference (chapterId) pour respecter les
* <p>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).</p>
*
@@ -24,6 +24,6 @@ public class QuestProgression {
private String id;
private String playthroughId;
private String chapterId;
private String questId;
private ProgressionStatus status;
}

View File

@@ -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;

View File

@@ -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<Clock> findById(String id);
/** Horloges d'une Partie, triées par {@code order} croissant. */
List<Clock> findByPlaythroughId(String playthroughId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.Front;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Fronts (menaces regroupant des horloges).
*/
public interface FrontRepository {
Front save(Front front);
Optional<Front> findById(String id);
/** Fronts d'une Partie, triés par {@code order} croissant. */
List<Front> findByPlaythroughId(String playthroughId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -17,15 +17,18 @@ public interface QuestProgressionRepository {
/** Liste toutes les progressions explicites d'un Playthrough. */
List<QuestProgression> findByPlaythroughId(String playthroughId);
/** Set des IDs de chapitres en COMPLETED pour un Playthrough donné (fast path éval). */
Set<String> findCompletedChapterIdsByPlaythroughId(String playthroughId);
/** Set des IDs de quêtes en COMPLETED pour un Playthrough donné (fast path éval). */
Set<String> findCompletedQuestIdsByPlaythroughId(String playthroughId);
/**
* Crée ou met à jour le statut d'une quête pour un Playthrough.
* Si {@code status == NOT_STARTED}, la ligne est supprimée (sémantique "absence").
*/
void setStatus(String playthroughId, String chapterId, ProgressionStatus status);
void setStatus(String playthroughId, String questId, ProgressionStatus status);
/** Supprime toutes les progressions d'un Playthrough (cascade applicative). */
void deleteAllByPlaythroughId(String playthroughId);
/** Supprime toutes les progressions d'une quête (cascade à la suppression d'une Quest). */
void deleteByQuestId(String questId);
}

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.playcontext.ports;
/**
* Port de sortie (mode séance) : génère le récapitulatif « précédemment… » d'une séance
* à partir de son journal. Implémenté par un client du Brain. One-shot, texte libre.
*/
public interface SessionRecapAssistant {
/**
* Résume le journal d'une séance en un récap à lire aux joueurs à l'ouverture
* de la séance suivante.
*
* @param transcript entrées du journal, chronologiques, une par ligne
* @param context méta courte (nom de la campagne / de la séance), peut être vide
* @return le récap rédigé (jamais null ; peut être court si le journal est maigre)
*/
String generateRecap(String transcript, String context);
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.playcontext.ports;
/**
* Erreur de génération du récap de séance (Brain injoignable, réponse vide…).
* Traduite en HTTP 502 par le controller.
*/
public class SessionRecapException extends RuntimeException {
public SessionRecapException(String message) {
super(message);
}
public SessionRecapException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,95 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.ports.NarrativeAssistException;
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Adapter de sortie : étoffe les champs d'une entité narrative via le Brain (POST one-shot).
* Générique par {@code entityType}. Le Core envoie les champs AVEC leur libellé (source de
* vérité) ; double garde-fou : on ne retient que les clés autorisées, valeurs non vides.
*/
@Component
public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
private static final String GENERATE_PATH = "/generate/narrative-fields";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainNarrativeFieldClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
@SuppressWarnings("unchecked")
public List<ProposedField> assist(String entityType, String context, String instruction, List<FieldSpec> fields) {
List<Map<String, String>> fieldPayload = new ArrayList<>();
Set<String> allowed = new HashSet<>();
if (fields != null) {
for (FieldSpec f : fields) {
if (f == null || f.key() == null) continue;
allowed.add(f.key());
Map<String, String> fm = new LinkedHashMap<>();
fm.put("key", f.key());
fm.put("label", f.label() == null ? f.key() : f.label());
fieldPayload.add(fm);
}
}
Map<String, Object> req = new LinkedHashMap<>();
req.put("entity_type", entityType == null ? "" : entityType);
req.put("context", context == null ? "" : context);
req.put("instruction", instruction == null ? "" : instruction);
req.put("fields", fieldPayload);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
Map<String, Object> resp;
try {
resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class);
} catch (ResourceAccessException e) {
throw new NarrativeAssistException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new NarrativeAssistException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new NarrativeAssistException("Erreur inattendue lors de l'appel au Brain.", e);
}
if (resp == null) {
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
}
List<ProposedField> out = new ArrayList<>();
Object rawFields = resp.get("fields");
if (rawFields instanceof Map<?, ?> m) {
for (Map.Entry<?, ?> e : m.entrySet()) {
String key = e.getKey() == null ? null : e.getKey().toString();
if (key == null || !allowed.contains(key)) continue; // whitelist stricte
String value = e.getValue() == null ? null : e.getValue().toString();
if (value == null || value.isBlank()) continue; // pas de remplissage vide
out.add(new ProposedField(key, value.trim()));
}
}
return out;
}
}

View File

@@ -0,0 +1,91 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.SceneDraft;
import com.loremind.domain.campaigncontext.ports.NarrativeAssistException;
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Adapter de sortie : ébauche des scènes pour un chapitre via le Brain (POST one-shot).
* Calqué sur les autres clients Brain. Ne retient que les ébauches avec un titre non vide.
*/
@Component
public class BrainSceneDraftClient implements SceneDraftAssistant {
private static final String GENERATE_PATH = "/generate/scene-drafts";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainSceneDraftClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
@SuppressWarnings("unchecked")
public List<SceneDraft> draftScenes(String context, String instruction, int count) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("context", context == null ? "" : context);
req.put("instruction", instruction == null ? "" : instruction);
req.put("count", count);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
Map<String, Object> resp;
try {
resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class);
} catch (ResourceAccessException e) {
throw new NarrativeAssistException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new NarrativeAssistException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new NarrativeAssistException("Erreur inattendue lors de l'appel au Brain.", e);
}
if (resp == null) {
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
}
List<SceneDraft> out = new ArrayList<>();
Object rawScenes = resp.get("scenes");
if (rawScenes instanceof List<?> list) {
for (Object item : list) {
if (!(item instanceof Map<?, ?> m)) continue;
String name = asString(m.get("name"));
if (name == null || name.isBlank()) continue; // un titre est obligatoire
out.add(new SceneDraft(
name.trim(),
trimOrNull(asString(m.get("description"))),
trimOrNull(asString(m.get("playerNarration")))));
}
}
return out;
}
private static String asString(Object o) {
return o != null ? o.toString() : null;
}
private static String trimOrNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
}

View File

@@ -0,0 +1,64 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.playcontext.ports.SessionRecapAssistant;
import com.loremind.domain.playcontext.ports.SessionRecapException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Adapter de sortie : récap « précédemment… » d'une séance via le Brain (POST one-shot).
* Calqué sur les autres clients Brain.
*/
@Component
public class BrainSessionRecapClient implements SessionRecapAssistant {
private static final String GENERATE_PATH = "/generate/session-recap";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainSessionRecapClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
@SuppressWarnings("unchecked")
public String generateRecap(String transcript, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("transcript", transcript == null ? "" : transcript);
req.put("context", context == null ? "" : context);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
Map<String, Object> resp;
try {
resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class);
} catch (ResourceAccessException e) {
throw new SessionRecapException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new SessionRecapException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new SessionRecapException("Erreur inattendue lors de l'appel au Brain.", e);
}
Object recap = resp != null ? resp.get("recap") : null;
if (recap == null || recap.toString().isBlank()) {
throw new SessionRecapException("Le Brain a renvoyé un récap vide.");
}
return recap.toString().trim();
}
}

View File

@@ -0,0 +1,67 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Convertit une {@code List<QuestNodeRef>} (VO du domaine) en JSON stocké en
* colonne TEXT, et inversement.
* <p>
* Même approche que {@code PrerequisiteListJsonConverter} : conversion manuelle
* map ↔ record pour garder le domaine pur (zéro annotation Jackson) et un format
* on-disk stable, indépendant des noms de classes Java.
* <p>
* Format JSON :
* [ {"nodeType": "CHAPTER", "nodeId": "42", "order": 0}, ... ]
*/
@Converter
public class QuestNodeListJsonConverter implements AttributeConverter<List<QuestNodeRef>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<QuestNodeRef> attribute) {
if (attribute == null || attribute.isEmpty()) return "[]";
try {
List<Map<String, Object>> raw = new ArrayList<>(attribute.size());
for (QuestNodeRef n : attribute) {
Map<String, Object> m = new HashMap<>();
m.put("nodeType", n.nodeType().name());
m.put("nodeId", n.nodeId());
m.put("order", n.order());
raw.add(m);
}
return MAPPER.writeValueAsString(raw);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<QuestNodeRef> → JSON", e);
}
}
@Override
public List<QuestNodeRef> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return new ArrayList<>();
try {
List<Map<String, Object>> raw = MAPPER.readValue(dbData, new TypeReference<>() {});
List<QuestNodeRef> result = new ArrayList<>(raw.size());
for (Map<String, Object> m : raw) {
NodeType type = NodeType.valueOf(String.valueOf(m.get("nodeType")));
String nodeId = String.valueOf(m.get("nodeId"));
Object o = m.get("order");
int order = (o instanceof Number num) ? num.intValue() : 0;
result.add(new QuestNodeRef(type, nodeId, order));
}
return result;
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → List<QuestNodeRef>", e);
}
}
}

View File

@@ -0,0 +1,45 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.SceneBattlemap;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
/**
* Convertit une List<SceneBattlemap> du domaine en chaîne JSON stockée en base,
* et inversement. Même pattern que SceneBranchListJsonConverter.
*/
@Converter
public class SceneBattlemapListJsonConverter implements AttributeConverter<List<SceneBattlemap>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<SceneBattlemap> attribute) {
if (attribute == null || attribute.isEmpty()) {
return "[]";
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<SceneBattlemap> → JSON", e);
}
}
@Override
public List<SceneBattlemap> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return Collections.emptyList();
}
try {
return MAPPER.readValue(dbData, new TypeReference<>() {
});
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → List<SceneBattlemap>", e);
}
}
}

View File

@@ -1,7 +1,5 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
@@ -39,16 +37,6 @@ public class ChapterJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
/**
* Conditions de déblocage (combinées en ET). Sérialisées en JSON dans une colonne TEXT
* via {@link PrerequisiteListJsonConverter} (sealed type côté domaine).
* Donnée de SCÉNARIO — l'état réel est sur Playthrough.QuestProgression.
*/
@Column(name = "prerequisites", columnDefinition = "TEXT")
@Convert(converter = PrerequisiteListJsonConverter.class)
@Builder.Default
private List<Prerequisite> prerequisites = new ArrayList<>();
@Column
private String icon;

View File

@@ -0,0 +1,76 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.playcontext.ClockTrigger;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des Horloges (Clocks) en PostgreSQL.
* Adaptateur d'infrastructure — n'est PAS dans le domaine.
*/
@Entity
@Table(name = "clocks", indexes = @Index(name = "ix_clock_playthrough", columnList = "playthrough_id"))
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ClockJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "playthrough_id", nullable = false)
private Long playthroughId;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
private int segments;
@Column(nullable = false)
private int filled;
@Column(name = "\"order\"", nullable = false)
private int order;
/** Déclencheur d'avancement auto (co-MJ). Défaut NONE ; colonne dotée d'un default SQL (V16). */
@Enumerated(EnumType.STRING)
@Column(name = "trigger_type", length = 32)
@Builder.Default
private ClockTrigger triggerType = ClockTrigger.NONE;
@Column(name = "trigger_ref")
private String triggerRef;
/** Front (menace) auquel l'horloge appartient (null = libre). Weak reference, pas de FK. */
@Column(name = "front_id")
private Long frontId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,56 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des Fronts en PostgreSQL.
* Adaptateur d'infrastructure — n'est PAS dans le domaine.
*/
@Entity
@Table(name = "fronts", indexes = @Index(name = "ix_front_playthrough", columnList = "playthrough_id"))
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FrontJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "playthrough_id", nullable = false)
private Long playthroughId;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,101 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA pour la persistance des Quests (table {@code quests}).
*
* <p>Calquée sur {@code ChapterJpaEntity} : rattachement campagne ({@code campaign_id})
* au lieu d'arc, + colonne {@code nodes} (liens vers chapitres/scènes) sérialisée
* en JSON. Le converter {@link PrerequisiteListJsonConverter} est RÉUTILISÉ tel
* quel pour les prérequis (format on-disk identique à celui des chapitres).</p>
*/
@Entity
@Table(name = "quests")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QuestJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
/** Arc de rattachement (nullable, weak ref — pas de FK, agrégat Quest indépendant). */
@Column(name = "arc_id")
private Long arcId;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column
private String icon;
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "prerequisites", columnDefinition = "TEXT")
@Convert(converter = PrerequisiteListJsonConverter.class)
@Builder.Default
private List<Prerequisite> prerequisites = new ArrayList<>();
@Column(name = "nodes", columnDefinition = "TEXT")
@Convert(converter = QuestNodeListJsonConverter.class)
@Builder.Default
private List<QuestNodeRef> nodes = new ArrayList<>();
@Column(name = "gm_notes", columnDefinition = "TEXT")
private String gmNotes;
@Column(name = "player_objectives", columnDefinition = "TEXT")
private String playerObjectives;
@Column(name = "narrative_stakes", columnDefinition = "TEXT")
private String narrativeStakes;
@Column(name = "related_page_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> relatedPageIds = new ArrayList<>();
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -8,17 +8,17 @@ import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Entité JPA pour la progression d'une quête (Chapter) au sein d'un Playthrough.
* Entité JPA pour la progression d'une quête (Quest) au sein d'un Playthrough.
*
* <p>Contrainte d'unicité sur (playthrough_id, chapter_id) : une seule ligne par
* <p>Contrainte d'unicité sur (playthrough_id, quest_id) : une seule ligne par
* couple quête × partie. L'absence de ligne = NOT_STARTED.</p>
*/
@Entity
@Table(
name = "quest_progression",
uniqueConstraints = @UniqueConstraint(
name = "uk_quest_progression_unique",
columnNames = {"playthrough_id", "chapter_id"}
name = "uk_quest_progression_quest",
columnNames = {"playthrough_id", "quest_id"}
),
indexes = @Index(name = "ix_quest_progression_playthrough", columnList = "playthrough_id")
)
@@ -35,8 +35,8 @@ public class QuestProgressionJpaEntity {
@Column(name = "playthrough_id", nullable = false)
private Long playthroughId;
@Column(name = "chapter_id", nullable = false)
private Long chapterId;
@Column(name = "quest_id", nullable = false)
private Long questId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)

View File

@@ -1,8 +1,11 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.SceneBattlemap;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.SceneType;
import com.loremind.infrastructure.persistence.converter.RoomListJsonConverter;
import com.loremind.infrastructure.persistence.converter.SceneBattlemapListJsonConverter;
import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
@@ -44,6 +47,12 @@ public class SceneJpaEntity {
@Column
private String icon;
/** Type narratif du nœud (Niveau 2). Défaut GENERIC ; colonne dotée d'un default SQL (V13). */
@Enumerated(EnumType.STRING)
@Column(name = "scene_type", length = 32)
@Builder.Default
private SceneType type = SceneType.GENERIC;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
// Contexte et ambiance
@@ -91,13 +100,21 @@ public class SceneJpaEntity {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/** Battlemap Foundry : fichier media (image/video). Null = pas de carte. */
@Column(name = "battlemap_media_file_id")
private String battlemapMediaFileId;
/**
* Battlemaps Foundry : liste de paires { media + sidecar } étiquetées
* (variantes Jour/Nuit, étages…). JSON en TEXT via converter (V22).
*/
@Column(name = "battlemaps", columnDefinition = "TEXT")
@Convert(converter = SceneBattlemapListJsonConverter.class)
@Builder.Default
private List<SceneBattlemap> battlemaps = new ArrayList<>();
/** Battlemap Foundry : fichier sidecar Universal VTT (json/dd2vtt). Null si absent. */
@Column(name = "battlemap_data_file_id")
private String battlemapDataFileId;
/** Position du nœud dans la vue graphe du chapitre (Niveau 2). Null = layout auto. */
@Column(name = "graph_x")
private Double graphX;
@Column(name = "graph_y")
private Double graphY;
// Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).

View File

@@ -49,6 +49,10 @@ public class SessionJpaEntity {
@Column(name = "ended_at")
private LocalDateTime endedAt;
/** Scène courante épinglée (weak ref nullable, mode cockpit). */
@Column(name = "current_scene_id")
private Long currentSceneId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.ClockJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Repository Spring Data JPA pour ClockJpaEntity.
*/
@Repository
public interface ClockJpaRepository extends JpaRepository<ClockJpaEntity, Long> {
List<ClockJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.FrontJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Repository Spring Data JPA pour FrontJpaEntity.
*/
@Repository
public interface FrontJpaRepository extends JpaRepository<FrontJpaEntity, Long> {
List<FrontJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
}

View File

@@ -0,0 +1,18 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.QuestJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Repository Spring Data JPA pour QuestJpaEntity.
*/
@Repository
public interface QuestJpaRepository extends JpaRepository<QuestJpaEntity, Long> {
List<QuestJpaEntity> findByCampaignId(Long campaignId);
List<QuestJpaEntity> findByArcId(Long arcId);
}

View File

@@ -16,10 +16,15 @@ public interface QuestProgressionJpaRepository extends JpaRepository<QuestProgre
List<QuestProgressionJpaEntity> findByPlaythroughId(Long playthroughId);
Optional<QuestProgressionJpaEntity> findByPlaythroughIdAndChapterId(Long playthroughId, Long chapterId);
Optional<QuestProgressionJpaEntity> findByPlaythroughIdAndQuestId(Long playthroughId, Long questId);
@Modifying
@Transactional
@Query("DELETE FROM QuestProgressionJpaEntity q WHERE q.playthroughId = :playthroughId")
void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId);
@Modifying
@Transactional
@Query("DELETE FROM QuestProgressionJpaEntity q WHERE q.questId = :questId")
void deleteByQuestId(@Param("questId") Long questId);
}

View File

@@ -117,13 +117,15 @@ public class PlaythroughMigrationRunner {
);
}
private int migrateProgressions(JdbcTemplate jdbc) {
int migrateProgressions(JdbcTemplate jdbc) {
if (!columnExists(jdbc, "chapters", "progression_status")) return 0;
if (!tableExists(jdbc, "quest_progression")) return 0;
// On copie les chapitres dont la progression était != NOT_STARTED, vers la Partie principale
// de la campagne du chapitre. Déduplique via NOT EXISTS sur (playthrough_id, chapter_id).
// On copie les chapitres dont la progression était != NOT_STARTED, vers la Partie
// principale de la campagne du chapitre. Niveau 1 : la colonne cible est quest_id
// (renommée par V11). L'id du chapitre == id de la quête (continuité d'id, décision D4).
// Déduplique via NOT EXISTS sur (playthrough_id, quest_id).
return jdbc.update(
"INSERT INTO quest_progression (playthrough_id, chapter_id, status) " +
"INSERT INTO quest_progression (playthrough_id, quest_id, status) " +
"SELECT p.id, ch.id, ch.progression_status " +
"FROM chapters ch " +
"JOIN arcs a ON a.id = ch.arc_id " +
@@ -132,7 +134,7 @@ public class PlaythroughMigrationRunner {
" AND ch.progression_status <> 'NOT_STARTED' " +
" AND NOT EXISTS (" +
" SELECT 1 FROM quest_progression qp " +
" WHERE qp.playthrough_id = p.id AND qp.chapter_id = ch.id" +
" WHERE qp.playthrough_id = p.id AND qp.quest_id = ch.id" +
" )"
);
}

View File

@@ -71,9 +71,6 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(jpaEntity.getDescription())
.arcId(jpaEntity.getArcId().toString())
.order(jpaEntity.getOrder())
.prerequisites(jpaEntity.getPrerequisites() != null
? new ArrayList<>(jpaEntity.getPrerequisites())
: new ArrayList<>())
.icon(jpaEntity.getIcon())
.gmNotes(jpaEntity.getGmNotes())
.playerObjectives(jpaEntity.getPlayerObjectives())
@@ -97,9 +94,6 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(chapter.getDescription())
.arcId(Long.parseLong(chapter.getArcId()))
.order(chapter.getOrder())
.prerequisites(chapter.getPrerequisites() != null
? new ArrayList<>(chapter.getPrerequisites())
: new ArrayList<>())
.icon(chapter.getIcon())
.gmNotes(chapter.getGmNotes())
.playerObjectives(chapter.getPlayerObjectives())

View File

@@ -0,0 +1,87 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.playcontext.Clock;
import com.loremind.domain.playcontext.ClockTrigger;
import com.loremind.domain.playcontext.ports.ClockRepository;
import com.loremind.infrastructure.persistence.entity.ClockJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ClockJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Adaptateur d'infrastructure : implémente le port {@link ClockRepository} via JPA/Postgres.
*/
@Repository
public class PostgresClockRepository implements ClockRepository {
private final ClockJpaRepository jpaRepository;
public PostgresClockRepository(ClockJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Clock save(Clock clock) {
return toDomain(jpaRepository.save(toJpaEntity(clock)));
}
@Override
public Optional<Clock> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
}
@Override
public List<Clock> findByPlaythroughId(String playthroughId) {
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private Clock toDomain(ClockJpaEntity e) {
return Clock.builder()
.id(e.getId().toString())
.playthroughId(e.getPlaythroughId() != null ? e.getPlaythroughId().toString() : null)
.name(e.getName())
.description(e.getDescription())
.segments(e.getSegments())
.filled(e.getFilled())
.order(e.getOrder())
.triggerType(e.getTriggerType() != null ? e.getTriggerType() : ClockTrigger.NONE)
.triggerRef(e.getTriggerRef())
.frontId(e.getFrontId() != null ? e.getFrontId().toString() : null)
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private ClockJpaEntity toJpaEntity(Clock c) {
Long id = c.getId() != null ? Long.parseLong(c.getId()) : null;
return ClockJpaEntity.builder()
.id(id)
.playthroughId(c.getPlaythroughId() != null ? Long.parseLong(c.getPlaythroughId()) : null)
.name(c.getName())
.description(c.getDescription())
.segments(c.getSegments())
.filled(c.getFilled())
.order(c.getOrder())
.triggerType(c.getTriggerType() != null ? c.getTriggerType() : ClockTrigger.NONE)
.triggerRef(c.getTriggerRef())
.frontId(c.getFrontId() != null ? Long.parseLong(c.getFrontId()) : null)
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,76 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.playcontext.Front;
import com.loremind.domain.playcontext.ports.FrontRepository;
import com.loremind.infrastructure.persistence.entity.FrontJpaEntity;
import com.loremind.infrastructure.persistence.jpa.FrontJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Adaptateur d'infrastructure : implémente le port {@link FrontRepository} via JPA/Postgres.
*/
@Repository
public class PostgresFrontRepository implements FrontRepository {
private final FrontJpaRepository jpaRepository;
public PostgresFrontRepository(FrontJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Front save(Front front) {
return toDomain(jpaRepository.save(toJpaEntity(front)));
}
@Override
public Optional<Front> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
}
@Override
public List<Front> findByPlaythroughId(String playthroughId) {
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private Front toDomain(FrontJpaEntity e) {
return Front.builder()
.id(e.getId().toString())
.playthroughId(e.getPlaythroughId() != null ? e.getPlaythroughId().toString() : null)
.name(e.getName())
.description(e.getDescription())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private FrontJpaEntity toJpaEntity(Front f) {
Long id = f.getId() != null ? Long.parseLong(f.getId()) : null;
return FrontJpaEntity.builder()
.id(id)
.playthroughId(f.getPlaythroughId() != null ? Long.parseLong(f.getPlaythroughId()) : null)
.name(f.getName())
.description(f.getDescription())
.order(f.getOrder())
.createdAt(f.getCreatedAt())
.updatedAt(f.getUpdatedAt())
.build();
}
}

View File

@@ -29,34 +29,34 @@ public class PostgresQuestProgressionRepository implements QuestProgressionRepos
}
@Override
public Set<String> findCompletedChapterIdsByPlaythroughId(String playthroughId) {
public Set<String> findCompletedQuestIdsByPlaythroughId(String playthroughId) {
Set<String> out = new HashSet<>();
for (QuestProgressionJpaEntity e : jpa.findByPlaythroughId(Long.parseLong(playthroughId))) {
if (e.getStatus() == ProgressionStatus.COMPLETED) {
out.add(e.getChapterId().toString());
out.add(e.getQuestId().toString());
}
}
return out;
}
@Override
public void setStatus(String playthroughId, String chapterId, ProgressionStatus status) {
public void setStatus(String playthroughId, String questId, ProgressionStatus status) {
Long pid = Long.parseLong(playthroughId);
Long cid = Long.parseLong(chapterId);
Long qid = Long.parseLong(questId);
// Sémantique : NOT_STARTED = absence de ligne.
if (status == null || status == ProgressionStatus.NOT_STARTED) {
jpa.findByPlaythroughIdAndChapterId(pid, cid)
jpa.findByPlaythroughIdAndQuestId(pid, qid)
.ifPresent(e -> jpa.deleteById(e.getId()));
return;
}
jpa.findByPlaythroughIdAndChapterId(pid, cid).ifPresentOrElse(
jpa.findByPlaythroughIdAndQuestId(pid, qid).ifPresentOrElse(
existing -> {
existing.setStatus(status);
jpa.save(existing);
},
() -> jpa.save(QuestProgressionJpaEntity.builder()
.playthroughId(pid)
.chapterId(cid)
.questId(qid)
.status(status)
.build())
);
@@ -67,11 +67,16 @@ public class PostgresQuestProgressionRepository implements QuestProgressionRepos
jpa.deleteByPlaythroughId(Long.parseLong(playthroughId));
}
@Override
public void deleteByQuestId(String questId) {
jpa.deleteByQuestId(Long.parseLong(questId));
}
private QuestProgression toDomain(QuestProgressionJpaEntity e) {
return QuestProgression.builder()
.id(e.getId().toString())
.playthroughId(e.getPlaythroughId().toString())
.chapterId(e.getChapterId().toString())
.questId(e.getQuestId().toString())
.status(e.getStatus())
.build();
}

View File

@@ -0,0 +1,129 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.infrastructure.persistence.entity.QuestJpaEntity;
import com.loremind.infrastructure.persistence.jpa.QuestJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Adaptateur d'infrastructure qui implémente le Port QuestRepository.
* Calqué sur {@code PostgresChapterRepository}.
*/
@Repository
public class PostgresQuestRepository implements QuestRepository {
private final QuestJpaRepository jpaRepository;
public PostgresQuestRepository(QuestJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Quest save(Quest quest) {
QuestJpaEntity saved = jpaRepository.save(toJpaEntity(quest));
return toDomainEntity(saved);
}
@Override
public Optional<Quest> findById(String id) {
return jpaRepository.findById(Long.parseLong(id))
.map(this::toDomainEntity);
}
@Override
public List<Quest> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignId(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public List<Quest> findByArcId(String arcId) {
if (arcId == null || arcId.isBlank()) return List.of(); // pas d'arc → aucune quête rattachée
return jpaRepository.findByArcId(Long.parseLong(arcId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public List<Quest> findAll() {
return jpaRepository.findAll().stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private Quest toDomainEntity(QuestJpaEntity e) {
return Quest.builder()
.id(e.getId().toString())
.campaignId(e.getCampaignId().toString())
.arcId(e.getArcId() != null ? e.getArcId().toString() : null)
.name(e.getName())
.description(e.getDescription())
.icon(e.getIcon())
.order(e.getOrder())
.prerequisites(e.getPrerequisites() != null
? new ArrayList<>(e.getPrerequisites())
: new ArrayList<>())
.nodes(e.getNodes() != null
? new ArrayList<>(e.getNodes())
: new ArrayList<>())
.gmNotes(e.getGmNotes())
.playerObjectives(e.getPlayerObjectives())
.narrativeStakes(e.getNarrativeStakes())
.relatedPageIds(e.getRelatedPageIds() != null
? new ArrayList<>(e.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(e.getIllustrationImageIds() != null
? new ArrayList<>(e.getIllustrationImageIds())
: new ArrayList<>())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private QuestJpaEntity toJpaEntity(Quest q) {
Long id = q.getId() != null ? Long.parseLong(q.getId()) : null;
return QuestJpaEntity.builder()
.id(id)
.campaignId(Long.parseLong(q.getCampaignId()))
.arcId(q.getArcId() != null && !q.getArcId().isBlank() ? Long.parseLong(q.getArcId()) : null)
.name(q.getName())
.description(q.getDescription())
.icon(q.getIcon())
.order(q.getOrder())
.prerequisites(q.getPrerequisites() != null
? new ArrayList<>(q.getPrerequisites())
: new ArrayList<>())
.nodes(q.getNodes() != null
? new ArrayList<>(q.getNodes())
: new ArrayList<>())
.gmNotes(q.getGmNotes())
.playerObjectives(q.getPlayerObjectives())
.narrativeStakes(q.getNarrativeStakes())
.relatedPageIds(q.getRelatedPageIds() != null
? new ArrayList<>(q.getRelatedPageIds())
: new ArrayList<>())
.illustrationImageIds(q.getIllustrationImageIds() != null
? new ArrayList<>(q.getIllustrationImageIds())
: new ArrayList<>())
.createdAt(q.getCreatedAt())
.updatedAt(q.getUpdatedAt())
.build();
}
}

View File

@@ -1,6 +1,7 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneType;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.infrastructure.persistence.entity.SceneJpaEntity;
import com.loremind.infrastructure.persistence.jpa.SceneJpaRepository;
@@ -72,6 +73,7 @@ public class PostgresSceneRepository implements SceneRepository {
.chapterId(jpaEntity.getChapterId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.type(jpaEntity.getType() != null ? jpaEntity.getType() : SceneType.GENERIC)
.location(jpaEntity.getLocation())
.timing(jpaEntity.getTiming())
.atmosphere(jpaEntity.getAtmosphere())
@@ -89,8 +91,11 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.battlemapMediaFileId(jpaEntity.getBattlemapMediaFileId())
.battlemapDataFileId(jpaEntity.getBattlemapDataFileId())
.battlemaps(jpaEntity.getBattlemaps() != null
? new ArrayList<>(jpaEntity.getBattlemaps())
: new ArrayList<>())
.graphX(jpaEntity.getGraphX())
.graphY(jpaEntity.getGraphY())
.branches(jpaEntity.getBranches() != null
? new ArrayList<>(jpaEntity.getBranches())
: new ArrayList<>())
@@ -111,6 +116,7 @@ public class PostgresSceneRepository implements SceneRepository {
.chapterId(Long.parseLong(scene.getChapterId()))
.order(scene.getOrder())
.icon(scene.getIcon())
.type(scene.getType() != null ? scene.getType() : SceneType.GENERIC)
.location(scene.getLocation())
.timing(scene.getTiming())
.atmosphere(scene.getAtmosphere())
@@ -128,8 +134,11 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>())
.battlemapMediaFileId(scene.getBattlemapMediaFileId())
.battlemapDataFileId(scene.getBattlemapDataFileId())
.battlemaps(scene.getBattlemaps() != null
? new ArrayList<>(scene.getBattlemaps())
: new ArrayList<>())
.graphX(scene.getGraphX())
.graphY(scene.getGraphY())
.branches(scene.getBranches() != null
? new ArrayList<>(scene.getBranches())
: new ArrayList<>())

View File

@@ -76,6 +76,7 @@ public class PostgresSessionRepository implements SessionRepository {
.playthroughId(jpa.getPlaythroughId() != null ? jpa.getPlaythroughId().toString() : null)
.startedAt(jpa.getStartedAt())
.endedAt(jpa.getEndedAt())
.currentSceneId(jpa.getCurrentSceneId() != null ? jpa.getCurrentSceneId().toString() : null)
.createdAt(jpa.getCreatedAt())
.updatedAt(jpa.getUpdatedAt())
.build();
@@ -89,6 +90,8 @@ public class PostgresSessionRepository implements SessionRepository {
.playthroughId(session.getPlaythroughId() != null ? Long.parseLong(session.getPlaythroughId()) : null)
.startedAt(session.getStartedAt())
.endedAt(session.getEndedAt())
.currentSceneId(session.getCurrentSceneId() != null && !session.getCurrentSceneId().isBlank()
? Long.parseLong(session.getCurrentSceneId()) : null)
.createdAt(session.getCreatedAt())
.updatedAt(session.getUpdatedAt())
.build();

View File

@@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.loremind.infrastructure.transfer.dto.ContentExport;
@@ -45,6 +46,7 @@ public class ExportService {
// Réutilise le converter JPA pour (dé)sérialiser les prérequis dans le MÊME
// format que la base (discriminant "kind"), au lieu de Jackson polymorphe.
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
private static final QuestNodeListJsonConverter NODE_CONVERTER = new QuestNodeListJsonConverter();
private final GameSystemJpaRepository gameSystemRepo;
private final LoreJpaRepository loreRepo;
@@ -69,6 +71,9 @@ public class ExportService {
private final SessionEntryJpaRepository sessionEntryRepo;
private final PlaythroughFlagJpaRepository playthroughFlagRepo;
private final QuestProgressionJpaRepository questProgressionRepo;
private final QuestJpaRepository questRepo;
private final ClockJpaRepository clockRepo;
private final FrontJpaRepository frontRepo;
private final ObjectMapper objectMapper;
private final String appVersion;
@@ -95,6 +100,9 @@ public class ExportService {
SessionEntryJpaRepository sessionEntryRepo,
PlaythroughFlagJpaRepository playthroughFlagRepo,
QuestProgressionJpaRepository questProgressionRepo,
QuestJpaRepository questRepo,
ClockJpaRepository clockRepo,
FrontJpaRepository frontRepo,
ObjectMapper objectMapper,
@Nullable BuildProperties buildProperties) {
this.gameSystemRepo = gameSystemRepo;
@@ -120,6 +128,9 @@ public class ExportService {
this.sessionEntryRepo = sessionEntryRepo;
this.playthroughFlagRepo = playthroughFlagRepo;
this.questProgressionRepo = questProgressionRepo;
this.questRepo = questRepo;
this.clockRepo = clockRepo;
this.frontRepo = frontRepo;
this.objectMapper = objectMapper;
this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev";
}
@@ -163,7 +174,10 @@ public class ExportService {
map(sessionRepo.findAll(), this::toSessionDto),
map(sessionEntryRepo.findAll(), this::toSessionEntryDto),
map(playthroughFlagRepo.findAll(), this::toFlagDto),
map(questProgressionRepo.findAll(), this::toQuestProgressionDto));
map(questProgressionRepo.findAll(), this::toQuestProgressionDto),
map(questRepo.findAll(), this::toQuestDto),
map(clockRepo.findAll(), this::toClockDto),
map(frontRepo.findAll(), this::toFrontDto));
}
/**
@@ -209,9 +223,16 @@ public class ExportService {
.flatMap(p -> playthroughFlagRepo.findByPlaythroughId(p.getId()).stream()).toList();
List<QuestProgressionJpaEntity> questEntities = ptEntities.stream()
.flatMap(p -> questProgressionRepo.findByPlaythroughId(p.getId()).stream()).toList();
List<ClockJpaEntity> clockEntities = ptEntities.stream()
.flatMap(p -> clockRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
List<FrontJpaEntity> frontEntities = ptEntities.stream()
.flatMap(p -> frontRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
List<CharacterJpaEntity> characterEntities = ptEntities.stream()
.flatMap(p -> characterRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
// Quêtes de la campagne (Niveau 1) — toujours incluses dans la clôture.
List<QuestJpaEntity> campaignQuests = questRepo.findByCampaignId(cid);
// Images/fichiers : uniquement les binaires RÉFÉRENCÉS par la clôture (si option active).
List<ImageJpaEntity> imageEntities = List.of();
List<StoredFileJpaEntity> fileEntities = List.of();
@@ -231,7 +252,10 @@ public class ExportService {
.distinct().toList();
Set<Long> fileRefs = new LinkedHashSet<>();
sceneEntities.forEach(s -> { addLong(fileRefs, s.getBattlemapMediaFileId()); addLong(fileRefs, s.getBattlemapDataFileId()); });
sceneEntities.forEach(s -> {
if (s.getBattlemaps() == null) return;
s.getBattlemaps().forEach(bm -> { addLong(fileRefs, bm.mediaFileId()); addLong(fileRefs, bm.dataFileId()); });
});
fileEntities = fileRefs.stream()
.map(id -> storedFileRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull).toList();
}
@@ -268,7 +292,10 @@ public class ExportService {
map(sessionEntities, this::toSessionDto),
map(entryEntities, this::toSessionEntryDto),
map(flagEntities, this::toFlagDto),
map(questEntities, this::toQuestProgressionDto));
map(questEntities, this::toQuestProgressionDto),
map(campaignQuests, this::toQuestDto),
map(clockEntities, this::toClockDto),
map(frontEntities, this::toFrontDto));
}
// ----- Helpers de chargement -----
@@ -409,6 +436,14 @@ public class ExportService {
}
Set<String> keys = new LinkedHashSet<>();
for (ContentExport.SceneDto s : export.scenes()) {
if (s.battlemaps() != null) {
s.battlemaps().forEach(bm -> {
addFileKey(keys, keyById, bm.mediaFileId());
addFileKey(keys, keyById, bm.dataFileId());
});
}
// Legacy (paire unique) : jamais renseigne sur les nouveaux exports,
// mais ce collecteur sert aussi de reference au format on-disk.
addFileKey(keys, keyById, s.battlemapMediaFileId());
addFileKey(keys, keyById, s.battlemapDataFileId());
}
@@ -487,8 +522,19 @@ public class ExportService {
}
private ContentExport.ChapterDto toChapterDto(ChapterJpaEntity e) {
// prerequisitesJson : champ conservé dans le bundle pour la rétro-compat de l'import
// legacy (un vieux backup HUB→quête le lit). Les chapitres n'ont plus de prérequis -> "[]".
return new ContentExport.ChapterDto(e.getId(), e.getName(), e.getDescription(),
e.getArcId(), e.getOrder(), PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()), e.getIcon(),
e.getArcId(), e.getOrder(), "[]", e.getIcon(),
e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(),
e.getRelatedPageIds(), e.getIllustrationImageIds());
}
private ContentExport.QuestDto toQuestDto(QuestJpaEntity e) {
return new ContentExport.QuestDto(
e.getId(), e.getCampaignId(), e.getArcId(), e.getName(), e.getDescription(), e.getIcon(), e.getOrder(),
PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()),
NODE_CONVERTER.convertToDatabaseColumn(e.getNodes()),
e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(),
e.getRelatedPageIds(), e.getIllustrationImageIds());
}
@@ -499,8 +545,9 @@ public class ExportService {
e.getTiming(), e.getAtmosphere(), e.getPlayerNarration(),
e.getGmSecretNotes(), e.getChoicesConsequences(), e.getCombatDifficulty(),
e.getEnemies(), e.getEnemyIds(), e.getRelatedPageIds(),
e.getIllustrationImageIds(), e.getBattlemapMediaFileId(),
e.getBattlemapDataFileId(), e.getBranches(), e.getRooms());
e.getIllustrationImageIds(), null, null, // legacy battlemap : plus émis
e.getBattlemaps(), e.getBranches(), e.getRooms(), e.getType(),
e.getGraphX(), e.getGraphY());
}
private ContentExport.CharacterDto toCharacterDto(CharacterJpaEntity e) {
@@ -561,6 +608,17 @@ public class ExportService {
return new ContentExport.PlaythroughDto(e.getId(), e.getCampaignId(), e.getName(), e.getDescription());
}
private ContentExport.ClockDto toClockDto(ClockJpaEntity e) {
return new ContentExport.ClockDto(e.getId(), e.getPlaythroughId(), e.getName(),
e.getDescription(), e.getSegments(), e.getFilled(), e.getOrder(),
e.getTriggerType(), e.getTriggerRef(), e.getFrontId());
}
private ContentExport.FrontDto toFrontDto(FrontJpaEntity e) {
return new ContentExport.FrontDto(e.getId(), e.getPlaythroughId(), e.getName(),
e.getDescription(), e.getOrder());
}
private ContentExport.SessionDto toSessionDto(SessionJpaEntity e) {
return new ContentExport.SessionDto(e.getId(), e.getName(), e.getCampaignId(), e.getPlaythroughId(),
e.getStartedAt() != null ? e.getStartedAt().toString() : null,
@@ -578,7 +636,9 @@ public class ExportService {
}
private ContentExport.QuestProgressionDto toQuestProgressionDto(QuestProgressionJpaEntity e) {
return new ContentExport.QuestProgressionDto(e.getId(), e.getPlaythroughId(), e.getChapterId(),
// Le champ DTO se nomme encore chapterId (format bundle v1) ; il porte désormais
// le quest id (== chapter id partagé). Le renommage du format est traité en Phase 5.
return new ContentExport.QuestProgressionDto(e.getId(), e.getPlaythroughId(), e.getQuestId(),
e.getStatus() != null ? e.getStatus().name() : null);
}
}

View File

@@ -1,7 +1,9 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.SceneBranch;
import java.util.ArrayList;
@@ -47,12 +49,17 @@ final class IdRemapper {
return out;
}
static List<Prerequisite> remapPrerequisites(Map<Long, Long> chapterMap, List<Prerequisite> prereqs) {
/**
* Remappe {@code QuestCompleted.questId} via {@code idMap}. L'appelant fournit la map
* sémantiquement correcte selon le contexte : {@code questMap} pour des prérequis de
* Quête (v2), {@code chapterMap} pour des prérequis de Chapitre legacy (v1).
*/
static List<Prerequisite> remapPrerequisites(Map<Long, Long> idMap, List<Prerequisite> prereqs) {
if (prereqs == null) return null;
List<Prerequisite> out = new ArrayList<>(prereqs.size());
for (Prerequisite p : prereqs) {
if (p instanceof Prerequisite.QuestCompleted qc) {
out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
out.add(new Prerequisite.QuestCompleted(remapStringId(idMap, qc.questId())));
} else {
out.add(p); // FlagSet / SessionReached : inchangés
}
@@ -60,11 +67,23 @@ final class IdRemapper {
return out;
}
/** Remap des nœuds d'une quête : nodeId via {@code sceneMap} (SCENE) ou {@code chapterMap} (CHAPTER). */
static List<QuestNodeRef> remapQuestNodes(Map<Long, Long> chapterMap, Map<Long, Long> sceneMap,
List<QuestNodeRef> nodes) {
if (nodes == null) return null;
List<QuestNodeRef> out = new ArrayList<>(nodes.size());
for (QuestNodeRef n : nodes) {
Map<Long, Long> map = (n.nodeType() == NodeType.SCENE) ? sceneMap : chapterMap;
out.add(new QuestNodeRef(n.nodeType(), remapStringId(map, n.nodeId()), n.order()));
}
return out;
}
static List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
if (branches == null) return null;
List<SceneBranch> out = new ArrayList<>(branches.size());
for (SceneBranch b : branches) {
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition(), b.kind()));
}
return out;
}

View File

@@ -1,9 +1,16 @@
package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.SceneBattlemap;
import com.loremind.domain.campaigncontext.SceneType;
import com.loremind.domain.playcontext.ClockTrigger;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.loremind.infrastructure.transfer.dto.ContentExport;
@@ -16,9 +23,11 @@ import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@@ -48,6 +57,7 @@ public class ImportService {
// (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite
// est scellé, non sérialisable en polymorphe par l'ObjectMapper standard).
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
private static final QuestNodeListJsonConverter NODE_CONVERTER = new QuestNodeListJsonConverter();
private final GameSystemJpaRepository gameSystemRepo;
private final LoreJpaRepository loreRepo;
@@ -68,6 +78,9 @@ public class ImportService {
private final SessionEntryJpaRepository sessionEntryRepo;
private final PlaythroughFlagJpaRepository playthroughFlagRepo;
private final QuestProgressionJpaRepository questProgressionRepo;
private final QuestJpaRepository questRepo;
private final ClockJpaRepository clockRepo;
private final FrontJpaRepository frontRepo;
private final ImageImporter imageImporter;
private final StoredFileImporter storedFileImporter;
private final ObjectMapper objectMapper;
@@ -91,6 +104,9 @@ public class ImportService {
SessionEntryJpaRepository sessionEntryRepo,
PlaythroughFlagJpaRepository playthroughFlagRepo,
QuestProgressionJpaRepository questProgressionRepo,
QuestJpaRepository questRepo,
ClockJpaRepository clockRepo,
FrontJpaRepository frontRepo,
ImageImporter imageImporter,
StoredFileImporter storedFileImporter,
ObjectMapper objectMapper) {
@@ -113,6 +129,9 @@ public class ImportService {
this.sessionEntryRepo = sessionEntryRepo;
this.playthroughFlagRepo = playthroughFlagRepo;
this.questProgressionRepo = questProgressionRepo;
this.questRepo = questRepo;
this.clockRepo = clockRepo;
this.frontRepo = frontRepo;
this.imageImporter = imageImporter;
this.storedFileImporter = storedFileImporter;
this.objectMapper = objectMapper;
@@ -143,8 +162,11 @@ public class ImportService {
Map<Long, Long> enemyMap = new HashMap<>();
Map<Long, Long> characterMap = new HashMap<>();
Map<Long, Long> sceneMap = new HashMap<>();
Map<Long, Long> questMap = new HashMap<>();
Map<Long, Long> playthroughMap = new HashMap<>();
Map<Long, Long> sessionMap = new HashMap<>();
Map<Long, Long> clockMap = new HashMap<>();
Map<Long, Long> frontMap = new HashMap<>();
// -- GameSystem
for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) {
@@ -281,6 +303,33 @@ public class ImportService {
}
result.count("playthroughFlags", flagCount);
// -- Front (menaces regroupant des horloges) : playthroughId remappé.
for (ContentExport.FrontDto d : nullSafe(export.fronts())) {
FrontJpaEntity e = new FrontJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
e.setName(d.name());
e.setDescription(d.description());
e.setOrder(d.order());
frontMap.put(d.id(), frontRepo.save(e).getId());
}
result.count("fronts", frontMap.size());
// -- Clock (horloges de Partie) : playthroughId + frontId remappés ; triggerRef quête -> 2e passe.
for (ContentExport.ClockDto d : nullSafe(export.clocks())) {
ClockJpaEntity e = new ClockJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
e.setName(d.name());
e.setDescription(d.description());
e.setSegments(d.segments());
e.setFilled(d.filled());
e.setOrder(d.order());
e.setTriggerType(d.triggerType() != null ? d.triggerType() : ClockTrigger.NONE);
e.setTriggerRef(d.triggerRef());
e.setFrontId(IdRemapper.remapId(frontMap, d.frontId()));
clockMap.put(d.id(), clockRepo.save(e).getId());
}
result.count("clocks", clockMap.size());
// -- Arc (relatedPageIds remappe en 2e passe)
for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
ArcJpaEntity e = new ArcJpaEntity();
@@ -358,14 +407,14 @@ public class ImportService {
result.count("randomTables", tableCount);
result.count("randomTableEntries", entryCount);
// -- Chapter (prerequisites + relatedPageIds remappes en 2e passe)
// -- Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de
// prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy.
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
ChapterJpaEntity e = new ChapterJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcId(IdRemapper.remapId(arcMap, d.arcId()));
e.setOrder(d.order());
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappe plus bas
e.setIcon(d.icon());
e.setGmNotes(d.gmNotes());
e.setPlayerObjectives(d.playerObjectives());
@@ -376,13 +425,55 @@ public class ImportService {
}
result.count("chapters", chapterMap.size());
// -- QuestProgression : playthroughId + chapterId remappes (chapitres deja inseres ;
// contrainte unique (playthroughId, chapterId) preservee car playthroughId neuf).
boolean bundleHasQuests = export.quests() != null;
// -- Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ;
// prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt).
for (ContentExport.QuestDto d : nullSafe(export.quests())) {
QuestJpaEntity e = new QuestJpaEntity();
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setArcId(IdRemapper.remapId(arcMap, d.arcId())); // arcMap déjà prêt (arcs importés avant) ; null→null
e.setName(d.name());
e.setDescription(d.description());
e.setIcon(d.icon());
e.setOrder(d.order());
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappé 2e passe
e.setNodes(NODE_CONVERTER.convertToEntityAttribute(d.nodesJson())); // remappé 2e passe
e.setGmNotes(d.gmNotes());
e.setPlayerObjectives(d.playerObjectives());
e.setNarrativeStakes(d.narrativeStakes());
e.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe
e.setIllustrationImageIds(d.illustrationImageIds());
questMap.put(d.id(), questRepo.save(e).getId());
}
// -- Quest legacy (bundle SANS champ quests) : on convertit les chapitres qui jouaient le
// rôle de quête (arc HUB OU porteurs de prérequis OU référencés par une progression) en
// vraies Quests, pour ne pas perdre les quêtes d'un vieux backup. Ici questMap est clé
// par ANCIEN CHAPTER id (pas de collision : on compare chapter-id à chapter-id).
if (!bundleHasQuests) {
convertLegacyChaptersToQuests(export, campaignMap, arcMap, questMap);
}
result.count("quests", questMap.size());
// -- QuestProgression : playthroughId + (quest id) remappés (entités déjà insérées ;
// contrainte unique (playthroughId, questId) préservée car playthroughId neuf).
int questProgCount = 0;
for (ContentExport.QuestProgressionDto d : nullSafe(export.questProgressions())) {
QuestProgressionJpaEntity e = new QuestProgressionJpaEntity();
e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId()));
Long oldRef = d.chapterId();
// v2 : oldRef est un quest id -> questMap. v1 : oldRef est un chapter id ; s'il a été
// converti en quête, questMap (clé chapter id) le résout, sinon fallback chapterMap.
Long newQuestId;
if (bundleHasQuests) {
newQuestId = IdRemapper.remapId(questMap, oldRef);
} else if (oldRef != null && questMap.containsKey(oldRef)) {
newQuestId = questMap.get(oldRef);
} else {
newQuestId = IdRemapper.remapId(chapterMap, oldRef);
}
e.setQuestId(newQuestId);
e.setStatus(parseProgressionStatus(d.status()));
questProgressionRepo.save(e);
questProgCount++;
@@ -451,6 +542,7 @@ public class ImportService {
e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId()));
e.setOrder(d.order());
e.setIcon(d.icon());
e.setType(d.type() != null ? d.type() : SceneType.GENERIC);
e.setLocation(d.location());
e.setTiming(d.timing());
e.setAtmosphere(d.atmosphere());
@@ -462,10 +554,17 @@ public class ImportService {
e.setEnemyIds(d.enemyIds()); // remappe plus bas
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
// Battlemap : ids StoredFile passes tels quels (meme logique que les refs
// d'images illustration, non remappees). Cf. ImportService doc.
e.setBattlemapMediaFileId(d.battlemapMediaFileId());
e.setBattlemapDataFileId(d.battlemapDataFileId());
// Battlemaps : ids StoredFile passes tels quels (meme logique que les refs
// d'images illustration, non remappees). Les exports anterieurs a V22
// portaient une paire unique -> reconstituee en premiere entree de liste.
List<SceneBattlemap> battlemaps = d.battlemaps();
if ((battlemaps == null || battlemaps.isEmpty())
&& (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) {
battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId()));
}
e.setBattlemaps(battlemaps != null ? battlemaps : List.of());
e.setGraphX(d.graphX());
e.setGraphY(d.graphY());
e.setBranches(d.branches()); // remappe plus bas
e.setRooms(d.rooms()); // Rooms: UUID, non remappes
sceneMap.put(d.id(), sceneRepo.save(e).getId());
@@ -522,11 +621,10 @@ public class ImportService {
});
}
// Chapter.relatedPageIds + prerequisites(QuestCompleted -> map Chapter)
// Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1)
for (Long newChapterId : chapterMap.values()) {
chapterRepo.findById(newChapterId).ifPresent(c -> {
c.setRelatedPageIds(IdRemapper.remapStringList(pageMap, c.getRelatedPageIds()));
c.setPrerequisites(IdRemapper.remapPrerequisites(chapterMap, c.getPrerequisites()));
chapterRepo.save(c);
});
}
@@ -549,9 +647,79 @@ public class ImportService {
});
}
// Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap),
// relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici.
for (Long newQuestId : questMap.values()) {
questRepo.findById(newQuestId).ifPresent(q -> {
q.setPrerequisites(IdRemapper.remapPrerequisites(questMap, q.getPrerequisites()));
q.setNodes(IdRemapper.remapQuestNodes(chapterMap, sceneMap, q.getNodes()));
q.setRelatedPageIds(IdRemapper.remapStringList(pageMap, q.getRelatedPageIds()));
questRepo.save(q);
});
}
// Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée
// (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper).
for (Long newClockId : clockMap.values()) {
clockRepo.findById(newClockId).ifPresent(c -> {
if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) {
c.setTriggerRef(IdRemapper.remapStringId(questMap, c.getTriggerRef()));
clockRepo.save(c);
}
});
}
return result.build();
}
/**
* Conversion legacy : pour un bundle SANS champ {@code quests}, recrée des Quests à partir
* des chapitres qui jouaient le rôle de quête (arc HUB, OU porteurs de prérequis, OU
* référencés par une {@code quest_progression}). Alimente {@code questMap} (clé = ANCIEN
* chapter id du bundle -> nouvel id de quête). Les prérequis / nœuds / relatedPageIds sont
* remappés en 2e passe, comme pour les quêtes v2.
*/
private void convertLegacyChaptersToQuests(ContentExport export,
Map<Long, Long> campaignMap,
Map<Long, Long> arcMap,
Map<Long, Long> questMap) {
Map<Long, ContentExport.ArcDto> arcById = new HashMap<>();
for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a);
Set<Long> progressedChapterIds = new HashSet<>();
for (ContentExport.QuestProgressionDto qp : nullSafe(export.questProgressions())) {
if (qp.chapterId() != null) progressedChapterIds.add(qp.chapterId());
}
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
ContentExport.ArcDto arc = arcById.get(d.arcId());
boolean isHub = arc != null && "HUB".equals(arc.type());
List<Prerequisite> prereqs = PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson());
boolean hasPrereqs = prereqs != null && !prereqs.isEmpty();
boolean inProgression = progressedChapterIds.contains(d.id());
if (!isHub && !hasPrereqs && !inProgression) continue;
QuestJpaEntity q = new QuestJpaEntity();
q.setCampaignId(IdRemapper.remapId(campaignMap, arc != null ? arc.campaignId() : null));
// Quête legacy issue d'un arc HUB → rattachée à cet arc (préserve la structure HUB) ;
// les quêtes issues de prérequis/progression seules restent transverses.
q.setArcId(isHub ? IdRemapper.remapId(arcMap, d.arcId()) : null);
q.setName(d.name());
q.setDescription(d.description());
q.setIcon(d.icon());
q.setOrder(d.order());
q.setPrerequisites(prereqs); // remappé 2e passe (questMap)
q.setNodes(new ArrayList<>(List.of(
new QuestNodeRef(NodeType.CHAPTER, String.valueOf(d.id()), 0)))); // remappé 2e passe (chapterMap)
q.setGmNotes(d.gmNotes());
q.setPlayerObjectives(d.playerObjectives());
q.setNarrativeStakes(d.narrativeStakes());
q.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe (pageMap)
// illustrationImageIds : les chapitres legacy les conservent de leur côté.
questMap.put(d.id(), questRepo.save(q).getId());
}
}
// ----- Lecture de l'archive -----
/** Contenu déballé d'un zip d'import : {@code data.json} + binaires images + fichiers. */

View File

@@ -1,7 +1,9 @@
package com.loremind.infrastructure.transfer.dto;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.SceneBattlemap;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.SceneType;
import com.loremind.domain.shared.template.TemplateField;
import java.util.List;
@@ -43,7 +45,13 @@ public record ContentExport(
List<SessionDto> sessions,
List<SessionEntryDto> sessionEntries,
List<PlaythroughFlagDto> playthroughFlags,
List<QuestProgressionDto> questProgressions
List<QuestProgressionDto> questProgressions,
// --- Quêtes (Niveau 1). Absent des bundles antérieurs -> null à la relecture (nullSafe). ---
List<QuestDto> quests,
// --- Horloges de progression (Clocks, état de Partie). Absent des anciens bundles -> nullSafe. ---
List<ClockDto> clocks,
// --- Fronts (menaces regroupant des horloges). Absent des anciens bundles -> nullSafe. ---
List<FrontDto> fronts
) {
/**
@@ -177,10 +185,17 @@ public record ContentExport(
List<String> enemyIds,
List<String> relatedPageIds,
List<String> illustrationImageIds,
// LEGACY (exports antérieurs à V22) : paire unique, lue à l'import pour
// reconstituer une entrée de `battlemaps`. Toujours null sur les nouveaux exports.
String battlemapMediaFileId,
String battlemapDataFileId,
// Battlemaps multiples (variantes Jour/Nuit, étages…) — remplace la paire ci-dessus.
List<SceneBattlemap> battlemaps,
List<SceneBranch> branches,
List<Room> rooms
List<Room> rooms,
SceneType type,
Double graphX,
Double graphY
) {}
public record CharacterDto(
@@ -327,11 +342,60 @@ public record ContentExport(
boolean value
) {}
/** La progression d'une quête (Chapter) dans une Partie ({@code status} = nom du ProgressionStatus). */
/** Une horloge de progression d'une Partie (Clock, façon Blades in the Dark). */
public record ClockDto(
Long id,
Long playthroughId,
String name,
String description,
int segments,
int filled,
int order,
com.loremind.domain.playcontext.ClockTrigger triggerType,
String triggerRef,
Long frontId
) {}
/** Un Front (menace regroupant des horloges) d'une Partie. */
public record FrontDto(
Long id,
Long playthroughId,
String name,
String description,
int order
) {}
/**
* La progression d'une quête dans une Partie ({@code status} = nom du ProgressionStatus).
* NB : le champ {@code chapterId} porte désormais le quest id (== chapter id partagé en
* format v1) — nom conservé pour la rétrocompat de lecture des anciens bundles.
*/
public record QuestProgressionDto(
Long id,
Long playthroughId,
Long chapterId,
String status
) {}
/**
* Une Quête (Niveau 1) : entité orthogonale rattachée à la campagne.
* {@code prerequisitesJson} / {@code nodesJson} = JSON brut des converters JPA
* (même logique que {@code ChapterDto.prerequisitesJson}).
*/
public record QuestDto(
Long id,
Long campaignId,
Long arcId,
String name,
String description,
String icon,
int order,
String prerequisitesJson,
String nodesJson,
String gmNotes,
String playerObjectives,
String narrativeStakes,
List<String> relatedPageIds,
List<String> illustrationImageIds
) {}
}

View File

@@ -30,6 +30,7 @@ public final class FoundryBundle {
public record Data(
String formatVersion,
Campaign campaign,
Options options,
List<Arc> arcs,
List<Quest> quests,
List<Scene> scenes,
@@ -39,6 +40,13 @@ public final class FoundryBundle {
List<Asset> assets
) {}
/**
* Perimetre choisi a l'export (additif, format 1.0) : le module s'en sert pour ne
* creer QUE ce qui a ete demande (ex. cartes+ennemis sans journaux). Un bundle sans
* ce champ (ancien export) = tout inclus — le module doit defaulter a true.
*/
public record Options(boolean maps, boolean journals, boolean tables) {}
public record Campaign(String id, String name, String description, String gameSystemId) {}
public record Arc(
@@ -59,11 +67,19 @@ public final class FoundryBundle {
String playerNarration, String gmSecretNotes, String choicesConsequences,
String combatDifficulty, String enemies, List<String> enemyIds,
List<String> illustrationAssetIds, Battlemap battlemap,
List<LabeledBattlemap> battlemaps,
List<Branch> branches, List<Room> rooms
) {}
public record Battlemap(String mediaAssetId, String dataAssetId) {}
/**
* Battlemap étiquetée (variantes Jour/Nuit, étages…). Le champ {@code battlemap}
* (première carte) est CONSERVÉ pour compatibilité avec les modules Foundry
* existants ; {@code battlemaps} porte la liste complète — additive, format 1.0.
*/
public record LabeledBattlemap(String label, String mediaAssetId, String dataAssetId) {}
public record Branch(String label, String targetSceneId, String condition) {}
public record Room(

View File

@@ -3,9 +3,9 @@ package com.loremind.infrastructure.transfer.foundry;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.RoomBranch;
import com.loremind.domain.campaigncontext.SceneBattlemap;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
@@ -90,12 +90,30 @@ public class FoundryExportService {
/** Reference d'un binaire a copier dans le zip : chemin cible + cle de stockage. */
public record BinaryRef(String path, String storageKey, boolean image) {}
/**
* Perimetre de l'export, choisi par l'utilisateur dans la modale :
* - maps : Scenes Foundry (battlemaps) + acteurs/tokens des ennemis lies.
* - journals : journaux narratifs (arcs, chapitres/quetes, scenes, PNJ, bestiaire)
* avec leurs illustrations.
* - tables : RollTables.
*/
public record ExportOptions(boolean maps, boolean journals, boolean tables) {
public static ExportOptions all() { return new ExportOptions(true, true, true); }
}
/** Bundle complet (tout le perimetre) — conserve pour les appels existants. */
public BuiltBundle buildBundle(String campaignId, String exportedAt) {
return buildBundle(campaignId, exportedAt, ExportOptions.all());
}
/**
* Assemble le bundle d'une campagne (sans toucher aux binaires : peu couteux).
* Le perimetre exclu n'est PAS embarque (ni entites ni binaires) : un export
* « cartes + ennemis » reste leger meme sur une campagne tres illustree.
*
* @throws NoSuchElementException si la campagne n'existe pas
*/
public BuiltBundle buildBundle(String campaignId, String exportedAt) {
public BuiltBundle buildBundle(String campaignId, String exportedAt, ExportOptions opts) {
CampaignJpaEntity campaign = campaignRepo.findById(Long.parseLong(campaignId))
.orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId));
@@ -113,44 +131,59 @@ public class FoundryExportService {
List<ArcJpaEntity> arcEntities = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder);
for (ArcJpaEntity arc : arcEntities) {
// Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) :
// leurs illustrations ne sont pas embarquees.
arcs.add(new FoundryBundle.Arc(
str(arc.getId()), arc.getName(), arc.getDescription(), arc.getOrder(),
arc.getType() != null ? arc.getType().name() : null, arc.getIcon(),
arc.getThemes(), arc.getStakes(), arc.getGmNotes(), arc.getRewards(), arc.getResolution(),
assets.images(arc.getIllustrationImageIds())));
opts.journals() ? assets.images(arc.getIllustrationImageIds()) : List.of()));
for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) {
quests.add(new FoundryBundle.Quest(
str(ch.getId()), str(arc.getId()), ch.getName(), ch.getDescription(), ch.getOrder(),
ch.getIcon(), ch.getPlayerObjectives(), ch.getNarrativeStakes(), ch.getGmNotes(),
prerequisites(ch.getPrerequisites()), assets.images(ch.getIllustrationImageIds())));
List.of(), opts.journals() ? assets.images(ch.getIllustrationImageIds()) : List.of()));
for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) {
scenes.add(toScene(sc, str(ch.getId()), assets));
scenes.add(toScene(sc, str(ch.getId()), assets, opts));
}
}
}
// PNJ : purement journal — hors perimetre sans les journaux.
List<FoundryBundle.Persona> npcs = new ArrayList<>();
if (opts.journals()) {
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
npcs.add(new FoundryBundle.Persona(
str(n.getId()), n.getName(), n.getFolder(), n.getOrder(),
assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null,
fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets)));
}
}
// Ennemis : necessaires aux cartes (acteurs/tokens, portrait = image du token)
// ET aux journaux (bestiaire). Les galeries d'images des champs ne servent que
// les journaux -> non embarquees en mode cartes seules.
List<FoundryBundle.Persona> enemies = new ArrayList<>();
if (opts.maps() || opts.journals()) {
for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
enemies.add(new FoundryBundle.Persona(
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(),
assets.image(e.getPortraitImageId()),
opts.journals() ? assets.image(e.getHeaderImageId()) : null,
e.getLevel(),
e.getFoundryRef(),
buildFoundryActor(e, enemyTemplate, foundryActorType),
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), e.getImageValues(), assets)));
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(),
opts.journals() ? e.getImageValues() : null, assets)));
}
}
List<FoundryBundle.RandomTable> randomTables = new ArrayList<>();
for (RandomTableJpaEntity t : randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
for (RandomTableJpaEntity t : opts.tables()
? randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())
: List.<RandomTableJpaEntity>of()) {
List<FoundryBundle.RandomTableEntry> entries = new ArrayList<>();
if (t.getEntries() != null) {
for (RandomTableEntryJpaEntity en : t.getEntries()) {
@@ -166,7 +199,9 @@ public class FoundryExportService {
str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId());
FoundryBundle.Data data = new FoundryBundle.Data(
FORMAT_VERSION, campaignNode, arcs, quests, scenes, npcs, enemies, randomTables, assets.assets());
FORMAT_VERSION, campaignNode,
new FoundryBundle.Options(opts.maps(), opts.journals(), opts.tables()),
arcs, quests, scenes, npcs, enemies, randomTables, assets.assets());
Map<String, Integer> counts = new LinkedHashMap<>();
counts.put("arcs", arcs.size());
@@ -218,22 +253,33 @@ public class FoundryExportService {
// ----- Mapping Scene -----
private FoundryBundle.Scene toScene(SceneJpaEntity sc, String questId, AssetRegistry assets) {
FoundryBundle.Battlemap battlemap = battlemap(sc.getBattlemapMediaFileId(), sc.getBattlemapDataFileId(), assets);
private FoundryBundle.Scene toScene(SceneJpaEntity sc, String questId, AssetRegistry assets, ExportOptions opts) {
List<FoundryBundle.LabeledBattlemap> battlemaps = opts.maps()
? battlemaps(sc.getBattlemaps(), assets)
: List.of();
// Champ legacy `battlemap` (première carte) conservé pour les modules Foundry existants.
FoundryBundle.Battlemap battlemap = battlemaps.isEmpty() ? null
: new FoundryBundle.Battlemap(battlemaps.get(0).mediaAssetId(), battlemaps.get(0).dataAssetId());
return new FoundryBundle.Scene(
str(sc.getId()), questId, sc.getName(), sc.getDescription(), sc.getOrder(), sc.getIcon(),
sc.getLocation(), sc.getTiming(), sc.getAtmosphere(),
sc.getPlayerNarration(), sc.getGmSecretNotes(), sc.getChoicesConsequences(),
sc.getCombatDifficulty(), sc.getEnemies(), copy(sc.getEnemyIds()),
assets.images(sc.getIllustrationImageIds()), battlemap,
branches(sc.getBranches()), rooms(sc.getRooms(), assets));
opts.journals() ? assets.images(sc.getIllustrationImageIds()) : List.of(),
battlemap, battlemaps,
branches(sc.getBranches()), rooms(sc.getRooms(), assets, opts));
}
private FoundryBundle.Battlemap battlemap(String mediaId, String dataId, AssetRegistry assets) {
String media = assets.file(mediaId, "battlemapMedia");
String data = assets.file(dataId, "battlemapData");
if (media == null && data == null) return null;
return new FoundryBundle.Battlemap(media, data);
private List<FoundryBundle.LabeledBattlemap> battlemaps(List<SceneBattlemap> maps, AssetRegistry assets) {
if (maps == null) return List.of();
List<FoundryBundle.LabeledBattlemap> out = new ArrayList<>();
for (SceneBattlemap bm : maps) {
String media = assets.file(bm.mediaFileId(), "battlemapMedia");
String data = assets.file(bm.dataFileId(), "battlemapData");
if (media == null && data == null) continue;
out.add(new FoundryBundle.LabeledBattlemap(bm.label(), media, data));
}
return out;
}
private List<FoundryBundle.Branch> branches(List<SceneBranch> branches) {
@@ -243,7 +289,7 @@ public class FoundryExportService {
return out;
}
private List<FoundryBundle.Room> rooms(List<Room> rooms, AssetRegistry assets) {
private List<FoundryBundle.Room> rooms(List<Room> rooms, AssetRegistry assets, ExportOptions opts) {
if (rooms == null) return List.of();
List<FoundryBundle.Room> out = new ArrayList<>();
for (Room r : rooms) {
@@ -254,22 +300,7 @@ public class FoundryExportService {
out.add(new FoundryBundle.Room(
r.getId(), r.getName(), r.getDescription(), r.getEnemies(), copy(r.getEnemyIds()),
r.getLoot(), r.getTraps(), r.getGmNotes(), r.getFloor(), r.getOrder(),
assets.images(r.getIllustrationImageIds()), null, rb));
}
return out;
}
private List<Map<String, Object>> prerequisites(List<Prerequisite> prereqs) {
if (prereqs == null || prereqs.isEmpty()) return List.of();
List<Map<String, Object>> out = new ArrayList<>();
for (Prerequisite p : prereqs) {
if (p instanceof Prerequisite.QuestCompleted qc) {
out.add(Map.of("type", "questCompleted", "questId", String.valueOf(qc.questId())));
} else if (p instanceof Prerequisite.SessionReached sr) {
out.add(Map.of("type", "sessionReached", "minSessionNumber", sr.minSessionNumber()));
} else if (p instanceof Prerequisite.FlagSet fs) {
out.add(Map.of("type", "flagSet", "flagName", fs.flagName()));
}
opts.journals() ? assets.images(r.getIllustrationImageIds()) : List.of(), null, rb));
}
return out;
}

View File

@@ -1,5 +1,8 @@
package com.loremind.infrastructure.transfer.pdf;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.domain.shared.template.FieldType;
@@ -43,6 +46,7 @@ public class PdfExportService {
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
private final SceneJpaRepository sceneRepo;
private final QuestJpaRepository questRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final GameSystemJpaRepository gameSystemRepo;
@@ -56,6 +60,7 @@ public class PdfExportService {
public PdfExportService(CampaignJpaRepository campaignRepo, ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo, SceneJpaRepository sceneRepo,
QuestJpaRepository questRepo,
NpcJpaRepository npcRepo, EnemyJpaRepository enemyRepo,
GameSystemJpaRepository gameSystemRepo, ImageJpaRepository imageRepo,
StoredFileJpaRepository storedFileRepo,
@@ -66,6 +71,7 @@ public class PdfExportService {
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
this.sceneRepo = sceneRepo;
this.questRepo = questRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.gameSystemRepo = gameSystemRepo;
@@ -116,6 +122,7 @@ public class PdfExportService {
cover(body, campaign);
narrative(body, bookmarks, campaign);
quests(body, bookmarks, campaign);
personas(body, bookmarks, "part-npcs", "Personnages non-joueurs (PNJ)", npcEntries(campaign), npcTemplate, false);
personas(body, bookmarks, "part-enemies", "Bestiaire", enemyEntries(campaign), enemyTemplate, true);
lore(body, bookmarks, campaign);
@@ -144,6 +151,16 @@ public class PdfExportService {
List<ArcJpaEntity> arcs = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder);
if (arcs.isEmpty()) return;
// Mode plat (miroir de l'UI Niveau 0) : 1 arc d'UN SEUL chapitre → on masque les
// niveaux Arc/Chapitre et on présente les scènes À PLAT (cohérent avec la sidebar).
if (arcs.size() == 1) {
List<ChapterJpaEntity> only = sortByOrder(chapterRepo.findByArcId(arcs.get(0).getId()), ChapterJpaEntity::getOrder);
if (only.size() == 1) {
narrativeFlat(b, bm, arcs.get(0), only.get(0));
return;
}
}
b.append("<h1 class=\"part\" id=\"part-narrative\">Structure narrative</h1>");
bm.append(bookmark("Structure narrative", "part-narrative", () -> {
StringBuilder sub = new StringBuilder();
@@ -165,7 +182,7 @@ public class PdfExportService {
for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) {
b.append("<div class=\"quest\"><div class=\"quest-head\">")
.append("<span class=\"eyebrow\">Quête</span>")
.append("<span class=\"eyebrow\">Chapitre</span>")
.append(esc(ch.getName())).append("</div>");
illustrations(b, ch.getIllustrationImageIds());
block(b, "Description", ch.getDescription());
@@ -182,6 +199,33 @@ public class PdfExportService {
}
}
/**
* Mode plat : scènes présentées sous « Scènes », sans en-têtes Arc/Chapitre. Le contenu
* narratif éventuel des niveaux masqués reste affiché en intro ({@code block} ignore les
* valeurs vides → en pratique rien n'apparaît pour un arc/chapitre auto-créés et vides).
*/
private void narrativeFlat(StringBuilder b, StringBuilder bm, ArcJpaEntity arc, ChapterJpaEntity ch) {
b.append("<h1 class=\"part\" id=\"part-narrative\">Scènes</h1>");
bm.append(bookmark("Scènes", "part-narrative", null));
illustrations(b, arc.getIllustrationImageIds());
block(b, "Description", arc.getDescription());
block(b, "Themes", arc.getThemes());
block(b, "Enjeux", arc.getStakes());
block(b, "Recompenses", arc.getRewards());
block(b, "Resolution", arc.getResolution());
block(b, "Notes MJ", arc.getGmNotes());
illustrations(b, ch.getIllustrationImageIds());
block(b, "Description", ch.getDescription());
block(b, "Objectifs joueurs", ch.getPlayerObjectives());
block(b, "Enjeux narratifs", ch.getNarrativeStakes());
block(b, "Notes MJ", ch.getGmNotes());
for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) {
scene(b, sc);
}
}
private void scene(StringBuilder b, SceneJpaEntity sc) {
b.append("<div class=\"scene\"><div class=\"scene-head\">")
.append("<span class=\"eyebrow\">Scène</span>")
@@ -194,13 +238,92 @@ public class PdfExportService {
block(b, "Choix & consequences", sc.getChoicesConsequences());
block(b, "Difficulte du combat", sc.getCombatDifficulty());
illustrations(b, sc.getIllustrationImageIds());
// Battlemap (image uniquement ; les videos ne sont pas rendables en PDF).
String battlemap = fileImageUri(sc.getBattlemapMediaFileId());
if (battlemap != null) {
b.append("<div class=\"illus\"><img src=\"").append(battlemap).append("\"/></div>");
// Battlemaps (images uniquement ; les videos ne sont pas rendables en PDF).
// Le libellé de la variante (Jour/Nuit…) est rendu en légende s'il est renseigné.
if (sc.getBattlemaps() != null) {
for (var bm : sc.getBattlemaps()) {
String battlemap = fileImageUri(bm.mediaFileId());
if (battlemap == null) continue;
b.append("<div class=\"illus\"><img src=\"").append(battlemap).append("\"/>");
if (bm.label() != null && !bm.label().isBlank()) {
b.append("<div class=\"eyebrow\">").append(esc(bm.label())).append("</div>");
}
b.append("</div>");
}
}
b.append("</div>");
}
// ----- Quêtes (Niveau 1 : entités orthogonales à l'arbre, rattachées à la campagne) -----
private void quests(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) {
List<QuestJpaEntity> quests = sortByOrder(questRepo.findByCampaignId(campaign.getId()), QuestJpaEntity::getOrder);
if (quests.isEmpty()) return;
// Index de noms pour résoudre les références : prérequis -> quête, nœuds -> chapitre/scène.
Map<String, String> questNames = new HashMap<>();
for (QuestJpaEntity q : quests) questNames.put(String.valueOf(q.getId()), q.getName());
Map<String, String> chapterNames = new HashMap<>();
Map<String, String> sceneNames = new HashMap<>();
for (ArcJpaEntity arc : arcRepo.findByCampaignId(campaign.getId())) {
for (ChapterJpaEntity ch : chapterRepo.findByArcId(arc.getId())) {
chapterNames.put(String.valueOf(ch.getId()), ch.getName());
for (SceneJpaEntity sc : sceneRepo.findByChapterId(ch.getId())) {
sceneNames.put(String.valueOf(sc.getId()), sc.getName());
}
}
}
b.append("<h1 class=\"part\" id=\"part-quests\">Quêtes</h1>");
bm.append(bookmark("Quêtes", "part-quests", () -> {
StringBuilder sub = new StringBuilder();
for (QuestJpaEntity q : quests) sub.append(bookmark(q.getName(), "quest-" + q.getId(), null));
return sub.toString();
}));
for (QuestJpaEntity q : quests) {
b.append("<div class=\"card\" id=\"quest-").append(q.getId()).append("\"><div class=\"card-body\">");
b.append("<div class=\"persona-name\">").append(esc(q.getName())).append("</div>");
block(b, "Description", q.getDescription());
block(b, "Conditions de déblocage", renderPrerequisites(q.getPrerequisites(), questNames));
block(b, "Nœuds liés", renderQuestNodes(q.getNodes(), chapterNames, sceneNames));
block(b, "Objectifs joueurs", q.getPlayerObjectives());
block(b, "Enjeux narratifs", q.getNarrativeStakes());
block(b, "Notes MJ", q.getGmNotes());
illustrations(b, q.getIllustrationImageIds());
b.append("</div></div>");
}
}
/** Prérequis d'une quête en texte lisible (une ligne par condition). */
private String renderPrerequisites(List<Prerequisite> prereqs, Map<String, String> questNames) {
if (prereqs == null || prereqs.isEmpty()) return null;
List<String> parts = new ArrayList<>();
for (Prerequisite p : prereqs) {
if (p instanceof Prerequisite.QuestCompleted qc) {
parts.add("Quête « " + questNames.getOrDefault(qc.questId(), "?") + " » terminée");
} else if (p instanceof Prerequisite.SessionReached sr) {
parts.add("Séance " + sr.minSessionNumber() + " atteinte");
} else if (p instanceof Prerequisite.FlagSet fs) {
parts.add("Fait : " + fs.flagName());
}
}
return parts.isEmpty() ? null : String.join("\n", parts);
}
/** Nœuds narratifs (chapitres / scènes) traversés par une quête, en texte lisible. */
private String renderQuestNodes(List<QuestNodeRef> nodes, Map<String, String> chapterNames, Map<String, String> sceneNames) {
if (nodes == null || nodes.isEmpty()) return null;
List<String> parts = new ArrayList<>();
for (QuestNodeRef n : nodes) {
if (n.nodeType() == NodeType.SCENE) {
parts.add("Scène : " + sceneNames.getOrDefault(n.nodeId(), "?"));
} else {
parts.add("Chapitre : " + chapterNames.getOrDefault(n.nodeId(), "?"));
}
}
return parts.isEmpty() ? null : String.join("\n", parts);
}
// ----- PNJ / Ennemis (groupes par dossier) -----

View File

@@ -0,0 +1,39 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.CampaignReadinessAssessment;
import com.loremind.application.campaigncontext.CampaignReadinessService;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST Controller du Pilier B (« guidage / readiness »). Expose le bilan de
* préparation d'une campagne : statut agrégé + liste des manques cliquables.
*
* <p>Read-model pur, toujours 200 quand la campagne existe (même DRAFT / gaps
* critiques : le guidage informe, il ne bloque rien). 404 si la campagne est inconnue.</p>
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/readiness")
public class CampaignReadinessController {
private final CampaignReadinessService readinessService;
private final CampaignRepository campaignRepository;
public CampaignReadinessController(CampaignReadinessService readinessService,
CampaignRepository campaignRepository) {
this.readinessService = readinessService;
this.campaignRepository = campaignRepository;
}
@GetMapping
public ResponseEntity<CampaignReadinessAssessment> getReadiness(@PathVariable String campaignId) {
if (!campaignRepository.existsById(campaignId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(readinessService.assess(campaignId));
}
}

View File

@@ -0,0 +1,148 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.CampaignReadinessAssessment;
import com.loremind.application.campaigncontext.CampaignReadinessService;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.ArcDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.SceneDTO;
import com.loremind.infrastructure.web.mapper.ArcMapper;
import com.loremind.infrastructure.web.mapper.ChapterMapper;
import com.loremind.infrastructure.web.mapper.NpcMapper;
import com.loremind.infrastructure.web.mapper.QuestMapper;
import com.loremind.infrastructure.web.mapper.RandomTableMapper;
import com.loremind.infrastructure.web.mapper.SceneMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Endpoint AGRÉGÉ de l'arbre de campagne : tout ce dont la sidebar a besoin en UNE
* requête HTTP — arcs → chapitres → scènes, PNJ, tables, ennemis, quêtes et readiness.
*
* <p>Remplace la rafale d'appels du front (~1 + N arcs + M chapitres + 5 listes ≈ 15-20
* requêtes À CHAQUE navigation) : la latence HTTP par appel dominait le temps de rendu
* de la sidebar. Les DTO/formes JSON sont STRICTEMENT ceux des endpoints unitaires
* (mêmes mappers ; les ennemis sont sérialisés en domaine, comme {@code EnemyController}).</p>
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/tree")
public class CampaignTreeController {
private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final NpcRepository npcRepository;
private final RandomTableRepository randomTableRepository;
private final EnemyRepository enemyRepository;
private final QuestRepository questRepository;
private final CampaignReadinessService readinessService;
private final ArcMapper arcMapper;
private final ChapterMapper chapterMapper;
private final SceneMapper sceneMapper;
private final NpcMapper npcMapper;
private final RandomTableMapper randomTableMapper;
private final QuestMapper questMapper;
public CampaignTreeController(CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
NpcRepository npcRepository,
RandomTableRepository randomTableRepository,
EnemyRepository enemyRepository,
QuestRepository questRepository,
CampaignReadinessService readinessService,
ArcMapper arcMapper,
ChapterMapper chapterMapper,
SceneMapper sceneMapper,
NpcMapper npcMapper,
RandomTableMapper randomTableMapper,
QuestMapper questMapper) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.npcRepository = npcRepository;
this.randomTableRepository = randomTableRepository;
this.enemyRepository = enemyRepository;
this.questRepository = questRepository;
this.readinessService = readinessService;
this.arcMapper = arcMapper;
this.chapterMapper = chapterMapper;
this.sceneMapper = sceneMapper;
this.npcMapper = npcMapper;
this.randomTableMapper = randomTableMapper;
this.questMapper = questMapper;
}
/**
* Payload complet de la sidebar. {@code chaptersByArc}/{@code scenesByChapter}
* reprennent la forme que le front construisait lui-même ({@code CampaignTreeData}).
*/
public record CampaignTreeDTO(
List<ArcDTO> arcs,
Map<String, List<ChapterDTO>> chaptersByArc,
Map<String, List<SceneDTO>> scenesByChapter,
List<NpcDTO> npcs,
List<RandomTableDTO> randomTables,
List<Enemy> enemies,
List<QuestDTO> quests,
CampaignReadinessAssessment readiness
) {}
@GetMapping
public ResponseEntity<CampaignTreeDTO> getTree(@PathVariable String campaignId) {
if (!campaignRepository.existsById(campaignId)) {
return ResponseEntity.notFound().build();
}
List<Arc> arcs = arcRepository.findByCampaignId(campaignId);
Map<String, List<ChapterDTO>> chaptersByArc = new HashMap<>();
Map<String, List<SceneDTO>> scenesByChapter = new HashMap<>();
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
chaptersByArc.put(arc.getId(),
chapters.stream().map(chapterMapper::toDTO).collect(Collectors.toList()));
for (Chapter chapter : chapters) {
scenesByChapter.put(chapter.getId(),
sceneRepository.findByChapterId(chapter.getId()).stream()
.map(sceneMapper::toDTO).collect(Collectors.toList()));
}
}
return ResponseEntity.ok(new CampaignTreeDTO(
arcs.stream().map(arcMapper::toDTO).collect(Collectors.toList()),
chaptersByArc,
scenesByChapter,
npcRepository.findByCampaignId(campaignId).stream()
.map(npcMapper::toDTO).collect(Collectors.toList()),
randomTableRepository.findByCampaignId(campaignId).stream()
.map(randomTableMapper::toDTO).collect(Collectors.toList()),
enemyRepository.findByCampaignId(campaignId),
questRepository.findByCampaignId(campaignId).stream()
.map(questMapper::toDTO).collect(Collectors.toList()),
readinessService.assess(campaignId)));
}
}

View File

@@ -1,7 +1,6 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.ChapterService;
import com.loremind.application.campaigncontext.ChapterStatusEnricher;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
import com.loremind.infrastructure.web.mapper.ChapterMapper;
@@ -13,8 +12,10 @@ import java.util.stream.Collectors;
/**
* REST Controller pour le contexte Chapter.
* Si {@code ?playthroughId=} est fourni, les DTOs renvoyés sont enrichis de leur
* {@code progressionStatus} et {@code effectiveStatus} relatifs à ce Playthrough.
*
* <p>Depuis le Niveau 1, le Chapitre est une donnée de SCÉNARIO pure : plus de prérequis
* ni de statut de progression (le gating et la progression vivent sur les Quêtes). Les
* endpoints GET n'enrichissent donc plus rien à partir d'un {@code playthroughId}.</p>
*/
@RestController
@RequestMapping("/api/chapters")
@@ -22,14 +23,10 @@ public class ChapterController {
private final ChapterService chapterService;
private final ChapterMapper chapterMapper;
private final ChapterStatusEnricher statusEnricher;
public ChapterController(ChapterService chapterService,
ChapterMapper chapterMapper,
ChapterStatusEnricher statusEnricher) {
public ChapterController(ChapterService chapterService, ChapterMapper chapterMapper) {
this.chapterService = chapterService;
this.chapterMapper = chapterMapper;
this.statusEnricher = statusEnricher;
}
@PostMapping
@@ -39,34 +36,21 @@ public class ChapterController {
}
@GetMapping("/{id}")
public ResponseEntity<ChapterDTO> getChapterById(
@PathVariable String id,
@RequestParam(value = "playthroughId", required = false) String playthroughId) {
public ResponseEntity<ChapterDTO> getChapterById(@PathVariable String id) {
return chapterService.getChapterById(id)
.map(chapter -> {
ChapterDTO dto = chapterMapper.toDTO(chapter);
if (playthroughId != null && !playthroughId.isBlank()) {
statusEnricher.enrich(List.of(dto), List.of(chapter), playthroughId);
}
return ResponseEntity.ok(dto);
})
.map(chapter -> ResponseEntity.ok(chapterMapper.toDTO(chapter)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<List<ChapterDTO>> getAllChapters(
@RequestParam(value = "arcId", required = false) String arcId,
@RequestParam(value = "playthroughId", required = false) String playthroughId) {
@RequestParam(value = "arcId", required = false) String arcId) {
List<Chapter> chapters = (arcId != null && !arcId.isBlank())
? chapterService.getChaptersByArcId(arcId)
: chapterService.getAllChapters();
List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO)
.collect(Collectors.toList());
if (playthroughId != null && !playthroughId.isBlank()) {
statusEnricher.enrich(chapterDTOs, chapters, playthroughId);
}
return ResponseEntity.ok(chapterDTOs);
}

View File

@@ -0,0 +1,88 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.playcontext.ClockService;
import com.loremind.domain.playcontext.Clock;
import com.loremind.domain.playcontext.ClockTrigger;
import com.loremind.infrastructure.web.dto.playcontext.ClockDTO;
import com.loremind.infrastructure.web.mapper.ClockMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* API REST des Horloges de progression (Clocks) d'une Partie (Play Context).
* Ressource imbriquée sous le Playthrough, comme {@code quest-progressions}.
*/
@RestController
@RequestMapping("/api/playthroughs/{playthroughId}/clocks")
public class ClockController {
/** Taille par défaut d'une horloge si non précisée. */
private static final int DEFAULT_SEGMENTS = 4;
private final ClockService clockService;
private final ClockMapper clockMapper;
public ClockController(ClockService clockService, ClockMapper clockMapper) {
this.clockService = clockService;
this.clockMapper = clockMapper;
}
public record ClockRequest(String name, String description, Integer segments,
String triggerType, String triggerRef, String frontId) {}
@GetMapping
public ResponseEntity<List<ClockDTO>> list(@PathVariable String playthroughId) {
List<ClockDTO> dtos = clockService.getByPlaythrough(playthroughId).stream()
.map(clockMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PostMapping
public ResponseEntity<ClockDTO> create(@PathVariable String playthroughId, @RequestBody ClockRequest req) {
Clock created = clockService.create(playthroughId, req.name(), req.description(), segmentsOr(req),
parseTrigger(req.triggerType()), req.triggerRef(), req.frontId());
return ResponseEntity.ok(clockMapper.toDTO(created));
}
@PutMapping("/{clockId}")
public ResponseEntity<ClockDTO> update(@PathVariable String playthroughId, @PathVariable String clockId,
@RequestBody ClockRequest req) {
Clock updated = clockService.update(clockId, req.name(), req.description(), segmentsOr(req),
parseTrigger(req.triggerType()), req.triggerRef(), req.frontId());
return ResponseEntity.ok(clockMapper.toDTO(updated));
}
@PutMapping("/{clockId}/advance")
public ResponseEntity<ClockDTO> advance(@PathVariable String playthroughId, @PathVariable String clockId) {
return ResponseEntity.ok(clockMapper.toDTO(clockService.advance(clockId)));
}
@PutMapping("/{clockId}/regress")
public ResponseEntity<ClockDTO> regress(@PathVariable String playthroughId, @PathVariable String clockId) {
return ResponseEntity.ok(clockMapper.toDTO(clockService.regress(clockId)));
}
@DeleteMapping("/{clockId}")
public ResponseEntity<Void> delete(@PathVariable String playthroughId, @PathVariable String clockId) {
clockService.delete(clockId);
return ResponseEntity.noContent().build();
}
private int segmentsOr(ClockRequest req) {
return req.segments() != null ? req.segments() : DEFAULT_SEGMENTS;
}
/** Parse tolérant du type de déclencheur : null / valeur inconnue -> NONE. */
private ClockTrigger parseTrigger(String value) {
if (value == null || value.isBlank()) return ClockTrigger.NONE;
try {
return ClockTrigger.valueOf(value);
} catch (IllegalArgumentException ex) {
return ClockTrigger.NONE;
}
}
}

View File

@@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@@ -33,12 +34,21 @@ public class FoundryExportController {
this.foundryExportService = foundryExportService;
}
/**
* Perimetre optionnel (tout par defaut) : {@code ?maps=&journals=&tables=} —
* ex. cartes + ennemis seulement : {@code ?journals=false&tables=false}.
*/
@GetMapping(produces = "application/zip")
public ResponseEntity<StreamingResponseBody> export(@PathVariable String campaignId) {
public ResponseEntity<StreamingResponseBody> export(
@PathVariable String campaignId,
@RequestParam(name = "maps", defaultValue = "true") boolean maps,
@RequestParam(name = "journals", defaultValue = "true") boolean journals,
@RequestParam(name = "tables", defaultValue = "true") boolean tables) {
String exportedAt = Instant.now().toString();
BuiltBundle bundle;
try {
bundle = foundryExportService.buildBundle(campaignId, exportedAt);
bundle = foundryExportService.buildBundle(campaignId, exportedAt,
new FoundryExportService.ExportOptions(maps, journals, tables));
} catch (NoSuchElementException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage());
}

View File

@@ -0,0 +1,57 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.playcontext.FrontService;
import com.loremind.domain.playcontext.Front;
import com.loremind.infrastructure.web.dto.playcontext.FrontDTO;
import com.loremind.infrastructure.web.mapper.FrontMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* API REST des Fronts (menaces regroupant des horloges) d'une Partie (Play Context).
* Ressource imbriquée sous le Playthrough, comme {@code clocks}.
*/
@RestController
@RequestMapping("/api/playthroughs/{playthroughId}/fronts")
public class FrontController {
private final FrontService frontService;
private final FrontMapper frontMapper;
public FrontController(FrontService frontService, FrontMapper frontMapper) {
this.frontService = frontService;
this.frontMapper = frontMapper;
}
public record FrontRequest(String name, String description) {}
@GetMapping
public ResponseEntity<List<FrontDTO>> list(@PathVariable String playthroughId) {
List<FrontDTO> dtos = frontService.getByPlaythrough(playthroughId).stream()
.map(frontMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PostMapping
public ResponseEntity<FrontDTO> create(@PathVariable String playthroughId, @RequestBody FrontRequest req) {
Front created = frontService.create(playthroughId, req.name(), req.description());
return ResponseEntity.ok(frontMapper.toDTO(created));
}
@PutMapping("/{frontId}")
public ResponseEntity<FrontDTO> update(@PathVariable String playthroughId, @PathVariable String frontId,
@RequestBody FrontRequest req) {
Front updated = frontService.update(frontId, req.name(), req.description());
return ResponseEntity.ok(frontMapper.toDTO(updated));
}
@DeleteMapping("/{frontId}")
public ResponseEntity<Void> delete(@PathVariable String playthroughId, @PathVariable String frontId) {
frontService.delete(frontId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,87 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.ArcService;
import com.loremind.application.campaigncontext.ChapterService;
import com.loremind.application.campaigncontext.SceneService;
import com.loremind.application.generationcontext.NarrativeAssistFieldsUseCase;
import com.loremind.domain.campaigncontext.EntityFieldPatchProposal;
import com.loremind.domain.campaigncontext.ports.NarrativeAssistException;
import com.loremind.infrastructure.web.mapper.ArcMapper;
import com.loremind.infrastructure.web.mapper.ChapterMapper;
import com.loremind.infrastructure.web.mapper.SceneMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
/**
* REST Controller du Pilier A (co-création « propose → applique »), GÉNÉRIQUE par type
* d'entité narrative ({@code arc|chapter|scene}).
*
* <p>{@code /generate} produit une PROPOSITION de patch (non persistée) ; l'utilisateur
* révise champ par champ côté front, puis {@code /apply} reçoit le record filtré aux champs
* acceptés et patche l'entité via son service (patch ciblé anti-écrasement).</p>
*/
@RestController
@RequestMapping("/api/assist/{entityType}/{entityId}")
public class NarrativeAssistController {
private final NarrativeAssistFieldsUseCase assistUseCase;
private final SceneService sceneService;
private final ChapterService chapterService;
private final ArcService arcService;
private final SceneMapper sceneMapper;
private final ChapterMapper chapterMapper;
private final ArcMapper arcMapper;
public NarrativeAssistController(NarrativeAssistFieldsUseCase assistUseCase,
SceneService sceneService,
ChapterService chapterService,
ArcService arcService,
SceneMapper sceneMapper,
ChapterMapper chapterMapper,
ArcMapper arcMapper) {
this.assistUseCase = assistUseCase;
this.sceneService = sceneService;
this.chapterService = chapterService;
this.arcService = arcService;
this.sceneMapper = sceneMapper;
this.chapterMapper = chapterMapper;
this.arcMapper = arcMapper;
}
/** Génère une proposition d'étoffage (non persistée). {@code campaignId} facultatif (contexte). */
@PostMapping("/generate")
public ResponseEntity<EntityFieldPatchProposal> generate(@PathVariable String entityType,
@PathVariable String entityId,
@RequestBody(required = false) GenerateRequest body) {
String campaignId = body != null ? body.campaignId() : null;
String instruction = body != null ? body.instruction() : null;
try {
return ResponseEntity.ok(assistUseCase.execute(entityType, entityId, campaignId, instruction));
} catch (NarrativeAssistException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
/** Applique les champs ACCEPTÉS (patch ciblé) sur l'entité du type demandé. */
@PostMapping("/apply")
public ResponseEntity<Object> apply(@PathVariable String entityType,
@PathVariable String entityId,
@RequestBody EntityFieldPatchProposal proposal) {
String type = entityType == null ? "" : entityType.trim().toLowerCase();
Object dto = switch (type) {
case "scene" -> sceneMapper.toDTO(sceneService.patchScene(entityId, proposal.fields()));
case "chapter" -> chapterMapper.toDTO(chapterService.patchChapter(entityId, proposal.fields()));
case "arc" -> arcMapper.toDTO(arcService.patchArc(entityId, proposal.fields()));
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
};
return ResponseEntity.ok(dto);
}
public record GenerateRequest(String campaignId, String instruction) {}
}

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.playcontext.ClockService;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.infrastructure.web.dto.playcontext.PlaythroughFlagDTO;
import org.springframework.http.ResponseEntity;
@@ -18,9 +19,11 @@ import java.util.Map;
public class PlaythroughFlagController {
private final PlaythroughFlagRepository repo;
private final ClockService clockService;
public PlaythroughFlagController(PlaythroughFlagRepository repo) {
public PlaythroughFlagController(PlaythroughFlagRepository repo, ClockService clockService) {
this.repo = repo;
this.clockService = clockService;
}
@GetMapping
@@ -35,7 +38,10 @@ public class PlaythroughFlagController {
public ResponseEntity<PlaythroughFlagDTO> setFlag(@PathVariable String playthroughId,
@PathVariable String name,
@RequestBody PlaythroughFlagDTO body) {
boolean wasRaised = Boolean.TRUE.equals(repo.findByPlaythroughId(playthroughId).get(name));
repo.setFlag(playthroughId, name, body.isValue());
// Co-MJ : le Fait vient de passer à vrai (transition) -> avancer les horloges liées.
if (body.isValue() && !wasRaised) clockService.onFlagRaised(playthroughId, name);
return ResponseEntity.ok(new PlaythroughFlagDTO(name, body.isValue()));
}

View File

@@ -0,0 +1,98 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.QuestService;
import com.loremind.application.campaigncontext.QuestStatusEnricher;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.infrastructure.web.dto.campaigncontext.QuestDTO;
import com.loremind.infrastructure.web.mapper.QuestMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* REST Controller pour le contexte Quest (Niveau 1). Quêtes ORTHOGONALES à l'arbre,
* rattachées à la campagne (décision D2).
*
* <p>Si {@code ?playthroughId=} est fourni, les DTOs sont enrichis de leur
* {@code progressionStatus} et {@code effectiveStatus} relatifs à ce Playthrough.</p>
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/quests")
public class QuestController {
private final QuestService questService;
private final QuestMapper questMapper;
private final QuestStatusEnricher statusEnricher;
public QuestController(QuestService questService,
QuestMapper questMapper,
QuestStatusEnricher statusEnricher) {
this.questService = questService;
this.questMapper = questMapper;
this.statusEnricher = statusEnricher;
}
@PostMapping
public ResponseEntity<QuestDTO> createQuest(@PathVariable String campaignId,
@RequestBody QuestDTO questDTO) {
Quest domain = questMapper.toDomain(questDTO);
domain.setCampaignId(campaignId); // le path fait autorité
Quest created = questService.createQuest(domain);
return ResponseEntity.ok(questMapper.toDTO(created));
}
@GetMapping
public ResponseEntity<List<QuestDTO>> listByCampaign(
@PathVariable String campaignId,
@RequestParam(value = "playthroughId", required = false) String playthroughId) {
List<Quest> quests = questService.getQuestsByCampaignId(campaignId);
List<QuestDTO> dtos = quests.stream().map(questMapper::toDTO).collect(Collectors.toList());
if (playthroughId != null && !playthroughId.isBlank()) {
statusEnricher.enrich(dtos, quests, playthroughId);
}
return ResponseEntity.ok(dtos);
}
@GetMapping("/{questId}")
public ResponseEntity<QuestDTO> getQuestById(
@PathVariable String campaignId,
@PathVariable String questId,
@RequestParam(value = "playthroughId", required = false) String playthroughId) {
return questService.getQuestById(questId)
.map(quest -> {
QuestDTO dto = questMapper.toDTO(quest);
if (playthroughId != null && !playthroughId.isBlank()) {
statusEnricher.enrich(List.of(dto), List.of(quest), playthroughId);
}
return ResponseEntity.ok(dto);
})
.orElse(ResponseEntity.notFound().build());
}
@PutMapping("/{questId}")
public ResponseEntity<QuestDTO> updateQuest(@PathVariable String campaignId,
@PathVariable String questId,
@RequestBody QuestDTO questDTO) {
Quest domain = questMapper.toDomain(questDTO);
domain.setCampaignId(campaignId);
Quest updated = questService.updateQuest(questId, domain);
return ResponseEntity.ok(questMapper.toDTO(updated));
}
@DeleteMapping("/{questId}")
public ResponseEntity<Void> deleteQuest(@PathVariable String campaignId, @PathVariable String questId) {
questService.deleteQuest(questId);
return ResponseEntity.noContent().build();
}
/** Réordonne les quêtes de la campagne : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@PathVariable String campaignId, @RequestBody ReorderRequest req) {
questService.reorderQuests(campaignId, req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(List<String> orderedIds) {}
}

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.playcontext.ClockService;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import org.springframework.http.ResponseEntity;
@@ -18,28 +19,30 @@ import java.util.Map;
public class QuestProgressionController {
private final QuestProgressionRepository repo;
private final ClockService clockService;
public QuestProgressionController(QuestProgressionRepository repo) {
public QuestProgressionController(QuestProgressionRepository repo, ClockService clockService) {
this.repo = repo;
this.clockService = clockService;
}
public record SetStatusRequest(String status) {}
/**
* GET : renvoie une map chapterId -> ProgressionStatus pour le Playthrough.
* GET : renvoie une map questId -> ProgressionStatus pour le Playthrough.
* Pratique pour le front qui peut indexer puissamment.
*/
@GetMapping
public ResponseEntity<Map<String, String>> list(@PathVariable String playthroughId) {
Map<String, String> out = new HashMap<>();
repo.findByPlaythroughId(playthroughId)
.forEach(qp -> out.put(qp.getChapterId(), qp.getStatus().name()));
.forEach(qp -> out.put(qp.getQuestId(), qp.getStatus().name()));
return ResponseEntity.ok(out);
}
@PutMapping("/{chapterId}")
@PutMapping("/{questId}")
public ResponseEntity<Void> setStatus(@PathVariable String playthroughId,
@PathVariable String chapterId,
@PathVariable String questId,
@RequestBody SetStatusRequest body) {
ProgressionStatus parsed = ProgressionStatus.NOT_STARTED;
if (body.status() != null && !body.status().isBlank()) {
@@ -49,7 +52,12 @@ public class QuestProgressionController {
return ResponseEntity.badRequest().build();
}
}
repo.setStatus(playthroughId, chapterId, parsed);
boolean wasCompleted = repo.findCompletedQuestIdsByPlaythroughId(playthroughId).contains(questId);
repo.setStatus(playthroughId, questId, parsed);
// Co-MJ : la quête vient de passer à COMPLETED (transition) -> avancer les horloges liées.
if (parsed == ProgressionStatus.COMPLETED && !wasCompleted) {
clockService.onQuestCompleted(playthroughId, questId);
}
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,80 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.ChapterService;
import com.loremind.application.campaigncontext.SceneService;
import com.loremind.application.generationcontext.DraftScenesUseCase;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneDraftProposal;
import com.loremind.domain.campaigncontext.ports.NarrativeAssistException;
import com.loremind.infrastructure.web.dto.campaigncontext.SceneDTO;
import com.loremind.infrastructure.web.mapper.SceneMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.stream.Collectors;
/**
* REST Controller du Pilier A (capacité « create ») : peuple un chapitre en scènes.
*
* <p>{@code /generate} produit une PROPOSITION d'ébauches (non persistée) ; l'utilisateur
* révise/coche côté front, puis {@code /apply} reçoit les ébauches retenues et crée les
* scènes dans le chapitre. Découplage propose/apply comme le reste du Pilier A.</p>
*/
@RestController
@RequestMapping("/api/chapters/{chapterId}/draft-scenes")
public class SceneDraftController {
private static final int DEFAULT_COUNT = 4;
private final DraftScenesUseCase draftUseCase;
private final SceneService sceneService;
private final ChapterService chapterService;
private final SceneMapper sceneMapper;
public SceneDraftController(DraftScenesUseCase draftUseCase,
SceneService sceneService,
ChapterService chapterService,
SceneMapper sceneMapper) {
this.draftUseCase = draftUseCase;
this.sceneService = sceneService;
this.chapterService = chapterService;
this.sceneMapper = sceneMapper;
}
/** Génère une proposition d'ébauches de scènes (non persistée). */
@PostMapping("/generate")
public ResponseEntity<SceneDraftProposal> generate(@PathVariable String chapterId,
@RequestBody(required = false) GenerateRequest body) {
String campaignId = body != null ? body.campaignId() : null;
String instruction = body != null ? body.instruction() : null;
int count = body != null && body.count() != null ? body.count() : DEFAULT_COUNT;
try {
return ResponseEntity.ok(draftUseCase.execute(chapterId, campaignId, instruction, count));
} catch (NarrativeAssistException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
/** Crée les scènes ACCEPTÉES dans le chapitre ; renvoie les scènes créées. */
@PostMapping("/apply")
public ResponseEntity<List<SceneDTO>> apply(@PathVariable String chapterId,
@RequestBody SceneDraftProposal proposal) {
// Garde-fou : ne jamais créer de scènes orphelines si le chapitre n'existe pas
// (l'apply ne passe pas par le use case, contrairement au /generate).
if (!chapterService.chapterExists(chapterId)) {
throw new IllegalArgumentException("Chapitre non trouvé: " + chapterId);
}
List<Scene> created = sceneService.createDraftScenes(chapterId, proposal.scenes());
List<SceneDTO> dtos = created.stream().map(sceneMapper::toDTO).collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
public record GenerateRequest(String campaignId, String instruction, Integer count) {}
}

View File

@@ -1,11 +1,15 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.playcontext.SessionRecapService;
import com.loremind.application.playcontext.SessionService;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.SessionRecapException;
import com.loremind.infrastructure.web.dto.playcontext.SessionDTO;
import com.loremind.infrastructure.web.mapper.SessionMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.stream.Collectors;
@@ -19,10 +23,14 @@ import java.util.stream.Collectors;
public class SessionController {
private final SessionService sessionService;
private final SessionRecapService recapService;
private final SessionMapper sessionMapper;
public SessionController(SessionService sessionService, SessionMapper sessionMapper) {
public SessionController(SessionService sessionService,
SessionRecapService recapService,
SessionMapper sessionMapper) {
this.sessionService = sessionService;
this.recapService = recapService;
this.sessionMapper = sessionMapper;
}
@@ -30,6 +38,8 @@ public class SessionController {
public record RenameSessionRequest(String name) {}
public record CurrentSceneRequest(String sceneId) {}
@PostMapping
public ResponseEntity<SessionDTO> startSession(@RequestBody StartSessionRequest request) {
Session session = sessionService.startSession(request.playthroughId());
@@ -83,4 +93,22 @@ public class SessionController {
sessionService.deleteSession(id);
return ResponseEntity.noContent().build();
}
/** Épingle (sceneId) ou dés-épingle (null/vide) la scène courante — mode cockpit. */
@PutMapping("/{id}/current-scene")
public ResponseEntity<SessionDTO> setCurrentScene(@PathVariable String id,
@RequestBody CurrentSceneRequest request) {
Session updated = sessionService.setCurrentScene(id, request.sceneId());
return ResponseEntity.ok(sessionMapper.toDTO(updated));
}
/** Récap « précédemment… » : résume le journal de la séance précédente de la même Partie. */
@PostMapping("/{id}/recap")
public ResponseEntity<SessionRecapService.RecapResult> recap(@PathVariable String id) {
try {
return ResponseEntity.ok(recapService.recapPreviousSession(id));
} catch (SessionRecapException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,37 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.playcontext.SessionPrepReport;
import com.loremind.application.playcontext.SessionPrepService;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST Controller « Préparer la prochaine séance » (Phase 3 co-MJ). Read-model pur :
* position des joueurs + contenu probable + manques ciblés + horloges en mouvement.
* 404 si la Partie est inconnue (calqué sur {@code CampaignReadinessController}).
*/
@RestController
@RequestMapping("/api/playthroughs/{playthroughId}/session-prep")
public class SessionPrepController {
private final SessionPrepService sessionPrepService;
private final PlaythroughRepository playthroughRepository;
public SessionPrepController(SessionPrepService sessionPrepService,
PlaythroughRepository playthroughRepository) {
this.sessionPrepService = sessionPrepService;
this.playthroughRepository = playthroughRepository;
}
@GetMapping
public ResponseEntity<SessionPrepReport> getPrep(@PathVariable String playthroughId) {
if (playthroughRepository.findById(playthroughId).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(sessionPrepService.prepare(playthroughId));
}
}

View File

@@ -17,22 +17,6 @@ public class ChapterDTO {
private String arcId;
private int order;
/** Conditions de déblocage (ET logique). Donnée de SCÉNARIO. */
private List<PrerequisiteDTO> prerequisites = new ArrayList<>();
/**
* Statut de progression — lecture seule, populé uniquement quand le client demande
* l'enrichissement pour un Playthrough donné (param ?playthroughId=).
* Valeurs : "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED".
*/
private String progressionStatus;
/**
* Statut effectif calculé côté backend ("LOCKED" | "AVAILABLE" | "IN_PROGRESS" | "COMPLETED").
* Read-only — populé en même temps que {@code progressionStatus}.
*/
private String effectiveStatus;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;

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