Compare commits

..

1 Commits

Author SHA1 Message Date
504c4b7b6c Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
2026-06-03 15:44:48 +02:00
147 changed files with 5347 additions and 392 deletions

View File

@@ -23,10 +23,12 @@ from app.domain.models import (
CharacterSummary,
NpcSummary,
GameSystemContext,
JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
PageSummary,
QuestSummary,
SessionContext,
)
from app.domain.ports import LLMChatProvider
@@ -294,7 +296,8 @@ class ChatUseCase:
else:
for scene in chapter.scenes:
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
block.append(f" - {scene.name} (scène){sc_hint}")
scene_kind = " (lieu explorable)" if scene.rooms else " (scène)"
block.append(f" - {scene.name}{scene_kind}{sc_hint}")
if scene.description:
block.append(f" Description : {scene.description}")
for br in scene.branches:
@@ -302,6 +305,19 @@ class ChatUseCase:
block.append(
f'"{br.label}" vers {br.target_scene_name}{cond}'
)
# Pièces du lieu explorable (mode donjon)
for room in scene.rooms:
floor = f" [étage {room.floor}]" if room.floor is not None else ""
block.append(f"{room.name}{floor}")
if room.description:
block.append(f" {room.description}")
if room.enemies:
block.append(f" Ennemis : {room.enemies}")
for rb in room.branches:
cond = f" (si : {rb.condition})" if rb.condition else ""
block.append(
f'"{rb.label}" vers {rb.target_room_name}{cond}'
)
return block
@staticmethod
@@ -359,35 +375,42 @@ class ChatUseCase:
@staticmethod
def _format_session(sc: SessionContext) -> str:
"""Bloc journal de la session en cours.
"""Bloc journal de la session en cours + résumé des sessions précédentes.
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
rappeler un évènement antérieur, ou rebondir sur une action joueur.
Pour les sessions PRÉCÉDENTES de la même campagne, on ne remonte que
les EVENTs (les moments marquants) pour préserver le contexte LLM.
"""
status = "EN COURS" if sc.active else "TERMINÉE"
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
previous_block = ChatUseCase._format_previous_events(sc.previous_events)
hub_block = ChatUseCase._format_hub_status(sc)
if not sc.entries:
entries_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
current_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
else:
lines: list[str] = []
for e in sc.entries:
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
# Indentation sur les contenus multi-lignes pour préserver la lisibilité
content = e.content.replace("\n", "\n ")
lines.append(f"- {label}{ts} : {content}")
entries_block = "\n".join(lines)
current_block = "\n".join(lines)
return (
"--- SESSION DE JEU EN COURS ---\n"
f"Nom : {sc.session_name}\n"
f"Statut : {status}{started}\n\n"
"Journal chronologique (du plus ancien au plus récent) :\n"
f"{entries_block}\n\n"
f"Statut : {status}{started}\n"
f"{hub_block}"
f"{previous_block}"
"\nJournal chronologique de la session courante (du plus ancien au plus récent) :\n"
f"{current_block}\n\n"
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
"- Tenir compte des évènements déjà capturés dans le journal ci-dessus.\n"
"- Tenir compte des évènements déjà capturés (sessions précédentes + journal courant).\n"
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
"qui s'enchaînent à ce qui vient de se passer.\n"
@@ -395,6 +418,87 @@ class ChatUseCase:
"il a besoin d'idées immédiatement actionnables."
)
@staticmethod
def _format_hub_status(sc: SessionContext) -> str:
"""Bloc Hub : quêtes ouvertes + flags actifs.
Vide si la campagne n'a aucun Arc HUB (toutes les listes vides côté Core).
Les quêtes LOCKED apparaissent par leur TITRE uniquement : l'IA sait
qu'elles existent (utile pour les teasers, les rumeurs en jeu) mais ne
peut pas spoiler leurs détails.
"""
if (not sc.available_quests
and not sc.in_progress_quests
and not sc.locked_quest_titles
and not sc.active_flags):
return ""
lines = ["", "État du Hub (quêtes parallèles et faits narratifs) :"]
if sc.in_progress_quests:
lines.append(" Quêtes en cours :")
lines.extend(ChatUseCase._format_quest_lines(sc.in_progress_quests))
if sc.available_quests:
lines.append(" Quêtes disponibles (non démarrées, prêtes à être lancées) :")
lines.extend(ChatUseCase._format_quest_lines(sc.available_quests))
if sc.locked_quest_titles:
titles = ", ".join(f'"{t}"' for t in sc.locked_quest_titles)
lines.append(
" Quêtes encore verrouillées (existent mais non accessibles — "
f"tu peux y faire allusion sous forme de rumeurs sans spoiler leur contenu) : {titles}"
)
if sc.active_flags:
lines.append(
" Faits actifs : " + ", ".join(f"`{f}`" for f in sc.active_flags)
)
lines.append(
" Conseille des actions cohérentes avec ces quêtes ouvertes. Ne fais "
"PAS comme si une quête verrouillée était déjà accessible aux PJ."
)
lines.append("") # séparateur visuel avant le récap des sessions précédentes
return "\n".join(lines)
@staticmethod
def _format_quest_lines(quests: list[QuestSummary]) -> list[str]:
"""Sérialise une liste de QuestSummary en lignes indentées."""
out: list[str] = []
for q in quests:
arc = f" [arc : {q.arc_name}]" if q.arc_name else ""
out.append(f" - {q.name}{arc}")
if q.description:
out.append(f" Synopsis : {q.description}")
return out
@staticmethod
def _format_previous_events(events: list[JournalEntrySummary]) -> str:
"""Bloc "Story so far" : EVENTs marquants des sessions antérieures.
Vide si la campagne en est à sa première session. On groupe par
session source pour aider l'IA à situer chaque évènement temporellement.
"""
if not events:
return ""
# Groupement par session source en préservant l'ordre d'apparition.
grouped: dict[str, list[JournalEntrySummary]] = {}
for e in events:
key = e.source_session_name or "(session inconnue)"
grouped.setdefault(key, []).append(e)
lines = ["\nRécapitulatif des sessions précédentes (évènements marquants uniquement) :"]
for session_name, items in grouped.items():
lines.append(f"{session_name} :")
for e in items:
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
content = e.content.replace("\n", "\n ")
lines.append(f" - {content}{ts}")
lines.append("") # ligne vide avant le bloc journal courant
return "\n".join(lines) + "\n"
@staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""

View File

@@ -122,9 +122,29 @@ class SceneBranchHint:
condition: str | None = None
@dataclass(frozen=True)
class RoomBranchHint:
"""Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
label: str
target_room_name: str
condition: str | None = None
@dataclass(frozen=True)
class RoomSummary:
"""Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHint] = field(default_factory=list)
@dataclass(frozen=True)
class SceneSummary:
"""Résumé d'une scène : nom + description courte + illustrations + branches."""
"""Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
name: str
description: str | None
@@ -133,6 +153,8 @@ class SceneSummary:
illustration_count: int = 0
# Connexions narratives sortantes (livre dont vous etes le heros).
branches: list[SceneBranchHint] = field(default_factory=list)
# Pièces du lieu explorable (vide = scène classique).
rooms: list[RoomSummary] = field(default_factory=list)
@dataclass(frozen=True)
@@ -233,26 +255,49 @@ class GameSystemContext:
@dataclass(frozen=True)
class JournalEntrySummary:
"""Une entrée du journal d'une Session : type + contenu + horodatage."""
"""Une entrée du journal d'une Session.
`source_session_name` n'est renseigné que pour les entrées issues de
sessions précédentes (option 3 : continuité narrative entre séances).
"""
type: str
content: str
occurred_at: str | None
source_session_name: str | None = None
@dataclass(frozen=True)
class QuestSummary:
"""Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
dans available_quests / in_progress_quests / locked_quest_titles.
"""
name: str
arc_name: str
description: str | None = None
@dataclass(frozen=True)
class SessionContext:
"""Contexte d'une Session de jeu en cours (Play Context).
Injecté dans le system prompt pendant qu'une partie est jouée pour que
l'IA voit le nom de la session, son statut, et un historique chronologique
des évènements/notes/jets capturés par le MJ.
Le journal a déjà été tronqué côté Core (cap à ~80 entrées récentes)
pour ne pas saturer le contexte LLM sur les sessions très longues.
Combine plusieurs niveaux :
- `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
- `previous_events` : EVENTs marquants des sessions précédentes (continuité)
- `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
- `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
- `active_flags` : noms des flags de campagne actuellement à true
"""
session_name: str
active: bool
started_at: str | None
entries: list[JournalEntrySummary]
previous_events: list[JournalEntrySummary]
available_quests: list[QuestSummary] = field(default_factory=list)
in_progress_quests: list[QuestSummary] = field(default_factory=list)
locked_quest_titles: list[str] = field(default_factory=list)
active_flags: list[str] = field(default_factory=list)

View File

@@ -32,6 +32,9 @@ from app.domain.models import (
PageContext,
PageGenerationContext,
PageSummary,
QuestSummary,
RoomBranchHint,
RoomSummary,
SceneBranchHint,
SceneSummary,
SessionContext,
@@ -43,7 +46,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.9.0-beta",
version="0.9.1-beta",
)
@@ -171,6 +174,24 @@ class SceneBranchHintDTO(BaseModel):
condition: str | None = None
class RoomBranchHintDTO(BaseModel):
"""Sortie d'une pièce vers une autre pièce du même lieu (donjon)."""
label: str
target_room_name: str
condition: str | None = None
class RoomSummaryDTO(BaseModel):
"""Pièce d'un lieu explorable. Omise par le Core si la scène est classique."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHintDTO] = Field(default_factory=list)
class SceneSummaryDTO(BaseModel):
"""Résumé d'une scène : nom + description courte (synopsis)."""
@@ -181,6 +202,8 @@ class SceneSummaryDTO(BaseModel):
illustration_count: int = 0
# Branches narratives sortantes, omises cote Core si vides.
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
# Pièces du lieu explorable, omises par Core si scène classique.
rooms: list[RoomSummaryDTO] = Field(default_factory=list)
class ChapterSummaryDTO(BaseModel):
@@ -246,24 +269,44 @@ class GameSystemContextDTO(BaseModel):
class JournalEntrySummaryDTO(BaseModel):
"""Une entrée du journal de session — type + contenu + horodatage."""
"""Une entrée du journal de session.
`source_session_name` est présent uniquement pour les évènements issus
des sessions précédentes — sert à ancrer temporellement dans le prompt.
"""
type: str
content: str
occurred_at: str | None = None
source_session_name: str | None = None
class QuestSummaryDTO(BaseModel):
"""Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine."""
name: str
arc_name: str
description: str | None = None
class SessionContextDTO(BaseModel):
"""Contexte d'une Session de jeu en cours (Play Context).
Injecté par le Core quand le chat est ancré sur une Session.
Contient le journal chronologique (déjà plafonné côté Core).
Combine le journal complet (`entries`), les EVENTs des sessions précédentes
(`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes
Hub de la campagne (disponibles / en cours / verrouillées) plus les flags
narratifs actuellement actifs.
"""
session_name: str
active: bool
started_at: str | None = None
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list)
available_quests: list[QuestSummaryDTO] = Field(default_factory=list)
in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list)
locked_quest_titles: list[str] = Field(default_factory=list)
active_flags: list[str] = Field(default_factory=list)
class ChatStreamRequestDTO(BaseModel):
@@ -580,6 +623,23 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
)
for br in sc.branches
],
rooms=[
RoomSummary(
name=room.name,
floor=room.floor,
description=room.description,
enemies=room.enemies,
branches=[
RoomBranchHint(
label=rb.label,
target_room_name=rb.target_room_name,
condition=rb.condition,
)
for rb in room.branches
],
)
for room in sc.rooms
],
)
for sc in ch.scenes
],
@@ -903,17 +963,31 @@ def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemConte
def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
if dto is None:
return None
entries = [
JournalEntrySummary(
type=e.type,
content=e.content,
occurred_at=e.occurred_at,
)
for e in dto.entries
]
return SessionContext(
session_name=dto.session_name,
active=dto.active,
started_at=dto.started_at,
entries=entries,
entries=[_to_journal_entry(e) for e in dto.entries],
previous_events=[_to_journal_entry(e) for e in dto.previous_events],
available_quests=[_to_quest_summary(q) for q in dto.available_quests],
in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests],
locked_quest_titles=list(dto.locked_quest_titles),
active_flags=list(dto.active_flags),
)
def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary:
return QuestSummary(
name=dto.name,
arc_name=dto.arc_name,
description=dto.description,
)
def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary:
return JournalEntrySummary(
type=dto.type,
content=dto.content,
occurred_at=dto.occurred_at,
source_session_name=dto.source_session_name,
)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.9.0-beta</version>
<version>0.9.1-beta</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

@@ -50,6 +50,15 @@ public class ArcService {
return arcRepository.save(arc);
}
/**
* Création à partir d'un Arc complet (utilisé par le controller pour faire passer
* les nouveaux champs comme type sans démultiplier les paramètres).
*/
public Arc createArc(Arc input) {
input.setId(null);
return arcRepository.save(input);
}
public Optional<Arc> getArcById(String id) {
return arcRepository.findById(id);
}

View File

