From c3d3394ec7a307029b5c779f64301103dba21d4a 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.loremind loremind-core - 0.9.0-beta + 0.9.1-beta LoreMind Core Backend 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> raw = new ArrayList<>(attribute.size()); + for (Prerequisite p : attribute) { + raw.add(toMap(p)); + } + return MAPPER.writeValueAsString(raw); + } catch (Exception e) { + throw new IllegalStateException("Erreur sérialisation List → JSON", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) return new ArrayList<>(); + try { + List> raw = MAPPER.readValue(dbData, new TypeReference<>() {}); + List result = new ArrayList<>(raw.size()); + for (Map m : raw) { + result.add(fromMap(m)); + } + return result; + } catch (Exception e) { + throw new IllegalStateException("Erreur désérialisation JSON → List", e); + } + } + + private Map toMap(Prerequisite p) { + Map m = new HashMap<>(); + if (p instanceof Prerequisite.QuestCompleted q) { + m.put(KIND, KIND_QUEST_COMPLETED); + m.put("questId", q.questId()); + } else if (p instanceof Prerequisite.SessionReached s) { + m.put(KIND, KIND_SESSION_REACHED); + m.put("minSessionNumber", s.minSessionNumber()); + } else if (p instanceof Prerequisite.FlagSet f) { + m.put(KIND, KIND_FLAG_SET); + m.put("flagName", f.flagName()); + } else { + throw new IllegalStateException("Prerequisite non géré : " + p.getClass().getName()); + } + return m; + } + + private Prerequisite fromMap(Map m) { + String kind = String.valueOf(m.get(KIND)); + switch (kind) { + case KIND_QUEST_COMPLETED: + return new Prerequisite.QuestCompleted(String.valueOf(m.get("questId"))); + case KIND_SESSION_REACHED: + Object n = m.get("minSessionNumber"); + return new Prerequisite.SessionReached(((Number) n).intValue()); + case KIND_FLAG_SET: + return new Prerequisite.FlagSet(String.valueOf(m.get("flagName"))); + default: + throw new IllegalStateException("Kind Prerequisite inconnu : " + kind); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/RoomListJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/RoomListJsonConverter.java new file mode 100644 index 0000000..32b296e --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/RoomListJsonConverter.java @@ -0,0 +1,43 @@ +package com.loremind.infrastructure.persistence.converter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.Room; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.Collections; +import java.util.List; + +/** + * Convertit la liste de pièces explorables d'une Scene en JSON pour la persistance. + * + *

Une Room contient elle-même une liste de {@link com.loremind.domain.campaigncontext.RoomBranch} + * (record Java) ; Jackson 2.12+ sait sérialiser/désérialiser les records nativement + * via le constructeur canonique, donc rien de spécial à faire pour les branches.

+ */ +@Converter +public class RoomListJsonConverter implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) return "[]"; + try { + return MAPPER.writeValueAsString(attribute); + } catch (Exception e) { + throw new IllegalStateException("Erreur sérialisation List → JSON", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) return Collections.emptyList(); + try { + return MAPPER.readValue(dbData, new TypeReference<>() {}); + } catch (Exception e) { + throw new IllegalStateException("Erreur désérialisation JSON → List", e); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java index 37f471b..156aa5f 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java @@ -1,5 +1,6 @@ package com.loremind.infrastructure.persistence.entity; +import com.loremind.domain.campaigncontext.ArcType; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -37,6 +38,15 @@ public class ArcJpaEntity { @Column(name = "\"order\"", nullable = false) private int order; + /** + * Type structurel de l'arc (LINEAR par défaut). + * Stocké en STRING pour rester lisible en DB et résistant aux refactos d'ordre. + */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "VARCHAR(16) DEFAULT 'LINEAR'") + @Builder.Default + private ArcType type = ArcType.LINEAR; + @Column private String icon; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java index cf9325b..6138f43 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java @@ -1,5 +1,7 @@ package com.loremind.infrastructure.persistence.entity; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -37,6 +39,16 @@ public class ChapterJpaEntity { @Column(name = "\"order\"", nullable = false) private int order; + /** + * Conditions de déblocage (combinées en ET). Sérialisées en JSON dans une colonne TEXT + * via {@link PrerequisiteListJsonConverter} (sealed type côté domaine). + * Donnée de SCÉNARIO — l'état réel est sur Playthrough.QuestProgression. + */ + @Column(name = "prerequisites", columnDefinition = "TEXT") + @Convert(converter = PrerequisiteListJsonConverter.class) + @Builder.Default + private List prerequisites = new ArrayList<>(); + @Column private String icon; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java index 37d47e4..60efb7b 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java @@ -59,9 +59,17 @@ public class CharacterJpaEntity { @Column(name = "key_value_values", columnDefinition = "TEXT") private Map> keyValueValues; - @Column(name = "campaign_id", nullable = false) + /** + * Ancienne référence directe vers la Campaign. Conservée nullable pour la + * migration ; le code utilise playthrough_id désormais. + */ + @Column(name = "campaign_id") private Long campaignId; + /** ID du Playthrough (partie) auquel ce PJ appartient. Weak ref cross-context. */ + @Column(name = "playthrough_id") + private Long playthroughId; + @Column(name = "\"order\"", nullable = false) private int order; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughFlagJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughFlagJpaEntity.java new file mode 100644 index 0000000..d4e4308 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughFlagJpaEntity.java @@ -0,0 +1,42 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Entité JPA pour les flags narratifs d'un Playthrough. + * + *

Remplace l'ancienne {@code CampaignFlagJpaEntity} : les flags suivent + * désormais la partie (table jouée), pas le scénario.

+ */ +@Entity +@Table( + name = "playthrough_flag", + uniqueConstraints = @UniqueConstraint( + name = "uk_playthrough_flag_name", + columnNames = {"playthrough_id", "name"} + ), + indexes = @Index(name = "ix_playthrough_flag_playthrough", columnList = "playthrough_id") +) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PlaythroughFlagJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "playthrough_id", nullable = false) + private Long playthroughId; + + @Column(nullable = false, length = 128) + private String name; + + @Column(nullable = false) + private boolean value; +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughJpaEntity.java new file mode 100644 index 0000000..580a68e --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughJpaEntity.java @@ -0,0 +1,54 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Entité JPA pour la table {@code playthroughs} — instances jouées d'une Campagne. + */ +@Entity +@Table( + name = "playthroughs", + indexes = @Index(name = "ix_playthrough_campaign", columnList = "campaign_id") +) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PlaythroughJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "campaign_id", nullable = false) + private Long campaignId; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestProgressionJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestProgressionJpaEntity.java new file mode 100644 index 0000000..4b32671 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestProgressionJpaEntity.java @@ -0,0 +1,44 @@ +package com.loremind.infrastructure.persistence.entity; + +import com.loremind.domain.campaigncontext.ProgressionStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Entité JPA pour la progression d'une quête (Chapter) au sein d'un Playthrough. + * + *

Contrainte d'unicité sur (playthrough_id, chapter_id) : une seule ligne par + * couple quête × partie. L'absence de ligne = NOT_STARTED.

+ */ +@Entity +@Table( + name = "quest_progression", + uniqueConstraints = @UniqueConstraint( + name = "uk_quest_progression_unique", + columnNames = {"playthrough_id", "chapter_id"} + ), + indexes = @Index(name = "ix_quest_progression_playthrough", columnList = "playthrough_id") +) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class QuestProgressionJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "playthrough_id", nullable = false) + private Long playthroughId; + + @Column(name = "chapter_id", nullable = false) + private Long chapterId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ProgressionStatus status; +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java index f1077b9..7564629 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java @@ -1,6 +1,8 @@ package com.loremind.infrastructure.persistence.entity; +import com.loremind.domain.campaigncontext.Room; import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.infrastructure.persistence.converter.RoomListJsonConverter; import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import jakarta.persistence.*; @@ -95,6 +97,15 @@ public class SceneJpaEntity { @Builder.Default private List branches = new ArrayList<>(); + /** + * Pièces explorables de cette scène (donjon, crypte…). Vide = scène classique. + * Sérialisé en JSON dans une colonne TEXT. + */ + @Column(name = "rooms", columnDefinition = "TEXT") + @Convert(converter = RoomListJsonConverter.class) + @Builder.Default + private List rooms = new ArrayList<>(); + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java index d3016a9..350fcdc 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java @@ -28,12 +28,20 @@ public class SessionJpaEntity { private String name; /** - * ID de la Campaign associée. Pas de @ManyToOne / pas de FK : c'est une - * weak reference inter-contexte (Play Context ↔ Campaign Context). + * Ancienne référence directe vers la Campaign. Conservée (nullable) pour la + * rétrocompatibilité de la migration, mais plus utilisée par le code. + * À supprimer manuellement quand toutes les sessions auront leur playthrough_id. */ - @Column(name = "campaign_id", nullable = false) + @Column(name = "campaign_id") private String campaignId; + /** + * ID du Playthrough (partie) auquel cette session appartient. + * Weak reference inter-contexte. + */ + @Column(name = "playthrough_id") + private Long playthroughId; + @Column(name = "started_at", nullable = false) private LocalDateTime startedAt; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java index 76db4e5..7ae0269 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java @@ -9,5 +9,5 @@ import java.util.List; @Repository public interface CharacterJpaRepository extends JpaRepository { - List findByCampaignIdOrderByOrderAsc(Long campaignId); + List findByPlaythroughIdOrderByOrderAsc(Long playthroughId); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughFlagJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughFlagJpaRepository.java new file mode 100644 index 0000000..c3c7bfc --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughFlagJpaRepository.java @@ -0,0 +1,25 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.PlaythroughFlagJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PlaythroughFlagJpaRepository extends JpaRepository { + + List findByPlaythroughId(Long playthroughId); + + Optional findByPlaythroughIdAndName(Long playthroughId, String name); + + @Modifying + @Transactional + @Query("DELETE FROM PlaythroughFlagJpaEntity f WHERE f.playthroughId = :playthroughId") + void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughJpaRepository.java new file mode 100644 index 0000000..7451ad4 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughJpaRepository.java @@ -0,0 +1,13 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.PlaythroughJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PlaythroughJpaRepository extends JpaRepository { + + List findByCampaignId(Long campaignId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestProgressionJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestProgressionJpaRepository.java new file mode 100644 index 0000000..bdd64fb --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestProgressionJpaRepository.java @@ -0,0 +1,25 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.QuestProgressionJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface QuestProgressionJpaRepository extends JpaRepository { + + List findByPlaythroughId(Long playthroughId); + + Optional findByPlaythroughIdAndChapterId(Long playthroughId, Long chapterId); + + @Modifying + @Transactional + @Query("DELETE FROM QuestProgressionJpaEntity q WHERE q.playthroughId = :playthroughId") + void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionJpaRepository.java index cb4548f..1111ff2 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionJpaRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionJpaRepository.java @@ -13,7 +13,9 @@ import java.util.Optional; @Repository public interface SessionJpaRepository extends JpaRepository { - List findByCampaignIdOrderByStartedAtDesc(String campaignId); + List findByPlaythroughIdOrderByStartedAtDesc(Long playthroughId); Optional findFirstByEndedAtIsNull(); + + Optional findFirstByPlaythroughIdAndEndedAtIsNull(Long playthroughId); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java b/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java new file mode 100644 index 0000000..88602ee --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java @@ -0,0 +1,184 @@ +package com.loremind.infrastructure.persistence.migration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Migration one-shot des données existantes vers le modèle Playthrough. + * + *

Idempotente : si une campagne a déjà au moins un Playthrough, on ne crée pas + * de Partie par défaut pour elle. Les opérations sur les sessions / characters / + * flags / progression sont toutes filtrées sur l'absence de {@code playthrough_id} + * pour ne rien dupliquer.

+ * + *

Étapes : + * 1. Crée un Playthrough "Partie principale" pour chaque Campaign qui n'en a pas + * 2. Renseigne {@code sessions.playthrough_id} depuis l'ancien {@code campaign_id} + * 3. Renseigne {@code characters.playthrough_id} depuis l'ancien {@code campaign_id} + * 4. Copie {@code campaign_flag} → {@code playthrough_flag} (si tables présentes) + * 5. Copie {@code chapters.progression_status} (≠ NOT_STARTED) → {@code quest_progression} + * + *

S'exécute au démarrage, après Hibernate ddl-auto=update qui aura créé les nouvelles tables.

+ */ +@Configuration +public class PlaythroughMigrationRunner { + + private static final Logger LOG = LoggerFactory.getLogger(PlaythroughMigrationRunner.class); + + private static final String DEFAULT_PLAYTHROUGH_NAME = "Partie principale"; + + @Bean + public ApplicationRunner runPlaythroughMigration(JdbcTemplate jdbc) { + return args -> migrate(jdbc); + } + + @Transactional + void migrate(JdbcTemplate jdbc) { + // Garde-fou : si la table playthroughs n'existe pas (anciens dumps), on saute. + if (!tableExists(jdbc, "playthroughs")) { + LOG.warn("Migration Playthrough : table 'playthroughs' absente — Hibernate ne l'a pas créée. Skip."); + return; + } + + int createdPlaythroughs = createDefaultPlaythroughs(jdbc); + int migratedSessions = migrateSessions(jdbc); + int migratedCharacters = migrateCharacters(jdbc); + int migratedFlags = migrateFlags(jdbc); + int migratedProgressions = migrateProgressions(jdbc); + // Relaxe les NOT NULL legacy : ddl-auto=update n'assouplit pas les contraintes. + relaxLegacyNotNullConstraints(jdbc); + + if (createdPlaythroughs + migratedSessions + migratedCharacters + migratedFlags + migratedProgressions > 0) { + LOG.info("Migration Playthrough : {} playthrough(s) créé(s), {} session(s), {} PJ, {} flag(s), {} progression(s)", + createdPlaythroughs, migratedSessions, migratedCharacters, migratedFlags, migratedProgressions); + } else { + LOG.debug("Migration Playthrough : rien à migrer."); + } + } + + private int createDefaultPlaythroughs(JdbcTemplate jdbc) { + List campaignsWithoutPlaythrough = jdbc.queryForList( + "SELECT c.id FROM campaigns c " + + "WHERE NOT EXISTS (SELECT 1 FROM playthroughs p WHERE p.campaign_id = c.id)", + Long.class + ); + if (campaignsWithoutPlaythrough.isEmpty()) return 0; + + LocalDateTime now = LocalDateTime.now(); + for (Long campaignId : campaignsWithoutPlaythrough) { + jdbc.update( + "INSERT INTO playthroughs (campaign_id, name, description, created_at, updated_at) " + + "VALUES (?, ?, NULL, ?, ?)", + campaignId, DEFAULT_PLAYTHROUGH_NAME, now, now + ); + } + return campaignsWithoutPlaythrough.size(); + } + + private int migrateSessions(JdbcTemplate jdbc) { + if (!columnExists(jdbc, "sessions", "campaign_id")) return 0; + return jdbc.update( + "UPDATE sessions s " + + "SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = CAST(s.campaign_id AS BIGINT) LIMIT 1) " + + "WHERE s.playthrough_id IS NULL AND s.campaign_id IS NOT NULL" + ); + } + + private int migrateCharacters(JdbcTemplate jdbc) { + if (!columnExists(jdbc, "characters", "campaign_id")) return 0; + return jdbc.update( + "UPDATE characters c " + + "SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = c.campaign_id LIMIT 1) " + + "WHERE c.playthrough_id IS NULL AND c.campaign_id IS NOT NULL" + ); + } + + private int migrateFlags(JdbcTemplate jdbc) { + if (!tableExists(jdbc, "campaign_flag") || !tableExists(jdbc, "playthrough_flag")) return 0; + // Copie uniquement ce qui n'a pas déjà été copié — on déduplique par (playthrough_id, name) + return jdbc.update( + "INSERT INTO playthrough_flag (playthrough_id, name, value) " + + "SELECT p.id, cf.name, cf.value " + + "FROM campaign_flag cf " + + "JOIN playthroughs p ON p.campaign_id = cf.campaign_id " + + "WHERE NOT EXISTS (" + + " SELECT 1 FROM playthrough_flag pf " + + " WHERE pf.playthrough_id = p.id AND pf.name = cf.name" + + ")" + ); + } + + private int migrateProgressions(JdbcTemplate jdbc) { + if (!columnExists(jdbc, "chapters", "progression_status")) return 0; + if (!tableExists(jdbc, "quest_progression")) return 0; + // On copie les chapitres dont la progression était != NOT_STARTED, vers la Partie principale + // de la campagne du chapitre. Déduplique via NOT EXISTS sur (playthrough_id, chapter_id). + return jdbc.update( + "INSERT INTO quest_progression (playthrough_id, chapter_id, status) " + + "SELECT p.id, ch.id, ch.progression_status " + + "FROM chapters ch " + + "JOIN arcs a ON a.id = ch.arc_id " + + "JOIN playthroughs p ON p.campaign_id = a.campaign_id " + + "WHERE ch.progression_status IS NOT NULL " + + " AND ch.progression_status <> 'NOT_STARTED' " + + " AND NOT EXISTS (" + + " SELECT 1 FROM quest_progression qp " + + " WHERE qp.playthrough_id = p.id AND qp.chapter_id = ch.id" + + " )" + ); + } + + /** + * Relâche les NOT NULL hérités des colonnes désormais optionnelles : + * sessions.campaign_id, characters.campaign_id, chapters.progression_status + * Idempotent : PostgreSQL ne bronche pas si la colonne est déjà NULLABLE. + */ + private void relaxLegacyNotNullConstraints(JdbcTemplate jdbc) { + relaxNotNull(jdbc, "sessions", "campaign_id"); + relaxNotNull(jdbc, "characters", "campaign_id"); + relaxNotNull(jdbc, "chapters", "progression_status"); + } + + private void relaxNotNull(JdbcTemplate jdbc, String table, String column) { + if (!columnExists(jdbc, table, column)) return; + try { + jdbc.execute("ALTER TABLE " + table + " ALTER COLUMN " + column + " DROP NOT NULL"); + } catch (DataAccessException ex) { + // Déjà NULLABLE ou table en cours de migration : log et continue. + LOG.debug("relaxNotNull({}.{}) : déjà nullable ou non-applicable", table, column); + } + } + + private boolean tableExists(JdbcTemplate jdbc, String table) { + try { + Integer n = jdbc.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ?", + Integer.class, table + ); + return n != null && n > 0; + } catch (DataAccessException ex) { + return false; + } + } + + private boolean columnExists(JdbcTemplate jdbc, String table, String column) { + try { + Integer n = jdbc.queryForObject( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?", + Integer.class, table, column + ); + return n != null && n > 0; + } catch (DataAccessException ex) { + return false; + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java index 2f7b963..cff458e 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java @@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository { .description(jpaEntity.getDescription()) .campaignId(jpaEntity.getCampaignId().toString()) .order(jpaEntity.getOrder()) + .type(jpaEntity.getType()) .icon(jpaEntity.getIcon()) .themes(jpaEntity.getThemes()) .stakes(jpaEntity.getStakes()) @@ -100,6 +101,7 @@ public class PostgresArcRepository implements ArcRepository { .description(arc.getDescription()) .campaignId(Long.parseLong(arc.getCampaignId())) .order(arc.getOrder()) + .type(arc.getType()) .icon(arc.getIcon()) .themes(arc.getThemes()) .stakes(arc.getStakes()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java index 0b2d039..5023bbc 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java @@ -12,6 +12,9 @@ import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port CampaignRepository. + *

+ * Depuis l'introduction de Playthrough : ne touche plus aux flags (ils + * appartiennent au Playthrough, pas à la Campagne). */ @Repository public class PostgresCampaignRepository implements CampaignRepository { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java index afe90cf..4263ee6 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java @@ -71,6 +71,9 @@ public class PostgresChapterRepository implements ChapterRepository { .description(jpaEntity.getDescription()) .arcId(jpaEntity.getArcId().toString()) .order(jpaEntity.getOrder()) + .prerequisites(jpaEntity.getPrerequisites() != null + ? new ArrayList<>(jpaEntity.getPrerequisites()) + : new ArrayList<>()) .icon(jpaEntity.getIcon()) .gmNotes(jpaEntity.getGmNotes()) .playerObjectives(jpaEntity.getPlayerObjectives()) @@ -97,6 +100,9 @@ public class PostgresChapterRepository implements ChapterRepository { .description(chapter.getDescription()) .arcId(Long.parseLong(chapter.getArcId())) .order(chapter.getOrder()) + .prerequisites(chapter.getPrerequisites() != null + ? new ArrayList<>(chapter.getPrerequisites()) + : new ArrayList<>()) .icon(chapter.getIcon()) .gmNotes(chapter.getGmNotes()) .playerObjectives(chapter.getPlayerObjectives()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java index 2413c96..e444ffa 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java @@ -33,8 +33,8 @@ public class PostgresCharacterRepository implements CharacterRepository { } @Override - public List findByCampaignId(String campaignId) { - return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() + public List findByPlaythroughId(String playthroughId) { + return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream() .map(this::toDomainEntity) .collect(Collectors.toList()); } @@ -58,7 +58,7 @@ public class PostgresCharacterRepository implements CharacterRepository { .values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>()) .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>()) - .campaignId(e.getCampaignId().toString()) + .playthroughId(e.getPlaythroughId() != null ? e.getPlaythroughId().toString() : null) .order(e.getOrder()) .createdAt(e.getCreatedAt()) .updatedAt(e.getUpdatedAt()) @@ -75,7 +75,7 @@ public class PostgresCharacterRepository implements CharacterRepository { .values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>()) .imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>()) .keyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>()) - .campaignId(Long.parseLong(c.getCampaignId())) + .playthroughId(c.getPlaythroughId() != null ? Long.parseLong(c.getPlaythroughId()) : null) .order(c.getOrder()) .createdAt(c.getCreatedAt()) .updatedAt(c.getUpdatedAt()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughFlagRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughFlagRepository.java new file mode 100644 index 0000000..81ac630 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughFlagRepository.java @@ -0,0 +1,56 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; +import com.loremind.infrastructure.persistence.entity.PlaythroughFlagJpaEntity; +import com.loremind.infrastructure.persistence.jpa.PlaythroughFlagJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.HashMap; +import java.util.Map; + +@Repository +public class PostgresPlaythroughFlagRepository implements PlaythroughFlagRepository { + + private final PlaythroughFlagJpaRepository jpa; + + public PostgresPlaythroughFlagRepository(PlaythroughFlagJpaRepository jpa) { + this.jpa = jpa; + } + + @Override + public Map findByPlaythroughId(String playthroughId) { + Long pid = Long.parseLong(playthroughId); + Map out = new HashMap<>(); + for (PlaythroughFlagJpaEntity f : jpa.findByPlaythroughId(pid)) { + out.put(f.getName(), f.isValue()); + } + return out; + } + + @Override + public void setFlag(String playthroughId, String name, boolean value) { + Long pid = Long.parseLong(playthroughId); + jpa.findByPlaythroughIdAndName(pid, name).ifPresentOrElse( + existing -> { + existing.setValue(value); + jpa.save(existing); + }, + () -> jpa.save(PlaythroughFlagJpaEntity.builder() + .playthroughId(pid) + .name(name) + .value(value) + .build()) + ); + } + + @Override + public void deleteFlag(String playthroughId, String name) { + Long pid = Long.parseLong(playthroughId); + jpa.findByPlaythroughIdAndName(pid, name).ifPresent(f -> jpa.deleteById(f.getId())); + } + + @Override + public void deleteAllByPlaythroughId(String playthroughId) { + jpa.deleteByPlaythroughId(Long.parseLong(playthroughId)); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughRepository.java new file mode 100644 index 0000000..eb67d98 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughRepository.java @@ -0,0 +1,77 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.infrastructure.persistence.entity.PlaythroughJpaEntity; +import com.loremind.infrastructure.persistence.jpa.PlaythroughJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +public class PostgresPlaythroughRepository implements PlaythroughRepository { + + private final PlaythroughJpaRepository jpa; + + public PostgresPlaythroughRepository(PlaythroughJpaRepository jpa) { + this.jpa = jpa; + } + + @Override + public Playthrough save(Playthrough p) { + PlaythroughJpaEntity saved = jpa.save(toJpa(p)); + return toDomain(saved); + } + + @Override + public Optional findById(String id) { + return jpa.findById(Long.parseLong(id)).map(this::toDomain); + } + + @Override + public List findByCampaignId(String campaignId) { + return jpa.findByCampaignId(Long.parseLong(campaignId)).stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findAll() { + return jpa.findAll().stream().map(this::toDomain).collect(Collectors.toList()); + } + + @Override + public void deleteById(String id) { + jpa.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpa.existsById(Long.parseLong(id)); + } + + private Playthrough toDomain(PlaythroughJpaEntity e) { + return Playthrough.builder() + .id(e.getId().toString()) + .campaignId(e.getCampaignId().toString()) + .name(e.getName()) + .description(e.getDescription()) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } + + private PlaythroughJpaEntity toJpa(Playthrough p) { + Long id = p.getId() != null ? Long.parseLong(p.getId()) : null; + return PlaythroughJpaEntity.builder() + .id(id) + .campaignId(Long.parseLong(p.getCampaignId())) + .name(p.getName()) + .description(p.getDescription()) + .createdAt(p.getCreatedAt()) + .updatedAt(p.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java new file mode 100644 index 0000000..f80c792 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java @@ -0,0 +1,78 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.playcontext.QuestProgression; +import com.loremind.domain.playcontext.ports.QuestProgressionRepository; +import com.loremind.infrastructure.persistence.entity.QuestProgressionJpaEntity; +import com.loremind.infrastructure.persistence.jpa.QuestProgressionJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Repository +public class PostgresQuestProgressionRepository implements QuestProgressionRepository { + + private final QuestProgressionJpaRepository jpa; + + public PostgresQuestProgressionRepository(QuestProgressionJpaRepository jpa) { + this.jpa = jpa; + } + + @Override + public List findByPlaythroughId(String playthroughId) { + return jpa.findByPlaythroughId(Long.parseLong(playthroughId)).stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Set findCompletedChapterIdsByPlaythroughId(String playthroughId) { + Set out = new HashSet<>(); + for (QuestProgressionJpaEntity e : jpa.findByPlaythroughId(Long.parseLong(playthroughId))) { + if (e.getStatus() == ProgressionStatus.COMPLETED) { + out.add(e.getChapterId().toString()); + } + } + return out; + } + + @Override + public void setStatus(String playthroughId, String chapterId, ProgressionStatus status) { + Long pid = Long.parseLong(playthroughId); + Long cid = Long.parseLong(chapterId); + // Sémantique : NOT_STARTED = absence de ligne. + if (status == null || status == ProgressionStatus.NOT_STARTED) { + jpa.findByPlaythroughIdAndChapterId(pid, cid) + .ifPresent(e -> jpa.deleteById(e.getId())); + return; + } + jpa.findByPlaythroughIdAndChapterId(pid, cid).ifPresentOrElse( + existing -> { + existing.setStatus(status); + jpa.save(existing); + }, + () -> jpa.save(QuestProgressionJpaEntity.builder() + .playthroughId(pid) + .chapterId(cid) + .status(status) + .build()) + ); + } + + @Override + public void deleteAllByPlaythroughId(String playthroughId) { + jpa.deleteByPlaythroughId(Long.parseLong(playthroughId)); + } + + private QuestProgression toDomain(QuestProgressionJpaEntity e) { + return QuestProgression.builder() + .id(e.getId().toString()) + .playthroughId(e.getPlaythroughId().toString()) + .chapterId(e.getChapterId().toString()) + .status(e.getStatus()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java index 57260a7..91646f4 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java @@ -92,6 +92,9 @@ public class PostgresSceneRepository implements SceneRepository { .branches(jpaEntity.getBranches() != null ? new ArrayList<>(jpaEntity.getBranches()) : new ArrayList<>()) + .rooms(jpaEntity.getRooms() != null + ? new ArrayList<>(jpaEntity.getRooms()) + : new ArrayList<>()) .createdAt(jpaEntity.getCreatedAt()) .updatedAt(jpaEntity.getUpdatedAt()) .build(); @@ -126,6 +129,9 @@ public class PostgresSceneRepository implements SceneRepository { .branches(scene.getBranches() != null ? new ArrayList<>(scene.getBranches()) : new ArrayList<>()) + .rooms(scene.getRooms() != null + ? new ArrayList<>(scene.getRooms()) + : new ArrayList<>()) .createdAt(scene.getCreatedAt()) .updatedAt(scene.getUpdatedAt()) .build(); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java index 8d5964f..f2b7e14 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java @@ -11,8 +11,8 @@ import java.util.Optional; import java.util.stream.Collectors; /** - * Adaptateur d'infrastructure qui implémente le Port SessionRepository. - * Convertit Session (domaine pur) ↔ SessionJpaEntity (persistance). + * Adaptateur d'infrastructure : implémente le port SessionRepository en utilisant + * playthrough_id comme parent (depuis la refonte Playthrough). */ @Repository public class PostgresSessionRepository implements SessionRepository { @@ -42,8 +42,8 @@ public class PostgresSessionRepository implements SessionRepository { } @Override - public List findByCampaignId(String campaignId) { - return jpaRepository.findByCampaignIdOrderByStartedAtDesc(campaignId).stream() + public List findByPlaythroughId(String playthroughId) { + return jpaRepository.findByPlaythroughIdOrderByStartedAtDesc(Long.parseLong(playthroughId)).stream() .map(this::toDomain) .collect(Collectors.toList()); } @@ -53,6 +53,12 @@ public class PostgresSessionRepository implements SessionRepository { return jpaRepository.findFirstByEndedAtIsNull().map(this::toDomain); } + @Override + public Optional findActiveByPlaythroughId(String playthroughId) { + return jpaRepository.findFirstByPlaythroughIdAndEndedAtIsNull(Long.parseLong(playthroughId)) + .map(this::toDomain); + } + @Override public void deleteById(String id) { jpaRepository.deleteById(Long.parseLong(id)); @@ -67,7 +73,7 @@ public class PostgresSessionRepository implements SessionRepository { return Session.builder() .id(jpa.getId().toString()) .name(jpa.getName()) - .campaignId(jpa.getCampaignId()) + .playthroughId(jpa.getPlaythroughId() != null ? jpa.getPlaythroughId().toString() : null) .startedAt(jpa.getStartedAt()) .endedAt(jpa.getEndedAt()) .createdAt(jpa.getCreatedAt()) @@ -80,7 +86,7 @@ public class PostgresSessionRepository implements SessionRepository { return SessionJpaEntity.builder() .id(id) .name(session.getName()) - .campaignId(session.getCampaignId()) + .playthroughId(session.getPlaythroughId() != null ? Long.parseLong(session.getPlaythroughId()) : null) .startedAt(session.getStartedAt()) .endedAt(session.getEndedAt()) .createdAt(session.getCreatedAt()) diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java index 64fd40b..d2f2aff 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java @@ -27,8 +27,8 @@ public class ArcController { @PostMapping public ResponseEntity createArc(@RequestBody ArcDTO arcDTO) { - Arc arc = arcMapper.toDomain(arcDTO); - Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon()); + // Surcharge "Arc complet" pour propager le champ type (LINEAR/HUB) à la création. + Arc createdArc = arcService.createArc(arcMapper.toDomain(arcDTO)); return ResponseEntity.ok(arcMapper.toDTO(createdArc)); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignFlagController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignFlagController.java new file mode 100644 index 0000000..9a88967 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignFlagController.java @@ -0,0 +1,30 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.CampaignReferencedFlagsService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * REST Controller des faits d'une Campagne. + * + *

Sémantique « déclaration implicite » : il n'y a pas de table de déclarations + * globales. La liste retournée est la déduplication des noms de faits référencés + * dans les prérequis des chapitres de la campagne.

+ */ +@RestController +@RequestMapping("/api/campaigns/{campaignId}/flags") +public class CampaignFlagController { + + private final CampaignReferencedFlagsService service; + + public CampaignFlagController(CampaignReferencedFlagsService service) { + this.service = service; + } + + @GetMapping + public ResponseEntity> list(@PathVariable String campaignId) { + return ResponseEntity.ok(service.listForCampaign(campaignId)); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java index 0970a4e..27ae2ea 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java @@ -1,6 +1,7 @@ package com.loremind.infrastructure.web.controller; import com.loremind.application.campaigncontext.ChapterService; +import com.loremind.application.campaigncontext.ChapterStatusEnricher; import com.loremind.domain.campaigncontext.Chapter; import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO; import com.loremind.infrastructure.web.mapper.ChapterMapper; @@ -12,6 +13,8 @@ import java.util.stream.Collectors; /** * REST Controller pour le contexte Chapter. + * Si {@code ?playthroughId=} est fourni, les DTOs renvoyés sont enrichis de leur + * {@code progressionStatus} et {@code effectiveStatus} relatifs à ce Playthrough. */ @RestController @RequestMapping("/api/chapters") @@ -19,42 +22,58 @@ public class ChapterController { private final ChapterService chapterService; private final ChapterMapper chapterMapper; + private final ChapterStatusEnricher statusEnricher; - public ChapterController(ChapterService chapterService, ChapterMapper chapterMapper) { + public ChapterController(ChapterService chapterService, + ChapterMapper chapterMapper, + ChapterStatusEnricher statusEnricher) { this.chapterService = chapterService; this.chapterMapper = chapterMapper; + this.statusEnricher = statusEnricher; } @PostMapping public ResponseEntity createChapter(@RequestBody ChapterDTO chapterDTO) { - Chapter chapter = chapterMapper.toDomain(chapterDTO); - Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon()); - return ResponseEntity.ok(chapterMapper.toDTO(createdChapter)); + Chapter created = chapterService.createChapter(chapterMapper.toDomain(chapterDTO)); + return ResponseEntity.ok(chapterMapper.toDTO(created)); } @GetMapping("/{id}") - public ResponseEntity getChapterById(@PathVariable String id) { + public ResponseEntity getChapterById( + @PathVariable String id, + @RequestParam(value = "playthroughId", required = false) String playthroughId) { return chapterService.getChapterById(id) - .map(chapter -> ResponseEntity.ok(chapterMapper.toDTO(chapter))) + .map(chapter -> { + ChapterDTO dto = chapterMapper.toDTO(chapter); + if (playthroughId != null && !playthroughId.isBlank()) { + statusEnricher.enrich(List.of(dto), List.of(chapter), playthroughId); + } + return ResponseEntity.ok(dto); + }) .orElse(ResponseEntity.notFound().build()); } @GetMapping public ResponseEntity> getAllChapters( - @RequestParam(value = "arcId", required = false) String arcId) { + @RequestParam(value = "arcId", required = false) String arcId, + @RequestParam(value = "playthroughId", required = false) String playthroughId) { List chapters = (arcId != null && !arcId.isBlank()) ? chapterService.getChaptersByArcId(arcId) : chapterService.getAllChapters(); List chapterDTOs = chapters.stream() .map(chapterMapper::toDTO) .collect(Collectors.toList()); + + if (playthroughId != null && !playthroughId.isBlank()) { + statusEnricher.enrich(chapterDTOs, chapters, playthroughId); + } return ResponseEntity.ok(chapterDTOs); } @PutMapping("/{id}") public ResponseEntity updateChapter(@PathVariable String id, @RequestBody ChapterDTO chapterDTO) { - Chapter updatedChapter = chapterService.updateChapter(id, chapterMapper.toDomain(chapterDTO)); - return ResponseEntity.ok(chapterMapper.toDTO(updatedChapter)); + Chapter updated = chapterService.updateChapter(id, chapterMapper.toDomain(chapterDTO)); + return ResponseEntity.ok(chapterMapper.toDTO(updated)); } @DeleteMapping("/{id}") diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java index ccec34a..5e53718 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java @@ -35,9 +35,9 @@ public class CharacterController { .orElse(ResponseEntity.notFound().build()); } - @GetMapping("/campaign/{campaignId}") - public ResponseEntity> getCharactersByCampaign(@PathVariable String campaignId) { - List dtos = characterService.getCharactersByCampaignId(campaignId).stream() + @GetMapping("/playthrough/{playthroughId}") + public ResponseEntity> getCharactersByPlaythrough(@PathVariable String playthroughId) { + List dtos = characterService.getCharactersByPlaythroughId(playthroughId).stream() .map(characterMapper::toDTO) .collect(Collectors.toList()); return ResponseEntity.ok(dtos); @@ -63,7 +63,7 @@ public class CharacterController { dto.getValues(), dto.getImageValues(), dto.getKeyValueValues(), - dto.getCampaignId(), + dto.getPlaythroughId(), order ); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughController.java new file mode 100644 index 0000000..09d77d5 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughController.java @@ -0,0 +1,64 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.playcontext.PlaythroughService; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.infrastructure.web.dto.playcontext.PlaythroughDTO; +import com.loremind.infrastructure.web.mapper.PlaythroughMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/playthroughs") +public class PlaythroughController { + + private final PlaythroughService service; + private final PlaythroughMapper mapper; + + public PlaythroughController(PlaythroughService service, PlaythroughMapper mapper) { + this.service = service; + this.mapper = mapper; + } + + @PostMapping + public ResponseEntity create(@RequestBody PlaythroughDTO body) { + Playthrough created = service.create(body.getCampaignId(), body.getName(), body.getDescription()); + return ResponseEntity.ok(mapper.toDTO(created)); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable String id) { + return service.getById(id) + .map(p -> ResponseEntity.ok(mapper.toDTO(p))) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping + public ResponseEntity> list( + @RequestParam(value = "campaignId", required = false) String campaignId) { + List list = (campaignId != null && !campaignId.isBlank()) + ? service.getByCampaignId(campaignId) + : List.of(); + return ResponseEntity.ok(list.stream().map(mapper::toDTO).collect(Collectors.toList())); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable String id, @RequestBody PlaythroughDTO body) { + Playthrough updated = service.update(id, mapper.toDomain(body)); + return ResponseEntity.ok(mapper.toDTO(updated)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/deletion-impact") + public ResponseEntity deletionImpact(@PathVariable String id) { + if (!service.exists(id)) return ResponseEntity.notFound().build(); + return ResponseEntity.ok(service.getDeletionImpact(id)); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughFlagController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughFlagController.java new file mode 100644 index 0000000..3de2526 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughFlagController.java @@ -0,0 +1,47 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; +import com.loremind.infrastructure.web.dto.playcontext.PlaythroughFlagDTO; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Endpoint des flags narratifs d'une Partie. + * Remplace l'ancien {@code /api/campaigns/{id}/flags} : les flags suivent la Partie. + */ +@RestController +@RequestMapping("/api/playthroughs/{playthroughId}/flags") +public class PlaythroughFlagController { + + private final PlaythroughFlagRepository repo; + + public PlaythroughFlagController(PlaythroughFlagRepository repo) { + this.repo = repo; + } + + @GetMapping + public ResponseEntity> list(@PathVariable String playthroughId) { + Map flags = repo.findByPlaythroughId(playthroughId); + List dtos = new ArrayList<>(flags.size()); + flags.forEach((name, value) -> dtos.add(new PlaythroughFlagDTO(name, value))); + return ResponseEntity.ok(dtos); + } + + @PutMapping("/{name}") + public ResponseEntity setFlag(@PathVariable String playthroughId, + @PathVariable String name, + @RequestBody PlaythroughFlagDTO body) { + repo.setFlag(playthroughId, name, body.isValue()); + return ResponseEntity.ok(new PlaythroughFlagDTO(name, body.isValue())); + } + + @DeleteMapping("/{name}") + public ResponseEntity deleteFlag(@PathVariable String playthroughId, @PathVariable String name) { + repo.deleteFlag(playthroughId, name); + return ResponseEntity.noContent().build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/QuestProgressionController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestProgressionController.java new file mode 100644 index 0000000..34d0745 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestProgressionController.java @@ -0,0 +1,55 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.playcontext.ports.QuestProgressionRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * Endpoint pour piloter la progression d'une quête (Chapter) au sein d'un Playthrough. + * + *

Modèle "absence = NOT_STARTED" — envoyer NOT_STARTED supprime la ligne.

+ */ +@RestController +@RequestMapping("/api/playthroughs/{playthroughId}/quest-progressions") +public class QuestProgressionController { + + private final QuestProgressionRepository repo; + + public QuestProgressionController(QuestProgressionRepository repo) { + this.repo = repo; + } + + public record SetStatusRequest(String status) {} + + /** + * GET : renvoie une map chapterId -> ProgressionStatus pour le Playthrough. + * Pratique pour le front qui peut indexer puissamment. + */ + @GetMapping + public ResponseEntity> list(@PathVariable String playthroughId) { + Map out = new HashMap<>(); + repo.findByPlaythroughId(playthroughId) + .forEach(qp -> out.put(qp.getChapterId(), qp.getStatus().name())); + return ResponseEntity.ok(out); + } + + @PutMapping("/{chapterId}") + public ResponseEntity setStatus(@PathVariable String playthroughId, + @PathVariable String chapterId, + @RequestBody SetStatusRequest body) { + ProgressionStatus parsed = ProgressionStatus.NOT_STARTED; + if (body.status() != null && !body.status().isBlank()) { + try { + parsed = ProgressionStatus.valueOf(body.status()); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().build(); + } + } + repo.setStatus(playthroughId, chapterId, parsed); + return ResponseEntity.noContent().build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java index b18e061..064377f 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java @@ -26,28 +26,32 @@ public class SessionController { this.sessionMapper = sessionMapper; } - public record StartSessionRequest(String campaignId) {} + public record StartSessionRequest(String playthroughId) {} public record RenameSessionRequest(String name) {} @PostMapping public ResponseEntity startSession(@RequestBody StartSessionRequest request) { - Session session = sessionService.startSession(request.campaignId()); + Session session = sessionService.startSession(request.playthroughId()); return ResponseEntity.ok(sessionMapper.toDTO(session)); } @GetMapping("/active") - public ResponseEntity getActiveSession() { - return sessionService.getActive() + public ResponseEntity getActiveSession( + @RequestParam(value = "playthroughId", required = false) String playthroughId) { + var maybe = (playthroughId == null || playthroughId.isBlank()) + ? sessionService.getActive() + : sessionService.getActiveByPlaythrough(playthroughId); + return maybe .map(s -> ResponseEntity.ok(sessionMapper.toDTO(s))) .orElse(ResponseEntity.noContent().build()); } @GetMapping - public ResponseEntity> getSessions(@RequestParam(value = "campaignId", required = false) String campaignId) { - List sessions = (campaignId == null || campaignId.isBlank()) + public ResponseEntity> getSessions(@RequestParam(value = "playthroughId", required = false) String playthroughId) { + List sessions = (playthroughId == null || playthroughId.isBlank()) ? sessionService.getAll() - : sessionService.getByCampaignId(campaignId); + : sessionService.getByPlaythroughId(playthroughId); List dtos = sessions.stream() .map(sessionMapper::toDTO) .collect(Collectors.toList()); diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ArcDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ArcDTO.java index 19b5e68..73f8a2a 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ArcDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ArcDTO.java @@ -17,6 +17,9 @@ public class ArcDTO { private String campaignId; private int order; + /** Type structurel : "LINEAR" (défaut) | "HUB". Sérialisé comme String pour faciliter le front. */ + private String type; + /** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */ private String icon; diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java index 54a654e..1e0765e 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java @@ -17,6 +17,22 @@ public class ChapterDTO { private String arcId; private int order; + /** Conditions de déblocage (ET logique). Donnée de SCÉNARIO. */ + private List prerequisites = new ArrayList<>(); + + /** + * Statut de progression — lecture seule, populé uniquement quand le client demande + * l'enrichissement pour un Playthrough donné (param ?playthroughId=). + * Valeurs : "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED". + */ + private String progressionStatus; + + /** + * Statut effectif calculé côté backend ("LOCKED" | "AVAILABLE" | "IN_PROGRESS" | "COMPLETED"). + * Read-only — populé en même temps que {@code progressionStatus}. + */ + private String effectiveStatus; + /** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */ private String icon; diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java index 85c7691..80b1103 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java @@ -22,6 +22,6 @@ public class CharacterDTO { private Map values = new HashMap<>(); private Map> imageValues = new HashMap<>(); private Map> keyValueValues = new HashMap<>(); - private String campaignId; + private String playthroughId; private int order; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/PrerequisiteDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/PrerequisiteDTO.java new file mode 100644 index 0000000..9a42e75 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/PrerequisiteDTO.java @@ -0,0 +1,30 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO unique pour tous les types de Prerequisite. + * Le champ {@code kind} discrimine le type (miroir du converter JPA et de l'union TS côté front). + * Les champs non pertinents pour un kind donné restent null. + *

+ * Valeurs de {@code kind} : "QUEST_COMPLETED" | "SESSION_REACHED" | "FLAG_SET". + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PrerequisiteDTO { + + /** Discriminant : QUEST_COMPLETED | SESSION_REACHED | FLAG_SET. */ + private String kind; + + /** Pour kind=QUEST_COMPLETED : ID de la quête à terminer. */ + private String questId; + + /** Pour kind=SESSION_REACHED : numéro de session minimum atteint. */ + private Integer minSessionNumber; + + /** Pour kind=FLAG_SET : nom du flag de campagne. */ + private String flagName; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomBranchDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomBranchDTO.java new file mode 100644 index 0000000..0024cee --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomBranchDTO.java @@ -0,0 +1,18 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Sortie d'une pièce vers une autre pièce d'un lieu explorable. + * Pendant web du record domaine RoomBranch. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RoomBranchDTO { + private String label; + private String targetRoomId; + private String condition; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomDTO.java new file mode 100644 index 0000000..37cc135 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomDTO.java @@ -0,0 +1,28 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * DTO d'une pièce d'un lieu explorable. + */ +@Data +public class RoomDTO { + + /** UUID stable généré côté front à la création. */ + private String id; + private String name; + private String description; + private String enemies; + private String loot; + private String traps; + private String gmNotes; + /** Étage (0 = RdC, 1 = 1er…). Nullable = pas d'étage défini. */ + private Integer floor; + private int order; + private List illustrationImageIds = new ArrayList<>(); + private String mapImageId; + private List branches = new ArrayList<>(); +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java index 674b86a..154f9cf 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java @@ -41,4 +41,7 @@ public class SceneDTO { /** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */ private List branches = new ArrayList<>(); + + /** Pièces du lieu explorable (donjon, crypte…). Vide = scène classique. */ + private List rooms = new ArrayList<>(); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughDTO.java new file mode 100644 index 0000000..b99991a --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughDTO.java @@ -0,0 +1,19 @@ +package com.loremind.infrastructure.web.dto.playcontext; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * DTO d'un Playthrough (Partie) — instance jouée d'une Campagne. + */ +@Data +public class PlaythroughDTO { + + private String id; + private String campaignId; + private String name; + private String description; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughFlagDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughFlagDTO.java new file mode 100644 index 0000000..65b2ad8 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughFlagDTO.java @@ -0,0 +1,13 @@ +package com.loremind.infrastructure.web.dto.playcontext; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PlaythroughFlagDTO { + private String name; + private boolean value; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java index a2d2ebc..a93959e 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java @@ -12,7 +12,7 @@ public class SessionDTO { private String id; private String name; - private String campaignId; + private String playthroughId; private LocalDateTime startedAt; /** Null = session en cours. */ private LocalDateTime endedAt; diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/ArcMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/ArcMapper.java index 5308ffa..c5e0019 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/ArcMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/ArcMapper.java @@ -1,6 +1,7 @@ package com.loremind.infrastructure.web.mapper; import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ArcType; import com.loremind.infrastructure.web.dto.campaigncontext.ArcDTO; import org.springframework.stereotype.Component; @@ -24,6 +25,7 @@ public class ArcMapper { dto.setDescription(arc.getDescription()); dto.setCampaignId(arc.getCampaignId()); dto.setOrder(arc.getOrder()); + dto.setType(arc.getType() != null ? arc.getType().name() : ArcType.LINEAR.name()); dto.setIcon(arc.getIcon()); dto.setThemes(arc.getThemes()); dto.setStakes(arc.getStakes()); @@ -47,6 +49,7 @@ public class ArcMapper { .description(dto.getDescription()) .campaignId(dto.getCampaignId()) .order(dto.getOrder()) + .type(parseType(dto.getType())) .icon(dto.getIcon()) .themes(dto.getThemes()) .stakes(dto.getStakes()) @@ -66,4 +69,14 @@ public class ArcMapper { private ArrayList copyList(List source) { return source != null ? new ArrayList<>(source) : new ArrayList<>(); } + + /** Parse tolérant : null/blank/inconnu => LINEAR (rétro-compatibilité). */ + private ArcType parseType(String raw) { + if (raw == null || raw.isBlank()) return ArcType.LINEAR; + try { + return ArcType.valueOf(raw); + } catch (IllegalArgumentException ex) { + return ArcType.LINEAR; + } + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java index 41afd6e..6eca364 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java @@ -8,15 +8,23 @@ import java.util.ArrayList; import java.util.List; /** - * Mapper pour convertir entre Chapter (entité de domaine) et ChapterDTO. + * Mapper Chapter (domaine) ↔ ChapterDTO (REST). + * + *

Ne touche plus à {@code progressionStatus} ni {@code effectiveStatus} : + * ces champs sont propres à un Playthrough et injectés par {@code ChapterStatusEnricher} + * quand le controller a un playthroughId.

*/ @Component public class ChapterMapper { + private final PrerequisiteMapper prerequisiteMapper; + + public ChapterMapper(PrerequisiteMapper prerequisiteMapper) { + this.prerequisiteMapper = prerequisiteMapper; + } + public ChapterDTO toDTO(Chapter chapter) { - if (chapter == null) { - return null; - } + if (chapter == null) return null; ChapterDTO dto = new ChapterDTO(); dto.setId(chapter.getId()); @@ -24,6 +32,9 @@ public class ChapterMapper { dto.setDescription(chapter.getDescription()); dto.setArcId(chapter.getArcId()); dto.setOrder(chapter.getOrder()); + dto.setPrerequisites(prerequisiteMapper.toDTOList(chapter.getPrerequisites())); + // progressionStatus / effectiveStatus : laissés null. Peuplés par ChapterStatusEnricher.enrich(...) + // si le client a fourni un playthroughId au controller. dto.setIcon(chapter.getIcon()); dto.setGmNotes(chapter.getGmNotes()); dto.setPlayerObjectives(chapter.getPlayerObjectives()); @@ -35,9 +46,7 @@ public class ChapterMapper { } public Chapter toDomain(ChapterDTO dto) { - if (dto == null) { - return null; - } + if (dto == null) return null; return Chapter.builder() .id(dto.getId()) @@ -45,6 +54,7 @@ public class ChapterMapper { .description(dto.getDescription()) .arcId(dto.getArcId()) .order(dto.getOrder()) + .prerequisites(prerequisiteMapper.toDomainList(dto.getPrerequisites())) .icon(dto.getIcon()) .gmNotes(dto.getGmNotes()) .playerObjectives(dto.getPlayerObjectives()) @@ -55,10 +65,6 @@ public class ChapterMapper { .build(); } - /** - * Copie sécurisée d'une liste (gère le cas null). - * Ceci est une méthode utilitaire privée pour éviter la duplication de code. - */ private List copyList(List source) { return source != null ? new ArrayList<>(source) : new ArrayList<>(); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java index 3269c61..7b78f4f 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java @@ -19,7 +19,7 @@ public class CharacterMapper { dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>()); dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>()); dto.setKeyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>()); - dto.setCampaignId(c.getCampaignId()); + dto.setPlaythroughId(c.getPlaythroughId()); dto.setOrder(c.getOrder()); return dto; } @@ -34,7 +34,7 @@ public class CharacterMapper { .values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>()) .imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>()) .keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>()) - .campaignId(dto.getCampaignId()) + .playthroughId(dto.getPlaythroughId()) .order(dto.getOrder()) .build(); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/PlaythroughMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/PlaythroughMapper.java new file mode 100644 index 0000000..efc0b7b --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/PlaythroughMapper.java @@ -0,0 +1,33 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.infrastructure.web.dto.playcontext.PlaythroughDTO; +import org.springframework.stereotype.Component; + +@Component +public class PlaythroughMapper { + + public PlaythroughDTO toDTO(Playthrough p) { + if (p == null) return null; + PlaythroughDTO dto = new PlaythroughDTO(); + dto.setId(p.getId()); + dto.setCampaignId(p.getCampaignId()); + dto.setName(p.getName()); + dto.setDescription(p.getDescription()); + dto.setCreatedAt(p.getCreatedAt()); + dto.setUpdatedAt(p.getUpdatedAt()); + return dto; + } + + public Playthrough toDomain(PlaythroughDTO dto) { + if (dto == null) return null; + return Playthrough.builder() + .id(dto.getId()) + .campaignId(dto.getCampaignId()) + .name(dto.getName()) + .description(dto.getDescription()) + .createdAt(dto.getCreatedAt()) + .updatedAt(dto.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/PrerequisiteMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/PrerequisiteMapper.java new file mode 100644 index 0000000..cdcdfc3 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/PrerequisiteMapper.java @@ -0,0 +1,64 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.infrastructure.web.dto.campaigncontext.PrerequisiteDTO; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Mapper entre Prerequisite (sealed type domaine) et PrerequisiteDTO (DTO API à discriminant). + */ +@Component +public class PrerequisiteMapper { + + public static final String KIND_QUEST_COMPLETED = "QUEST_COMPLETED"; + public static final String KIND_SESSION_REACHED = "SESSION_REACHED"; + public static final String KIND_FLAG_SET = "FLAG_SET"; + + public PrerequisiteDTO toDTO(Prerequisite p) { + if (p == null) return null; + if (p instanceof Prerequisite.QuestCompleted q) { + return new PrerequisiteDTO(KIND_QUEST_COMPLETED, q.questId(), null, null); + } + if (p instanceof Prerequisite.SessionReached s) { + return new PrerequisiteDTO(KIND_SESSION_REACHED, null, s.minSessionNumber(), null); + } + if (p instanceof Prerequisite.FlagSet f) { + return new PrerequisiteDTO(KIND_FLAG_SET, null, null, f.flagName()); + } + throw new IllegalStateException("Prerequisite non géré : " + p.getClass().getName()); + } + + public Prerequisite toDomain(PrerequisiteDTO dto) { + if (dto == null || dto.getKind() == null) return null; + switch (dto.getKind()) { + case KIND_QUEST_COMPLETED: + return new Prerequisite.QuestCompleted(dto.getQuestId()); + case KIND_SESSION_REACHED: + if (dto.getMinSessionNumber() == null) { + throw new IllegalArgumentException("minSessionNumber requis pour SESSION_REACHED"); + } + return new Prerequisite.SessionReached(dto.getMinSessionNumber()); + case KIND_FLAG_SET: + return new Prerequisite.FlagSet(dto.getFlagName()); + default: + throw new IllegalArgumentException("Kind Prerequisite inconnu : " + dto.getKind()); + } + } + + public List toDTOList(List list) { + if (list == null) return new ArrayList<>(); + List out = new ArrayList<>(list.size()); + for (Prerequisite p : list) out.add(toDTO(p)); + return out; + } + + public List toDomainList(List list) { + if (list == null) return new ArrayList<>(); + List out = new ArrayList<>(list.size()); + for (PrerequisiteDTO d : list) out.add(toDomain(d)); + return out; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java index d0aeda0..2b3049c 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java @@ -1,7 +1,11 @@ package com.loremind.infrastructure.web.mapper; +import com.loremind.domain.campaigncontext.Room; +import com.loremind.domain.campaigncontext.RoomBranch; import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.infrastructure.web.dto.campaigncontext.RoomBranchDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.RoomDTO; import com.loremind.infrastructure.web.dto.campaigncontext.SceneBranchDTO; import com.loremind.infrastructure.web.dto.campaigncontext.SceneDTO; import org.springframework.stereotype.Component; @@ -46,6 +50,7 @@ public class SceneMapper { ? new ArrayList<>(scene.getMapImageIds()) : new ArrayList<>()); dto.setBranches(toBranchDTOs(scene.getBranches())); + dto.setRooms(toRoomDTOs(scene.getRooms())); return dto; } @@ -79,6 +84,7 @@ public class SceneMapper { ? new ArrayList<>(dto.getMapImageIds()) : new ArrayList<>()) .branches(toBranchDomain(dto.getBranches())) + .rooms(toRoomDomain(dto.getRooms())) .build(); } @@ -97,4 +103,62 @@ public class SceneMapper { .map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition())) .collect(Collectors.toList()); } + + // ─────────────── Mapping des pièces (VO <-> DTO) ─────────────── + + private List toRoomDTOs(List rooms) { + if (rooms == null) return new ArrayList<>(); + return rooms.stream().map(this::toRoomDTO).collect(Collectors.toList()); + } + + private RoomDTO toRoomDTO(Room r) { + RoomDTO dto = new RoomDTO(); + dto.setId(r.getId()); + dto.setName(r.getName()); + dto.setDescription(r.getDescription()); + dto.setEnemies(r.getEnemies()); + dto.setLoot(r.getLoot()); + dto.setTraps(r.getTraps()); + dto.setGmNotes(r.getGmNotes()); + dto.setFloor(r.getFloor()); + dto.setOrder(r.getOrder()); + dto.setIllustrationImageIds(r.getIllustrationImageIds() != null + ? new ArrayList<>(r.getIllustrationImageIds()) + : new ArrayList<>()); + dto.setMapImageId(r.getMapImageId()); + dto.setBranches(r.getBranches() == null + ? new ArrayList<>() + : r.getBranches().stream() + .map(b -> new RoomBranchDTO(b.label(), b.targetRoomId(), b.condition())) + .collect(Collectors.toList())); + return dto; + } + + private List toRoomDomain(List dtos) { + if (dtos == null) return new ArrayList<>(); + return dtos.stream().map(this::toRoomDomain).collect(Collectors.toList()); + } + + private Room toRoomDomain(RoomDTO d) { + return Room.builder() + .id(d.getId()) + .name(d.getName()) + .description(d.getDescription()) + .enemies(d.getEnemies()) + .loot(d.getLoot()) + .traps(d.getTraps()) + .gmNotes(d.getGmNotes()) + .floor(d.getFloor()) + .order(d.getOrder()) + .illustrationImageIds(d.getIllustrationImageIds() != null + ? new ArrayList<>(d.getIllustrationImageIds()) + : new ArrayList<>()) + .mapImageId(d.getMapImageId()) + .branches(d.getBranches() == null + ? new ArrayList<>() + : d.getBranches().stream() + .map(b -> new RoomBranch(b.getLabel(), b.getTargetRoomId(), b.getCondition())) + .collect(Collectors.toList())) + .build(); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java index b62882b..d219af6 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java @@ -15,7 +15,7 @@ public class SessionMapper { SessionDTO dto = new SessionDTO(); dto.setId(session.getId()); dto.setName(session.getName()); - dto.setCampaignId(session.getCampaignId()); + dto.setPlaythroughId(session.getPlaythroughId()); dto.setStartedAt(session.getStartedAt()); dto.setEndedAt(session.getEndedAt()); dto.setCreatedAt(session.getCreatedAt()); diff --git a/web/package-lock.json b/web/package-lock.json index f7648fd..24e75a5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.9.0-beta", + "version": "0.9.1-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.9.0-beta", + "version": "0.9.1-beta", "dependencies": { "@angular/animations": "^17.0.0", "@angular/common": "^17.0.0", diff --git a/web/package.json b/web/package.json index 3ee1f79..a85e75f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.9.0-beta", + "version": "0.9.1-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index f17cd2a..0b54bbb 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -16,6 +16,8 @@ export const routes: Routes = [ { path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) }, { path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) }, { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) }, diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.html b/web/src/app/campaigns/arc/arc-create/arc-create.component.html index 3e8a2b2..41c9472 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.html +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.html @@ -17,6 +17,22 @@ /> +
+ +
+ + +
+
+
+
+ +
+ + +
+
+
diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss index bc29f28..2033914 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss @@ -3,6 +3,44 @@ max-width: 640px; } +.arc-type-choice { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.arc-type-option { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.85rem 0.95rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 10px; + cursor: pointer; + background: var(--color-surface, #fff); + transition: border-color 120ms ease, background 120ms ease; + + &:hover { border-color: var(--color-primary, #2c6cd6); } + + &.selected { + border-color: var(--color-primary, #2c6cd6); + background: rgba(66, 133, 244, 0.06); + } + + input[type="radio"] { display: none; } +} + +.arc-type-title { + font-weight: 600; + font-size: 0.95rem; +} + +.arc-type-desc { + font-size: 0.78rem; + color: var(--color-text-muted, #666); + line-height: 1.3; +} + // Header local : titre à gauche, actions (Assistant IA) à droite. .page-header { display: flex; diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index a661990..6ccaa99 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -85,6 +85,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { this.form = this.fb.group({ name: ['', Validators.required], description: [''], + type: ['LINEAR', Validators.required], themes: [''], stakes: [''], gmNotes: [''], @@ -137,6 +138,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { this.form.patchValue({ name: arc.name, description: arc.description ?? '', + type: arc.type ?? 'LINEAR', themes: arc.themes ?? '', stakes: arc.stakes ?? '', gmNotes: arc.gmNotes ?? '', @@ -155,6 +157,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { description: this.form.value.description, campaignId: this.campaignId, order: this.arc.order ?? 1, + type: this.form.value.type, themes: this.form.value.themes, stakes: this.form.value.stakes, gmNotes: this.form.value.gmNotes, diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.html b/web/src/app/campaigns/arc/arc-view/arc-view.component.html index 639fe17..9131767 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.html +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.html @@ -6,7 +6,9 @@ {{ arc.name }} -

Arc narratif

+

+ {{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }} +

+
+ +

📜 Synopsis

{{ arc.description }}

diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.scss b/web/src/app/campaigns/arc/arc-view/arc-view.component.scss index 9c48f33..09d94e5 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.scss +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.scss @@ -1 +1,112 @@ // Styles partagés via styles/_view.scss + +// === Vue Hub === +.hub-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; + + .view-section-title { margin: 0; } +} + +.hub-quest-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.85rem; +} + +.hub-quest-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.85rem 0.9rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 10px; + background: var(--color-surface, #fff); + text-align: left; + cursor: pointer; + transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease; + min-height: 110px; + + &:hover { + border-color: var(--color-primary, #2c6cd6); + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06); + } + + &.locked { + opacity: 0.7; + background: rgba(128, 128, 128, 0.04); + border-style: dashed; + + &:hover { + // Pas d'illusion d'interactivité pour les quêtes verrouillées + transform: none; + border-color: var(--color-border, #e2e2e2); + box-shadow: none; + } + } +} + +.hub-quest-card-head { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.hub-quest-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-primary, #2c6cd6); +} + +.hub-quest-card-name { + font-weight: 600; + font-size: 0.95rem; + line-height: 1.2; +} + +.hub-quest-card-desc { + font-size: 0.82rem; + color: var(--color-text-muted, #666); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.hub-quest-card-footer { + margin-top: auto; +} + +.hub-quest-card-locked-hint { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.4rem; + padding-top: 0.4rem; + border-top: 1px dashed rgba(128, 128, 128, 0.25); + font-size: 0.72rem; + color: var(--color-text-muted, #777); + + > lucide-icon, + > span { + display: inline-flex; + align-items: center; + gap: 0.3rem; + } +} + +.hub-quest-card-prereq-list { + list-style: disc; + padding-left: 1rem; + margin: 0.15rem 0 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts index e6ab623..60f7836 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular'; +import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular'; import { resolveCampaignIcon } from '../../campaign-icons'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; @@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Arc } from '../../../services/campaign.model'; +import { Arc, Chapter, Prerequisite } from '../../../services/campaign.model'; import { Page } from '../../../services/page.model'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; @@ -32,12 +32,22 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia export class ArcViewComponent implements OnInit, OnDestroy { readonly Pencil = Pencil; readonly Trash2 = Trash2; + readonly AlertCircle = AlertCircle; readonly resolveCampaignIcon = resolveCampaignIcon; campaignId = ''; arcId = ''; arc: Arc | null = null; + /** Chapitres de l'arc courant — exploités pour le rendu HUB (grille de quêtes). */ + hubQuests: Chapter[] = []; + + /** + * Indexe les chapitres de toute la campagne par id pour résoudre les libellés + * des prérequis QUEST_COMPLETED quand on les affiche dans les tooltips de verrouillage. + */ + private allChaptersById: Record = {}; + /** ID du Lore associé à la campagne (null si pas d'univers lié). */ loreId: string | null = null; /** Pages du Lore — pour résoudre relatedPageIds en titres. */ @@ -85,10 +95,42 @@ export class ArcViewComponent implements OnInit, OnDestroy { this.availablePages = pages; this.pageTitleService.set(arc.name); + // Quêtes du Hub : chapitres de l'arc courant, triés par order puis par nom. + this.hubQuests = [...(treeData.chaptersByArc[this.arcId] ?? [])].sort((a, b) => { + const oa = a.order ?? 0; + const ob = b.order ?? 0; + if (oa !== ob) return oa - ob; + return a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' }); + }); + // Index global pour résoudre les noms de quêtes référencées par les prérequis. + this.allChaptersById = {}; + Object.values(treeData.chaptersByArc).forEach(list => + list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; }) + ); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); }); } + /** Construit un libellé lisible pour un prérequis (tooltip de verrouillage). */ + describePrerequisite(p: Prerequisite): string { + switch (p.kind) { + case 'QUEST_COMPLETED': + return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`; + case 'SESSION_REACHED': + return `Session ${p.minSessionNumber} atteinte`; + case 'FLAG_SET': + return `Fait : ${p.flagName}`; + } + } + + openQuest(q: Chapter): void { + if (!q.id) return; + this.router.navigate([ + '/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', q.id + ]); + } + titleOfRelated(pageId: string): string { return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)'; } diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index d4059a7..57782b7 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -30,9 +30,12 @@ export function loadCampaignTreeData( characterService: CharacterService, npcService: NpcService ): Observable { + // Note refonte Playthrough : les PJ appartiennent désormais à une Partie, + // pas à la campagne — on ne les charge plus ici (les vues qui les affichent + // doivent passer par PlaythroughService et appeler characterService.getByPlaythrough). return forkJoin({ arcs: service.getArcs(campaignId), - characters: characterService.getByCampaign(campaignId), + characters: of([] as Character[]), npcs: npcService.getByCampaign(campaignId) }).pipe( switchMap(({ arcs, characters, npcs }) => { diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index c45c420..65f8274 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -196,60 +196,38 @@
- -
+ +

- Sessions de jeu + Mes parties

- - - - - - - - -
-
-
- -
- {{ session.name }} - - ● En cours - Terminée le {{ session.endedAt | date:'dd/MM/yyyy' }} - +
+
+ +
+ {{ p.name }} + {{ p.description }}
-
-

Aucune session de jeu pour le moment.

+
+

Aucune partie. Créez-en une pour démarrer une session avec une table.

+ + diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss index 6b94930..f2d5408 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss @@ -1,3 +1,58 @@ +.section-hint { + margin: -0.25rem 0 0.85rem; + font-size: 0.85rem; + color: var(--color-text-muted, #666); + max-width: 70ch; + line-height: 1.4; +} + +// ==== Parties (Playthroughs) ==== +.playthroughs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.playthrough-card { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.85rem 1rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 10px; + background: var(--color-surface, #fff); + cursor: pointer; + transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease; + + &:hover { + border-color: var(--color-primary, #2c6cd6); + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06); + } +} + +.playthrough-icon { color: var(--color-primary, #2c6cd6); flex: 0 0 auto; } + +.playthrough-info { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.playthrough-name { + font-weight: 600; + font-size: 0.95rem; +} + +.playthrough-meta { + font-size: 0.78rem; + color: var(--color-text-muted, #777); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .campaign-detail { padding: 2.5rem 2rem; display: flex; diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts index 041a52e..93fce7c 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts @@ -13,6 +13,8 @@ import { GameSystem } from '../../../services/game-system.model'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { SessionService } from '../../../services/session.service'; +import { PlaythroughService } from '../../../services/playthrough.service'; +import { Playthrough } from '../../../services/campaign.model'; import { Session } from '../../../services/session.model'; import { Character } from '../../../services/character.model'; import { Npc } from '../../../services/npc.model'; @@ -69,6 +71,11 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { /** Indicateur de lancement en cours pour éviter les double-clics. */ startingSession = false; + /** Parties (Playthroughs) de cette campagne. */ + playthroughs: Playthrough[] = []; + /** Partie par défaut (1re) — fallback pour les actions session/PJ tant que l'UI Playthrough n'est pas finie. */ + defaultPlaythroughId: string | null = null; + /** Mode édition inline. */ editing = false; editName = ''; @@ -92,6 +99,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { private characterService: CharacterService, private npcService: NpcService, private sessionService: SessionService, + private playthroughService: PlaythroughService, private layoutService: LayoutService, private pageTitleService: PageTitleService, private confirmDialog: ConfirmDialogService @@ -109,11 +117,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { allCampaigns: this.campaignService.getAllCampaigns(), treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe( catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData)) - ) + ), + playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) })) - ).subscribe(({ campaign, allCampaigns, treeData }) => { + ).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => { this.campaign = campaign; this.editing = false; + this.playthroughs = playthroughs; + this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null; this.loadLinkedLore(campaign); this.loadLinkedGameSystem(campaign); this.loadCharacters(campaign.id!); @@ -145,10 +156,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { allCampaigns: this.campaignService.getAllCampaigns(), treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe( catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData)) - ) - }).subscribe(({ campaign, allCampaigns, treeData }) => { + ), + playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) + }).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => { this.campaign = campaign; this.editing = false; + this.playthroughs = playthroughs; + this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null; this.loadLinkedLore(campaign); this.loadLinkedGameSystem(campaign); this.loadCharacters(campaign.id!); @@ -186,9 +200,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { ).subscribe(gs => this.linkedGameSystem = gs); } - /** Charge les fiches de personnages (PJ) de la campagne. */ - private loadCharacters(campaignId: string): void { - this.characterService.getByCampaign(campaignId).pipe( + /** Charge les PJ de la Partie par défaut (refonte Playthrough). */ + private loadCharacters(_campaignId: string): void { + if (!this.defaultPlaythroughId) { + this.characters = []; + return; + } + this.characterService.getByPlaythrough(this.defaultPlaythroughId).pipe( catchError(() => of([] as Character[])) ).subscribe(list => this.characters = list); } @@ -200,55 +218,33 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { ).subscribe(list => this.npcs = list); } - /** - * Charge les sessions de cette campagne ET la session active globale. - * La session globale conditionne si le bouton "Lancer" est activable - * (règle métier : une seule session active simultanément dans l'app). - */ - private loadSessions(campaignId: string): void { - this.sessionService.getSessions(campaignId).pipe( - catchError(() => of([] as Session[])) - ).subscribe(list => this.sessions = list); + // Sessions retirées de cette vue (vivent dans Playthrough Detail). + private loadSessions(_campaignId: string): void { this.sessions = []; } - this.sessionService.getActiveSession().pipe( - catchError(() => of(null)) - ).subscribe(active => this.activeSessionGlobal = active); + // ─────────────── Playthroughs (Parties) ─────────────── + + openPlaythrough(p: Playthrough): void { + this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', p.id]); } - /** True si une session est active mais sur une AUTRE campagne (lancement bloqué). */ - get isLaunchBlockedByOtherCampaign(): boolean { - return !!this.activeSessionGlobal - && !!this.campaign - && this.activeSessionGlobal.campaignId !== this.campaign.id; - } - - /** Session active sur la campagne courante (le MJ joue déjà ici). */ - get activeSessionOnCurrentCampaign(): Session | null { - if (!this.activeSessionGlobal || !this.campaign) return null; - return this.activeSessionGlobal.campaignId === this.campaign.id - ? this.activeSessionGlobal - : null; - } - - startSession(): void { - if (!this.campaign || this.startingSession || this.isLaunchBlockedByOtherCampaign) return; - this.startingSession = true; - this.sessionService.startSession(this.campaign.id!).subscribe({ - next: session => { - this.startingSession = false; - this.router.navigate(['/sessions', session.id]); + /** Crée une nouvelle Partie avec un nom par défaut, puis y navigue. */ + newPlaythroughInFlight = false; + createPlaythrough(): void { + if (!this.campaign || this.newPlaythroughInFlight) return; + this.newPlaythroughInFlight = true; + const defaultName = `Nouvelle partie ${this.playthroughs.length + 1}`; + this.playthroughService.create({ + campaignId: this.campaign.id!, + name: defaultName + }).subscribe({ + next: created => { + this.newPlaythroughInFlight = false; + this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', created.id]); }, - error: () => { - this.startingSession = false; - console.error('Erreur lors du lancement de la session'); - } + error: () => { this.newPlaythroughInFlight = false; } }); } - openSession(session: Session): void { - this.router.navigate(['/sessions', session.id]); - } - createCharacter(): void { if (!this.campaign) return; this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']); @@ -444,7 +440,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`); if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`); if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`); - if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`); + if (impact.playthroughs > 0) parts.push(`${impact.playthroughs} partie${impact.playthroughs > 1 ? 's' : ''} (avec ses PJ, sessions, faits)`); const details: string[] = []; if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`); diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html index 2392949..038fcbe 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html @@ -26,6 +26,22 @@
+ +
+

🗺️ Conditions de déblocage de la quête

+ + Définition du scénario : toutes les conditions doivent être remplies (ET) pour + que la quête soit débloquée dans une Partie. La progression en cours et l'état + des faits se gèrent dans l'écran de la Partie, pas ici. + + + +
+
diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss index 281d355..433318b 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss @@ -3,6 +3,57 @@ max-width: 640px; } +// === Section Hub : prérequis + progression === +.hub-section { + border: 1px solid rgba(66, 133, 244, 0.25); + background: rgba(66, 133, 244, 0.03); + border-radius: 12px; + padding: 1rem 1.1rem 1.25rem; + margin-bottom: 1rem; +} + +.hub-section-title { + margin: 0 0 0.85rem; + font-size: 1rem; + font-weight: 600; + color: var(--color-primary, #2c6cd6); +} + +.progression-choice { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; +} + +.progression-option { + padding: 0.5rem 0.7rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 8px; + text-align: center; + font-size: 0.88rem; + cursor: pointer; + background: var(--color-surface, #fff); + transition: border-color 120ms ease, background 120ms ease; + + &:hover { border-color: var(--color-primary, #2c6cd6); } + + &.selected { + border-color: var(--color-primary, #2c6cd6); + background: rgba(66, 133, 244, 0.08); + font-weight: 600; + } + + input[type="radio"] { display: none; } +} + +.hub-flags-panel { + margin-top: 0.75rem; + padding: 0.85rem; + border: 1px dashed var(--color-border, #d8d8d8); + border-radius: 8px; + background: var(--color-surface, #fff); +} + // Header local : titre à gauche, actions (Assistant IA) à droite. .page-header { display: flex; diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts index c50c66c..85dc465 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts @@ -11,27 +11,38 @@ import { NpcService } from '../../../services/npc.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Chapter } from '../../../services/campaign.model'; +import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model'; import { Page } from '../../../services/page.model'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { PrerequisiteEditorComponent } from '../../../shared/prerequisite-editor/prerequisite-editor.component'; +import { CampaignFlagService } from '../../../services/campaign-flag.service'; import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; /** - * Écran de détail/modification d'un Chapitre. - * Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId + * Écran d'édition d'un Chapitre. Donnée de SCÉNARIO uniquement. * - * Inclut le picker de pages Lore (B2 cross-context) si la campagne parente - * est associée à un Lore. + * Depuis Playthrough : la progression et le toggle des flags se font dans le + * contexte d'une Partie (voir Playthrough Detail). On garde ici uniquement les + * prérequis (conditions de déblocage), qui font partie du scénario. */ @Component({ selector: 'app-chapter-edit', standalone: true, - imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent], + imports: [ + CommonModule, + ReactiveFormsModule, + LucideAngularModule, + LoreLinkPickerComponent, + AiChatDrawerComponent, + ImageGalleryComponent, + IconPickerComponent, + PrerequisiteEditorComponent + ], templateUrl: './chapter-edit.component.html', styleUrls: ['./chapter-edit.component.scss'] }) @@ -41,7 +52,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy { readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; selectedIcon: string | null = null; - /** État drawer chat IA (b5.7 — intégration Campagne). */ chatOpen = false; readonly chatQuickSuggestions = [ 'Propose des objectifs clairs pour les joueurs dans ce chapitre', @@ -63,6 +73,18 @@ export class ChapterEditComponent implements OnInit, OnDestroy { illustrationImageIds: string[] = []; mapImageIds: string[] = []; + /** Prérequis (donnée de scénario). */ + prerequisites: Prerequisite[] = []; + + /** Quêtes candidates pour QUEST_COMPLETED (chapitres de la campagne, sauf celui-ci). */ + availableQuests: Chapter[] = []; + + /** Faits déclarés au niveau Campagne (autocomplete FLAG_SET). */ + availableFlagNames: string[] = []; + + /** L'arc parent — pour conditionner la section Hub (prérequis pertinents). */ + parentArc: Arc | null = null; + constructor( private fb: FormBuilder, private route: ActivatedRoute, @@ -73,7 +95,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy { private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private campaignFlagService: CampaignFlagService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -85,9 +108,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy { } ngOnInit(): void { - // On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular - // réutilise le composant quand on navigue entre chapitres frères via - // l'arbre (même route pattern), et ngOnInit ne se relance pas. this.route.paramMap.subscribe(pm => { const newCampaignId = pm.get('campaignId')!; const newArcId = pm.get('arcId')!; @@ -124,6 +144,21 @@ export class ChapterEditComponent implements OnInit, OnDestroy { this.selectedIcon = chapter.icon ?? null; this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])]; this.mapImageIds = [...(chapter.mapImageIds ?? [])]; + + this.prerequisites = [...(chapter.prerequisites ?? [])]; + + const allChapters: Chapter[] = []; + Object.values(treeData.chaptersByArc).forEach(list => allChapters.push(...list)); + this.availableQuests = allChapters.filter(c => c.id !== this.chapterId); + + this.parentArc = treeData.arcs.find(a => a.id === this.arcId) ?? null; + + // Autocomplete des FLAG_SET : les noms de faits déjà référencés ailleurs + // dans la campagne (déduit des autres quêtes). + this.campaignFlagService.listReferenced(this.campaignId).subscribe({ + next: names => { this.availableFlagNames = names; } + }); + this.form.patchValue({ name: chapter.name, description: chapter.description ?? '', @@ -136,6 +171,10 @@ export class ChapterEditComponent implements OnInit, OnDestroy { }); } + onPrerequisitesChange(next: Prerequisite[]): void { + this.prerequisites = next; + } + submit(): void { if (this.form.invalid || !this.chapter) return; this.campaignService.updateChapter(this.chapterId, { @@ -146,6 +185,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy { gmNotes: this.form.value.gmNotes, playerObjectives: this.form.value.playerObjectives, narrativeStakes: this.form.value.narrativeStakes, + prerequisites: this.prerequisites, relatedPageIds: this.relatedPageIds, illustrationImageIds: this.illustrationImageIds, mapImageIds: this.mapImageIds, @@ -176,10 +216,5 @@ export class ChapterEditComponent implements OnInit, OnDestroy { this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]); } - ngOnDestroy(): void { - // Volontairement vide : la sidebar reste prise en charge par le composant - // suivant (autre sous-route ou le composant detail parent) qui appellera - // show(). Eviter d'appeler hide() ici previent le clignotement / la - // disparition de la sidebar lors des navigations internes a la section. - } + ngOnDestroy(): void {} } diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html index 5127541..98b1cb7 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html @@ -6,7 +6,7 @@ {{ chapter.name }} -

Chapitre

+

{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}

+
+

{{ playthrough.name }}

+

{{ playthrough.description }}

+
+
+ +
+ + + +
+ + + + + +
+ + +
+

Personnages joueurs

+

Aucun PJ pour cette partie.

+ +
+ + +
+

Sessions

+

Aucune session encore. Lancez la première !

+
    +
  • + {{ s.name }} + {{ s.active ? 'En cours' : 'Terminée' }} +
  • +
+
+ +
diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss new file mode 100644 index 0000000..908decc --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss @@ -0,0 +1,109 @@ +.playthrough-page { + padding: 2.5rem 2rem; + max-width: 880px; +} + +.page-header { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; + + h1 { margin: 0 0 0.2rem; font-size: 1.6rem; } +} + +.header-info { flex: 1; } + +.subtitle { + margin: 0; + font-size: 0.9rem; + color: var(--color-text-muted, #666); +} + +.play-action { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.btn-primary.big, +.btn-secondary.big { + padding: 0.75rem 1.25rem; + font-size: 0.95rem; +} + +.block { + margin-bottom: 1.75rem; + + h2 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.05rem; + margin: 0 0 0.7rem; + } +} + +.empty { + font-size: 0.88rem; + color: var(--color-text-muted, #777); + font-style: italic; +} + +.character-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + a { + display: inline-block; + padding: 0.35rem 0.7rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 999px; + text-decoration: none; + color: inherit; + + &:hover { border-color: var(--color-primary, #2c6cd6); } + } +} + +.session-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; + + li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0.85rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 8px; + cursor: pointer; + transition: border-color 120ms ease; + + &:hover { border-color: var(--color-primary, #2c6cd6); } + } +} + +.session-status { + font-size: 0.78rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + background: rgba(128, 128, 128, 0.1); + color: var(--color-text-muted, #666); + + &.active { + background: rgba(52, 168, 83, 0.15); + color: #2f7a47; + font-weight: 600; + } +} diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts new file mode 100644 index 0000000..91b3192 --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts @@ -0,0 +1,145 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil } from 'lucide-angular'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { PlaythroughService } from '../../../services/playthrough.service'; +import { SessionService } from '../../../services/session.service'; +import { LayoutService } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Playthrough } from '../../../services/campaign.model'; +import { Session } from '../../../services/session.model'; +import { Character } from '../../../services/character.model'; +import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; + +/** + * Vue détail d'une Partie (Playthrough). + * Minimal MVP — affiche les infos, le lien vers les faits, la liste des sessions + * et les PJ de cette Partie. + */ +@Component({ + selector: 'app-playthrough-detail', + standalone: true, + imports: [CommonModule, RouterModule, LucideAngularModule], + templateUrl: './playthrough-detail.component.html', + styleUrls: ['./playthrough-detail.component.scss'] +}) +export class PlaythroughDetailComponent implements OnInit, OnDestroy { + readonly ArrowLeft = ArrowLeft; + readonly Play = Play; + readonly Flag = Flag; + readonly Users = Users; + readonly Trash2 = Trash2; + readonly Pencil = Pencil; + + campaignId = ''; + playthroughId = ''; + + playthrough: Playthrough | null = null; + sessions: Session[] = []; + characters: Character[] = []; + /** Session active de CETTE Partie (null si aucune). Plus de check global. */ + activeOnThis: Session | null = null; + + startingSession = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private campaignService: CampaignService, + private characterService: CharacterService, + private npcService: NpcService, + private playthroughService: PlaythroughService, + private sessionService: SessionService, + private layoutService: LayoutService, + private pageTitleService: PageTitleService, + private confirmDialog: ConfirmDialogService + ) {} + + ngOnInit(): void { + this.route.paramMap.subscribe(pm => { + const cid = pm.get('campaignId')!; + const pid = pm.get('playthroughId')!; + if (cid !== this.campaignId || pid !== this.playthroughId) { + this.campaignId = cid; + this.playthroughId = pid; + this.load(); + } + }); + } + + private load(): void { + forkJoin({ + campaign: this.campaignService.getCampaignById(this.campaignId), + allCampaigns: this.campaignService.getAllCampaigns(), + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService), + playthrough: this.playthroughService.getById(this.playthroughId), + sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))), + characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))), + activeOnThis: this.sessionService.getActiveByPlaythrough(this.playthroughId).pipe(catchError(() => of(null))) + }).subscribe(({ campaign, allCampaigns, treeData, playthrough, sessions, characters, activeOnThis }) => { + this.playthrough = playthrough; + this.sessions = sessions; + this.characters = characters; + this.activeOnThis = activeOnThis; + this.pageTitleService.set(`${playthrough.name} — ${campaign.name}`); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + }); + } + + startSession(): void { + if (this.startingSession || this.activeOnThis) return; + this.startingSession = true; + this.sessionService.startSession(this.playthroughId).subscribe({ + next: s => { this.startingSession = false; this.router.navigate(['/sessions', s.id]); }, + error: () => { this.startingSession = false; } + }); + } + + openSession(s: Session): void { + this.router.navigate(['/sessions', s.id]); + } + + openFlags(): void { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'flags']); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } + + delete(): void { + if (!this.playthrough) return; + this.playthroughService.deletionImpact(this.playthroughId).subscribe({ + next: impact => { + const parts: string[] = []; + if (impact.sessions > 0) parts.push(`${impact.sessions} session(s)`); + if (impact.characters > 0) parts.push(`${impact.characters} PJ`); + if (impact.flags > 0) parts.push(`${impact.flags} fait(s)`); + if (impact.progressions > 0) parts.push(`${impact.progressions} progression(s) de quête`); + const details: string[] = []; + if (parts.length) details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`); + details.push('Cette action est irréversible.'); + this.confirmDialog.confirm({ + title: 'Supprimer la Partie', + message: `Supprimer "${this.playthrough?.name}" ?`, + details, + confirmLabel: 'Supprimer', + variant: 'danger' + }).then(ok => { + if (!ok) return; + this.playthroughService.delete(this.playthroughId).subscribe({ + next: () => this.router.navigate(['/campaigns', this.campaignId]) + }); + }); + } + }); + } + + ngOnDestroy(): void {} +} diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html new file mode 100644 index 0000000..ca379ef --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html @@ -0,0 +1,20 @@ +
+ + + + +
diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.scss b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.scss new file mode 100644 index 0000000..0f4c955 --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.scss @@ -0,0 +1,24 @@ +.flags-page { + padding: 2.5rem 2rem; + max-width: 720px; +} + +.page-header { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; + + h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; + } +} + +.subtitle { + margin: 0; + font-size: 0.88rem; + color: var(--color-text-muted, #666); + max-width: 60ch; + line-height: 1.4; +} diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts new file mode 100644 index 0000000..1901d8f --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts @@ -0,0 +1,75 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { forkJoin } from 'rxjs'; +import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { PlaythroughService } from '../../../services/playthrough.service'; +import { LayoutService } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Playthrough } from '../../../services/campaign.model'; +import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; +import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-flags-manager/playthrough-flags-manager.component'; + +/** + * Page dédiée aux "Faits" d'une Partie. + * Route : /campaigns/:campaignId/playthroughs/:playthroughId/flags + */ +@Component({ + selector: 'app-playthrough-flags-page', + standalone: true, + imports: [CommonModule, RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent], + templateUrl: './playthrough-flags-page.component.html', + styleUrls: ['./playthrough-flags-page.component.scss'] +}) +export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy { + readonly ArrowLeft = ArrowLeft; + + campaignId = ''; + playthroughId = ''; + playthrough: Playthrough | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private campaignService: CampaignService, + private characterService: CharacterService, + private npcService: NpcService, + private playthroughService: PlaythroughService, + private layoutService: LayoutService, + private pageTitleService: PageTitleService + ) {} + + ngOnInit(): void { + this.route.paramMap.subscribe(pm => { + const cid = pm.get('campaignId')!; + const pid = pm.get('playthroughId')!; + if (cid !== this.campaignId || pid !== this.playthroughId) { + this.campaignId = cid; + this.playthroughId = pid; + this.load(); + } + }); + } + + private load(): void { + forkJoin({ + campaign: this.campaignService.getCampaignById(this.campaignId), + allCampaigns: this.campaignService.getAllCampaigns(), + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService), + playthrough: this.playthroughService.getById(this.playthroughId) + }).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => { + this.playthrough = playthrough; + this.pageTitleService.set(`${playthrough.name} — Faits`); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } + + ngOnDestroy(): void {} +} diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html index 328f33b..baa0c28 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html @@ -231,6 +231,18 @@ + + + + Si cette scène représente un lieu à parcourir pièce par pièce, ajoutez-les ici. + La scène bascule alors en mode « donjon » côté affichage. + + + + +
diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts index 679a71e..60be4ec 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts @@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Scene, SceneBranch } from '../../../services/campaign.model'; +import { Scene, SceneBranch, Room } from '../../../services/campaign.model'; import { Page } from '../../../services/page.model'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component'; @@ -19,6 +19,7 @@ import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-l import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { RoomsEditorComponent } from '../../../shared/rooms-editor/rooms-editor.component'; import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; @@ -29,7 +30,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia @Component({ selector: 'app-scene-edit', standalone: true, - imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent], + imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent], templateUrl: './scene-edit.component.html', styleUrls: ['./scene-edit.component.scss'] }) @@ -67,6 +68,11 @@ export class SceneEditComponent implements OnInit, OnDestroy { /** Branches narratives (état local mutable, persisté au submit). */ branches: SceneBranch[] = []; + /** Pièces du lieu explorable (état local, persisté au submit). */ + rooms: Room[] = []; + + onRoomsChange(next: Room[]): void { this.rooms = next; } + constructor( private fb: FormBuilder, private route: ActivatedRoute, @@ -144,6 +150,7 @@ export class SceneEditComponent implements OnInit, OnDestroy { this.mapImageIds = [...(scene.mapImageIds ?? [])]; this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId); this.branches = (scene.branches ?? []).map(b => ({ ...b })); + this.rooms = (scene.rooms ?? []).map(r => ({ ...r, branches: [...(r.branches ?? [])] })); this.form.patchValue({ name: scene.name, description: scene.description ?? '', @@ -180,6 +187,7 @@ export class SceneEditComponent implements OnInit, OnDestroy { illustrationImageIds: this.illustrationImageIds, mapImageIds: this.mapImageIds, branches: this.branches, + rooms: this.rooms, icon: this.selectedIcon }).subscribe({ next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]), diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.html b/web/src/app/campaigns/scene/scene-view/scene-view.component.html index 2642dbc..587dbbf 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.html +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.html @@ -100,4 +100,48 @@
+ +
+

🏰 Pièces du lieu

+
+
+
+ #{{ i + 1 }} + {{ r.name }} + + Étage {{ r.floor }} + +
+

{{ r.description }}

+
+
+ ⚔️ Ennemis +

{{ r.enemies }}

+
+
+ 💰 Loot +

{{ r.loot }}

+
+
+ ⚠️ Pièges +

{{ r.traps }}

+
+
+ 🔒 Notes MJ +

{{ r.gmNotes }}

+
+
+
+ → Sorties +
    +
  • + {{ b.label }} → {{ roomNameById(scene, b.targetRoomId) }} + (si : {{ b.condition }}) +
  • +
+
+
+
+
+ diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.scss b/web/src/app/campaigns/scene/scene-view/scene-view.component.scss index 9c48f33..dcdcba8 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.scss +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.scss @@ -1 +1,84 @@ // Styles partagés via styles/_view.scss + +.rooms-readonly { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.room-readonly { + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 10px; + padding: 0.85rem 1rem; + background: var(--color-surface, #fff); +} + +.room-readonly-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; +} + +.room-readonly-index { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.78rem; + color: var(--color-text-muted, #777); +} + +.room-readonly-name { + font-weight: 600; + font-size: 1rem; + flex: 1; +} + +.room-readonly-floor { + font-size: 0.78rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + background: rgba(66, 133, 244, 0.1); + color: #2c6cd6; +} + +.room-readonly-desc { + margin: 0 0 0.6rem; + font-size: 0.9rem; + line-height: 1.5; + color: var(--color-text, #333); + white-space: pre-wrap; +} + +.room-readonly-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.7rem; + margin-bottom: 0.5rem; + + > div { + strong { display: block; font-size: 0.82rem; margin-bottom: 0.2rem; color: var(--color-text-muted, #555); } + p { margin: 0; font-size: 0.88rem; white-space: pre-wrap; } + } +} + +.room-readonly-private { + background: rgba(192, 57, 43, 0.05); + padding: 0.4rem 0.55rem; + border-radius: 6px; + border-left: 3px solid rgba(192, 57, 43, 0.4); +} + +.room-readonly-branches { + margin-top: 0.45rem; + padding-top: 0.45rem; + border-top: 1px dashed var(--color-border, #ececec); + font-size: 0.85rem; + + strong { display: block; font-size: 0.82rem; margin-bottom: 0.15rem; color: var(--color-text-muted, #555); } + ul { margin: 0; padding-left: 1.1rem; } + li { margin-bottom: 0.1rem; } +} + +.branch-cond { + color: var(--color-text-muted, #777); + font-style: italic; +} diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts index 24d9bc5..6fccbeb 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts @@ -99,6 +99,11 @@ export class SceneViewComponent implements OnInit, OnDestroy { return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)'; } + /** Résout le nom d'une pièce cible (pour afficher les sorties inter-pièces). */ + roomNameById(scene: Scene | null, roomId: string): string { + return scene?.rooms?.find(r => r.id === roomId)?.name ?? '(pièce supprimée)'; + } + editMode(): void { this.router.navigate([ '/campaigns', this.campaignId, 'arcs', this.arcId, diff --git a/web/src/app/services/campaign-flag.service.ts b/web/src/app/services/campaign-flag.service.ts new file mode 100644 index 0000000..cfbb291 --- /dev/null +++ b/web/src/app/services/campaign-flag.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** + * Liste les noms de faits référencés par les conditions des quêtes d'une Campagne. + * Modèle "déclaration implicite" : un fait existe dès qu'au moins une quête le + * référence dans ses prérequis FLAG_SET. + */ +@Injectable({ providedIn: 'root' }) +export class CampaignFlagService { + + constructor(private http: HttpClient) {} + + /** Noms de faits dédupliqués et triés. */ + listReferenced(campaignId: string): Observable { + return this.http.get(`/api/campaigns/${campaignId}/flags`); + } +} diff --git a/web/src/app/services/campaign.model.ts b/web/src/app/services/campaign.model.ts index 4ac6b11..571dd34 100644 --- a/web/src/app/services/campaign.model.ts +++ b/web/src/app/services/campaign.model.ts @@ -21,6 +21,27 @@ export interface CampaignCreate { gameSystemId?: string | null; } +/** Type structurel d'un Arc (miroir de l'enum Java ArcType). */ +export type ArcType = 'LINEAR' | 'HUB'; + +/** Statut de progression piloté manuellement par le MJ (persisté). */ +export type ProgressionStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED'; + +/** + * Statut effectif d'une quête tel qu'affiché dans la vue Hub. + * Calculé côté backend — ne jamais le re-dériver côté front. + */ +export type QuestStatus = 'LOCKED' | 'AVAILABLE' | 'IN_PROGRESS' | 'COMPLETED'; + +/** + * Condition de déblocage d'une quête. Union TS discriminée sur `kind` — + * miroir du sealed type Java Prerequisite. Le narrowing TS marche sur le `kind`. + */ +export type Prerequisite = + | { kind: 'QUEST_COMPLETED'; questId: string } + | { kind: 'SESSION_REACHED'; minSessionNumber: number } + | { kind: 'FLAG_SET'; flagName: string }; + export interface Arc { id?: string; name: string; @@ -29,6 +50,9 @@ export interface Arc { order?: number; chapterCount?: number; + /** Type structurel (défaut LINEAR côté backend si omis). */ + type?: ArcType; + /** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS). */ icon?: string | null; @@ -55,6 +79,7 @@ export interface ArcCreate { description?: string; campaignId: string; order: number; + type?: ArcType; icon?: string | null; themes?: string; @@ -76,6 +101,20 @@ export interface Chapter { order?: number; icon?: string | null; + /** Conditions de déblocage (ET logique). Donnée de SCÉNARIO. */ + prerequisites?: Prerequisite[]; + + /** + * Statut de progression read-only — peuplé par le backend uniquement quand + * un playthroughId est passé en query param. Ne pas inclure en écriture. + */ + progressionStatus?: ProgressionStatus; + + /** + * Statut effectif read-only — idem, dépend du Playthrough. + */ + effectiveStatus?: QuestStatus; + // Champs narratifs enrichis gmNotes?: string; playerObjectives?: string; @@ -93,6 +132,8 @@ export interface ChapterCreate { order: number; icon?: string | null; + prerequisites?: Prerequisite[]; + gmNotes?: string; playerObjectives?: string; narrativeStakes?: string; @@ -102,6 +143,35 @@ export interface ChapterCreate { mapImageIds?: string[]; } +/** + * Partie / instance jouée d'une Campagne par une table donnée. + * Porte la progression dynamique des quêtes, les flags et les sessions. + */ +export interface Playthrough { + id?: string; + campaignId: string; + name: string; + description?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface PlaythroughCreate { + campaignId: string; + name: string; + description?: string; +} + +/** + * Valeur courante d'un fait pour une Partie donnée. + * Pas de déclaration explicite : un fait existe dès qu'au moins une quête le + * référence via un prérequis FLAG_SET. + */ +export interface PlaythroughFlag { + name: string; + value: boolean; +} + /** * Branche narrative : sortie possible d'une scène vers une autre du même chapitre. * Pendant TS du Value Object Java SceneBranch. @@ -112,6 +182,37 @@ export interface SceneBranch { condition?: string; } +/** + * Sortie d'une pièce vers une autre pièce du même lieu explorable. + * Pendant TS du record domaine RoomBranch. + */ +export interface RoomBranch { + label: string; + targetRoomId: string; + condition?: string; +} + +/** + * Pièce d'un lieu explorable attachée à une Scene. + * Dès qu'une Scene a au moins une Room, elle bascule sur le rendu « donjon ». + */ +export interface Room { + /** UUID stable généré côté client à la création (sert de cible aux RoomBranch). */ + id: string; + name: string; + description?: string; + enemies?: string; + loot?: string; + traps?: string; + gmNotes?: string; + /** Étage : 0 = RdC, 1 = 1er etc. null/undefined = pas d'étage défini. */ + floor?: number | null; + order: number; + illustrationImageIds?: string[]; + mapImageId?: string | null; + branches?: RoomBranch[]; +} + export interface Scene { id?: string; name: string; @@ -136,6 +237,9 @@ export interface Scene { /** Sorties narratives (graphe intra-chapitre). */ branches?: SceneBranch[]; + + /** Pièces explorables. Vide = scène classique, non-vide = lieu type donjon. */ + rooms?: Room[]; } export interface SceneCreate { @@ -158,4 +262,5 @@ export interface SceneCreate { illustrationImageIds?: string[]; mapImageIds?: string[]; branches?: SceneBranch[]; + rooms?: Room[]; } diff --git a/web/src/app/services/campaign.service.ts b/web/src/app/services/campaign.service.ts index f756f41..0c41e64 100644 --- a/web/src/app/services/campaign.service.ts +++ b/web/src/app/services/campaign.service.ts @@ -8,7 +8,7 @@ export interface CampaignDeletionImpact { arcs: number; chapters: number; scenes: number; - characters: number; + playthroughs: number; } /** Compte des entités qui seront supprimées en cascade avec un arc. */ @@ -85,13 +85,20 @@ export class CampaignService { } // ========== CHAPTER ========== - getChapters(arcId: string): Observable { - const params = new HttpParams().set('arcId', arcId); + /** + * Liste les chapitres d'un arc. Si {@code playthroughId} est fourni, le backend + * enrichit les DTOs avec progressionStatus + effectiveStatus relatifs à la Partie. + */ + getChapters(arcId: string, playthroughId?: string): Observable { + let params = new HttpParams().set('arcId', arcId); + if (playthroughId) params = params.set('playthroughId', playthroughId); return this.http.get('/api/chapters', { params }); } - getChapterById(id: string): Observable { - return this.http.get(`/api/chapters/${id}`); + getChapterById(id: string, playthroughId?: string): Observable { + let params = new HttpParams(); + if (playthroughId) params = params.set('playthroughId', playthroughId); + return this.http.get(`/api/chapters/${id}`, { params }); } createChapter(payload: ChapterCreate): Observable { diff --git a/web/src/app/services/character.model.ts b/web/src/app/services/character.model.ts index ec7c621..5694dc6 100644 --- a/web/src/app/services/character.model.ts +++ b/web/src/app/services/character.model.ts @@ -1,10 +1,7 @@ /** - * Fiche de personnage joueur (PJ) d'une campagne. - * Refonte 2026-04-30 : abandon du markdownContent au profit d'un systeme - * template-based pilote par le GameSystem de la campagne. - * - portraitImageId / headerImageId : champs universels hard-codes - * - values : Map - * - imageValues : Map + * Fiche de personnage joueur (PJ) d'une Partie (Playthrough). + * Refonte Playthrough : les PJ appartiennent à la Partie (table jouée), + * plus à la Campagne (scénario). */ export interface Character { id?: string; @@ -15,7 +12,7 @@ export interface Character { imageValues?: Record; /** Champs KEY_VALUE_LIST : fieldName -> label -> value. */ keyValueValues?: Record>; - campaignId: string; + playthroughId: string; order?: number; } @@ -26,5 +23,5 @@ export interface CharacterCreate { values?: Record; imageValues?: Record; keyValueValues?: Record>; - campaignId: string; + playthroughId: string; } diff --git a/web/src/app/services/character.service.ts b/web/src/app/services/character.service.ts index fa7f12e..6adf2a7 100644 --- a/web/src/app/services/character.service.ts +++ b/web/src/app/services/character.service.ts @@ -12,8 +12,8 @@ export class CharacterService { constructor(private http: HttpClient) {} - getByCampaign(campaignId: string): Observable { - return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); + getByPlaythrough(playthroughId: string): Observable { + return this.http.get(`${this.apiUrl}/playthrough/${playthroughId}`); } getById(id: string): Observable { diff --git a/web/src/app/services/playthrough-flag.service.ts b/web/src/app/services/playthrough-flag.service.ts new file mode 100644 index 0000000..de9047c --- /dev/null +++ b/web/src/app/services/playthrough-flag.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { PlaythroughFlag } from './campaign.model'; + +/** + * Endpoints des flags narratifs d'une Partie (Playthrough). + * Remplace l'ancien CampaignFlagService. + */ +@Injectable({ providedIn: 'root' }) +export class PlaythroughFlagService { + + constructor(private http: HttpClient) {} + + list(playthroughId: string): Observable { + return this.http.get(`/api/playthroughs/${playthroughId}/flags`); + } + + setFlag(playthroughId: string, name: string, value: boolean): Observable { + return this.http.put( + `/api/playthroughs/${playthroughId}/flags/${encodeURIComponent(name)}`, + { name, value } + ); + } + + deleteFlag(playthroughId: string, name: string): Observable { + return this.http.delete( + `/api/playthroughs/${playthroughId}/flags/${encodeURIComponent(name)}` + ); + } +} diff --git a/web/src/app/services/playthrough.service.ts b/web/src/app/services/playthrough.service.ts new file mode 100644 index 0000000..135b984 --- /dev/null +++ b/web/src/app/services/playthrough.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Playthrough, PlaythroughCreate } from './campaign.model'; + +export interface PlaythroughDeletionImpact { + sessions: number; + characters: number; + flags: number; + progressions: number; +} + +@Injectable({ providedIn: 'root' }) +export class PlaythroughService { + + private apiUrl = '/api/playthroughs'; + + constructor(private http: HttpClient) {} + + listByCampaign(campaignId: string): Observable { + const params = new HttpParams().set('campaignId', campaignId); + return this.http.get(this.apiUrl, { params }); + } + + getById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + create(payload: PlaythroughCreate): Observable { + return this.http.post(this.apiUrl, payload); + } + + update(id: string, payload: Playthrough): Observable { + return this.http.put(`${this.apiUrl}/${id}`, payload); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } + + deletionImpact(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}/deletion-impact`); + } +} diff --git a/web/src/app/services/quest-progression.service.ts b/web/src/app/services/quest-progression.service.ts new file mode 100644 index 0000000..35c2cb4 --- /dev/null +++ b/web/src/app/services/quest-progression.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ProgressionStatus } from './campaign.model'; + +/** + * Endpoints de progression des quêtes pour un Playthrough. + * Modèle "absence = NOT_STARTED" — envoyer NOT_STARTED supprime la ligne côté backend. + */ +@Injectable({ providedIn: 'root' }) +export class QuestProgressionService { + + constructor(private http: HttpClient) {} + + /** Map chapterId -> ProgressionStatus pour le Playthrough donné. */ + list(playthroughId: string): Observable> { + return this.http.get>( + `/api/playthroughs/${playthroughId}/quest-progressions` + ); + } + + setStatus(playthroughId: string, chapterId: string, status: ProgressionStatus): Observable { + return this.http.put( + `/api/playthroughs/${playthroughId}/quest-progressions/${chapterId}`, + { status } + ); + } +} diff --git a/web/src/app/services/session.model.ts b/web/src/app/services/session.model.ts index 05a52c4..ac76219 100644 --- a/web/src/app/services/session.model.ts +++ b/web/src/app/services/session.model.ts @@ -5,7 +5,7 @@ export interface Session { id: string; name: string; - campaignId: string; + playthroughId: string; startedAt: string; /** Null/undefined = session en cours. */ endedAt: string | null; diff --git a/web/src/app/services/session.service.ts b/web/src/app/services/session.service.ts index 00de128..cbb752d 100644 --- a/web/src/app/services/session.service.ts +++ b/web/src/app/services/session.service.ts @@ -5,7 +5,7 @@ import { Session } from './session.model'; /** * Service HTTP pour le Play Context (gestion des Sessions de jeu). - * Port de sortie vers le Backend Java (Architecture Hexagonale). + * Depuis Playthrough : une Session est rattachée à un Playthrough, pas à une Campagne. */ @Injectable({ providedIn: 'root' @@ -15,20 +15,31 @@ export class SessionService { constructor(private http: HttpClient) {} - /** Lance une nouvelle session sur la campagne donnée. */ - startSession(campaignId: string): Observable { - return this.http.post(this.apiUrl, { campaignId }); + /** Lance une nouvelle session sur la Partie (Playthrough) donnée. */ + startSession(playthroughId: string): Observable { + return this.http.post(this.apiUrl, { playthroughId }); } - /** Récupère la session active (204 No Content si aucune). */ + /** + * Récupère UNE session active dans l'app (legacy, multi-actives possibles désormais). + * Préférer {@link getActiveByPlaythrough} quand on veut le statut d'une Partie précise. + */ getActiveSession(): Observable { return this.http.get(`${this.apiUrl}/active`, { observe: 'body' }); } - getSessions(campaignId?: string): Observable { + /** Récupère la session active de la Partie donnée (null si aucune). */ + getActiveByPlaythrough(playthroughId: string): Observable { + return this.http.get(`${this.apiUrl}/active`, { + params: new HttpParams().set('playthroughId', playthroughId), + observe: 'body' + }); + } + + getSessions(playthroughId?: string): Observable { let params = new HttpParams(); - if (campaignId) { - params = params.set('campaignId', campaignId); + if (playthroughId) { + params = params.set('playthroughId', playthroughId); } return this.http.get(this.apiUrl, { params }); } diff --git a/web/src/app/sessions/session-detail/session-detail.component.html b/web/src/app/sessions/session-detail/session-detail.component.html index 1f23ee5..363cd07 100644 --- a/web/src/app/sessions/session-detail/session-detail.component.html +++ b/web/src/app/sessions/session-detail/session-detail.component.html @@ -1,6 +1,6 @@
- + Retour à la campagne @@ -175,7 +175,8 @@