From 504c4b7b6ce8d05fa36ae3183b94d05c7dc468ab Mon Sep 17 00:00:00 2001
From: "IETM_FIXE\\ietm6"
Date: Wed, 3 Jun 2026 15:44:48 +0200
Subject: [PATCH] =?UTF-8?q?Plusieurs=20ajouts=20:=20-=20Possibilit=C3=A9?=
=?UTF-8?q?=20de=20configurer=20des=20lieux=20dans=20une=20sc=C3=A8ne=20:?=
=?UTF-8?q?=20permet=20de=20configurer=20un=20donjon=20par=20exemple=20ave?=
=?UTF-8?q?c=20les=20pi=C3=A8ces,=20les=20tr=C3=A9sors=20par=20pi=C3=A8ce,?=
=?UTF-8?q?=20la=20narration.....=20Mise=20en=20place=20=C3=A9galement=20d?=
=?UTF-8?q?e=20conditions=20permettant=20de=20conditionner=20le=20d=C3=A9b?=
=?UTF-8?q?locage=20des=20qu=C3=AAtes.=20-=20Possibilit=C3=A9=20de=20trans?=
=?UTF-8?q?form=C3=A9=20un=20arc=20en=20instance=20non=20lin=C3=A9aire=20a?=
=?UTF-8?q?fin=20de=20faire=20un=20hub.=20Permet=20de=20jouer=20de=20pr?=
=?UTF-8?q?=C3=A9parer=20des=20campagnes=20type=20Dragon=20of=20Icespire?=
=?UTF-8?q?=20peak=20plus=20facilement.=20-=20Configuration=20de=20partie?=
=?UTF-8?q?=20:=20chaque=20partie=20va=20contenir=20les=20s=C3=A9ances,=20?=
=?UTF-8?q?ce=20qui=20permettra=20de=20suivre=20le=20d=C3=A9blocage=20des?=
=?UTF-8?q?=20conditions=20pour=20les=20qu=C3=AAtes.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Passage en 0.9.1-beta
---
brain/app/application/chat.py | 122 +++++++++-
brain/app/domain/models.py | 61 ++++-
brain/app/main.py | 100 +++++++--
core/pom.xml | 2 +-
.../campaigncontext/ArcService.java | 9 +
.../CampaignReferencedFlagsService.java | 49 ++++
.../campaigncontext/CampaignService.java | 76 +++----
.../campaigncontext/ChapterService.java | 10 +
.../ChapterStatusEnricher.java | 104 +++++++++
.../campaigncontext/CharacterService.java | 23 +-
.../CampaignStructuralContextBuilder.java | 58 ++++-
.../SessionStructuralContextBuilder.java | 210 +++++++++++++++---
.../StreamChatForSessionUseCase.java | 17 +-
.../playcontext/PlaythroughService.java | 110 +++++++++
.../playcontext/SessionService.java | 49 ++--
.../loremind/domain/campaigncontext/Arc.java | 8 +
.../domain/campaigncontext/ArcType.java | 14 ++
.../domain/campaigncontext/Campaign.java | 18 +-
.../domain/campaigncontext/Chapter.java | 8 +
.../domain/campaigncontext/Character.java | 10 +-
.../domain/campaigncontext/Prerequisite.java | 26 +++
.../PrerequisiteEvaluator.java | 65 ++++++
.../campaigncontext/ProgressionStatus.java | 17 ++
.../domain/campaigncontext/QuestStatus.java | 19 ++
.../loremind/domain/campaigncontext/Room.java | 69 ++++++
.../domain/campaigncontext/RoomBranch.java | 22 ++
.../domain/campaigncontext/Scene.java | 9 +
.../ports/CharacterRepository.java | 2 +-
.../CampaignStructuralContext.java | 28 ++-
.../generationcontext/SessionContext.java | 45 +++-
.../domain/playcontext/Playthrough.java | 36 +++
.../domain/playcontext/QuestProgression.java | 29 +++
.../loremind/domain/playcontext/Session.java | 16 +-
.../ports/PlaythroughFlagRepository.java | 20 ++
.../ports/PlaythroughRepository.java | 24 ++
.../ports/QuestProgressionRepository.java | 31 +++
.../playcontext/ports/SessionRepository.java | 8 +-
.../ai/BrainChatPayloadBuilder.java | 62 ++++++
.../PrerequisiteListJsonConverter.java | 98 ++++++++
.../converter/RoomListJsonConverter.java | 43 ++++
.../persistence/entity/ArcJpaEntity.java | 10 +
.../persistence/entity/ChapterJpaEntity.java | 12 +
.../entity/CharacterJpaEntity.java | 10 +-
.../entity/PlaythroughFlagJpaEntity.java | 42 ++++
.../entity/PlaythroughJpaEntity.java | 54 +++++
.../entity/QuestProgressionJpaEntity.java | 44 ++++
.../persistence/entity/SceneJpaEntity.java | 11 +
.../persistence/entity/SessionJpaEntity.java | 14 +-
.../jpa/CharacterJpaRepository.java | 2 +-
.../jpa/PlaythroughFlagJpaRepository.java | 25 +++
.../jpa/PlaythroughJpaRepository.java | 13 ++
.../jpa/QuestProgressionJpaRepository.java | 25 +++
.../persistence/jpa/SessionJpaRepository.java | 4 +-
.../migration/PlaythroughMigrationRunner.java | 184 +++++++++++++++
.../postgres/PostgresArcRepository.java | 2 +
.../postgres/PostgresCampaignRepository.java | 3 +
.../postgres/PostgresChapterRepository.java | 6 +
.../postgres/PostgresCharacterRepository.java | 8 +-
.../PostgresPlaythroughFlagRepository.java | 56 +++++
.../PostgresPlaythroughRepository.java | 77 +++++++
.../PostgresQuestProgressionRepository.java | 78 +++++++
.../postgres/PostgresSceneRepository.java | 6 +
.../postgres/PostgresSessionRepository.java | 18 +-
.../web/controller/ArcController.java | 4 +-
.../controller/CampaignFlagController.java | 30 +++
.../web/controller/ChapterController.java | 37 ++-
.../web/controller/CharacterController.java | 8 +-
.../web/controller/PlaythroughController.java | 64 ++++++
.../controller/PlaythroughFlagController.java | 47 ++++
.../QuestProgressionController.java | 55 +++++
.../web/controller/SessionController.java | 18 +-
.../web/dto/campaigncontext/ArcDTO.java | 3 +
.../web/dto/campaigncontext/ChapterDTO.java | 16 ++
.../web/dto/campaigncontext/CharacterDTO.java | 2 +-
.../dto/campaigncontext/PrerequisiteDTO.java | 30 +++
.../dto/campaigncontext/RoomBranchDTO.java | 18 ++
.../web/dto/campaigncontext/RoomDTO.java | 28 +++
.../web/dto/campaigncontext/SceneDTO.java | 3 +
.../web/dto/playcontext/PlaythroughDTO.java | 19 ++
.../dto/playcontext/PlaythroughFlagDTO.java | 13 ++
.../web/dto/playcontext/SessionDTO.java | 2 +-
.../infrastructure/web/mapper/ArcMapper.java | 13 ++
.../web/mapper/ChapterMapper.java | 28 ++-
.../web/mapper/CharacterMapper.java | 4 +-
.../web/mapper/PlaythroughMapper.java | 33 +++
.../web/mapper/PrerequisiteMapper.java | 64 ++++++
.../web/mapper/SceneMapper.java | 64 ++++++
.../web/mapper/SessionMapper.java | 2 +-
web/package-lock.json | 4 +-
web/package.json | 2 +-
web/src/app/app.routes.ts | 2 +
.../arc/arc-create/arc-create.component.html | 16 ++
.../arc/arc-create/arc-create.component.scss | 38 ++++
.../arc/arc-create/arc-create.component.ts | 5 +-
.../arc/arc-edit/arc-edit.component.html | 16 ++
.../arc/arc-edit/arc-edit.component.scss | 38 ++++
.../arc/arc-edit/arc-edit.component.ts | 3 +
.../arc/arc-view/arc-view.component.html | 38 +++-
.../arc/arc-view/arc-view.component.scss | 111 +++++++++
.../arc/arc-view/arc-view.component.ts | 46 +++-
web/src/app/campaigns/campaign-tree.helper.ts | 5 +-
.../campaign-detail.component.html | 62 ++----
.../campaign-detail.component.scss | 55 +++++
.../campaign-detail.component.ts | 96 ++++----
.../chapter-edit/chapter-edit.component.html | 16 ++
.../chapter-edit/chapter-edit.component.scss | 51 +++++
.../chapter-edit/chapter-edit.component.ts | 69 ++++--
.../chapter-view/chapter-view.component.html | 27 ++-
.../chapter-view/chapter-view.component.scss | 34 +++
.../chapter-view/chapter-view.component.ts | 22 +-
.../character-edit.component.ts | 12 +-
.../playthrough-detail.component.html | 66 ++++++
.../playthrough-detail.component.scss | 109 +++++++++
.../playthrough-detail.component.ts | 145 ++++++++++++
.../playthrough-flags-page.component.html | 20 ++
.../playthrough-flags-page.component.scss | 24 ++
.../playthrough-flags-page.component.ts | 75 +++++++
.../scene-edit/scene-edit.component.html | 12 +
.../scene/scene-edit/scene-edit.component.ts | 12 +-
.../scene-view/scene-view.component.html | 44 ++++
.../scene-view/scene-view.component.scss | 83 +++++++
.../scene/scene-view/scene-view.component.ts | 5 +
web/src/app/services/campaign-flag.service.ts | 19 ++
web/src/app/services/campaign.model.ts | 105 +++++++++
web/src/app/services/campaign.service.ts | 17 +-
web/src/app/services/character.model.ts | 13 +-
web/src/app/services/character.service.ts | 4 +-
.../app/services/playthrough-flag.service.ts | 31 +++
web/src/app/services/playthrough.service.ts | 44 ++++
.../app/services/quest-progression.service.ts | 28 +++
web/src/app/services/session.model.ts | 2 +-
web/src/app/services/session.service.ts | 27 ++-
.../session-detail.component.html | 5 +-
.../session-detail.component.ts | 16 +-
.../session-reference-panel.component.ts | 8 +-
.../playthrough-flags-manager.component.html | 21 ++
.../playthrough-flags-manager.component.scss | 133 +++++++++++
.../playthrough-flags-manager.component.ts | 70 ++++++
.../prerequisite-editor.component.html | 79 +++++++
.../prerequisite-editor.component.scss | 108 +++++++++
.../prerequisite-editor.component.ts | 99 +++++++++
.../quest-status-badge.component.html | 4 +
.../quest-status-badge.component.scss | 43 ++++
.../quest-status-badge.component.ts | 46 ++++
.../rooms-editor/rooms-editor.component.html | 126 +++++++++++
.../rooms-editor/rooms-editor.component.scss | 159 +++++++++++++
.../rooms-editor/rooms-editor.component.ts | 133 +++++++++++
147 files changed, 5347 insertions(+), 392 deletions(-)
create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java
create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java
create mode 100644 core/src/main/java/com/loremind/application/playcontext/PlaythroughService.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/Prerequisite.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/PrerequisiteEvaluator.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ProgressionStatus.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/QuestStatus.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/Room.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/RoomBranch.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/Playthrough.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughFlagRepository.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughRepository.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/converter/PrerequisiteListJsonConverter.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/converter/RoomListJsonConverter.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughFlagJpaEntity.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughJpaEntity.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestProgressionJpaEntity.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughFlagJpaRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughJpaRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestProgressionJpaRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughFlagRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/CampaignFlagController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughFlagController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/QuestProgressionController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/PrerequisiteDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomBranchDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughFlagDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/PlaythroughMapper.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/PrerequisiteMapper.java
create mode 100644 web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html
create mode 100644 web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss
create mode 100644 web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts
create mode 100644 web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html
create mode 100644 web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.scss
create mode 100644 web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts
create mode 100644 web/src/app/services/campaign-flag.service.ts
create mode 100644 web/src/app/services/playthrough-flag.service.ts
create mode 100644 web/src/app/services/playthrough.service.ts
create mode 100644 web/src/app/services/quest-progression.service.ts
create mode 100644 web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.html
create mode 100644 web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.scss
create mode 100644 web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.ts
create mode 100644 web/src/app/shared/prerequisite-editor/prerequisite-editor.component.html
create mode 100644 web/src/app/shared/prerequisite-editor/prerequisite-editor.component.scss
create mode 100644 web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts
create mode 100644 web/src/app/shared/quest-status-badge/quest-status-badge.component.html
create mode 100644 web/src/app/shared/quest-status-badge/quest-status-badge.component.scss
create mode 100644 web/src/app/shared/quest-status-badge/quest-status-badge.component.ts
create mode 100644 web/src/app/shared/rooms-editor/rooms-editor.component.html
create mode 100644 web/src/app/shared/rooms-editor/rooms-editor.component.scss
create mode 100644 web/src/app/shared/rooms-editor/rooms-editor.component.ts
diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py
index 68758ac..3f220e3 100644
--- a/brain/app/application/chat.py
+++ b/brain/app/application/chat.py
@@ -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."""
diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py
index 449a883..fb04aa1 100644
--- a/brain/app/domain/models.py
+++ b/brain/app/domain/models.py
@@ -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)
diff --git a/brain/app/main.py b/brain/app/main.py
index d87cb4e..cd24348 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -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,
)
diff --git a/core/pom.xml b/core/pom.xml
index d7dd225..51459af 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
com.loremindloremind-core
- 0.9.0-beta
+ 0.9.1-betaLoreMind CoreBackend Core - Architecture Hexagonale
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java
index 933f06e..972d460 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java
@@ -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 getArcById(String id) {
return arcRepository.findById(id);
}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java
new file mode 100644
index 0000000..e9175db
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java
@@ -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.
+ *
+ *
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).
+ */
+@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 listForCampaign(String campaignId) {
+ TreeSet 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);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
index 6cc3cb9..7701c20 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
@@ -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).
+ *
+ *
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).
*/
@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.
- *
- *
{@code loreId} est nullable : une campagne peut exister sans univers associé.
- */
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 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 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 arcs = arcRepository.findByCampaignId(id);
- for (Arc arc : arcs) {
- List 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);
}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java
index 18fda92..c9a0ab4 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java
@@ -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 getChapterById(String id) {
return chapterRepository.findById(id);
}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java
new file mode 100644
index 0000000..eaf6bfe
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java
@@ -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é.
+ *
+ *
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.
+ */
+@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 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 flags = flagRepository.findByPlaythroughId(playthroughId);
+ Set completedQuestIds = progressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
+ int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
+
+ Map 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 dtos, List domain, String playthroughId) {
+ if (dtos == null || dtos.isEmpty()) return;
+ PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
+ Map 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());
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java
index 0e42f91..8117096 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java
@@ -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 values,
Map> imageValues,
Map> 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 getCharactersByCampaignId(String campaignId) {
- return characterRepository.findByCampaignId(campaignId);
+ public List 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;
diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
index 3569767..53bb13d 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
@@ -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 characters = characterRepository.findByCampaignId(campaignId).stream()
- .sorted(Comparator.comparingInt(Character::getOrder))
- .map(this::toCharacterSummary)
- .collect(Collectors.toList());
+ List characters = (playthroughId == null || playthroughId.isBlank())
+ ? List.of()
+ : characterRepository.findByPlaythroughId(playthroughId).stream()
+ .sorted(Comparator.comparingInt(Character::getOrder))
+ .map(this::toCharacterSummary)
+ .collect(Collectors.toList());
List npcs = npcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Npc::getOrder))
@@ -175,11 +186,42 @@ public class CampaignStructuralContextBuilder {
b.condition()))
.collect(Collectors.toList());
+ List 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 toRoomSummaries(Scene scene) {
+ if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
+ Map 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 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). */
diff --git a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
index 311abee..1c600c6 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
@@ -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.
*
- *
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.
+ *
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).
*/
@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 buildOptional(String sessionId) {
@@ -50,24 +79,155 @@ public class SessionStructuralContextBuilder {
}
private SessionContext toContext(Session session) {
- List 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 kept = allEntries.size() <= MAX_ENTRIES
- ? allEntries
- : allEntries.subList(allEntries.size() - MAX_ENTRIES, allEntries.size());
-
- List summaries = kept.stream()
- .map(e -> new JournalEntrySummary(
- e.getType() != null ? e.getType().name() : "NOTE",
- e.getContent(),
- e.getOccurredAt()))
- .collect(Collectors.toList());
+ List currentEntries = loadCurrentEntries(session);
+ List 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 loadCurrentEntries(Session session) {
+ List allEntries = entryRepository.findBySessionId(session.getId());
+ List 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 loadPreviousEvents(Session current) {
+ if (current.getPlaythroughId() == null) return List.of();
+ List siblingSessions = sessionRepository.findByPlaythroughId(current.getPlaythroughId());
+ List 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 available,
+ List inProgress,
+ List lockedTitles,
+ List 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 maybePlaythrough = playthroughRepository.findById(playthroughId);
+ if (maybePlaythrough.isEmpty()) {
+ return new HubStatus(List.of(), List.of(), List.of(), List.of());
+ }
+ String campaignId = maybePlaythrough.get().getCampaignId();
+
+ Map flags = playthroughFlagRepository.findByPlaythroughId(playthroughId);
+ List activeFlags = buildActiveFlags(flags);
+
+ List arcs = arcRepository.findByCampaignId(campaignId);
+ Map 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 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 available = new ArrayList<>();
+ List inProgress = new ArrayList<>();
+ List 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 buildActiveFlags(Map flags) {
+ return flags.entrySet().stream()
+ .filter(Map.Entry::getValue)
+ .map(Map.Entry::getKey)
+ .sorted()
+ .collect(Collectors.toList());
}
}
diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
index dea49ed..0bdd7f8 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
@@ -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);
diff --git a/core/src/main/java/com/loremind/application/playcontext/PlaythroughService.java b/core/src/main/java/com/loremind/application/playcontext/PlaythroughService.java
new file mode 100644
index 0000000..3c726c9
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/playcontext/PlaythroughService.java
@@ -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).
+ *
+ *
Cascade de suppression : un Playthrough supprime ses flags, ses progressions,
+ * ses PJ et ses sessions (avec leurs entrées de journal).
+ */
+@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 getById(String id) {
+ return playthroughRepository.findById(id);
+ }
+
+ public List 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);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionService.java b/core/src/main/java/com/loremind/application/playcontext/SessionService.java
index 9ba6d6e..2306e45 100644
--- a/core/src/main/java/com/loremind/application/playcontext/SessionService.java
+++ b/core/src/main/java/com/loremind/application/playcontext/SessionService.java
@@ -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.
*
- *
Règle métier : une seule Session peut être active (endedAt null) à la fois
- * dans l'application.
+ *
Règle métier : une seule Session peut être active (endedAt null) à la fois.
+ *
Depuis Playthrough : une Session appartient à un Playthrough (pas directement à une Campaign).
*/
@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 getActiveByPlaythrough(String playthroughId) {
+ return sessionRepository.findActiveByPlaythroughId(playthroughId);
+ }
+
public List getAll() {
return sessionRepository.findAll();
}
- public List getByCampaignId(String campaignId) {
- return sessionRepository.findByCampaignId(campaignId);
+ public List 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)) {
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java b/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java
index d5ced15..ef1a6c7 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java
@@ -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;
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java b/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java
new file mode 100644
index 0000000..65ddf10
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java
@@ -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
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
index 4d89f91..e6dfee1 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
@@ -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).
+ *
+ *
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}.
+ *
+ *
Entité pure du domaine, sans dépendance technique.
*/
@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;
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java
index 391358d..7eb70a9 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java
@@ -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 prerequisites = new ArrayList<>();
+
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java
index ab46e87..d351b31 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java
@@ -59,10 +59,14 @@ public class Character {
*/
private Map> 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;
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Prerequisite.java b/core/src/main/java/com/loremind/domain/campaigncontext/Prerequisite.java
new file mode 100644
index 0000000..82ce788
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Prerequisite.java
@@ -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 {}
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/PrerequisiteEvaluator.java b/core/src/main/java/com/loremind/domain/campaigncontext/PrerequisiteEvaluator.java
new file mode 100644
index 0000000..86d3045
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/PrerequisiteEvaluator.java
@@ -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 completedQuestIds,
+ int currentSessionCount,
+ Map campaignFlags
+ ) {}
+
+ /** True si TOUS les prérequis sont satisfaits (ET logique). Vide => true. */
+ public boolean areAllSatisfied(List 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 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);
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ProgressionStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/ProgressionStatus.java
new file mode 100644
index 0000000..1bf1213
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ProgressionStatus.java
@@ -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
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/QuestStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/QuestStatus.java
new file mode 100644
index 0000000..6347a1e
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/QuestStatus.java
@@ -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
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Room.java b/core/src/main/java/com/loremind/domain/campaigncontext/Room.java
new file mode 100644
index 0000000..f8b0100
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Room.java
@@ -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.
+ *
+ *
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.
+ *
+ *
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.
+ */
+@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 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 branches = new ArrayList<>();
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/RoomBranch.java b/core/src/main/java/com/loremind/domain/campaigncontext/RoomBranch.java
new file mode 100644
index 0000000..ef6cecf
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/RoomBranch.java
@@ -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}.
+ *
+ *
Record Java immuable, sérialisé via Jackson dans la liste JSONB
+ * {@code rooms} de la Scene.
+ *
+ *
Règle métier : {@code targetRoomId} doit pointer vers une Room de la
+ * MÊME Scene (validation côté service).
+ *
+ * @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);
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java
index 3c7298c..6ea62a3 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java
@@ -72,6 +72,15 @@ public class Scene {
@Builder.Default
private List 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 rooms = new ArrayList<>();
+
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java
index a0adcdc..b9c9090 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java
@@ -14,7 +14,7 @@ public interface CharacterRepository {
Optional findById(String id);
- List findByCampaignId(String campaignId);
+ List findByPlaythroughId(String playthroughId);
void deleteById(String id);
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
index a3d2e18..d043788 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
@@ -69,12 +69,13 @@ public record CampaignStructuralContext(
List 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 branches) {
+ List branches,
+ List 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 branches) {
+ }
+
+ /** Indice d'une sortie entre pièces ; {@code targetRoomName} déjà résolu. */
+ public record RoomBranchHint(String label, String targetRoomName, String condition) {
+ }
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java b/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
index c7b7bb4..8d41231 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
@@ -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.
*
*
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).
+ * 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.
*
*
Value Object du Generation Context — record Java immutable.
*
- * @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 entries) {
+ List entries,
+ List previousEvents,
+ List availableQuests,
+ List inProgressQuests,
+ List lockedQuestTitles,
+ List 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) {}
}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/Playthrough.java b/core/src/main/java/com/loremind/domain/playcontext/Playthrough.java
new file mode 100644
index 0000000..c38f2e2
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/Playthrough.java
@@ -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.
+ *
+ *
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.
+ *
+ *
Fait partie du Play Context. Référence la Campagne par weak reference
+ * (campaignId) pour respecter les Bounded Contexts.
+ */
+@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;
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java b/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java
new file mode 100644
index 0000000..0b79f0b
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java
@@ -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é.
+ *
+ *
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).
+ *
+ *
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).
+ *
+ *
Sémantique : l'absence de ligne dans le repo équivaut à NOT_STARTED.
+ * On ne persiste donc que les transitions explicites IN_PROGRESS / COMPLETED.
+ */
+@Data
+@Builder
+public class QuestProgression {
+
+ private String id;
+ private String playthroughId;
+ private String chapterId;
+ private ProgressionStatus status;
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/Session.java b/core/src/main/java/com/loremind/domain/playcontext/Session.java
index d9a1298..2391fd1 100644
--- a/core/src/main/java/com/loremind/domain/playcontext/Session.java
+++ b/core/src/main/java/com/loremind/domain/playcontext/Session.java
@@ -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).
*
- *
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.
- *
- *
Fait partie du Play Context. Référence la Campaign par weak reference
- * (campaignId) pour respecter la séparation des Bounded Contexts.
+ *
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.
*
*
{@code endedAt == null} signifie que la session est en cours.
* Une seule session peut être en cours dans l'application à la fois.
@@ -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;
diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughFlagRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughFlagRepository.java
new file mode 100644
index 0000000..541dd24
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughFlagRepository.java
@@ -0,0 +1,20 @@
+package com.loremind.domain.playcontext.ports;
+
+import java.util.Map;
+
+/**
+ * Port de sortie pour les flags narratifs d'un Playthrough.
+ *
+ *
Anciennement {@code CampaignFlagRepository} : les flags suivent maintenant
+ * une partie (Playthrough), pas un scénario (Campaign).
+ */
+public interface PlaythroughFlagRepository {
+
+ Map findByPlaythroughId(String playthroughId);
+
+ void setFlag(String playthroughId, String name, boolean value);
+
+ void deleteFlag(String playthroughId, String name);
+
+ void deleteAllByPlaythroughId(String playthroughId);
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughRepository.java
new file mode 100644
index 0000000..d195b67
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughRepository.java
@@ -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 findById(String id);
+
+ List findByCampaignId(String campaignId);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java
new file mode 100644
index 0000000..1409e87
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java
@@ -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.
+ *
+ *
Modèle "absence = NOT_STARTED" : on ne stocke que les transitions
+ * explicites IN_PROGRESS / COMPLETED.
+ */
+public interface QuestProgressionRepository {
+
+ /** Liste toutes les progressions explicites d'un Playthrough. */
+ List findByPlaythroughId(String playthroughId);
+
+ /** Set des IDs de chapitres en COMPLETED pour un Playthrough donné (fast path éval). */
+ Set 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);
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java
index 2a20908..6eb6a90 100644
--- a/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java
+++ b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java
@@ -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 findAll();
- List findByCampaignId(String campaignId);
+ List 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 findActive();
+ /** Retourne la session active du Playthrough donné, s'il y en a une. */
+ Optional findActiveByPlaythroughId(String playthroughId);
+
void deleteById(String id);
boolean existsById(String id);
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
index cd641c5..3d881b2 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
@@ -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 questSummaryToMap(QuestSummary q) {
+ Map 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 roomSummaryToMap(com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary r) {
+ Map 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 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 narrativeEntityToMap(NarrativeEntityContext ne) {
Map map = new LinkedHashMap<>();
map.put("entity_type", ne.entityType());
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/PrerequisiteListJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/PrerequisiteListJsonConverter.java
new file mode 100644
index 0000000..a80ace2
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/PrerequisiteListJsonConverter.java
@@ -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 (sealed type du domaine) en JSON stocké en base.
+ *
+ * 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.
+ *
+ * Format JSON :
+ * [
+ * {"kind": "QUEST_COMPLETED", "questId": "42"},
+ * {"kind": "SESSION_REACHED", "minSessionNumber": 3},
+ * {"kind": "FLAG_SET", "flagName": "forgeron_rencontre"}
+ * ]
+ */
+@Converter
+public class PrerequisiteListJsonConverter implements AttributeConverter, 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 attribute) {
+ if (attribute == null || attribute.isEmpty()) return "[]";
+ try {
+ List