@@ -0,0 +1,49 @@
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 org.springframework.stereotype.Service;
import java.util.List;
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.
*
* <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>
*/
@Service
public class CampaignReferencedFlagsService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
public CampaignReferencedFlagsService(ArcRepository arcRepository,
ChapterRepository chapterRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
}
/** 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()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
}
}
}
return List.copyOf(unique);
}
}

View File

@@ -1,13 +1,15 @@
package com.loremind.application.campaigncontext;
import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
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.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,9 +17,11 @@ import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le contexte Campaign.
* Orchestre la logique métier en utilisant le Port CampaignRepository.
* Fait partie de la couche Application de l'Architecture Hexagonale.
* Service d'application pour le contexte Campaign (scénario).
*
* <p>Depuis Playthrough : les PJ et les sessions ne sont plus rattachés directement
* à la Campagne ; ils dépendent d'un Playthrough (Partie). La cascade de suppression
* d'une campagne englobe donc ses Playthroughs (qui à leur tour cascadent).</p>
*/
@Service
public class CampaignService {
@@ -26,35 +30,27 @@ public class CampaignService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
private final PlaythroughRepository playthroughRepository;
private final PlaythroughService playthroughService;
public CampaignService(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
PlaythroughRepository playthroughRepository,
PlaythroughService playthroughService) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
this.playthroughRepository = playthroughRepository;
this.playthroughService = playthroughService;
}
/**
* Parameter Object pour la création / mise à jour d'une Campaign.
* Évite une signature à rallonge et rend les évolutions futures (theme,
* coverImageUrl, etc.) sans casser les appelants.
*
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
*/
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
/**
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
*/
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder()
@@ -64,7 +60,12 @@ public class CampaignService {
.gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0)
.build();
return campaignRepository.save(campaign);
Campaign saved = campaignRepository.save(campaign);
// Une campagne sans Partie n'a pas de sens jouable : on crée d'office
// une "Partie principale" pour que l'utilisateur puisse jouer immédiatement.
playthroughService.create(saved.getId(), "Partie principale", null);
return saved;
}
public Optional<Campaign> getCampaignById(String id) {
@@ -89,19 +90,10 @@ public class CampaignService {
return campaignRepository.save(campaign);
}
/**
* Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
*/
private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id;
}
/**
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
*/
public DeletionImpact getDeletionImpact(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
int chapterTotal = 0;
@@ -113,22 +105,21 @@ public class CampaignService {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
}
int characterTotal = characterRepository.findByCampaignId(id).size();
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
int playthroughTotal = playthroughRepository.findByCampaignId(id).size();
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, playthroughTotal);
}
/**
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
* scènes, plus les personnages). L'opération est transactionnelle : soit
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
*/
@Transactional
public void deleteCampaign(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
for (Chapter chapter : chapters) {
// 1. Cascade des Playthroughs (qui cascadent eux-mêmes sur PJ/sessions/valeurs flags/progressions).
for (Playthrough p : playthroughRepository.findByCampaignId(id)) {
playthroughService.delete(p.getId());
}
// 2. Cascade du scénario : arcs → chapitres → scènes
// (Pas de déclarations globales de faits : ils existent implicitement via les
// prérequis FLAG_SET des chapitres, qui partent avec les chapitres.)
for (Arc arc : arcRepository.findByCampaignId(id)) {
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
@@ -136,9 +127,6 @@ public class CampaignService {
}
arcRepository.deleteById(arc.getId());
}
for (var character : characterRepository.findByCampaignId(id)) {
characterRepository.deleteById(character.getId());
}
campaignRepository.deleteById(id);
}

View File

@@ -44,6 +44,16 @@ public class ChapterService {
return chapterRepository.save(chapter);
}
/**
* Création à partir d'un Chapter complet (utilisé par le controller pour faire passer
* les nouveaux champs comme progressionStatus / prerequisites sans démultiplier les
* paramètres). L'id est forcé à null pour laisser la DB le générer.
*/
public Chapter createChapter(Chapter input) {
input.setId(null);
return chapterRepository.save(input);
}
public Optional<Chapter> getChapterById(String id) {
return chapterRepository.findById(id);
}

View File

@@ -0,0 +1,104 @@
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

@@ -11,6 +11,7 @@ import java.util.Optional;
/**
* Service d'application pour les fiches de personnages (PJ).
* Les PJ appartiennent désormais à un Playthrough (Partie), pas à la Campagne.
*/
@Service
public class CharacterService {
@@ -21,11 +22,6 @@ public class CharacterService {
this.characterRepository = characterRepository;
}
/**
* Parameter Object pour la création / mise à jour d'un Character.
* `order` est fourni par le controller ; si absent, le service le calcule.
* Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
*/
public record CharacterData(
String name,
String portraitImageId,
@@ -33,14 +29,14 @@ public class CharacterService {
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
String playthroughId,
Integer order
) {}
public Character createCharacter(CharacterData data) {
int order = data.order() != null
? data.order()
: nextOrderFor(data.campaignId());
: nextOrderFor(data.playthroughId());
Character character = Character.builder()
.name(data.name())
.portraitImageId(data.portraitImageId())
@@ -48,7 +44,7 @@ public class CharacterService {
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.playthroughId(data.playthroughId())
.order(order)
.build();
return characterRepository.save(character);
@@ -58,8 +54,8 @@ public class CharacterService {
return characterRepository.findById(id);
}
public List<Character> getCharactersByCampaignId(String campaignId) {
return characterRepository.findByCampaignId(campaignId);
public List<Character> getCharactersByPlaythroughId(String playthroughId) {
return characterRepository.findByPlaythroughId(playthroughId);
}
public Character updateCharacter(String id, CharacterData data) {
@@ -74,7 +70,7 @@ public class CharacterService {
if (data.order() != null) {
existing.setOrder(data.order());
}
// campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP).
// playthroughId immuable après création.
return characterRepository.save(existing);
}
@@ -82,9 +78,8 @@ public class CharacterService {
characterRepository.deleteById(id);
}
/** Renvoie la prochaine position libre — append en fin de liste. */
private int nextOrderFor(String campaignId) {
return characterRepository.findByCampaignId(campaignId).stream()
private int nextOrderFor(String playthroughId) {
return characterRepository.findByPlaythroughId(playthroughId).stream()
.mapToInt(Character::getOrder)
.max()
.orElse(-1) + 1;

View File

@@ -18,6 +18,8 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHin
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomBranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component;
@@ -66,11 +68,18 @@ public class CampaignStructuralContextBuilder {
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
/**
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
* nom + description courte à chaque niveau).
* @throws IllegalArgumentException si la Campagne est introuvable
* Construit la carte narrative d'une Campagne. Sans playthroughId, les PJ
* sont omis (ils sont propres à une Partie).
*/
public CampaignStructuralContext build(String campaignId) {
return build(campaignId, null);
}
/**
* Variante avec playthroughId : injecte les PJ de la Partie indiquée.
* Les PNJ restent campagne-scope (donnée de scénario).
*/
public CampaignStructuralContext build(String campaignId, String playthroughId) {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId));
@@ -80,10 +89,12 @@ public class CampaignStructuralContextBuilder {
.map(this::toArcSummary)
.collect(Collectors.toList());
List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Character::getOrder))
.map(this::toCharacterSummary)
.collect(Collectors.toList());
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
? List.of()
: characterRepository.findByPlaythroughId(playthroughId).stream()
.sorted(Comparator.comparingInt(Character::getOrder))
.map(this::toCharacterSummary)
.collect(Collectors.toList());
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Npc::getOrder))
@@ -175,11 +186,42 @@ public class CampaignStructuralContextBuilder {
b.condition()))
.collect(Collectors.toList());
List<RoomSummary> rooms = toRoomSummaries(scene);
return new SceneSummary(
scene.getName(),
scene.getDescription(),
countImages(scene.getIllustrationImageIds()),
hints);
hints,
rooms);
}
/**
* Projette les pièces d'une scène en RoomSummary pour le contexte IA.
* Pas de notes MJ, pas de loot ni pièges : le prompt reste lisible. L'IA
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
* suffisant pour proposer de la narration ou anticiper les choix.
*/
private List<RoomSummary> toRoomSummaries(Scene scene) {
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Room::getId,
com.loremind.domain.campaigncontext.Room::getName,
(a, b) -> a));
return scene.getRooms().stream()
.map(r -> {
List<RoomBranchHint> hints = r.getBranches() == null
? List.of()
: r.getBranches().stream()
.map(b -> new RoomBranchHint(
b.label(),
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition()))
.collect(Collectors.toList());
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints);
})
.collect(Collectors.toList());
}
/** Helper defensif : compte les illustrations attachees (null-safe). */

View File

@@ -1,42 +1,71 @@
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.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
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.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
*
* <p>Charge la Session + les N dernières entrées du journal et les mappe vers
* le Value Object {@link SessionContext}. La limite d'entrées évite de saturer
* la fenêtre de contexte du LLM sur des sessions très longues.</p>
* <p>Depuis la refonte Playthrough : la session connaît son playthroughId ; la progression
* et les flags viennent du Playthrough (pas plus de la Campagne ni du Chapter).</p>
*/
@Component
public class SessionStructuralContextBuilder {
/**
* Plafond du nombre d'entrées remontées au LLM.
* Choisi pour rester dans des limites raisonnables (≈ 5-10k tokens max
* pour des entrées moyennes de 200 chars). Si la session déborde,
* on garde les entrées les plus récentes (fin de chronologie).
*/
private static final int MAX_ENTRIES = 80;
private static final int MAX_CURRENT_ENTRIES = 80;
private static final int MAX_PREVIOUS_EVENTS = 60;
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final PlaythroughFlagRepository playthroughFlagRepository;
private final QuestProgressionRepository questProgressionRepository;
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
SessionEntryRepository entryRepository) {
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
PlaythroughFlagRepository playthroughFlagRepository,
QuestProgressionRepository questProgressionRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.playthroughFlagRepository = playthroughFlagRepository;
this.questProgressionRepository = questProgressionRepository;
}
public Optional<SessionContext> buildOptional(String sessionId) {
@@ -50,24 +79,155 @@ public class SessionStructuralContextBuilder {
}
private SessionContext toContext(Session session) {
List<SessionEntry> allEntries = entryRepository.findBySessionId(session.getId());
// findBySessionId renvoie en ASC. On garde la fin si la liste dépasse le plafond
// — c'est l'info récente qui aide le plus l'IA pendant la partie.
List<SessionEntry> kept = allEntries.size() <= MAX_ENTRIES
? allEntries
: allEntries.subList(allEntries.size() - MAX_ENTRIES, allEntries.size());
List<JournalEntrySummary> summaries = kept.stream()
.map(e -> new JournalEntrySummary(
e.getType() != null ? e.getType().name() : "NOTE",
e.getContent(),
e.getOccurredAt()))
.collect(Collectors.toList());
List<JournalEntrySummary> currentEntries = loadCurrentEntries(session);
List<JournalEntrySummary> previousEvents = loadPreviousEvents(session);
HubStatus hub = computeHubStatus(session.getPlaythroughId());
return new SessionContext(
session.getName(),
session.isActive(),
session.getStartedAt(),
summaries);
currentEntries,
previousEvents,
hub.available(),
hub.inProgress(),
hub.lockedTitles(),
hub.activeFlags());
}
private List<JournalEntrySummary> loadCurrentEntries(Session session) {
List<SessionEntry> allEntries = entryRepository.findBySessionId(session.getId());
List<SessionEntry> kept = allEntries.size() <= MAX_CURRENT_ENTRIES
? allEntries
: allEntries.subList(allEntries.size() - MAX_CURRENT_ENTRIES, allEntries.size());
return kept.stream()
.map(e -> toSummary(e, null))
.collect(Collectors.toList());
}
/**
* EVENTs des sessions précédentes du MÊME Playthrough (même table).
* On ne mélange jamais les EVENTs de tables différentes.
*/
private List<JournalEntrySummary> loadPreviousEvents(Session current) {
if (current.getPlaythroughId() == null) return List.of();
List<Session> siblingSessions = sessionRepository.findByPlaythroughId(current.getPlaythroughId());
List<JournalEntrySummary> events = new ArrayList<>();
for (Session past : siblingSessions) {
if (past.getId().equals(current.getId())) continue;
for (SessionEntry entry : entryRepository.findBySessionId(past.getId())) {
if (entry.getType() == EntryType.EVENT) {
events.add(toSummary(entry, past.getName()));
}
}
}
events.sort(Comparator.comparing(
JournalEntrySummary::occurredAt,
Comparator.nullsLast(Comparator.naturalOrder())));
if (events.size() > MAX_PREVIOUS_EVENTS) {
return events.subList(events.size() - MAX_PREVIOUS_EVENTS, events.size());
}
return events;
}
private JournalEntrySummary toSummary(SessionEntry entry, String sourceSessionName) {
return new JournalEntrySummary(
entry.getType() != null ? entry.getType().name() : "NOTE",
entry.getContent(),
entry.getOccurredAt(),
sourceSessionName);
}
/** Agrégat interne des données Hub à injecter dans le SessionContext. */
private record HubStatus(
List<QuestSummary> available,
List<QuestSummary> inProgress,
List<String> lockedTitles,
List<String> activeFlags
) {}
/**
* Calcule l'état des quêtes Hub du Playthrough courant :
* - AVAILABLE / IN_PROGRESS → résumé complet
* - LOCKED → titre uniquement (anti-spoiler)
* - COMPLETED → omis (déjà raconté par les EVENTs)
*/
private HubStatus computeHubStatus(String playthroughId) {
if (playthroughId == null) {
return new HubStatus(List.of(), List.of(), List.of(), List.of());
}
Optional<Playthrough> maybePlaythrough = playthroughRepository.findById(playthroughId);
if (maybePlaythrough.isEmpty()) {
return new HubStatus(List.of(), List.of(), List.of(), List.of());
}
String campaignId = maybePlaythrough.get().getCampaignId();
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));
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
if (!anyHub) {
return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
}
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
progressionByChapter.put(qp.getChapterId(), qp.getStatus());
}
// IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
PrerequisiteEvaluator.EvaluationContext ctx =
new PrerequisiteEvaluator.EvaluationContext(completedIds, sessionCount, flags);
List<QuestSummary> available = new ArrayList<>();
List<QuestSummary> inProgress = new ArrayList<>();
List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
if (arc.getType() != ArcType.HUB) continue;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
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;
switch (status) {
case AVAILABLE:
available.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
break;
case IN_PROGRESS:
inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
break;
case LOCKED:
lockedTitles.add(c.getName());
break;
case COMPLETED:
// Omis (déjà dans le journal des EVENTs).
break;
}
}
}
return new HubStatus(available, inProgress, lockedTitles, activeFlags);
}
private List<String> buildActiveFlags(Map<String, Boolean> flags) {
return flags.entrySet().stream()
.filter(Map.Entry::getValue)
.map(Map.Entry::getKey)
.sorted()
.collect(Collectors.toList());
}
}

View File

@@ -12,7 +12,9 @@ import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
@@ -39,6 +41,7 @@ import java.util.function.Consumer;
public class StreamChatForSessionUseCase {
private final SessionRepository sessionRepository;
private final PlaythroughRepository playthroughRepository;
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
@@ -48,6 +51,7 @@ public class StreamChatForSessionUseCase {
public StreamChatForSessionUseCase(
SessionRepository sessionRepository,
PlaythroughRepository playthroughRepository,
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
@@ -55,6 +59,7 @@ public class StreamChatForSessionUseCase {
SessionStructuralContextBuilder sessionContextBuilder,
AiChatProvider aiChatProvider) {
this.sessionRepository = sessionRepository;
this.playthroughRepository = playthroughRepository;
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
@@ -74,11 +79,17 @@ public class StreamChatForSessionUseCase {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
Campaign campaign = campaignRepository.findById(session.getCampaignId())
// Chaîne de résolution : Session → Playthrough → Campaign.
Playthrough playthrough = playthroughRepository.findById(session.getPlaythroughId())
.orElseThrow(() -> new IllegalArgumentException(
"Campagne associée à la session introuvable : " + session.getCampaignId()));
"Partie associée à la session introuvable : " + session.getPlaythroughId()));
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId());
Campaign campaign = campaignRepository.findById(playthrough.getCampaignId())
.orElseThrow(() -> new IllegalArgumentException(
"Campagne associée à la partie introuvable : " + playthrough.getCampaignId()));
// Le campaign context inclut les PJ de CE Playthrough (les PJ sont par-table).
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId(), playthrough.getId());
LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
SessionContext sessionContext = sessionContextBuilder.build(sessionId);

View File

@@ -0,0 +1,110 @@
package com.loremind.application.playcontext;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
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 org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* Service applicatif pour le cycle de vie d'un Playthrough (Partie).
*
* <p>Cascade de suppression : un Playthrough supprime ses flags, ses progressions,
* ses PJ et ses sessions (avec leurs entrées de journal).</p>
*/
@Service
public class PlaythroughService {
private final PlaythroughRepository playthroughRepository;
private final CampaignRepository campaignRepository;
private final PlaythroughFlagRepository flagRepository;
private final QuestProgressionRepository progressionRepository;
private final CharacterRepository characterRepository;
private final SessionRepository sessionRepository;
private final SessionService sessionService;
public PlaythroughService(PlaythroughRepository playthroughRepository,
CampaignRepository campaignRepository,
PlaythroughFlagRepository flagRepository,
QuestProgressionRepository progressionRepository,
CharacterRepository characterRepository,
SessionRepository sessionRepository,
SessionService sessionService) {
this.playthroughRepository = playthroughRepository;
this.campaignRepository = campaignRepository;
this.flagRepository = flagRepository;
this.progressionRepository = progressionRepository;
this.characterRepository = characterRepository;
this.sessionRepository = sessionRepository;
this.sessionService = sessionService;
}
/** Compte des entités qui seront supprimées en cascade avec la Partie. */
public record DeletionImpact(int sessions, int characters, int flags, int progressions) {}
public Playthrough create(String campaignId, String name, String description) {
if (campaignId == null || campaignId.isBlank()) {
throw new IllegalArgumentException("campaignId requis.");
}
if (!campaignRepository.existsById(campaignId)) {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
}
Playthrough p = Playthrough.builder()
.campaignId(campaignId)
.name((name == null || name.isBlank()) ? "Partie principale" : name.trim())
.description(description)
.build();
return playthroughRepository.save(p);
}
public Playthrough update(String id, Playthrough updated) {
Playthrough existing = playthroughRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Partie introuvable : " + id));
BeanUtils.copyProperties(updated, existing, "id", "campaignId", "createdAt");
return playthroughRepository.save(existing);
}
public Optional<Playthrough> getById(String id) {
return playthroughRepository.findById(id);
}
public List<Playthrough> getByCampaignId(String campaignId) {
return playthroughRepository.findByCampaignId(campaignId);
}
public DeletionImpact getDeletionImpact(String id) {
int sessions = sessionRepository.findByPlaythroughId(id).size();
int characters = characterRepository.findByPlaythroughId(id).size();
int flags = flagRepository.findByPlaythroughId(id).size();
int progressions = progressionRepository.findByPlaythroughId(id).size();
return new DeletionImpact(sessions, characters, flags, progressions);
}
@Transactional
public void delete(String id) {
if (!playthroughRepository.existsById(id)) {
throw new IllegalArgumentException("Partie introuvable : " + id);
}
// Cascade : sessions (et leurs entries), PJ, flags, progressions
for (Session s : sessionRepository.findByPlaythroughId(id)) {
sessionService.deleteSession(s.getId());
}
characterRepository.findByPlaythroughId(id).forEach(c -> characterRepository.deleteById(c.getId()));
flagRepository.deleteAllByPlaythroughId(id);
progressionRepository.deleteAllByPlaythroughId(id);
playthroughRepository.deleteById(id);
}
public boolean exists(String id) {
return playthroughRepository.existsById(id);
}
}

View File

@@ -1,7 +1,7 @@
package com.loremind.application.playcontext;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
@@ -15,10 +15,9 @@ import java.util.Optional;
/**
* Service d'application pour le Play Context.
* Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
* Fait partie de la couche Application de l'Architecture Hexagonale.
*
* <p>Règle métier : une seule Session peut être active (endedAt null) à la fois
* dans l'application.</p>
* <p>Règle métier : une seule Session peut être active (endedAt null) à la fois.</p>
* <p>Depuis Playthrough : une Session appartient à un Playthrough (pas directement à une Campaign).</p>
*/
@Service
public class SessionService {
@@ -27,41 +26,43 @@ public class SessionService {
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final CampaignRepository campaignRepository;
private final PlaythroughRepository playthroughRepository;
public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
CampaignRepository campaignRepository) {
PlaythroughRepository playthroughRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.campaignRepository = campaignRepository;
this.playthroughRepository = playthroughRepository;
}
/**
* Lance une nouvelle session sur la campagne donnée.
* Échoue si une session est déjà active ou si la campagne n'existe pas.
* Lance une nouvelle session sur le Playthrough donné.
* Échoue si une session est déjà active ou si le Playthrough n'existe pas.
*/
public Session startSession(String campaignId) {
if (campaignId == null || campaignId.isBlank()) {
throw new IllegalArgumentException("campaignId est requis pour démarrer une session.");
public Session startSession(String playthroughId) {
if (playthroughId == null || playthroughId.isBlank()) {
throw new IllegalArgumentException("playthroughId est requis pour démarrer une session.");
}
if (!campaignRepository.existsById(campaignId)) {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
if (!playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
sessionRepository.findActive().ifPresent(s -> {
throw new IllegalStateException("Une session est déjà en cours (id=" + s.getId() + "). Termine-la avant d'en lancer une nouvelle.");
// Règle métier : une seule session active par Partie (pas de verrou global cross-Partie).
sessionRepository.findActiveByPlaythroughId(playthroughId).ifPresent(s -> {
throw new IllegalStateException(
"Une session est déjà en cours pour cette Partie (id=" + s.getId() +
"). Termine-la avant d'en lancer une nouvelle.");
});
LocalDateTime now = LocalDateTime.now();
Session session = Session.builder()
.name(generateDefaultName(now))
.campaignId(campaignId)
.playthroughId(playthroughId)
.startedAt(now)
.build();
return sessionRepository.save(session);
}
/** Termine la session active si elle correspond à l'id donné. */
public Session endSession(String id) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
@@ -90,18 +91,18 @@ public class SessionService {
return sessionRepository.findActive();
}
public Optional<Session> getActiveByPlaythrough(String playthroughId) {
return sessionRepository.findActiveByPlaythroughId(playthroughId);
}
public List<Session> getAll() {
return sessionRepository.findAll();
}
public List<Session> getByCampaignId(String campaignId) {
return sessionRepository.findByCampaignId(campaignId);
public List<Session> getByPlaythroughId(String playthroughId) {
return sessionRepository.findByPlaythroughId(playthroughId);
}
/**
* Supprime une session et toutes ses entrées de journal en cascade.
* Transactionnel : soit tout disparaît, soit rien.
*/
@Transactional
public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) {

View File

@@ -21,6 +21,14 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne
/**
* Type structurel de l'arc. Détermine son rendu UI et la sémantique de ses chapitres
* (séquence narrative LINEAR vs. quêtes parallèles d'un HUB).
* Défaut LINEAR pour rétro-compatibilité avec les arcs existants.
*/
@Builder.Default
private ArcType type = ArcType.LINEAR;
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;

View File

@@ -0,0 +1,14 @@
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.
*
* Value Object du domaine (Bounded Context : Campaign).
*/
public enum ArcType {
LINEAR,
HUB
}

View File

@@ -6,8 +6,12 @@ import java.time.LocalDateTime;
/**
* Entité de domaine représentant une Campaign.
* Conteneur global pour organiser la narration d'une campagne.
* Entité pure du domaine, sans dépendance technique.
* Conteneur du SCÉNARIO (générique, ré-utilisable par plusieurs tables).
*
* <p>Toute donnée dynamique propre à une table jouée (progression des quêtes,
* flags narratifs, sessions, PJ) vit dans un {@link com.loremind.domain.playcontext.Playthrough}.</p>
*
* <p>Entité pure du domaine, sans dépendance technique.</p>
*/
@Data
@Builder
@@ -21,17 +25,13 @@ public class Campaign {
private int arcsCount;
/**
* Référence faible (weak reference) vers un Lore.
* Nullable : une campagne peut exister sans univers associé (one-shot, test, pitch libre).
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context
* (respect des Bounded Contexts en DDD).
* Référence faible vers un Lore. Nullable.
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
*/
private String loreId;
/**
* Référence faible (weak reference) vers un GameSystem.
* Nullable : une campagne peut être "générique" (pas de système de JDR déclaré).
* Weak reference pour respecter la séparation des Bounded Contexts.
* Référence faible vers un GameSystem. Nullable.
*/
private String gameSystemId;

View File

@@ -21,6 +21,14 @@ 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

@@ -59,10 +59,14 @@ public class Character {
*/
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente. */
private String campaignId;
/**
* Référence vers le Playthrough (= la partie / table) auquel ce PJ appartient.
* Les PJ sont propres à une table jouée, pas au scénario générique de la campagne.
* Weak reference cross-context.
*/
private String playthroughId;
/** Ordre d'affichage dans la liste des PJ de la campagne. */
/** Ordre d'affichage dans la liste des PJ de la Partie. */
private int order;
private LocalDateTime createdAt;

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.campaigncontext;
/**
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
*
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
* {@link PrerequisiteEvaluator}.
*
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
* (pas de OR pour le moment).
*/
public sealed interface Prerequisite
permits Prerequisite.QuestCompleted,
Prerequisite.SessionReached,
Prerequisite.FlagSet {
/** La quête référencée par {@code questId} doit être en COMPLETED. */
record QuestCompleted(String questId) implements Prerequisite {}
/** Le compteur de sessions de la campagne doit avoir atteint {@code minSessionNumber}. */
record SessionReached(int minSessionNumber) implements Prerequisite {}
/** Le flag campagne nommé {@code flagName} doit être à true. */
record FlagSet(String flagName) implements Prerequisite {}
}

View File

@@ -0,0 +1,65 @@
package com.loremind.domain.campaigncontext;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
* et en dérive le {@link QuestStatus} effectif.
*
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
* compile-time est compensée par le throw final qui fait crasher tout nouvel
* implémentant non câblé.
*/
public final class PrerequisiteEvaluator {
/**
* Contexte minimal nécessaire à l'évaluation. On ne passe pas la Campaign entière
* pour ne pas créer de couplage fort ; juste les faits nécessaires.
*/
public record EvaluationContext(
Set<String> completedQuestIds,
int currentSessionCount,
Map<String, Boolean> campaignFlags
) {}
/** True si TOUS les prérequis sont satisfaits (ET logique). Vide => true. */
public boolean areAllSatisfied(List<Prerequisite> prerequisites, EvaluationContext ctx) {
if (prerequisites == null || prerequisites.isEmpty()) return true;
return prerequisites.stream().allMatch(p -> isSatisfied(p, ctx));
}
/** Évalue un seul prérequis. */
public boolean isSatisfied(Prerequisite prereq, EvaluationContext ctx) {
if (prereq instanceof Prerequisite.QuestCompleted q) {
return ctx.completedQuestIds().contains(q.questId());
}
if (prereq instanceof Prerequisite.SessionReached s) {
return ctx.currentSessionCount() >= s.minSessionNumber();
}
if (prereq instanceof Prerequisite.FlagSet f) {
return Boolean.TRUE.equals(ctx.campaignFlags().get(f.flagName()));
}
throw new IllegalStateException("Prerequisite non géré : " + prereq.getClass().getName());
}
/** Dérive le statut effectif à partir de la progression manuelle + des prérequis. */
public QuestStatus computeStatus(
ProgressionStatus progression,
List<Prerequisite> prerequisites,
EvaluationContext ctx
) {
switch (progression) {
case COMPLETED: return QuestStatus.COMPLETED;
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
case NOT_STARTED:
return areAllSatisfied(prerequisites, ctx)
? QuestStatus.AVAILABLE
: QuestStatus.LOCKED;
default:
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
package com.loremind.domain.campaigncontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* Pièce d'un lieu explorable (donjon, crypte…) attaché à une Scene.
*
* <p>Une Scene devient « explorable » dès qu'elle a au moins une Room. Tant
* qu'elle n'en a pas, elle se comporte comme un beat narratif classique.</p>
*
* <p>Pas un record Java parce que la liste {@code branches} est mutable côté
* builder ; on garde la classe Lombok pour la cohérence avec le reste du
* domaine (Arc, Chapter, Scene). L'ID est généré côté front (UUID) au moment
* de la création — pas d'auto-increment DB puisque c'est un Value Object
* sérialisé en JSONB sur Scene.</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Room {
/** ID stable (UUID généré côté client). Sert de cible aux {@link RoomBranch}. */
private String id;
/** Nom de la pièce (« Antichambre », « Salle du trône »). */
private String name;
/** Narration / description lue ou résumée aux joueurs en entrant. */
private String description;
/** Énemis, créatures, boss éventuels (markdown libre). */
private String enemies;
/** Loot / récompenses présentes dans la pièce. */
private String loot;
/** Pièges / dangers environnementaux. */
private String traps;
/** Notes privées du MJ (cachées des joueurs). */
private String gmNotes;
/** Étage / niveau de la pièce. 0 = rez-de-chaussée. Nullable = pas d'étage défini. */
private Integer floor;
/** Ordre d'affichage dans la liste (au sein d'un même étage le cas échéant). */
private int order;
/** IDs d'images d'illustration / ambiance. */
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/** ID de l'image « plan » de la pièce (1 image dédiée, schéma tactique). */
private String mapImageId;
/**
* Sorties vers d'autres pièces. {@link RoomBranch#targetRoomId()} doit pointer
* vers une Room de la même Scene.
*/
@Builder.Default
private List<RoomBranch> branches = new ArrayList<>();
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext;
/**
* Sortie d'une pièce vers une autre pièce de la même Scene explorable.
* Équivalent inter-pièces de {@link SceneBranch}.
*
* <p>Record Java immuable, sérialisé via Jackson dans la liste JSONB
* {@code rooms} de la Scene.</p>
*
* <p>Règle métier : {@code targetRoomId} doit pointer vers une Room de la
* MÊME Scene (validation côté service).</p>
*
* @param label Libellé visible (« Porte nord », « Trappe au sol »).
* @param targetRoomId ID stable de la Room de destination (UUID Room.id).
* @param condition Condition optionnelle (« si les PJ ont la clé en argent »).
*/
public record RoomBranch(String label, String targetRoomId, String condition) {
public static RoomBranch of(String label, String targetRoomId) {
return new RoomBranch(label, targetRoomId, null);
}
}

View File

@@ -72,6 +72,15 @@ public class Scene {
@Builder.Default
private List<SceneBranch> branches = new ArrayList<>();
/**
* Pièces du lieu explorable représenté par cette scène (donjon, crypte, manoir…).
* Vide => scène classique « beat narratif » (comportement inchangé).
* Non vide => la scène devient explorable, l'UI affiche un layout dédié pièce-par-pièce.
* Sérialisé en JSONB.
*/
@Builder.Default
private List<Room> rooms = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -14,7 +14,7 @@ public interface CharacterRepository {
Optional<Character> findById(String id);
List<Character> findByCampaignId(String campaignId);
List<Character> findByPlaythroughId(String playthroughId);
void deleteById(String id);

View File

@@ -69,12 +69,13 @@ public record CampaignStructuralContext(
List<SceneSummary> scenes) {
}
/** Résumé d'une scène : nom + description courte + branches narratives. */
/** Résumé d'une scène : nom + description courte + branches + pièces explorables. */
public record SceneSummary(
String name,
String description,
int illustrationCount,
List<BranchHint> branches) {
List<BranchHint> branches,
List<RoomSummary> rooms) {
}
/**
@@ -86,4 +87,27 @@ public record CampaignStructuralContext(
*/
public record BranchHint(String label, String targetSceneName, String condition) {
}
/**
* Pièce d'un lieu explorable (donjon, crypte). Projection volontairement plate
* pour le prompt IA : pas de notes MJ (jamais leakées dans le contexte campagne),
* la narration et les ennemis suffisent à camper la pièce.
*
* @param name Nom de la pièce.
* @param floor Étage (nullable).
* @param description Narration courte.
* @param enemies Ennemis (texte libre).
* @param branches Sorties vers d'autres pièces (noms résolus).
*/
public record RoomSummary(
String name,
Integer floor,
String description,
String enemies,
List<RoomBranchHint> branches) {
}
/** Indice d'une sortie entre pièces ; {@code targetRoomName} déjà résolu. */
public record RoomBranchHint(String label, String targetRoomName, String condition) {
}
}

View File

@@ -8,26 +8,51 @@ import java.util.List;
* de l'IA pour qu'elle ait conscience de la partie en cours et de son journal.
*
* <p>Pendant qu'une session se joue, l'IA reçoit en plus du Lore/Campagne/GameSystem :
* le nom de la session, son statut (en cours / terminée) et un résumé chronologique
* des entrées du journal (notes, évènements, jets, actions joueurs).</p>
* le nom de la session, son statut, un résumé chronologique du journal,
* et — depuis l'ajout du mode Hub — l'état des quêtes ouvertes de la campagne et
* les flags actifs.</p>
*
* <p>Value Object du Generation Context — record Java immutable.</p>
*
* @param sessionName Nom de la session telle qu'affichée au MJ.
* @param active True si la session est en cours, false si terminée.
* @param startedAt Horodatage de démarrage.
* @param entries Entrées du journal triées chronologiquement (anciennes → récentes).
* Limité côté builder pour éviter de saturer le contexte LLM.
* @param sessionName Nom de la session courante telle qu'affichée au MJ.
* @param active True si la session est en cours, false si terminée.
* @param startedAt Horodatage de démarrage de la session courante.
* @param entries Entrées du journal de la session courante (cap côté builder).
* @param previousEvents Évènements marquants des sessions précédentes (continuité narrative).
* @param availableQuests Quêtes Hub actuellement débloquées et non démarrées.
* @param inProgressQuests Quêtes Hub en cours.
* @param lockedQuestTitles Titres des quêtes Hub verrouillées — uniquement le titre
* pour signaler leur existence sans spoiler.
* @param activeFlags Noms des flags de campagne à true.
*/
public record SessionContext(
String sessionName,
boolean active,
LocalDateTime startedAt,
List<JournalEntrySummary> entries) {
List<JournalEntrySummary> entries,
List<JournalEntrySummary> previousEvents,
List<QuestSummary> availableQuests,
List<QuestSummary> inProgressQuests,
List<String> lockedQuestTitles,
List<String> activeFlags) {
/** Résumé d'une entrée de journal — type + contenu + horodatage. */
/**
* Résumé d'une entrée de journal — type + contenu + horodatage + (optionnel) session source.
* {@code sourceSessionName} renseigné uniquement pour les évènements issus de sessions
* précédentes, pour aider l'IA à les ancrer temporellement.
*/
public record JournalEntrySummary(
String type,
String content,
LocalDateTime occurredAt) {}
LocalDateTime occurredAt,
String sourceSessionName) {}
/**
* Résumé d'une quête (= Chapter dans un Arc HUB) telle qu'exposée à l'IA.
* On omet volontairement les notes MJ : pas de fuite côté prompt.
*/
public record QuestSummary(
String name,
String arcName,
String description) {}
}

View File

@@ -0,0 +1,36 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Instance jouée d'une Campagne par une table donnée.
*
* <p>Sépare clairement le SCÉNARIO (Campaign : arcs, chapitres, prérequis) de
* l'ÉTAT DE JEU d'une table précise (progression des quêtes, flags narratifs,
* sessions tenues, PJ). Permet à plusieurs tables de jouer la même campagne
* indépendamment.</p>
*
* <p>Fait partie du Play Context. Référence la Campagne par weak reference
* (campaignId) pour respecter les Bounded Contexts.</p>
*/
@Data
@Builder
public class Playthrough {
private String id;
/** Weak reference vers la Campagne (le scénario joué). */
private String campaignId;
/** Nom donné par le MJ à cette partie (ex. : "Table du vendredi"). */
private String name;
/** Notes libres sur la partie / la table — facultatif. */
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,29 @@
package com.loremind.domain.playcontext;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import lombok.Builder;
import lombok.Data;
/**
* État de progression d'une quête (Chapter) pour un Playthrough donné.
*
* <p>Remplace l'ancien champ {@code Chapter.progressionStatus} qui mélangeait
* 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
* Bounded Contexts. Le type {@link ProgressionStatus} reste défini dans
* Campaign Context (c'est un Value Object générique, partageable).</p>
*
* <p>Sémantique : l'absence de ligne dans le repo équivaut à NOT_STARTED.
* On ne persiste donc que les transitions explicites IN_PROGRESS / COMPLETED.</p>
*/
@Data
@Builder
public class QuestProgression {
private String id;
private String playthroughId;
private String chapterId;
private ProgressionStatus status;
}

View File

@@ -6,14 +6,12 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
* Entité de domaine représentant une Session de jeu en cours ou passée.
* Entité de domaine représentant une Session de jeu (une soirée).
*
* <p>Une Session est une instance jouée d'une Campaign. La Campaign reste
* un scénario générique réutilisable ; la Session capture une partie réelle
* (date, journal, etc.) sans polluer le scénario d'origine.</p>
*
* <p>Fait partie du Play Context. Référence la Campaign par weak reference
* (campaignId) pour respecter la séparation des Bounded Contexts.</p>
* <p>Une Session appartient à un {@link Playthrough} (une instance jouée d'une
* campagne par une table). Un Playthrough a typiquement plusieurs sessions
* dans le temps ; la progression et les flags persistent entre elles via le
* Playthrough parent.</p>
*
* <p>{@code endedAt == null} signifie que la session est en cours.
* Une seule session peut être en cours dans l'application à la fois.</p>
@@ -25,8 +23,8 @@ public class Session {
private String id;
private String name;
/** Weak reference vers Campaign — pas de dépendance directe inter-contexte. */
private String campaignId;
/** Weak reference vers le Playthrough parent. */
private String playthroughId;
private LocalDateTime startedAt;

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.playcontext.ports;
import java.util.Map;
/**
* Port de sortie pour les flags narratifs d'un Playthrough.
*
* <p>Anciennement {@code CampaignFlagRepository} : les flags suivent maintenant
* une partie (Playthrough), pas un scénario (Campaign).</p>
*/
public interface PlaythroughFlagRepository {
Map<String, Boolean> findByPlaythroughId(String playthroughId);
void setFlag(String playthroughId, String name, boolean value);
void deleteFlag(String playthroughId, String name);
void deleteAllByPlaythroughId(String playthroughId);
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.Playthrough;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Playthroughs (parties jouées).
*/
public interface PlaythroughRepository {
Playthrough save(Playthrough playthrough);
Optional<Playthrough> findById(String id);
List<Playthrough> findByCampaignId(String campaignId);
List<Playthrough> findAll();
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,31 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.playcontext.QuestProgression;
import java.util.List;
import java.util.Set;
/**
* Port de sortie pour la persistance des progressions de quêtes d'un Playthrough.
*
* <p>Modèle "absence = NOT_STARTED" : on ne stocke que les transitions
* explicites IN_PROGRESS / COMPLETED.</p>
*/
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);
/**
* 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);
/** Supprime toutes les progressions d'un Playthrough (cascade applicative). */
void deleteAllByPlaythroughId(String playthroughId);
}

View File

@@ -7,7 +7,6 @@ import java.util.Optional;
/**
* Port de sortie pour la persistance des Sessions.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface SessionRepository {
@@ -17,11 +16,14 @@ public interface SessionRepository {
List<Session> findAll();
List<Session> findByCampaignId(String campaignId);
List<Session> findByPlaythroughId(String playthroughId);
/** Retourne la session en cours (endedAt null) s'il y en a une. */
/** Retourne UNE session active dans l'app (sémantique « legacy » — multi-actives possibles). */
Optional<Session> findActive();
/** Retourne la session active du Playthrough donné, s'il y en a une. */
Optional<Session> findActiveByPlaythroughId(String playthroughId);
void deleteById(String id);
boolean existsById(String id);

View File

@@ -16,6 +16,7 @@ import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
@@ -76,6 +77,40 @@ public class BrainChatPayloadBuilder {
map.put("entries", sc.entries() != null
? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList())
: List.of());
// Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session).
if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) {
map.put("previous_events", sc.previousEvents().stream()
.map(this::journalEntryToMap)
.collect(Collectors.toList()));
}
// État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas
// saturer le prompt sur les campagnes sans Hub.
if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) {
map.put("available_quests", sc.availableQuests().stream()
.map(this::questSummaryToMap)
.collect(Collectors.toList()));
}
if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) {
map.put("in_progress_quests", sc.inProgressQuests().stream()
.map(this::questSummaryToMap)
.collect(Collectors.toList()));
}
if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) {
map.put("locked_quest_titles", sc.lockedQuestTitles());
}
if (sc.activeFlags() != null && !sc.activeFlags().isEmpty()) {
map.put("active_flags", sc.activeFlags());
}
return map;
}
private Map<String, Object> questSummaryToMap(QuestSummary q) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", q.name());
map.put("arc_name", q.arcName());
if (q.description() != null && !q.description().isBlank()) {
map.put("description", q.description());
}
return map;
}
@@ -86,6 +121,9 @@ public class BrainChatPayloadBuilder {
if (e.occurredAt() != null) {
map.put("occurred_at", e.occurredAt().toString());
}
if (e.sourceSessionName() != null && !e.sourceSessionName().isBlank()) {
map.put("source_session_name", e.sourceSessionName());
}
return map;
}
@@ -243,6 +281,12 @@ public class BrainChatPayloadBuilder {
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
// Pièces du lieu explorable : omises si scène classique.
if (s.rooms() != null && !s.rooms().isEmpty()) {
map.put("rooms", s.rooms().stream()
.map(this::roomSummaryToMap)
.collect(Collectors.toList()));
}
});
}
@@ -256,6 +300,24 @@ public class BrainChatPayloadBuilder {
return map;
}
private Map<String, Object> roomSummaryToMap(com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary r) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", r.name());
if (r.floor() != null) map.put("floor", r.floor());
if (r.description() != null && !r.description().isBlank()) map.put("description", r.description());
if (r.enemies() != null && !r.enemies().isBlank()) map.put("enemies", r.enemies());
if (r.branches() != null && !r.branches().isEmpty()) {
map.put("branches", r.branches().stream().map(b -> {
Map<String, Object> bm = new LinkedHashMap<>();
bm.put("label", b.label());
bm.put("target_room_name", b.targetRoomName());
if (b.condition() != null && !b.condition().isBlank()) bm.put("condition", b.condition());
return bm;
}).collect(Collectors.toList()));
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.entityType());

View File

@@ -0,0 +1,98 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Prerequisite;
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 List<Prerequisite> (sealed type du domaine) en JSON stocké en base.
* <p>
* On utilise un discriminant {@code kind} explicite pour éviter de polluer le domaine
* avec des annotations Jackson (@JsonTypeInfo / @JsonSubTypes). Le format on-disk est
* stable et indépendant des noms de classes Java.
* <p>
* Format JSON :
* [
* {"kind": "QUEST_COMPLETED", "questId": "42"},
* {"kind": "SESSION_REACHED", "minSessionNumber": 3},
* {"kind": "FLAG_SET", "flagName": "forgeron_rencontre"}
* ]
*/
@Converter
public class PrerequisiteListJsonConverter implements AttributeConverter<List<Prerequisite>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String KIND = "kind";
private static final String KIND_QUEST_COMPLETED = "QUEST_COMPLETED";
private static final String KIND_SESSION_REACHED = "SESSION_REACHED";
private static final String KIND_FLAG_SET = "FLAG_SET";
@Override
public String convertToDatabaseColumn(List<Prerequisite> attribute) {
if (attribute == null || attribute.isEmpty()) return "[]";
try {
List<Map<String, Object>> raw = new ArrayList<>(attribute.size());
for (Prerequisite p : attribute) {
raw.add(toMap(p));
}
return MAPPER.writeValueAsString(raw);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<Prerequisite> → JSON", e);
}
}
@Override
public List<Prerequisite> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return new ArrayList<>();
try {
List<Map<String, Object>> raw = MAPPER.readValue(dbData, new TypeReference<>() {});
List<Prerequisite> result = new ArrayList<>(raw.size());
for (Map<String, Object> m : raw) {
result.add(fromMap(m));
}
return result;
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → List<Prerequisite>", e);
}
}
private Map<String, Object> toMap(Prerequisite p) {
Map<String, Object> m = new HashMap<>();
if (p instanceof Prerequisite.QuestCompleted q) {
m.put(KIND, KIND_QUEST_COMPLETED);
m.put("questId", q.questId());
} else if (p instanceof Prerequisite.SessionReached s) {
m.put(KIND, KIND_SESSION_REACHED);
m.put("minSessionNumber", s.minSessionNumber());
} else if (p instanceof Prerequisite.FlagSet f) {
m.put(KIND, KIND_FLAG_SET);
m.put("flagName", f.flagName());
} else {
throw new IllegalStateException("Prerequisite non géré : " + p.getClass().getName());
}
return m;
}
private Prerequisite fromMap(Map<String, Object> m) {
String kind = String.valueOf(m.get(KIND));
switch (kind) {
case KIND_QUEST_COMPLETED:
return new Prerequisite.QuestCompleted(String.valueOf(m.get("questId")));
case KIND_SESSION_REACHED:
Object n = m.get("minSessionNumber");
return new Prerequisite.SessionReached(((Number) n).intValue());
case KIND_FLAG_SET:
return new Prerequisite.FlagSet(String.valueOf(m.get("flagName")));
default:
throw new IllegalStateException("Kind Prerequisite inconnu : " + kind);
}
}
}

View File

@@ -0,0 +1,43 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Room;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
/**
* Convertit la liste de pièces explorables d'une Scene en JSON pour la persistance.
*
* <p>Une Room contient elle-même une liste de {@link com.loremind.domain.campaigncontext.RoomBranch}
* (record Java) ; Jackson 2.12+ sait sérialiser/désérialiser les records nativement
* via le constructeur canonique, donc rien de spécial à faire pour les branches.</p>
*/
@Converter
public class RoomListJsonConverter implements AttributeConverter<List<Room>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<Room> attribute) {
if (attribute == null || attribute.isEmpty()) return "[]";
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<Room> → JSON", e);
}
}
@Override
public List<Room> 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<Room>", e);
}
}
}

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
@@ -37,6 +38,15 @@ public class ArcJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
/**
* Type structurel de l'arc (LINEAR par défaut).
* Stocké en STRING pour rester lisible en DB et résistant aux refactos d'ordre.
*/
@Enumerated(EnumType.STRING)
@Column(nullable = false, columnDefinition = "VARCHAR(16) DEFAULT 'LINEAR'")
@Builder.Default
private ArcType type = ArcType.LINEAR;
@Column
private String icon;

View File

@@ -1,5 +1,7 @@
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;
@@ -37,6 +39,16 @@ 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

@@ -59,9 +59,17 @@ public class CharacterJpaEntity {
@Column(name = "key_value_values", columnDefinition = "TEXT")
private Map<String, Map<String, String>> keyValueValues;
@Column(name = "campaign_id", nullable = false)
/**
* Ancienne référence directe vers la Campaign. Conservée nullable pour la
* migration ; le code utilise playthrough_id désormais.
*/
@Column(name = "campaign_id")
private Long campaignId;
/** ID du Playthrough (partie) auquel ce PJ appartient. Weak ref cross-context. */
@Column(name = "playthrough_id")
private Long playthroughId;
@Column(name = "\"order\"", nullable = false)
private int order;

View File

@@ -0,0 +1,42 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Entité JPA pour les flags narratifs d'un Playthrough.
*
* <p>Remplace l'ancienne {@code CampaignFlagJpaEntity} : les flags suivent
* désormais la partie (table jouée), pas le scénario.</p>
*/
@Entity
@Table(
name = "playthrough_flag",
uniqueConstraints = @UniqueConstraint(
name = "uk_playthrough_flag_name",
columnNames = {"playthrough_id", "name"}
),
indexes = @Index(name = "ix_playthrough_flag_playthrough", columnList = "playthrough_id")
)
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PlaythroughFlagJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "playthrough_id", nullable = false)
private Long playthroughId;
@Column(nullable = false, length = 128)
private String name;
@Column(nullable = false)
private boolean value;
}

View File

@@ -0,0 +1,54 @@
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 table {@code playthroughs} — instances jouées d'une Campagne.
*/
@Entity
@Table(
name = "playthroughs",
indexes = @Index(name = "ix_playthrough_campaign", columnList = "campaign_id")
)
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PlaythroughJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@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

@@ -0,0 +1,44 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Entité JPA pour la progression d'une quête (Chapter) au sein d'un Playthrough.
*
* <p>Contrainte d'unicité sur (playthrough_id, chapter_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"}
),
indexes = @Index(name = "ix_quest_progression_playthrough", columnList = "playthrough_id")
)
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QuestProgressionJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "playthrough_id", nullable = false)
private Long playthroughId;
@Column(name = "chapter_id", nullable = false)
private Long chapterId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private ProgressionStatus status;
}

View File

@@ -1,6 +1,8 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.infrastructure.persistence.converter.RoomListJsonConverter;
import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*;
@@ -95,6 +97,15 @@ public class SceneJpaEntity {
@Builder.Default
private List<SceneBranch> branches = new ArrayList<>();
/**
* Pièces explorables de cette scène (donjon, crypte…). Vide = scène classique.
* Sérialisé en JSON dans une colonne TEXT.
*/
@Column(name = "rooms", columnDefinition = "TEXT")
@Convert(converter = RoomListJsonConverter.class)
@Builder.Default
private List<Room> rooms = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -28,12 +28,20 @@ public class SessionJpaEntity {
private String name;
/**
* ID de la Campaign associée. Pas de @ManyToOne / pas de FK : c'est une
* weak reference inter-contexte (Play Context ↔ Campaign Context).
* Ancienne référence directe vers la Campaign. Conservée (nullable) pour la
* rétrocompatibilité de la migration, mais plus utilisée par le code.
* À supprimer manuellement quand toutes les sessions auront leur playthrough_id.
*/
@Column(name = "campaign_id", nullable = false)
@Column(name = "campaign_id")
private String campaignId;
/**
* ID du Playthrough (partie) auquel cette session appartient.
* Weak reference inter-contexte.
*/
@Column(name = "playthrough_id")
private Long playthroughId;
@Column(name = "started_at", nullable = false)
private LocalDateTime startedAt;

View File

@@ -9,5 +9,5 @@ import java.util.List;
@Repository
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
List<CharacterJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
}

View File

@@ -0,0 +1,25 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.PlaythroughFlagJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Repository
public interface PlaythroughFlagJpaRepository extends JpaRepository<PlaythroughFlagJpaEntity, Long> {
List<PlaythroughFlagJpaEntity> findByPlaythroughId(Long playthroughId);
Optional<PlaythroughFlagJpaEntity> findByPlaythroughIdAndName(Long playthroughId, String name);
@Modifying
@Transactional
@Query("DELETE FROM PlaythroughFlagJpaEntity f WHERE f.playthroughId = :playthroughId")
void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId);
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.PlaythroughJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface PlaythroughJpaRepository extends JpaRepository<PlaythroughJpaEntity, Long> {
List<PlaythroughJpaEntity> findByCampaignId(Long campaignId);
}

View File

@@ -0,0 +1,25 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.QuestProgressionJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Repository
public interface QuestProgressionJpaRepository extends JpaRepository<QuestProgressionJpaEntity, Long> {
List<QuestProgressionJpaEntity> findByPlaythroughId(Long playthroughId);
Optional<QuestProgressionJpaEntity> findByPlaythroughIdAndChapterId(Long playthroughId, Long chapterId);
@Modifying
@Transactional
@Query("DELETE FROM QuestProgressionJpaEntity q WHERE q.playthroughId = :playthroughId")
void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId);
}

View File

@@ -13,7 +13,9 @@ import java.util.Optional;
@Repository
public interface SessionJpaRepository extends JpaRepository<SessionJpaEntity, Long> {
List<SessionJpaEntity> findByCampaignIdOrderByStartedAtDesc(String campaignId);
List<SessionJpaEntity> findByPlaythroughIdOrderByStartedAtDesc(Long playthroughId);
Optional<SessionJpaEntity> findFirstByEndedAtIsNull();
Optional<SessionJpaEntity> findFirstByPlaythroughIdAndEndedAtIsNull(Long playthroughId);
}

View File

@@ -0,0 +1,184 @@
package com.loremind.infrastructure.persistence.migration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* Migration one-shot des données existantes vers le modèle Playthrough.
*
* <p>Idempotente : si une campagne a déjà au moins un Playthrough, on ne crée pas
* de Partie par défaut pour elle. Les opérations sur les sessions / characters /
* flags / progression sont toutes filtrées sur l'absence de {@code playthrough_id}
* pour ne rien dupliquer.</p>
*
* <p>Étapes :
* 1. Crée un Playthrough "Partie principale" pour chaque Campaign qui n'en a pas
* 2. Renseigne {@code sessions.playthrough_id} depuis l'ancien {@code campaign_id}
* 3. Renseigne {@code characters.playthrough_id} depuis l'ancien {@code campaign_id}
* 4. Copie {@code campaign_flag} → {@code playthrough_flag} (si tables présentes)
* 5. Copie {@code chapters.progression_status} (≠ NOT_STARTED) → {@code quest_progression}
*
* <p>S'exécute au démarrage, après Hibernate ddl-auto=update qui aura créé les nouvelles tables.</p>
*/
@Configuration
public class PlaythroughMigrationRunner {
private static final Logger LOG = LoggerFactory.getLogger(PlaythroughMigrationRunner.class);
private static final String DEFAULT_PLAYTHROUGH_NAME = "Partie principale";
@Bean
public ApplicationRunner runPlaythroughMigration(JdbcTemplate jdbc) {
return args -> migrate(jdbc);
}
@Transactional
void migrate(JdbcTemplate jdbc) {
// Garde-fou : si la table playthroughs n'existe pas (anciens dumps), on saute.
if (!tableExists(jdbc, "playthroughs")) {
LOG.warn("Migration Playthrough : table 'playthroughs' absente — Hibernate ne l'a pas créée. Skip.");
return;
}
int createdPlaythroughs = createDefaultPlaythroughs(jdbc);
int migratedSessions = migrateSessions(jdbc);
int migratedCharacters = migrateCharacters(jdbc);
int migratedFlags = migrateFlags(jdbc);
int migratedProgressions = migrateProgressions(jdbc);
// Relaxe les NOT NULL legacy : ddl-auto=update n'assouplit pas les contraintes.
relaxLegacyNotNullConstraints(jdbc);
if (createdPlaythroughs + migratedSessions + migratedCharacters + migratedFlags + migratedProgressions > 0) {
LOG.info("Migration Playthrough : {} playthrough(s) créé(s), {} session(s), {} PJ, {} flag(s), {} progression(s)",
createdPlaythroughs, migratedSessions, migratedCharacters, migratedFlags, migratedProgressions);
} else {
LOG.debug("Migration Playthrough : rien à migrer.");
}
}
private int createDefaultPlaythroughs(JdbcTemplate jdbc) {
List<Long> campaignsWithoutPlaythrough = jdbc.queryForList(
"SELECT c.id FROM campaigns c " +
"WHERE NOT EXISTS (SELECT 1 FROM playthroughs p WHERE p.campaign_id = c.id)",
Long.class
);
if (campaignsWithoutPlaythrough.isEmpty()) return 0;
LocalDateTime now = LocalDateTime.now();
for (Long campaignId : campaignsWithoutPlaythrough) {
jdbc.update(
"INSERT INTO playthroughs (campaign_id, name, description, created_at, updated_at) " +
"VALUES (?, ?, NULL, ?, ?)",
campaignId, DEFAULT_PLAYTHROUGH_NAME, now, now
);
}
return campaignsWithoutPlaythrough.size();
}
private int migrateSessions(JdbcTemplate jdbc) {
if (!columnExists(jdbc, "sessions", "campaign_id")) return 0;
return jdbc.update(
"UPDATE sessions s " +
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = CAST(s.campaign_id AS BIGINT) LIMIT 1) " +
"WHERE s.playthrough_id IS NULL AND s.campaign_id IS NOT NULL"
);
}
private int migrateCharacters(JdbcTemplate jdbc) {
if (!columnExists(jdbc, "characters", "campaign_id")) return 0;
return jdbc.update(
"UPDATE characters c " +
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = c.campaign_id LIMIT 1) " +
"WHERE c.playthrough_id IS NULL AND c.campaign_id IS NOT NULL"
);
}
private int migrateFlags(JdbcTemplate jdbc) {
if (!tableExists(jdbc, "campaign_flag") || !tableExists(jdbc, "playthrough_flag")) return 0;
// Copie uniquement ce qui n'a pas déjà été copié — on déduplique par (playthrough_id, name)
return jdbc.update(
"INSERT INTO playthrough_flag (playthrough_id, name, value) " +
"SELECT p.id, cf.name, cf.value " +
"FROM campaign_flag cf " +
"JOIN playthroughs p ON p.campaign_id = cf.campaign_id " +
"WHERE NOT EXISTS (" +
" SELECT 1 FROM playthrough_flag pf " +
" WHERE pf.playthrough_id = p.id AND pf.name = cf.name" +
")"
);
}
private 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).
return jdbc.update(
"INSERT INTO quest_progression (playthrough_id, chapter_id, status) " +
"SELECT p.id, ch.id, ch.progression_status " +
"FROM chapters ch " +
"JOIN arcs a ON a.id = ch.arc_id " +
"JOIN playthroughs p ON p.campaign_id = a.campaign_id " +
"WHERE ch.progression_status IS NOT NULL " +
" 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" +
" )"
);
}
/**
* Relâche les NOT NULL hérités des colonnes désormais optionnelles :
* sessions.campaign_id, characters.campaign_id, chapters.progression_status
* Idempotent : PostgreSQL ne bronche pas si la colonne est déjà NULLABLE.
*/
private void relaxLegacyNotNullConstraints(JdbcTemplate jdbc) {
relaxNotNull(jdbc, "sessions", "campaign_id");
relaxNotNull(jdbc, "characters", "campaign_id");
relaxNotNull(jdbc, "chapters", "progression_status");
}
private void relaxNotNull(JdbcTemplate jdbc, String table, String column) {
if (!columnExists(jdbc, table, column)) return;
try {
jdbc.execute("ALTER TABLE " + table + " ALTER COLUMN " + column + " DROP NOT NULL");
} catch (DataAccessException ex) {
// Déjà NULLABLE ou table en cours de migration : log et continue.
LOG.debug("relaxNotNull({}.{}) : déjà nullable ou non-applicable", table, column);
}
}
private boolean tableExists(JdbcTemplate jdbc, String table) {
try {
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ?",
Integer.class, table
);
return n != null && n > 0;
} catch (DataAccessException ex) {
return false;
}
}
private boolean columnExists(JdbcTemplate jdbc, String table, String column) {
try {
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
Integer.class, table, column
);
return n != null && n > 0;
} catch (DataAccessException ex) {
return false;
}
}
}

View File

@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(jpaEntity.getDescription())
.campaignId(jpaEntity.getCampaignId().toString())
.order(jpaEntity.getOrder())
.type(jpaEntity.getType())
.icon(jpaEntity.getIcon())
.themes(jpaEntity.getThemes())
.stakes(jpaEntity.getStakes())
@@ -100,6 +101,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(arc.getDescription())
.campaignId(Long.parseLong(arc.getCampaignId()))
.order(arc.getOrder())
.type(arc.getType())
.icon(arc.getIcon())
.themes(arc.getThemes())
.stakes(arc.getStakes())

View File

@@ -12,6 +12,9 @@ import java.util.stream.Collectors;
/**
* Adaptateur d'infrastructure qui implémente le Port CampaignRepository.
* <p>
* Depuis l'introduction de Playthrough : ne touche plus aux flags (ils
* appartiennent au Playthrough, pas à la Campagne).
*/
@Repository
public class PostgresCampaignRepository implements CampaignRepository {

View File

@@ -71,6 +71,9 @@ 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,6 +100,9 @@ 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

@@ -33,8 +33,8 @@ public class PostgresCharacterRepository implements CharacterRepository {
}
@Override
public List<Character> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
public List<Character> findByPlaythroughId(String playthroughId) {
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@@ -58,7 +58,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.playthroughId(e.getPlaythroughId() != null ? e.getPlaythroughId().toString() : null)
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
@@ -75,7 +75,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
.keyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(c.getCampaignId()))
.playthroughId(c.getPlaythroughId() != null ? Long.parseLong(c.getPlaythroughId()) : null)
.order(c.getOrder())
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())

View File

@@ -0,0 +1,56 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.infrastructure.persistence.entity.PlaythroughFlagJpaEntity;
import com.loremind.infrastructure.persistence.jpa.PlaythroughFlagJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.Map;
@Repository
public class PostgresPlaythroughFlagRepository implements PlaythroughFlagRepository {
private final PlaythroughFlagJpaRepository jpa;
public PostgresPlaythroughFlagRepository(PlaythroughFlagJpaRepository jpa) {
this.jpa = jpa;
}
@Override
public Map<String, Boolean> findByPlaythroughId(String playthroughId) {
Long pid = Long.parseLong(playthroughId);
Map<String, Boolean> out = new HashMap<>();
for (PlaythroughFlagJpaEntity f : jpa.findByPlaythroughId(pid)) {
out.put(f.getName(), f.isValue());
}
return out;
}
@Override
public void setFlag(String playthroughId, String name, boolean value) {
Long pid = Long.parseLong(playthroughId);
jpa.findByPlaythroughIdAndName(pid, name).ifPresentOrElse(
existing -> {
existing.setValue(value);
jpa.save(existing);
},
() -> jpa.save(PlaythroughFlagJpaEntity.builder()
.playthroughId(pid)
.name(name)
.value(value)
.build())
);
}
@Override
public void deleteFlag(String playthroughId, String name) {
Long pid = Long.parseLong(playthroughId);
jpa.findByPlaythroughIdAndName(pid, name).ifPresent(f -> jpa.deleteById(f.getId()));
}
@Override
public void deleteAllByPlaythroughId(String playthroughId) {
jpa.deleteByPlaythroughId(Long.parseLong(playthroughId));
}
}

View File

@@ -0,0 +1,77 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.infrastructure.persistence.entity.PlaythroughJpaEntity;
import com.loremind.infrastructure.persistence.jpa.PlaythroughJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresPlaythroughRepository implements PlaythroughRepository {
private final PlaythroughJpaRepository jpa;
public PostgresPlaythroughRepository(PlaythroughJpaRepository jpa) {
this.jpa = jpa;
}
@Override
public Playthrough save(Playthrough p) {
PlaythroughJpaEntity saved = jpa.save(toJpa(p));
return toDomain(saved);
}
@Override
public Optional<Playthrough> findById(String id) {
return jpa.findById(Long.parseLong(id)).map(this::toDomain);
}
@Override
public List<Playthrough> findByCampaignId(String campaignId) {
return jpa.findByCampaignId(Long.parseLong(campaignId)).stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public List<Playthrough> findAll() {
return jpa.findAll().stream().map(this::toDomain).collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpa.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpa.existsById(Long.parseLong(id));
}
private Playthrough toDomain(PlaythroughJpaEntity e) {
return Playthrough.builder()
.id(e.getId().toString())
.campaignId(e.getCampaignId().toString())
.name(e.getName())
.description(e.getDescription())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private PlaythroughJpaEntity toJpa(Playthrough p) {
Long id = p.getId() != null ? Long.parseLong(p.getId()) : null;
return PlaythroughJpaEntity.builder()
.id(id)
.campaignId(Long.parseLong(p.getCampaignId()))
.name(p.getName())
.description(p.getDescription())
.createdAt(p.getCreatedAt())
.updatedAt(p.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,78 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.infrastructure.persistence.entity.QuestProgressionJpaEntity;
import com.loremind.infrastructure.persistence.jpa.QuestProgressionJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Repository
public class PostgresQuestProgressionRepository implements QuestProgressionRepository {
private final QuestProgressionJpaRepository jpa;
public PostgresQuestProgressionRepository(QuestProgressionJpaRepository jpa) {
this.jpa = jpa;
}
@Override
public List<QuestProgression> findByPlaythroughId(String playthroughId) {
return jpa.findByPlaythroughId(Long.parseLong(playthroughId)).stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public Set<String> findCompletedChapterIdsByPlaythroughId(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());
}
}
return out;
}
@Override
public void setStatus(String playthroughId, String chapterId, ProgressionStatus status) {
Long pid = Long.parseLong(playthroughId);
Long cid = Long.parseLong(chapterId);
// Sémantique : NOT_STARTED = absence de ligne.
if (status == null || status == ProgressionStatus.NOT_STARTED) {
jpa.findByPlaythroughIdAndChapterId(pid, cid)
.ifPresent(e -> jpa.deleteById(e.getId()));
return;
}
jpa.findByPlaythroughIdAndChapterId(pid, cid).ifPresentOrElse(
existing -> {
existing.setStatus(status);
jpa.save(existing);
},
() -> jpa.save(QuestProgressionJpaEntity.builder()
.playthroughId(pid)
.chapterId(cid)
.status(status)
.build())
);
}
@Override
public void deleteAllByPlaythroughId(String playthroughId) {
jpa.deleteByPlaythroughId(Long.parseLong(playthroughId));
}
private QuestProgression toDomain(QuestProgressionJpaEntity e) {
return QuestProgression.builder()
.id(e.getId().toString())
.playthroughId(e.getPlaythroughId().toString())
.chapterId(e.getChapterId().toString())
.status(e.getStatus())
.build();
}
}

View File

@@ -92,6 +92,9 @@ public class PostgresSceneRepository implements SceneRepository {
.branches(jpaEntity.getBranches() != null
? new ArrayList<>(jpaEntity.getBranches())
: new ArrayList<>())
.rooms(jpaEntity.getRooms() != null
? new ArrayList<>(jpaEntity.getRooms())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.build();
@@ -126,6 +129,9 @@ public class PostgresSceneRepository implements SceneRepository {
.branches(scene.getBranches() != null
? new ArrayList<>(scene.getBranches())
: new ArrayList<>())
.rooms(scene.getRooms() != null
? new ArrayList<>(scene.getRooms())
: new ArrayList<>())
.createdAt(scene.getCreatedAt())
.updatedAt(scene.getUpdatedAt())
.build();

View File

@@ -11,8 +11,8 @@ import java.util.Optional;
import java.util.stream.Collectors;
/**
* Adaptateur d'infrastructure qui implémente le Port SessionRepository.
* Convertit Session (domaine pur) ↔ SessionJpaEntity (persistance).
* Adaptateur d'infrastructure : implémente le port SessionRepository en utilisant
* playthrough_id comme parent (depuis la refonte Playthrough).
*/
@Repository
public class PostgresSessionRepository implements SessionRepository {
@@ -42,8 +42,8 @@ public class PostgresSessionRepository implements SessionRepository {
}
@Override
public List<Session> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByStartedAtDesc(campaignId).stream()
public List<Session> findByPlaythroughId(String playthroughId) {
return jpaRepository.findByPlaythroughIdOrderByStartedAtDesc(Long.parseLong(playthroughId)).stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@@ -53,6 +53,12 @@ public class PostgresSessionRepository implements SessionRepository {
return jpaRepository.findFirstByEndedAtIsNull().map(this::toDomain);
}
@Override
public Optional<Session> findActiveByPlaythroughId(String playthroughId) {
return jpaRepository.findFirstByPlaythroughIdAndEndedAtIsNull(Long.parseLong(playthroughId))
.map(this::toDomain);
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
@@ -67,7 +73,7 @@ public class PostgresSessionRepository implements SessionRepository {
return Session.builder()
.id(jpa.getId().toString())
.name(jpa.getName())
.campaignId(jpa.getCampaignId())
.playthroughId(jpa.getPlaythroughId() != null ? jpa.getPlaythroughId().toString() : null)
.startedAt(jpa.getStartedAt())
.endedAt(jpa.getEndedAt())
.createdAt(jpa.getCreatedAt())
@@ -80,7 +86,7 @@ public class PostgresSessionRepository implements SessionRepository {
return SessionJpaEntity.builder()
.id(id)
.name(session.getName())
.campaignId(session.getCampaignId())
.playthroughId(session.getPlaythroughId() != null ? Long.parseLong(session.getPlaythroughId()) : null)
.startedAt(session.getStartedAt())
.endedAt(session.getEndedAt())
.createdAt(session.getCreatedAt())

View File

@@ -27,8 +27,8 @@ public class ArcController {
@PostMapping
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
Arc arc = arcMapper.toDomain(arcDTO);
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon());
// Surcharge "Arc complet" pour propager le champ type (LINEAR/HUB) à la création.
Arc createdArc = arcService.createArc(arcMapper.toDomain(arcDTO));
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
}

View File

@@ -0,0 +1,30 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.CampaignReferencedFlagsService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* REST Controller des faits d'une Campagne.
*
* <p>Sémantique « déclaration implicite » : il n'y a pas de table de déclarations
* globales. La liste retournée est la déduplication des noms de faits référencés
* dans les prérequis des chapitres de la campagne.</p>
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/flags")
public class CampaignFlagController {
private final CampaignReferencedFlagsService service;
public CampaignFlagController(CampaignReferencedFlagsService service) {
this.service = service;
}
@GetMapping
public ResponseEntity<List<String>> list(@PathVariable String campaignId) {
return ResponseEntity.ok(service.listForCampaign(campaignId));
}
}

View File

@@ -1,6 +1,7 @@
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;
@@ -12,6 +13,8 @@ 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.
*/
@RestController
@RequestMapping("/api/chapters")
@@ -19,42 +22,58 @@ public class ChapterController {
private final ChapterService chapterService;
private final ChapterMapper chapterMapper;
private final ChapterStatusEnricher statusEnricher;
public ChapterController(ChapterService chapterService, ChapterMapper chapterMapper) {
public ChapterController(ChapterService chapterService,
ChapterMapper chapterMapper,
ChapterStatusEnricher statusEnricher) {
this.chapterService = chapterService;
this.chapterMapper = chapterMapper;
this.statusEnricher = statusEnricher;
}
@PostMapping
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
Chapter chapter = chapterMapper.toDomain(chapterDTO);
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon());
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
Chapter created = chapterService.createChapter(chapterMapper.toDomain(chapterDTO));
return ResponseEntity.ok(chapterMapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<ChapterDTO> getChapterById(@PathVariable String id) {
public ResponseEntity<ChapterDTO> getChapterById(
@PathVariable String id,
@RequestParam(value = "playthroughId", required = false) String playthroughId) {
return chapterService.getChapterById(id)
.map(chapter -> ResponseEntity.ok(chapterMapper.toDTO(chapter)))
.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);
})
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<List<ChapterDTO>> getAllChapters(
@RequestParam(value = "arcId", required = false) String arcId) {
@RequestParam(value = "arcId", required = false) String arcId,
@RequestParam(value = "playthroughId", required = false) String playthroughId) {
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);
}
@PutMapping("/{id}")
public ResponseEntity<ChapterDTO> updateChapter(@PathVariable String id, @RequestBody ChapterDTO chapterDTO) {
Chapter updatedChapter = chapterService.updateChapter(id, chapterMapper.toDomain(chapterDTO));
return ResponseEntity.ok(chapterMapper.toDTO(updatedChapter));
Chapter updated = chapterService.updateChapter(id, chapterMapper.toDomain(chapterDTO));
return ResponseEntity.ok(chapterMapper.toDTO(updated));
}
@DeleteMapping("/{id}")

View File

@@ -35,9 +35,9 @@ public class CharacterController {
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<CharacterDTO>> getCharactersByCampaign(@PathVariable String campaignId) {
List<CharacterDTO> dtos = characterService.getCharactersByCampaignId(campaignId).stream()
@GetMapping("/playthrough/{playthroughId}")
public ResponseEntity<List<CharacterDTO>> getCharactersByPlaythrough(@PathVariable String playthroughId) {
List<CharacterDTO> dtos = characterService.getCharactersByPlaythroughId(playthroughId).stream()
.map(characterMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
@@ -63,7 +63,7 @@ public class CharacterController {
dto.getValues(),
dto.getImageValues(),
dto.getKeyValueValues(),
dto.getCampaignId(),
dto.getPlaythroughId(),
order
);
}

View File

@@ -0,0 +1,64 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.infrastructure.web.dto.playcontext.PlaythroughDTO;
import com.loremind.infrastructure.web.mapper.PlaythroughMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/playthroughs")
public class PlaythroughController {
private final PlaythroughService service;
private final PlaythroughMapper mapper;
public PlaythroughController(PlaythroughService service, PlaythroughMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity<PlaythroughDTO> create(@RequestBody PlaythroughDTO body) {
Playthrough created = service.create(body.getCampaignId(), body.getName(), body.getDescription());
return ResponseEntity.ok(mapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<PlaythroughDTO> getById(@PathVariable String id) {
return service.getById(id)
.map(p -> ResponseEntity.ok(mapper.toDTO(p)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<List<PlaythroughDTO>> list(
@RequestParam(value = "campaignId", required = false) String campaignId) {
List<Playthrough> list = (campaignId != null && !campaignId.isBlank())
? service.getByCampaignId(campaignId)
: List.of();
return ResponseEntity.ok(list.stream().map(mapper::toDTO).collect(Collectors.toList()));
}
@PutMapping("/{id}")
public ResponseEntity<PlaythroughDTO> update(@PathVariable String id, @RequestBody PlaythroughDTO body) {
Playthrough updated = service.update(id, mapper.toDomain(body));
return ResponseEntity.ok(mapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<PlaythroughService.DeletionImpact> deletionImpact(@PathVariable String id) {
if (!service.exists(id)) return ResponseEntity.notFound().build();
return ResponseEntity.ok(service.getDeletionImpact(id));
}
}

View File

@@ -0,0 +1,47 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.infrastructure.web.dto.playcontext.PlaythroughFlagDTO;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Endpoint des flags narratifs d'une Partie.
* Remplace l'ancien {@code /api/campaigns/{id}/flags} : les flags suivent la Partie.
*/
@RestController
@RequestMapping("/api/playthroughs/{playthroughId}/flags")
public class PlaythroughFlagController {
private final PlaythroughFlagRepository repo;
public PlaythroughFlagController(PlaythroughFlagRepository repo) {
this.repo = repo;
}
@GetMapping
public ResponseEntity<List<PlaythroughFlagDTO>> list(@PathVariable String playthroughId) {
Map<String, Boolean> flags = repo.findByPlaythroughId(playthroughId);
List<PlaythroughFlagDTO> dtos = new ArrayList<>(flags.size());
flags.forEach((name, value) -> dtos.add(new PlaythroughFlagDTO(name, value)));
return ResponseEntity.ok(dtos);
}
@PutMapping("/{name}")
public ResponseEntity<PlaythroughFlagDTO> setFlag(@PathVariable String playthroughId,
@PathVariable String name,
@RequestBody PlaythroughFlagDTO body) {
repo.setFlag(playthroughId, name, body.isValue());
return ResponseEntity.ok(new PlaythroughFlagDTO(name, body.isValue()));
}
@DeleteMapping("/{name}")
public ResponseEntity<Void> deleteFlag(@PathVariable String playthroughId, @PathVariable String name) {
repo.deleteFlag(playthroughId, name);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,55 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* Endpoint pour piloter la progression d'une quête (Chapter) au sein d'un Playthrough.
*
* <p>Modèle "absence = NOT_STARTED" — envoyer NOT_STARTED supprime la ligne.</p>
*/
@RestController
@RequestMapping("/api/playthroughs/{playthroughId}/quest-progressions")
public class QuestProgressionController {
private final QuestProgressionRepository repo;
public QuestProgressionController(QuestProgressionRepository repo) {
this.repo = repo;
}
public record SetStatusRequest(String status) {}
/**
* GET : renvoie une map chapterId -> 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()));
return ResponseEntity.ok(out);
}
@PutMapping("/{chapterId}")
public ResponseEntity<Void> setStatus(@PathVariable String playthroughId,
@PathVariable String chapterId,
@RequestBody SetStatusRequest body) {
ProgressionStatus parsed = ProgressionStatus.NOT_STARTED;
if (body.status() != null && !body.status().isBlank()) {
try {
parsed = ProgressionStatus.valueOf(body.status());
} catch (IllegalArgumentException ex) {
return ResponseEntity.badRequest().build();
}
}
repo.setStatus(playthroughId, chapterId, parsed);
return ResponseEntity.noContent().build();
}
}

View File

@@ -26,28 +26,32 @@ public class SessionController {
this.sessionMapper = sessionMapper;
}
public record StartSessionRequest(String campaignId) {}
public record StartSessionRequest(String playthroughId) {}
public record RenameSessionRequest(String name) {}
@PostMapping
public ResponseEntity<SessionDTO> startSession(@RequestBody StartSessionRequest request) {
Session session = sessionService.startSession(request.campaignId());
Session session = sessionService.startSession(request.playthroughId());
return ResponseEntity.ok(sessionMapper.toDTO(session));
}
@GetMapping("/active")
public ResponseEntity<SessionDTO> getActiveSession() {
return sessionService.getActive()
public ResponseEntity<SessionDTO> getActiveSession(
@RequestParam(value = "playthroughId", required = false) String playthroughId) {
var maybe = (playthroughId == null || playthroughId.isBlank())
? sessionService.getActive()
: sessionService.getActiveByPlaythrough(playthroughId);
return maybe
.map(s -> ResponseEntity.ok(sessionMapper.toDTO(s)))
.orElse(ResponseEntity.noContent().build());
}
@GetMapping
public ResponseEntity<List<SessionDTO>> getSessions(@RequestParam(value = "campaignId", required = false) String campaignId) {
List<Session> sessions = (campaignId == null || campaignId.isBlank())
public ResponseEntity<List<SessionDTO>> getSessions(@RequestParam(value = "playthroughId", required = false) String playthroughId) {
List<Session> sessions = (playthroughId == null || playthroughId.isBlank())
? sessionService.getAll()
: sessionService.getByCampaignId(campaignId);
: sessionService.getByPlaythroughId(playthroughId);
List<SessionDTO> dtos = sessions.stream()
.map(sessionMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -17,6 +17,9 @@ public class ArcDTO {
private String campaignId;
private int order;
/** Type structurel : "LINEAR" (défaut) | "HUB". Sérialisé comme String pour faciliter le front. */
private String type;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;

View File

@@ -17,6 +17,22 @@ 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;

View File

@@ -22,6 +22,6 @@ public class CharacterDTO {
private Map<String, String> values = new HashMap<>();
private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId;
private String playthroughId;
private int order;
}

View File

@@ -0,0 +1,30 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO unique pour tous les types de Prerequisite.
* Le champ {@code kind} discrimine le type (miroir du converter JPA et de l'union TS côté front).
* Les champs non pertinents pour un kind donné restent null.
* <p>
* Valeurs de {@code kind} : "QUEST_COMPLETED" | "SESSION_REACHED" | "FLAG_SET".
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PrerequisiteDTO {
/** Discriminant : QUEST_COMPLETED | SESSION_REACHED | FLAG_SET. */
private String kind;
/** Pour kind=QUEST_COMPLETED : ID de la quête à terminer. */
private String questId;
/** Pour kind=SESSION_REACHED : numéro de session minimum atteint. */
private Integer minSessionNumber;
/** Pour kind=FLAG_SET : nom du flag de campagne. */
private String flagName;
}

View File

@@ -0,0 +1,18 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Sortie d'une pièce vers une autre pièce d'un lieu explorable.
* Pendant web du record domaine RoomBranch.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RoomBranchDTO {
private String label;
private String targetRoomId;
private String condition;
}

View File

@@ -0,0 +1,28 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* DTO d'une pièce d'un lieu explorable.
*/
@Data
public class RoomDTO {
/** UUID stable généré côté front à la création. */
private String id;
private String name;
private String description;
private String enemies;
private String loot;
private String traps;
private String gmNotes;
/** Étage (0 = RdC, 1 = 1er…). Nullable = pas d'étage défini. */
private Integer floor;
private int order;
private List<String> illustrationImageIds = new ArrayList<>();
private String mapImageId;
private List<RoomBranchDTO> branches = new ArrayList<>();
}

View File

@@ -41,4 +41,7 @@ public class SceneDTO {
/** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */
private List<SceneBranchDTO> branches = new ArrayList<>();
/** Pièces du lieu explorable (donjon, crypte…). Vide = scène classique. */
private List<RoomDTO> rooms = new ArrayList<>();
}

View File

@@ -0,0 +1,19 @@
package com.loremind.infrastructure.web.dto.playcontext;
import lombok.Data;
import java.time.LocalDateTime;
/**
* DTO d'un Playthrough (Partie) — instance jouée d'une Campagne.
*/
@Data
public class PlaythroughDTO {
private String id;
private String campaignId;
private String name;
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.web.dto.playcontext;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PlaythroughFlagDTO {
private String name;
private boolean value;
}

View File

@@ -12,7 +12,7 @@ public class SessionDTO {
private String id;
private String name;
private String campaignId;
private String playthroughId;
private LocalDateTime startedAt;
/** Null = session en cours. */
private LocalDateTime endedAt;

View File

@@ -1,6 +1,7 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.infrastructure.web.dto.campaigncontext.ArcDTO;
import org.springframework.stereotype.Component;
@@ -24,6 +25,7 @@ public class ArcMapper {
dto.setDescription(arc.getDescription());
dto.setCampaignId(arc.getCampaignId());
dto.setOrder(arc.getOrder());
dto.setType(arc.getType() != null ? arc.getType().name() : ArcType.LINEAR.name());
dto.setIcon(arc.getIcon());
dto.setThemes(arc.getThemes());
dto.setStakes(arc.getStakes());
@@ -47,6 +49,7 @@ public class ArcMapper {
.description(dto.getDescription())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.type(parseType(dto.getType()))
.icon(dto.getIcon())
.themes(dto.getThemes())
.stakes(dto.getStakes())
@@ -66,4 +69,14 @@ public class ArcMapper {
private <T> ArrayList<T> copyList(List<T> source) {
return source != null ? new ArrayList<>(source) : new ArrayList<>();
}
/** Parse tolérant : null/blank/inconnu => LINEAR (rétro-compatibilité). */
private ArcType parseType(String raw) {
if (raw == null || raw.isBlank()) return ArcType.LINEAR;
try {
return ArcType.valueOf(raw);
} catch (IllegalArgumentException ex) {
return ArcType.LINEAR;
}
}
}

View File

@@ -8,15 +8,23 @@ import java.util.ArrayList;
import java.util.List;
/**
* Mapper pour convertir entre Chapter (entité de domaine) et ChapterDTO.
* Mapper Chapter (domaine) ChapterDTO (REST).
*
* <p>Ne touche plus à {@code progressionStatus} ni {@code effectiveStatus} :
* ces champs sont propres à un Playthrough et injectés par {@code ChapterStatusEnricher}
* quand le controller a un playthroughId.</p>
*/
@Component
public class ChapterMapper {
private final PrerequisiteMapper prerequisiteMapper;
public ChapterMapper(PrerequisiteMapper prerequisiteMapper) {
this.prerequisiteMapper = prerequisiteMapper;
}
public ChapterDTO toDTO(Chapter chapter) {
if (chapter == null) {
return null;
}
if (chapter == null) return null;
ChapterDTO dto = new ChapterDTO();
dto.setId(chapter.getId());
@@ -24,6 +32,9 @@ public class ChapterMapper {
dto.setDescription(chapter.getDescription());
dto.setArcId(chapter.getArcId());
dto.setOrder(chapter.getOrder());
dto.setPrerequisites(prerequisiteMapper.toDTOList(chapter.getPrerequisites()));
// progressionStatus / effectiveStatus : laissés null. Peuplés par ChapterStatusEnricher.enrich(...)
// si le client a fourni un playthroughId au controller.
dto.setIcon(chapter.getIcon());
dto.setGmNotes(chapter.getGmNotes());
dto.setPlayerObjectives(chapter.getPlayerObjectives());
@@ -35,9 +46,7 @@ public class ChapterMapper {
}
public Chapter toDomain(ChapterDTO dto) {
if (dto == null) {
return null;
}
if (dto == null) return null;
return Chapter.builder()
.id(dto.getId())
@@ -45,6 +54,7 @@ public class ChapterMapper {
.description(dto.getDescription())
.arcId(dto.getArcId())
.order(dto.getOrder())
.prerequisites(prerequisiteMapper.toDomainList(dto.getPrerequisites()))
.icon(dto.getIcon())
.gmNotes(dto.getGmNotes())
.playerObjectives(dto.getPlayerObjectives())
@@ -55,10 +65,6 @@ public class ChapterMapper {
.build();
}
/**
* Copie sécurisée d'une liste (gère le cas null).
* Ceci est une méthode utilitaire privée pour éviter la duplication de code.
*/
private <T> List<T> copyList(List<T> source) {
return source != null ? new ArrayList<>(source) : new ArrayList<>();
}

View File

@@ -19,7 +19,7 @@ public class CharacterMapper {
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
dto.setKeyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(c.getCampaignId());
dto.setPlaythroughId(c.getPlaythroughId());
dto.setOrder(c.getOrder());
return dto;
}
@@ -34,7 +34,7 @@ public class CharacterMapper {
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId())
.playthroughId(dto.getPlaythroughId())
.order(dto.getOrder())
.build();
}

View File

@@ -0,0 +1,33 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.infrastructure.web.dto.playcontext.PlaythroughDTO;
import org.springframework.stereotype.Component;
@Component
public class PlaythroughMapper {
public PlaythroughDTO toDTO(Playthrough p) {
if (p == null) return null;
PlaythroughDTO dto = new PlaythroughDTO();
dto.setId(p.getId());
dto.setCampaignId(p.getCampaignId());
dto.setName(p.getName());
dto.setDescription(p.getDescription());
dto.setCreatedAt(p.getCreatedAt());
dto.setUpdatedAt(p.getUpdatedAt());
return dto;
}
public Playthrough toDomain(PlaythroughDTO dto) {
if (dto == null) return null;
return Playthrough.builder()
.id(dto.getId())
.campaignId(dto.getCampaignId())
.name(dto.getName())
.description(dto.getDescription())
.createdAt(dto.getCreatedAt())
.updatedAt(dto.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,64 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.infrastructure.web.dto.campaigncontext.PrerequisiteDTO;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Mapper entre Prerequisite (sealed type domaine) et PrerequisiteDTO (DTO API à discriminant).
*/
@Component
public class PrerequisiteMapper {
public static final String KIND_QUEST_COMPLETED = "QUEST_COMPLETED";
public static final String KIND_SESSION_REACHED = "SESSION_REACHED";
public static final String KIND_FLAG_SET = "FLAG_SET";
public PrerequisiteDTO toDTO(Prerequisite p) {
if (p == null) return null;
if (p instanceof Prerequisite.QuestCompleted q) {
return new PrerequisiteDTO(KIND_QUEST_COMPLETED, q.questId(), null, null);
}
if (p instanceof Prerequisite.SessionReached s) {
return new PrerequisiteDTO(KIND_SESSION_REACHED, null, s.minSessionNumber(), null);
}
if (p instanceof Prerequisite.FlagSet f) {
return new PrerequisiteDTO(KIND_FLAG_SET, null, null, f.flagName());
}
throw new IllegalStateException("Prerequisite non géré : " + p.getClass().getName());
}
public Prerequisite toDomain(PrerequisiteDTO dto) {
if (dto == null || dto.getKind() == null) return null;
switch (dto.getKind()) {
case KIND_QUEST_COMPLETED:
return new Prerequisite.QuestCompleted(dto.getQuestId());
case KIND_SESSION_REACHED:
if (dto.getMinSessionNumber() == null) {
throw new IllegalArgumentException("minSessionNumber requis pour SESSION_REACHED");
}
return new Prerequisite.SessionReached(dto.getMinSessionNumber());
case KIND_FLAG_SET:
return new Prerequisite.FlagSet(dto.getFlagName());
default:
throw new IllegalArgumentException("Kind Prerequisite inconnu : " + dto.getKind());
}
}
public List<PrerequisiteDTO> toDTOList(List<Prerequisite> list) {
if (list == null) return new ArrayList<>();
List<PrerequisiteDTO> out = new ArrayList<>(list.size());
for (Prerequisite p : list) out.add(toDTO(p));
return out;
}
public List<Prerequisite> toDomainList(List<PrerequisiteDTO> list) {
if (list == null) return new ArrayList<>();
List<Prerequisite> out = new ArrayList<>(list.size());
for (PrerequisiteDTO d : list) out.add(toDomain(d));
return out;
}
}

View File

@@ -1,7 +1,11 @@
package com.loremind.infrastructure.web.mapper;
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.infrastructure.web.dto.campaigncontext.RoomBranchDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.RoomDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.SceneBranchDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.SceneDTO;
import org.springframework.stereotype.Component;
@@ -46,6 +50,7 @@ public class SceneMapper {
? new ArrayList<>(scene.getMapImageIds())
: new ArrayList<>());
dto.setBranches(toBranchDTOs(scene.getBranches()));
dto.setRooms(toRoomDTOs(scene.getRooms()));
return dto;
}
@@ -79,6 +84,7 @@ public class SceneMapper {
? new ArrayList<>(dto.getMapImageIds())
: new ArrayList<>())
.branches(toBranchDomain(dto.getBranches()))
.rooms(toRoomDomain(dto.getRooms()))
.build();
}
@@ -97,4 +103,62 @@ public class SceneMapper {
.map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition()))
.collect(Collectors.toList());
}
// ─────────────── Mapping des pièces (VO <-> DTO) ───────────────
private List<RoomDTO> toRoomDTOs(List<Room> rooms) {
if (rooms == null) return new ArrayList<>();
return rooms.stream().map(this::toRoomDTO).collect(Collectors.toList());
}
private RoomDTO toRoomDTO(Room r) {
RoomDTO dto = new RoomDTO();
dto.setId(r.getId());
dto.setName(r.getName());
dto.setDescription(r.getDescription());
dto.setEnemies(r.getEnemies());
dto.setLoot(r.getLoot());
dto.setTraps(r.getTraps());
dto.setGmNotes(r.getGmNotes());
dto.setFloor(r.getFloor());
dto.setOrder(r.getOrder());
dto.setIllustrationImageIds(r.getIllustrationImageIds() != null
? new ArrayList<>(r.getIllustrationImageIds())
: new ArrayList<>());
dto.setMapImageId(r.getMapImageId());
dto.setBranches(r.getBranches() == null
? new ArrayList<>()
: r.getBranches().stream()
.map(b -> new RoomBranchDTO(b.label(), b.targetRoomId(), b.condition()))
.collect(Collectors.toList()));
return dto;
}
private List<Room> toRoomDomain(List<RoomDTO> dtos) {
if (dtos == null) return new ArrayList<>();
return dtos.stream().map(this::toRoomDomain).collect(Collectors.toList());
}
private Room toRoomDomain(RoomDTO d) {
return Room.builder()
.id(d.getId())
.name(d.getName())
.description(d.getDescription())
.enemies(d.getEnemies())
.loot(d.getLoot())
.traps(d.getTraps())
.gmNotes(d.getGmNotes())
.floor(d.getFloor())
.order(d.getOrder())
.illustrationImageIds(d.getIllustrationImageIds() != null
? new ArrayList<>(d.getIllustrationImageIds())
: new ArrayList<>())
.mapImageId(d.getMapImageId())
.branches(d.getBranches() == null
? new ArrayList<>()
: d.getBranches().stream()
.map(b -> new RoomBranch(b.getLabel(), b.getTargetRoomId(), b.getCondition()))
.collect(Collectors.toList()))
.build();
}
}

View File

@@ -15,7 +15,7 @@ public class SessionMapper {
SessionDTO dto = new SessionDTO();
dto.setId(session.getId());
dto.setName(session.getName());
dto.setCampaignId(session.getCampaignId());
dto.setPlaythroughId(session.getPlaythroughId());
dto.setStartedAt(session.getStartedAt());
dto.setEndedAt(session.getEndedAt());
dto.setCreatedAt(session.getCreatedAt());

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.9.0-beta",
"version": "0.9.1-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.9.0-beta",
"version": "0.9.1-beta",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.9.0-beta",
"version": "0.9.1-beta",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -16,6 +16,8 @@ export const routes: Routes = [
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) },
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },

View File

@@ -17,6 +17,22 @@
/>
</div>
<div class="field">
<label>Structure de l'arc</label>
<div class="arc-type-choice">
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
<input type="radio" formControlName="type" value="LINEAR" />
<span class="arc-type-title">Linéaire</span>
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle classique.</span>
</label>
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
<input type="radio" formControlName="type" value="HUB" />
<span class="arc-type-title">Hub</span>
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions — type sandbox (ex : Phandalin / Icespire Peak).</span>
</label>
</div>
</div>
<div class="field">
<label for="arc-create-description">Description</label>
<textarea

View File

@@ -3,6 +3,44 @@
max-width: 640px;
}
.arc-type-choice {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.arc-type-option {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 0.95rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
cursor: pointer;
background: var(--color-surface, #fff);
transition: border-color 120ms ease, background 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
&.selected {
border-color: var(--color-primary, #2c6cd6);
background: rgba(66, 133, 244, 0.06);
}
input[type="radio"] { display: none; }
}
.arc-type-title {
font-weight: 600;
font-size: 0.95rem;
}
.arc-type-desc {
font-size: 0.78rem;
color: var(--color-text-muted, #666);
line-height: 1.3;
}
// Override local : titre en violet (pas en blanc comme le .page-header global).
.page-header h1 { color: #a5b4fc; }

View File

@@ -44,7 +44,9 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: ['']
description: [''],
// Type structurel : LINEAR (séquentiel) par défaut, HUB (sandbox/quêtes parallèles).
type: ['LINEAR', Validators.required]
});
}
@@ -72,6 +74,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
description: this.form.value.description,
campaignId: this.campaignId,
order: this.existingArcCount + 1,
type: this.form.value.type,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),

View File

@@ -71,6 +71,22 @@
</textarea>
</div>
<div class="field">
<label>Structure de l'arc</label>
<div class="arc-type-choice">
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
<input type="radio" formControlName="type" value="LINEAR" />
<span class="arc-type-title">Linéaire</span>
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle.</span>
</label>
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
<input type="radio" formControlName="type" value="HUB" />
<span class="arc-type-title">Hub</span>
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions (sandbox).</span>
</label>
</div>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>

View File

@@ -3,6 +3,44 @@
max-width: 640px;
}
.arc-type-choice {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.arc-type-option {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 0.95rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
cursor: pointer;
background: var(--color-surface, #fff);
transition: border-color 120ms ease, background 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
&.selected {
border-color: var(--color-primary, #2c6cd6);
background: rgba(66, 133, 244, 0.06);
}
input[type="radio"] { display: none; }
}
.arc-type-title {
font-weight: 600;
font-size: 0.95rem;
}
.arc-type-desc {
font-size: 0.78rem;
color: var(--color-text-muted, #666);
line-height: 1.3;
}
// Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header {
display: flex;

View File

@@ -85,6 +85,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
type: ['LINEAR', Validators.required],
themes: [''],
stakes: [''],
gmNotes: [''],
@@ -137,6 +138,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.form.patchValue({
name: arc.name,
description: arc.description ?? '',
type: arc.type ?? 'LINEAR',
themes: arc.themes ?? '',
stakes: arc.stakes ?? '',
gmNotes: arc.gmNotes ?? '',
@@ -155,6 +157,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
description: this.form.value.description,
campaignId: this.campaignId,
order: this.arc.order ?? 1,
type: this.form.value.type,
themes: this.form.value.themes,
stakes: this.form.value.stakes,
gmNotes: this.form.value.gmNotes,

View File

@@ -6,7 +6,9 @@
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ arc.name }}
</h1>
<p class="view-subtitle">Arc narratif</p>
<p class="view-subtitle">
{{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }}
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
@@ -31,6 +33,40 @@
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut.
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
<section class="view-section" *ngIf="arc.type === 'HUB'">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
<p class="view-section-empty" *ngIf="hubQuests.length === 0">
Aucune quête pour ce hub. Créez un chapitre pour ajouter une première quête.
</p>
<div class="hub-quest-grid" *ngIf="hubQuests.length > 0">
<button type="button"
class="hub-quest-card"
*ngFor="let q of hubQuests"
(click)="openQuest(q)">
<div class="hub-quest-card-head">
<span class="hub-quest-card-icon" *ngIf="q.icon">
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon>
</span>
<span class="hub-quest-card-name">{{ q.name }}</span>
</div>
<p class="hub-quest-card-desc" *ngIf="q.description?.trim()">{{ q.description }}</p>
<div class="hub-quest-card-locked-hint" *ngIf="(q.prerequisites?.length ?? 0) > 0">
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span>
<ul class="hub-quest-card-prereq-list">
<li *ngFor="let p of q.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
</div>
</button>
</div>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
<p class="view-section-body" *ngIf="arc.description?.trim(); else emptyDesc">{{ arc.description }}</p>

View File

@@ -1 +1,112 @@
// Styles partagés via styles/_view.scss
// === Vue Hub ===
.hub-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
.view-section-title { margin: 0; }
}
.hub-quest-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.85rem;
}
.hub-quest-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.85rem 0.9rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
background: var(--color-surface, #fff);
text-align: left;
cursor: pointer;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
min-height: 110px;
&:hover {
border-color: var(--color-primary, #2c6cd6);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06);
}
&.locked {
opacity: 0.7;
background: rgba(128, 128, 128, 0.04);
border-style: dashed;
&:hover {
// Pas d'illusion d'interactivité pour les quêtes verrouillées
transform: none;
border-color: var(--color-border, #e2e2e2);
box-shadow: none;
}
}
}
.hub-quest-card-head {
display: flex;
align-items: center;
gap: 0.45rem;
}
.hub-quest-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-primary, #2c6cd6);
}
.hub-quest-card-name {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.2;
}
.hub-quest-card-desc {
font-size: 0.82rem;
color: var(--color-text-muted, #666);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.hub-quest-card-footer {
margin-top: auto;
}
.hub-quest-card-locked-hint {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.4rem;
padding-top: 0.4rem;
border-top: 1px dashed rgba(128, 128, 128, 0.25);
font-size: 0.72rem;
color: var(--color-text-muted, #777);
> lucide-icon,
> span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
}
.hub-quest-card-prereq-list {
list-style: disc;
padding-left: 1rem;
margin: 0.15rem 0 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Arc } from '../../../services/campaign.model';
import { Arc, Chapter, Prerequisite } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
@@ -32,12 +32,22 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly AlertCircle = AlertCircle;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';
arc: Arc | null = null;
/** Chapitres de l'arc courant — exploités pour le rendu HUB (grille de quêtes). */
hubQuests: Chapter[] = [];
/**
* Indexe les chapitres de toute la campagne par id pour résoudre les libellés
* des prérequis QUEST_COMPLETED quand on les affiche dans les tooltips de verrouillage.
*/
private allChaptersById: Record<string, Chapter> = {};
/** ID du Lore associé à la campagne (null si pas d'univers lié). */
loreId: string | null = null;
/** Pages du Lore — pour résoudre relatedPageIds en titres. */
@@ -85,10 +95,42 @@ export class ArcViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(arc.name);
// Quêtes du Hub : chapitres de l'arc courant, triés par order puis par nom.
this.hubQuests = [...(treeData.chaptersByArc[this.arcId] ?? [])].sort((a, b) => {
const oa = a.order ?? 0;
const ob = b.order ?? 0;
if (oa !== ob) return oa - ob;
return a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' });
});
// Index global pour résoudre les noms de quêtes référencées par les prérequis.
this.allChaptersById = {};
Object.values(treeData.chaptersByArc).forEach(list =>
list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; })
);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
/** Construit un libellé lisible pour un prérequis (tooltip de verrouillage). */
describePrerequisite(p: Prerequisite): string {
switch (p.kind) {
case 'QUEST_COMPLETED':
return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`;
case 'SESSION_REACHED':
return `Session ${p.minSessionNumber} atteinte`;
case 'FLAG_SET':
return `Fait : ${p.flagName}`;
}
}
openQuest(q: Chapter): void {
if (!q.id) return;
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', q.id
]);
}
titleOfRelated(pageId: string): string {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}

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