From 0e350142de4b089606ec75d119708a84d56c068c Mon Sep 17 00:00:00 2001
From: "IETM_FIXE\\ietm6"
Date: Tue, 19 May 2026 18:45:56 +0200
Subject: [PATCH 01/57] =?UTF-8?q?mise=20=C3=A0=20jour=20vers=200.8.7-beta?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
brain/app/main.py | 2 +-
core/pom.xml | 2 +-
web/package-lock.json | 4 ++--
web/package.json | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/brain/app/main.py b/brain/app/main.py
index 1765088..afff8de 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -41,7 +41,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.8.6",
+ version="0.8.7-beta",
)
diff --git a/core/pom.xml b/core/pom.xml
index f5bf9ba..951365f 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
com.loremindloremind-core
- 0.8.6
+ 0.8.7-betaLoreMind CoreBackend Core - Architecture Hexagonale
diff --git a/web/package-lock.json b/web/package-lock.json
index 4606886..d1dd6c7 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "loremind-web",
- "version": "0.8.6",
+ "version": "0.8.7-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
- "version": "0.8.6",
+ "version": "0.8.7-beta",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",
diff --git a/web/package.json b/web/package.json
index 1416b3d..cda3e9a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "loremind-web",
- "version": "0.8.6",
+ "version": "0.8.7-beta",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",
From bc05b1536d872e1bda6dd6c365cb7d5aa2476a1b Mon Sep 17 00:00:00 2001
From: "IETM_FIXE\\ietm6"
Date: Tue, 19 May 2026 19:15:00 +0200
Subject: [PATCH 02/57] =?UTF-8?q?Correction=20du=20bug=20de=20switch=20ent?=
=?UTF-8?q?re=20lore=20/=20campagne=20et=20la=20sidebar=20qui=20ne=20s'act?=
=?UTF-8?q?ualise=20pas=20en=20cons=C3=A9quence.=20Ajout=20d'un=20test=20p?=
=?UTF-8?q?laywright=20pour=20=C3=A9viter=20toute=20r=C3=A9gression=20?=
=?UTF-8?q?=C3=A0=20l'avenir?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../tests/secondary-sidebar-isolation.spec.ts | 77 +++++++++++++++++++
web/src/app/campaigns/campaigns.component.ts | 8 +-
.../game-systems/game-systems.component.ts | 7 +-
web/src/app/lore/lore.component.ts | 6 ++
web/src/app/settings/settings.component.ts | 7 +-
5 files changed, 102 insertions(+), 3 deletions(-)
create mode 100644 web/e2e/tests/secondary-sidebar-isolation.spec.ts
diff --git a/web/e2e/tests/secondary-sidebar-isolation.spec.ts b/web/e2e/tests/secondary-sidebar-isolation.spec.ts
new file mode 100644
index 0000000..12dabdd
--- /dev/null
+++ b/web/e2e/tests/secondary-sidebar-isolation.spec.ts
@@ -0,0 +1,77 @@
+import { test, expect } from '@playwright/test';
+import { seedLoreWithFolder, deleteLore, type SeededLore } from '../fixtures/api';
+
+/**
+ * Regression : la secondary sidebar fuyait entre sections.
+ *
+ * Bug initial (2026-05-19) : on est sur /lore/:id (la sidebar affiche l'arbre
+ * du Lore), on clique sur "Campagne" dans la sidebar principale → on arrive
+ * sur /campaigns, MAIS la sidebar secondaire continuait d'afficher l'arbre
+ * du Lore precedent.
+ *
+ * Cause : les composants top-level (campaigns.component, lore.component,
+ * game-systems.component, settings.component) ne nettoyaient pas la sidebar
+ * heritee d'une section precedente. Fix : appel a layoutService.hide() dans
+ * leur ngOnInit.
+ */
+test.describe('Secondary sidebar — isolation entre sections', () => {
+ let seededLore: SeededLore;
+
+ test.beforeEach(async ({ request }) => {
+ seededLore = await seedLoreWithFolder(request);
+ });
+
+ test.afterEach(async ({ request }) => {
+ if (seededLore?.id) await deleteLore(request, seededLore.id);
+ });
+
+ test('Lore detail → /campaigns : la sidebar secondaire disparait', async ({ page }) => {
+ // 1. Sur le detail d'un Lore, la sidebar secondaire est affichee avec
+ // le nom du Lore comme titre.
+ await page.goto(`/lore/${seededLore.id}`);
+ await expect(page.locator('app-secondary-sidebar')).toBeVisible();
+ await expect(page.locator('app-secondary-sidebar')).toContainText(seededLore.name);
+
+ // 2. Navigation vers la liste des campagnes (top-level).
+ await page.goto('/campaigns');
+ await expect(page.getByRole('heading', { name: /Vos Campagnes/i })).toBeVisible();
+
+ // 3. La sidebar secondaire ne doit PAS persister (sinon elle afficherait
+ // encore l'arbre du Lore precedent). Le *ngIf au niveau d'AppComponent
+ // la retire completement du DOM quand layoutService est en etat hidden.
+ await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
+ });
+
+ test('Lore detail → /game-systems : la sidebar secondaire disparait', async ({ page }) => {
+ await page.goto(`/lore/${seededLore.id}`);
+ await expect(page.locator('app-secondary-sidebar')).toBeVisible();
+
+ await page.goto('/game-systems');
+ await expect(page.getByRole('heading', { name: /Systèmes de JDR/i })).toBeVisible();
+ await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
+ });
+
+ test('Lore detail → /settings : la sidebar secondaire disparait', async ({ page }) => {
+ await page.goto(`/lore/${seededLore.id}`);
+ await expect(page.locator('app-secondary-sidebar')).toBeVisible();
+
+ await page.goto('/settings');
+ // Settings n'a pas de h1 forcement evident, on se base sur l'URL + l'absence
+ // de sidebar secondaire (objet du test).
+ await expect(page).toHaveURL(/\/settings$/);
+ await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
+ });
+
+ test('Lore detail → /lore (liste racine) : la sidebar secondaire disparait', async ({ page }) => {
+ // Sur le detail, la sidebar est visible
+ await page.goto(`/lore/${seededLore.id}`);
+ await expect(page.locator('app-secondary-sidebar')).toBeVisible();
+
+ // Retour a la liste racine du Lore
+ await page.goto('/lore');
+ await expect(page.getByRole('heading', { name: /Vos Univers/i })).toBeVisible();
+
+ // La sidebar ne doit plus apparaitre sur la liste racine.
+ await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
+ });
+});
diff --git a/web/src/app/campaigns/campaigns.component.ts b/web/src/app/campaigns/campaigns.component.ts
index 5017df5..3b8fce2 100644
--- a/web/src/app/campaigns/campaigns.component.ts
+++ b/web/src/app/campaigns/campaigns.component.ts
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
import { CampaignService } from '../services/campaign.service';
+import { LayoutService } from '../services/layout.service';
import { Campaign } from '../services/campaign.model';
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component';
@@ -22,10 +23,15 @@ export class CampaignsComponent implements OnInit {
constructor(
private router: Router,
- private campaignService: CampaignService
+ private campaignService: CampaignService,
+ private layoutService: LayoutService
) {}
ngOnInit(): void {
+ // Liste racine de la section Campagnes : aucune sidebar secondaire ne
+ // doit subsister (ex: si on arrive depuis une page Lore qui en affichait
+ // une, elle persisterait sans ce hide() — cf. bug rapporte 2026-05-19).
+ this.layoutService.hide();
this.loadCampaigns();
}
diff --git a/web/src/app/game-systems/game-systems.component.ts b/web/src/app/game-systems/game-systems.component.ts
index e2d1d01..42d0bd7 100644
--- a/web/src/app/game-systems/game-systems.component.ts
+++ b/web/src/app/game-systems/game-systems.component.ts
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
import { GameSystemService } from '../services/game-system.service';
+import { LayoutService } from '../services/layout.service';
import { GameSystem } from '../services/game-system.model';
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
@@ -24,10 +25,14 @@ export class GameSystemsComponent implements OnInit {
constructor(
private router: Router,
private gameSystemService: GameSystemService,
- private confirmDialog: ConfirmDialogService
+ private confirmDialog: ConfirmDialogService,
+ private layoutService: LayoutService
) {}
ngOnInit(): void {
+ // Page racine : on s'assure de ne pas heriter de la sidebar d'une
+ // section precedente (cf. fix CampaignsComponent / LoreComponent).
+ this.layoutService.hide();
this.load();
}
diff --git a/web/src/app/lore/lore.component.ts b/web/src/app/lore/lore.component.ts
index 1f227af..7292bd5 100644
--- a/web/src/app/lore/lore.component.ts
+++ b/web/src/app/lore/lore.component.ts
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
import { LoreService } from '../services/lore.service';
+import { LayoutService } from '../services/layout.service';
import { Lore } from '../services/lore.model';
import { LoreCreateComponent } from './lore-create/lore-create.component';
@@ -26,10 +27,15 @@ export class LoreComponent implements OnInit {
constructor(
private loreService: LoreService,
+ private layoutService: LayoutService,
private router: Router
) {}
ngOnInit(): void {
+ // Liste racine de la section Lore : aucune sidebar secondaire ne doit
+ // subsister (sinon elle persiste depuis la section precedente — bug
+ // symetrique a celui de CampaignsComponent).
+ this.layoutService.hide();
this.loadLores();
}
diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts
index 0d754f0..88207ee 100644
--- a/web/src/app/settings/settings.component.ts
+++ b/web/src/app/settings/settings.component.ts
@@ -7,6 +7,7 @@ import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Do
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
+import { LayoutService } from '../services/layout.service';
import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../services/license.service';
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
@@ -133,10 +134,14 @@ export class SettingsComponent implements OnInit, OnDestroy {
private updatesService: UpdatesService,
public config: ConfigService,
private licenseService: LicenseService,
- private confirmDialog: ConfirmDialogService
+ private confirmDialog: ConfirmDialogService,
+ private layoutService: LayoutService
) {}
ngOnInit(): void {
+ // Page racine : on s'assure de ne pas heriter de la sidebar d'une
+ // section precedente (cf. fix CampaignsComponent / LoreComponent).
+ this.layoutService.hide();
this.loadSettings();
if (this.config.updateCheckEnabled) {
this.checkUpdates();
From e3fc96a1bc487bfe6b9b045aac83339a3b05255b Mon Sep 17 00:00:00 2001
From: "IETM_FIXE\\ietm6"
Date: Wed, 20 May 2026 14:59:26 +0200
Subject: [PATCH 03/57] =?UTF-8?q?Ajout=20d'un=20mode=20"jeu"=20(possibilit?=
=?UTF-8?q?=C3=A9=20de=20lancer=20des=20sessions=20dans=20une=20campagne).?=
=?UTF-8?q?=20Cela=20permet=20de=20faire=20de=20prendre=20des=20notes=20en?=
=?UTF-8?q?=20live=20au=20cours=20d'une=20partie=20et=20d'avoir=20plusieur?=
=?UTF-8?q?s=20outils=20sous=20la=20main=20pour=20aider=20le=20mj=20:=20-?=
=?UTF-8?q?=20Possibilit=C3=A9=20de=20parler=20=C3=A0=20une=20IA=20pour=20?=
=?UTF-8?q?r=C3=A8gle=20de=20jeu=20ou=20=C3=A9l=C3=A9ment=20de=20lore=20/?=
=?UTF-8?q?=20campagne=20au=20cours=20d'une=20partie=20comme=20aide=20m?=
=?UTF-8?q?=C3=A9moire=20-=20Onglet=20d=C3=A9di=C3=A9=20aux=20personnages?=
=?UTF-8?q?=20de=20la=20campagne=20-=20Onglet=20d=C3=A9di=C3=A9=20aux=20sc?=
=?UTF-8?q?=C3=A8nes=20-=20Onglet=20avec=20d=C3=A8s=20pour=20ceux=20qui=20?=
=?UTF-8?q?souhaitent=20;?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
---
brain/app/application/chat.py | 57 ++-
brain/app/domain/models.py | 27 ++
brain/app/main.py | 64 +++-
core/pom.xml | 2 +-
.../SessionStructuralContextBuilder.java | 73 ++++
.../StreamChatForSessionUseCase.java | 111 ++++++
.../playcontext/SessionEntryService.java | 81 ++++
.../playcontext/SessionService.java | 117 ++++++
.../domain/generationcontext/ChatRequest.java | 11 +-
.../generationcontext/SessionContext.java | 33 ++
.../domain/playcontext/EntryType.java | 16 +
.../loremind/domain/playcontext/Session.java | 42 +++
.../domain/playcontext/SessionEntry.java | 39 ++
.../ports/SessionEntryRepository.java | 26 ++
.../playcontext/ports/SessionRepository.java | 28 ++
.../ai/BrainChatPayloadBuilder.java | 28 ++
.../entity/SessionEntryJpaEntity.java | 60 +++
.../persistence/entity/SessionJpaEntity.java | 61 +++
.../jpa/SessionEntryJpaRepository.java | 15 +
.../persistence/jpa/SessionJpaRepository.java | 19 +
.../PostgresSessionEntryRepository.java | 85 +++++
.../postgres/PostgresSessionRepository.java | 90 +++++
.../web/controller/AiChatController.java | 34 ++
.../web/controller/SessionController.java | 82 +++++
.../controller/SessionEntryController.java | 68 ++++
.../ChatStreamSessionRequestDTO.java | 16 +
.../web/dto/playcontext/SessionDTO.java | 22 ++
.../web/dto/playcontext/SessionEntryDTO.java | 21 ++
.../web/mapper/SessionEntryMapper.java | 22 ++
.../web/mapper/SessionMapper.java | 26 ++
web/package-lock.json | 4 +-
web/package.json | 2 +-
web/src/app/app.routes.ts | 1 +
.../campaign-detail.component.html | 56 +++
.../campaign-detail.component.scss | 54 +++
.../campaign-detail.component.ts | 67 +++-
web/src/app/services/ai-chat.service.ts | 12 +-
web/src/app/services/session-entry.model.ts | 35 ++
web/src/app/services/session-entry.service.ts | 35 ++
web/src/app/services/session.model.ts | 15 +
web/src/app/services/session.service.ts | 51 +++
.../session-ai-chat-panel.component.html | 72 ++++
.../session-ai-chat-panel.component.scss | 168 +++++++++
.../session-ai-chat-panel.component.ts | 147 ++++++++
.../session-detail.component.html | 188 ++++++++++
.../session-detail.component.scss | 346 ++++++++++++++++++
.../session-detail.component.ts | 276 ++++++++++++++
.../session-dice-panel.component.html | 61 +++
.../session-dice-panel.component.scss | 172 +++++++++
.../session-dice-panel.component.ts | 92 +++++
.../session-reference-panel.component.html | 120 ++++++
.../session-reference-panel.component.scss | 143 ++++++++
.../session-reference-panel.component.ts | 138 +++++++
53 files changed, 3614 insertions(+), 17 deletions(-)
create mode 100644 core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
create mode 100644 core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
create mode 100644 core/src/main/java/com/loremind/application/playcontext/SessionEntryService.java
create mode 100644 core/src/main/java/com/loremind/application/playcontext/SessionService.java
create mode 100644 core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/EntryType.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/Session.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/SessionEntry.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/SessionEntryRepository.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionEntryJpaEntity.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionEntryJpaRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionJpaRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionEntryRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/SessionEntryController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/generationcontext/ChatStreamSessionRequestDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionEntryDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/SessionEntryMapper.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java
create mode 100644 web/src/app/services/session-entry.model.ts
create mode 100644 web/src/app/services/session-entry.service.ts
create mode 100644 web/src/app/services/session.model.ts
create mode 100644 web/src/app/services/session.service.ts
create mode 100644 web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.html
create mode 100644 web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.scss
create mode 100644 web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.ts
create mode 100644 web/src/app/sessions/session-detail/session-detail.component.html
create mode 100644 web/src/app/sessions/session-detail/session-detail.component.scss
create mode 100644 web/src/app/sessions/session-detail/session-detail.component.ts
create mode 100644 web/src/app/sessions/session-dice-panel/session-dice-panel.component.html
create mode 100644 web/src/app/sessions/session-dice-panel/session-dice-panel.component.scss
create mode 100644 web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts
create mode 100644 web/src/app/sessions/session-reference-panel/session-reference-panel.component.html
create mode 100644 web/src/app/sessions/session-reference-panel/session-reference-panel.component.scss
create mode 100644 web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts
diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py
index 9559d5e..68758ac 100644
--- a/brain/app/application/chat.py
+++ b/brain/app/application/chat.py
@@ -27,6 +27,7 @@ from app.domain.models import (
NarrativeEntityContext,
PageContext,
PageSummary,
+ SessionContext,
)
from app.domain.ports import LLMChatProvider
@@ -67,6 +68,7 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
+ session_context: SessionContext | None = None,
) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user.
@@ -76,7 +78,7 @@ class ChatUseCase:
cette règle à la frontière HTTP.
"""
system_prompt = self._build_system_prompt(
- lore_context, page_context, campaign_context, narrative_entity, game_system_context
+ lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
)
async for token in self._llm.stream_chat(
messages,
@@ -92,12 +94,13 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
+ session_context: SessionContext | None = None,
) -> str:
"""Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte).
"""
return self._build_system_prompt(
- lore_context, page_context, campaign_context, narrative_entity, game_system_context
+ lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
)
# --- Construction du system prompt --------------------------------------
@@ -109,6 +112,7 @@ class ChatUseCase:
campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None,
+ session: SessionContext | None = None,
) -> str:
sections = [_BASE_SYSTEM]
if lore is not None:
@@ -121,6 +125,8 @@ class ChatUseCase:
sections.append(self._format_page(page))
if narrative is not None:
sections.append(self._format_narrative_entity(narrative))
+ if session is not None:
+ sections.append(self._format_session(session))
return "\n\n".join(sections)
# --- Blocs Lore ---------------------------------------------------------
@@ -342,6 +348,53 @@ class ChatUseCase:
f"{sections_block}"
)
+ # --- Bloc Session de jeu (Play Context) ---------------------------------
+
+ _ENTRY_TYPE_LABELS = {
+ "NOTE": "Note du MJ",
+ "EVENT": "Évènement",
+ "DICE_ROLL": "Jet de dés",
+ "PLAYER_ACTION": "Action joueur",
+ }
+
+ @staticmethod
+ def _format_session(sc: SessionContext) -> str:
+ """Bloc journal de la session en cours.
+
+ 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.
+ """
+ status = "EN COURS" if sc.active else "TERMINÉE"
+ started = f" — démarrée {sc.started_at}" if sc.started_at else ""
+
+ if not sc.entries:
+ entries_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)
+
+ 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"
+ "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"
+ "- Ê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"
+ "- Éviter les longs développements : le MJ est en train d'animer une partie, "
+ "il a besoin d'idées immédiatement actionnables."
+ )
+
@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 4b0b3da..449a883 100644
--- a/brain/app/domain/models.py
+++ b/brain/app/domain/models.py
@@ -229,3 +229,30 @@ class GameSystemContext:
system_name: str
system_description: str | None
sections: dict[str, str]
+
+
+@dataclass(frozen=True)
+class JournalEntrySummary:
+ """Une entrée du journal d'une Session : type + contenu + horodatage."""
+
+ type: str
+ content: str
+ occurred_at: str | 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.
+ """
+
+ session_name: str
+ active: bool
+ started_at: str | None
+ entries: list[JournalEntrySummary]
diff --git a/brain/app/main.py b/brain/app/main.py
index afff8de..d87cb4e 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -26,6 +26,7 @@ from app.domain.models import (
NpcSummary,
ChatMessage,
GameSystemContext,
+ JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
@@ -33,6 +34,7 @@ from app.domain.models import (
PageSummary,
SceneBranchHint,
SceneSummary,
+ SessionContext,
)
from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
@@ -41,7 +43,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.8.7-beta",
+ version="0.9.0-beta",
)
@@ -243,13 +245,34 @@ class GameSystemContextDTO(BaseModel):
sections: dict[str, str] = Field(default_factory=dict)
+class JournalEntrySummaryDTO(BaseModel):
+ """Une entrée du journal de session — type + contenu + horodatage."""
+
+ type: str
+ content: str
+ occurred_at: 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).
+ """
+
+ session_name: str
+ active: bool
+ started_at: str | None = None
+ entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
+
+
class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels.
- Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels,
- mais au moins l'un des deux "niveaux haut" (lore_context ou
- campaign_context) doit être fourni. Le validateur `check_scope` applique
- cette règle à la frontière HTTP.
+ Les contextes (lore, page, campaign, narrative_entity, session) sont
+ optionnels, mais au moins l'un des contextes "racines" (lore_context,
+ campaign_context ou session_context) doit être fourni. Le validateur
+ `check_scope` applique cette règle à la frontière HTTP.
"""
messages: list[ChatMessageDTO] = Field(min_length=1)
@@ -258,10 +281,15 @@ class ChatStreamRequestDTO(BaseModel):
campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None
game_system_context: GameSystemContextDTO | None = None
+ session_context: SessionContextDTO | None = None
def has_scope(self) -> bool:
- """Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
- return self.lore_context is not None or self.campaign_context is not None
+ """Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni."""
+ return (
+ self.lore_context is not None
+ or self.campaign_context is not None
+ or self.session_context is not None
+ )
# --- Factories d'injection de dépendance ---
@@ -385,6 +413,7 @@ async def chat_stream(
campaign_context = _to_campaign_context(body.campaign_context)
narrative_entity = _to_narrative_entity(body.narrative_entity)
game_system_context = _to_game_system_context(body.game_system_context)
+ session_context = _to_session_context(body.session_context)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
@@ -397,6 +426,7 @@ async def chat_stream(
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
+ session_context=session_context,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
@@ -421,6 +451,7 @@ async def chat_stream(
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
+ session_context=session_context,
):
# json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
@@ -867,3 +898,22 @@ def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemConte
system_description=dto.system_description,
sections=dict(dto.sections),
)
+
+
+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,
+ )
diff --git a/core/pom.xml b/core/pom.xml
index 951365f..d7dd225 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
com.loremindloremind-core
- 0.8.7-beta
+ 0.9.0-betaLoreMind CoreBackend Core - Architecture Hexagonale
diff --git a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
new file mode 100644
index 0000000..311abee
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
@@ -0,0 +1,73 @@
+package com.loremind.application.generationcontext;
+
+import com.loremind.domain.generationcontext.SessionContext;
+import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
+import com.loremind.domain.playcontext.Session;
+import com.loremind.domain.playcontext.SessionEntry;
+import com.loremind.domain.playcontext.ports.SessionEntryRepository;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+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.
+ */
+@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 final SessionRepository sessionRepository;
+ private final SessionEntryRepository entryRepository;
+
+ public SessionStructuralContextBuilder(SessionRepository sessionRepository,
+ SessionEntryRepository entryRepository) {
+ this.sessionRepository = sessionRepository;
+ this.entryRepository = entryRepository;
+ }
+
+ public Optional buildOptional(String sessionId) {
+ return sessionRepository.findById(sessionId).map(this::toContext);
+ }
+
+ public SessionContext build(String sessionId) {
+ Session session = sessionRepository.findById(sessionId)
+ .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
+ return toContext(session);
+ }
+
+ 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());
+
+ return new SessionContext(
+ session.getName(),
+ session.isActive(),
+ session.getStartedAt(),
+ summaries);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
new file mode 100644
index 0000000..dea49ed
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
@@ -0,0 +1,111 @@
+package com.loremind.application.generationcontext;
+
+import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
+import com.loremind.domain.campaigncontext.Campaign;
+import com.loremind.domain.campaigncontext.ports.CampaignRepository;
+import com.loremind.domain.gamesystemcontext.GenerationIntent;
+import com.loremind.domain.generationcontext.CampaignStructuralContext;
+import com.loremind.domain.generationcontext.ChatMessage;
+import com.loremind.domain.generationcontext.ChatRequest;
+import com.loremind.domain.generationcontext.ChatUsage;
+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.Session;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Use case applicatif : chat IA pendant une Session de jeu.
+ *
+ * Orchestre la composition des contextes :
+ * 1. Charge la Session puis la Campagne associée (weak reference).
+ * 2. Construit le CampaignStructuralContext (carte narrative + PJ/PNJ).
+ * 3. Construit le LoreStructuralContext si la campagne est liée à un Lore.
+ * 4. Construit le GameSystemContext si elle a un système de JDR.
+ * 5. Construit le SessionContext (journal horodaté, statut).
+ * 6. Délègue au port {@link AiChatProvider} pour le streaming.
+ *
+ *
+ *
La conversation est éphémère (pas de persistance) : pendant une partie,
+ * l'utilité est d'avoir une assistance immédiate, pas de garder un historique.
+ * Le journal de session joue déjà ce rôle de mémoire persistante.
+ */
+@Service
+public class StreamChatForSessionUseCase {
+
+ private final SessionRepository sessionRepository;
+ private final CampaignRepository campaignRepository;
+ private final CampaignStructuralContextBuilder campaignContextBuilder;
+ private final LoreStructuralContextBuilder loreContextBuilder;
+ private final GameSystemContextBuilder gameSystemContextBuilder;
+ private final SessionStructuralContextBuilder sessionContextBuilder;
+ private final AiChatProvider aiChatProvider;
+
+ public StreamChatForSessionUseCase(
+ SessionRepository sessionRepository,
+ CampaignRepository campaignRepository,
+ CampaignStructuralContextBuilder campaignContextBuilder,
+ LoreStructuralContextBuilder loreContextBuilder,
+ GameSystemContextBuilder gameSystemContextBuilder,
+ SessionStructuralContextBuilder sessionContextBuilder,
+ AiChatProvider aiChatProvider) {
+ this.sessionRepository = sessionRepository;
+ this.campaignRepository = campaignRepository;
+ this.campaignContextBuilder = campaignContextBuilder;
+ this.loreContextBuilder = loreContextBuilder;
+ this.gameSystemContextBuilder = gameSystemContextBuilder;
+ this.sessionContextBuilder = sessionContextBuilder;
+ this.aiChatProvider = aiChatProvider;
+ }
+
+ public void execute(
+ String sessionId,
+ List messages,
+ Consumer onUsage,
+ Consumer onToken,
+ Runnable onComplete,
+ Consumer onError) {
+
+ Session session = sessionRepository.findById(sessionId)
+ .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
+
+ Campaign campaign = campaignRepository.findById(session.getCampaignId())
+ .orElseThrow(() -> new IllegalArgumentException(
+ "Campagne associée à la session introuvable : " + session.getCampaignId()));
+
+ CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId());
+ LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
+ GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
+ SessionContext sessionContext = sessionContextBuilder.build(sessionId);
+
+ ChatRequest request = ChatRequest.builder()
+ .messages(messages)
+ .loreContext(loreContext)
+ .campaignContext(campaignContext)
+ .gameSystemContext(gameSystemContext)
+ .sessionContext(sessionContext)
+ .build();
+
+ aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
+ }
+
+ private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
+ if (!campaign.isLinkedToLore()) return null;
+ return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
+ }
+
+ /**
+ * Pendant une session active, on injecte les sections les plus utiles en partie
+ * (combats, PNJ, mécaniques) — intent SCENE est le plus proche de ce besoin.
+ */
+ private GameSystemContext loadGameSystemContextOrNull(Campaign campaign) {
+ if (!campaign.isLinkedToGameSystem()) return null;
+ return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), GenerationIntent.SCENE)
+ .orElse(null);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionEntryService.java b/core/src/main/java/com/loremind/application/playcontext/SessionEntryService.java
new file mode 100644
index 0000000..69d39b0
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/playcontext/SessionEntryService.java
@@ -0,0 +1,81 @@
+package com.loremind.application.playcontext;
+
+import com.loremind.domain.playcontext.EntryType;
+import com.loremind.domain.playcontext.SessionEntry;
+import com.loremind.domain.playcontext.ports.SessionEntryRepository;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Service d'application pour le journal d'une Session.
+ * Gère le cycle CRUD des entrées (note, évènement, jet, action joueur).
+ */
+@Service
+public class SessionEntryService {
+
+ private final SessionEntryRepository entryRepository;
+ private final SessionRepository sessionRepository;
+
+ public SessionEntryService(SessionEntryRepository entryRepository,
+ SessionRepository sessionRepository) {
+ this.entryRepository = entryRepository;
+ this.sessionRepository = sessionRepository;
+ }
+
+ /** Données fournies par l'API pour créer ou éditer une entrée. */
+ public record EntryData(EntryType type, String content, LocalDateTime occurredAt) {}
+
+ public SessionEntry createEntry(String sessionId, EntryData data) {
+ if (sessionId == null || sessionId.isBlank()) {
+ throw new IllegalArgumentException("sessionId est requis.");
+ }
+ if (!sessionRepository.existsById(sessionId)) {
+ throw new IllegalArgumentException("Session introuvable : " + sessionId);
+ }
+ validateContent(data.content());
+
+ LocalDateTime now = LocalDateTime.now();
+ SessionEntry entry = SessionEntry.builder()
+ .sessionId(sessionId)
+ .type(data.type() != null ? data.type() : EntryType.NOTE)
+ .content(data.content().trim())
+ .occurredAt(data.occurredAt() != null ? data.occurredAt() : now)
+ .build();
+ return entryRepository.save(entry);
+ }
+
+ public SessionEntry updateEntry(String id, EntryData data) {
+ validateContent(data.content());
+ SessionEntry existing = entryRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Entrée introuvable : " + id));
+ if (data.type() != null) existing.setType(data.type());
+ existing.setContent(data.content().trim());
+ if (data.occurredAt() != null) existing.setOccurredAt(data.occurredAt());
+ return entryRepository.save(existing);
+ }
+
+ public Optional getById(String id) {
+ return entryRepository.findById(id);
+ }
+
+ public List getBySessionId(String sessionId) {
+ return entryRepository.findBySessionId(sessionId);
+ }
+
+ public void deleteEntry(String id) {
+ if (!entryRepository.existsById(id)) {
+ throw new IllegalArgumentException("Entrée introuvable : " + id);
+ }
+ entryRepository.deleteById(id);
+ }
+
+ private void validateContent(String content) {
+ if (content == null || content.isBlank()) {
+ throw new IllegalArgumentException("Le contenu d'une entrée ne peut pas être vide.");
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionService.java b/core/src/main/java/com/loremind/application/playcontext/SessionService.java
new file mode 100644
index 0000000..9ba6d6e
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/playcontext/SessionService.java
@@ -0,0 +1,117 @@
+package com.loremind.application.playcontext;
+
+import com.loremind.domain.campaigncontext.ports.CampaignRepository;
+import com.loremind.domain.playcontext.Session;
+import com.loremind.domain.playcontext.ports.SessionEntryRepository;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+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.
+ */
+@Service
+public class SessionService {
+
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ private final SessionRepository sessionRepository;
+ private final SessionEntryRepository entryRepository;
+ private final CampaignRepository campaignRepository;
+
+ public SessionService(SessionRepository sessionRepository,
+ SessionEntryRepository entryRepository,
+ CampaignRepository campaignRepository) {
+ this.sessionRepository = sessionRepository;
+ this.entryRepository = entryRepository;
+ this.campaignRepository = campaignRepository;
+ }
+
+ /**
+ * Lance une nouvelle session sur la campagne donnée.
+ * Échoue si une session est déjà active ou si la campagne n'existe pas.
+ */
+ public Session startSession(String campaignId) {
+ if (campaignId == null || campaignId.isBlank()) {
+ throw new IllegalArgumentException("campaignId est requis pour démarrer une session.");
+ }
+ if (!campaignRepository.existsById(campaignId)) {
+ throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
+ }
+ 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.");
+ });
+
+ LocalDateTime now = LocalDateTime.now();
+ Session session = Session.builder()
+ .name(generateDefaultName(now))
+ .campaignId(campaignId)
+ .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));
+ if (!session.isActive()) {
+ throw new IllegalStateException("Cette session est déjà terminée.");
+ }
+ session.setEndedAt(LocalDateTime.now());
+ return sessionRepository.save(session);
+ }
+
+ public Session renameSession(String id, String newName) {
+ if (newName == null || newName.isBlank()) {
+ throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
+ }
+ Session session = sessionRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
+ session.setName(newName.trim());
+ return sessionRepository.save(session);
+ }
+
+ public Optional getById(String id) {
+ return sessionRepository.findById(id);
+ }
+
+ public Optional getActive() {
+ return sessionRepository.findActive();
+ }
+
+ public List getAll() {
+ return sessionRepository.findAll();
+ }
+
+ public List getByCampaignId(String campaignId) {
+ return sessionRepository.findByCampaignId(campaignId);
+ }
+
+ /**
+ * 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)) {
+ throw new IllegalArgumentException("Session introuvable : " + id);
+ }
+ entryRepository.deleteBySessionId(id);
+ sessionRepository.deleteById(id);
+ }
+
+ private String generateDefaultName(LocalDateTime startedAt) {
+ return "Session du " + startedAt.format(DATE_FORMATTER);
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
index 48f5d1a..0da2e28 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
@@ -36,7 +36,8 @@ public record ChatRequest(
PageContext pageContext,
CampaignStructuralContext campaignContext,
NarrativeEntityContext narrativeEntity,
- GameSystemContext gameSystemContext) {
+ GameSystemContext gameSystemContext,
+ SessionContext sessionContext) {
public static Builder builder() {
return new Builder();
@@ -50,6 +51,7 @@ public record ChatRequest(
private CampaignStructuralContext campaignContext;
private NarrativeEntityContext narrativeEntity;
private GameSystemContext gameSystemContext;
+ private SessionContext sessionContext;
private Builder() {}
@@ -83,9 +85,14 @@ public record ChatRequest(
return this;
}
+ public Builder sessionContext(SessionContext sessionContext) {
+ this.sessionContext = sessionContext;
+ return this;
+ }
+
public ChatRequest build() {
return new ChatRequest(messages, loreContext, pageContext,
- campaignContext, narrativeEntity, gameSystemContext);
+ campaignContext, narrativeEntity, gameSystemContext, sessionContext);
}
}
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java b/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
new file mode 100644
index 0000000..c7b7bb4
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
@@ -0,0 +1,33 @@
+package com.loremind.domain.generationcontext;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Contexte structurel d'une Session de jeu — injecté dans le system prompt
+ * 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).
+ *
+ *
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.
+ */
+public record SessionContext(
+ String sessionName,
+ boolean active,
+ LocalDateTime startedAt,
+ List entries) {
+
+ /** Résumé d'une entrée de journal — type + contenu + horodatage. */
+ public record JournalEntrySummary(
+ String type,
+ String content,
+ LocalDateTime occurredAt) {}
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/EntryType.java b/core/src/main/java/com/loremind/domain/playcontext/EntryType.java
new file mode 100644
index 0000000..09fbddc
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/EntryType.java
@@ -0,0 +1,16 @@
+package com.loremind.domain.playcontext;
+
+/**
+ * Type d'entrée du journal de session.
+ * Permet à l'UI de catégoriser visuellement la timeline (icône, couleur).
+ */
+public enum EntryType {
+ /** Note libre du MJ (défaut). */
+ NOTE,
+ /** Moment marquant du scénario (combat gagné, décision majeure...). */
+ EVENT,
+ /** Jet de dés / test de caractéristique. */
+ DICE_ROLL,
+ /** Action déclarée par un joueur. */
+ PLAYER_ACTION
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/Session.java b/core/src/main/java/com/loremind/domain/playcontext/Session.java
new file mode 100644
index 0000000..d9a1298
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/Session.java
@@ -0,0 +1,42 @@
+package com.loremind.domain.playcontext;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Entité de domaine représentant une Session de jeu en cours ou passé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.
+ *
+ *
{@code endedAt == null} signifie que la session est en cours.
+ * Une seule session peut être en cours dans l'application à la fois.
+ */
+@Data
+@Builder
+public class Session {
+
+ private String id;
+ private String name;
+
+ /** Weak reference vers Campaign — pas de dépendance directe inter-contexte. */
+ private String campaignId;
+
+ private LocalDateTime startedAt;
+
+ /** Null = session en cours ; renseigné = session terminée. */
+ private LocalDateTime endedAt;
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ public boolean isActive() {
+ return this.endedAt == null;
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/SessionEntry.java b/core/src/main/java/com/loremind/domain/playcontext/SessionEntry.java
new file mode 100644
index 0000000..cdc2e2b
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/SessionEntry.java
@@ -0,0 +1,39 @@
+package com.loremind.domain.playcontext;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Entrée du journal d'une Session.
+ * Représente un évènement horodaté capturé pendant ou après une partie :
+ * note libre du MJ, évènement marquant, jet de dés, action de joueur.
+ *
+ *
Fait partie du Play Context. Référence la Session par weak reference
+ * (sessionId) — l'orchestration en cascade est gérée par le service applicatif.
Diffère du {@link AiChatDrawerComponent} :
+ * - conversation 100% éphémère (le journal joue le rôle de mémoire persistante)
+ * - intégré dans le panneau latéral, pas en drawer
+ * - chaque réponse peut être ajoutée au journal en un clic (event {@link saveToJournal})
+ *
+ *
Le backend reçoit le contexte complet via {@code /api/ai/chat/stream-session} :
+ * lore + campagne + GameSystem + journal — l'IA "sait" tout ce qui s'est passé.
Charge à la volée les PJ/PNJ et l'arbre de scènes de la campagne associée
+ * à la session. La navigation vers les fiches s'ouvre dans un nouvel onglet
+ * pour ne pas casser le flux de la session en cours.
+ *
+ *
Le sous-composant {@link SessionDicePanelComponent} émet un événement
+ * de jet qui remonte ici puis vers le parent via {@link rolled}.
+ */
+@Component({
+ selector: 'app-session-reference-panel',
+ standalone: true,
+ imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent],
+ templateUrl: './session-reference-panel.component.html',
+ styleUrls: ['./session-reference-panel.component.scss']
+})
+export class SessionReferencePanelComponent implements OnChanges {
+ readonly User = User;
+ readonly Drama = Drama;
+ readonly Swords = Swords;
+ readonly Dices = Dices;
+ readonly ExternalLink = ExternalLink;
+ readonly Sparkles = Sparkles;
+
+ @Input() campaignId!: string;
+ @Input() sessionId!: string;
+ @Input() canAddToJournal = true;
+ @Output() rolled = new EventEmitter();
+ /** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
+ @Output() aiReplyToJournal = new EventEmitter();
+
+ activeTab: TabId = 'dice';
+
+ characters: Character[] = [];
+ npcs: Npc[] = [];
+ treeData: CampaignTreeData | null = null;
+
+ loadingChars = false;
+ loadingTree = false;
+ /** True dès qu'un tab "lourd" a été chargé pour éviter de rappeler l'API en boucle. */
+ private charsLoaded = false;
+ private treeLoaded = false;
+
+ constructor(
+ private campaignService: CampaignService,
+ private characterService: CharacterService,
+ private npcService: NpcService
+ ) {}
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['campaignId']) {
+ this.charsLoaded = false;
+ this.treeLoaded = false;
+ this.characters = [];
+ this.npcs = [];
+ this.treeData = null;
+ }
+ }
+
+ selectTab(tab: TabId): void {
+ this.activeTab = tab;
+ if (tab === 'characters') this.ensureCharactersLoaded();
+ if (tab === 'scenes') this.ensureTreeLoaded();
+ }
+
+ private ensureCharactersLoaded(): void {
+ if (this.charsLoaded || this.loadingChars || !this.campaignId) return;
+ this.loadingChars = true;
+ this.characterService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Character[])))
+ .subscribe(list => { this.characters = list; this.tryFinishCharsLoad(); });
+ this.npcService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Npc[])))
+ .subscribe(list => { this.npcs = list; this.tryFinishCharsLoad(); });
+ }
+
+ private tryFinishCharsLoad(): void {
+ // On considère que le chargement est fini quand au moins une des deux listes
+ // a été assignée (vide ou pleine). Le double subscribe ci-dessus garantit
+ // qu'on tombe ici deux fois ; idempotent.
+ this.loadingChars = false;
+ this.charsLoaded = true;
+ }
+
+ private ensureTreeLoaded(): void {
+ if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
+ this.loadingTree = true;
+ loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService).pipe(
+ catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
+ ).subscribe(data => {
+ this.treeData = data;
+ this.loadingTree = false;
+ this.treeLoaded = true;
+ });
+ }
+
+ /**
+ * Ouvre une fiche dans un nouvel onglet pour préserver l'écran de session.
+ * Le MJ peut consulter sans perdre son journal ni son historique de dés.
+ */
+ openInNewTab(path: (string | number)[]): void {
+ const url = path.map(p => String(p)).join('/');
+ window.open('/' + url, '_blank', 'noopener');
+ }
+
+ /** Helpers de typage pour le template (Angular n'infère pas bien sans). */
+ chaptersOf(arc: Arc): Chapter[] {
+ return this.treeData?.chaptersByArc[arc.id!] ?? [];
+ }
+ scenesOf(chapter: Chapter): Scene[] {
+ return this.treeData?.scenesByChapter[chapter.id!] ?? [];
+ }
+
+ onDiceRolled(result: DiceRollResult): void {
+ this.rolled.emit(result);
+ }
+
+ onAiSaveToJournal(content: string): void {
+ this.aiReplyToJournal.emit(content);
+ }
+}
From 504c4b7b6ce8d05fa36ae3183b94d05c7dc468ab Mon Sep 17 00:00:00 2001
From: "IETM_FIXE\\ietm6"
Date: Wed, 3 Jun 2026 15:44:48 +0200
Subject: [PATCH 04/57] =?UTF-8?q?Plusieurs=20ajouts=20:=20-=20Possibilit?=
=?UTF-8?q?=C3=A9=20de=20configurer=20des=20lieux=20dans=20une=20sc=C3=A8n?=
=?UTF-8?q?e=20:=20permet=20de=20configurer=20un=20donjon=20par=20exemple?=
=?UTF-8?q?=20avec=20les=20pi=C3=A8ces,=20les=20tr=C3=A9sors=20par=20pi?=
=?UTF-8?q?=C3=A8ce,=20la=20narration.....=20Mise=20en=20place=20=C3=A9gal?=
=?UTF-8?q?ement=20de=20conditions=20permettant=20de=20conditionner=20le?=
=?UTF-8?q?=20d=C3=A9blocage=20des=20qu=C3=AAtes.=20-=20Possibilit=C3=A9?=
=?UTF-8?q?=20de=20transform=C3=A9=20un=20arc=20en=20instance=20non=20lin?=
=?UTF-8?q?=C3=A9aire=20afin=20de=20faire=20un=20hub.=20Permet=20de=20joue?=
=?UTF-8?q?r=20de=20pr=C3=A9parer=20des=20campagnes=20type=20Dragon=20of?=
=?UTF-8?q?=20Icespire=20peak=20plus=20facilement.=20-=20Configuration=20d?=
=?UTF-8?q?e=20partie=20:=20chaque=20partie=20va=20contenir=20les=20s?=
=?UTF-8?q?=C3=A9ances,=20ce=20qui=20permettra=20de=20suivre=20le=20d?=
=?UTF-8?q?=C3=A9blocage=20des=20conditions=20pour=20les=20qu=C3=AAtes.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Passage en 0.9.1-beta
---
brain/app/application/chat.py | 122 +++++++++-
brain/app/domain/models.py | 61 ++++-
brain/app/main.py | 100 +++++++--
core/pom.xml | 2 +-
.../campaigncontext/ArcService.java | 9 +
.../CampaignReferencedFlagsService.java | 49 ++++
.../campaigncontext/CampaignService.java | 76 +++----
.../campaigncontext/ChapterService.java | 10 +
.../ChapterStatusEnricher.java | 104 +++++++++
.../campaigncontext/CharacterService.java | 23 +-
.../CampaignStructuralContextBuilder.java | 58 ++++-
.../SessionStructuralContextBuilder.java | 210 +++++++++++++++---
.../StreamChatForSessionUseCase.java | 17 +-
.../playcontext/PlaythroughService.java | 110 +++++++++
.../playcontext/SessionService.java | 49 ++--
.../loremind/domain/campaigncontext/Arc.java | 8 +
.../domain/campaigncontext/ArcType.java | 14 ++
.../domain/campaigncontext/Campaign.java | 18 +-
.../domain/campaigncontext/Chapter.java | 8 +
.../domain/campaigncontext/Character.java | 10 +-
.../domain/campaigncontext/Prerequisite.java | 26 +++
.../PrerequisiteEvaluator.java | 65 ++++++
.../campaigncontext/ProgressionStatus.java | 17 ++
.../domain/campaigncontext/QuestStatus.java | 19 ++
.../loremind/domain/campaigncontext/Room.java | 69 ++++++
.../domain/campaigncontext/RoomBranch.java | 22 ++
.../domain/campaigncontext/Scene.java | 9 +
.../ports/CharacterRepository.java | 2 +-
.../CampaignStructuralContext.java | 28 ++-
.../generationcontext/SessionContext.java | 45 +++-
.../domain/playcontext/Playthrough.java | 36 +++
.../domain/playcontext/QuestProgression.java | 29 +++
.../loremind/domain/playcontext/Session.java | 16 +-
.../ports/PlaythroughFlagRepository.java | 20 ++
.../ports/PlaythroughRepository.java | 24 ++
.../ports/QuestProgressionRepository.java | 31 +++
.../playcontext/ports/SessionRepository.java | 8 +-
.../ai/BrainChatPayloadBuilder.java | 62 ++++++
.../PrerequisiteListJsonConverter.java | 98 ++++++++
.../converter/RoomListJsonConverter.java | 43 ++++
.../persistence/entity/ArcJpaEntity.java | 10 +
.../persistence/entity/ChapterJpaEntity.java | 12 +
.../entity/CharacterJpaEntity.java | 10 +-
.../entity/PlaythroughFlagJpaEntity.java | 42 ++++
.../entity/PlaythroughJpaEntity.java | 54 +++++
.../entity/QuestProgressionJpaEntity.java | 44 ++++
.../persistence/entity/SceneJpaEntity.java | 11 +
.../persistence/entity/SessionJpaEntity.java | 14 +-
.../jpa/CharacterJpaRepository.java | 2 +-
.../jpa/PlaythroughFlagJpaRepository.java | 25 +++
.../jpa/PlaythroughJpaRepository.java | 13 ++
.../jpa/QuestProgressionJpaRepository.java | 25 +++
.../persistence/jpa/SessionJpaRepository.java | 4 +-
.../migration/PlaythroughMigrationRunner.java | 184 +++++++++++++++
.../postgres/PostgresArcRepository.java | 2 +
.../postgres/PostgresCampaignRepository.java | 3 +
.../postgres/PostgresChapterRepository.java | 6 +
.../postgres/PostgresCharacterRepository.java | 8 +-
.../PostgresPlaythroughFlagRepository.java | 56 +++++
.../PostgresPlaythroughRepository.java | 77 +++++++
.../PostgresQuestProgressionRepository.java | 78 +++++++
.../postgres/PostgresSceneRepository.java | 6 +
.../postgres/PostgresSessionRepository.java | 18 +-
.../web/controller/ArcController.java | 4 +-
.../controller/CampaignFlagController.java | 30 +++
.../web/controller/ChapterController.java | 37 ++-
.../web/controller/CharacterController.java | 8 +-
.../web/controller/PlaythroughController.java | 64 ++++++
.../controller/PlaythroughFlagController.java | 47 ++++
.../QuestProgressionController.java | 55 +++++
.../web/controller/SessionController.java | 18 +-
.../web/dto/campaigncontext/ArcDTO.java | 3 +
.../web/dto/campaigncontext/ChapterDTO.java | 16 ++
.../web/dto/campaigncontext/CharacterDTO.java | 2 +-
.../dto/campaigncontext/PrerequisiteDTO.java | 30 +++
.../dto/campaigncontext/RoomBranchDTO.java | 18 ++
.../web/dto/campaigncontext/RoomDTO.java | 28 +++
.../web/dto/campaigncontext/SceneDTO.java | 3 +
.../web/dto/playcontext/PlaythroughDTO.java | 19 ++
.../dto/playcontext/PlaythroughFlagDTO.java | 13 ++
.../web/dto/playcontext/SessionDTO.java | 2 +-
.../infrastructure/web/mapper/ArcMapper.java | 13 ++
.../web/mapper/ChapterMapper.java | 28 ++-
.../web/mapper/CharacterMapper.java | 4 +-
.../web/mapper/PlaythroughMapper.java | 33 +++
.../web/mapper/PrerequisiteMapper.java | 64 ++++++
.../web/mapper/SceneMapper.java | 64 ++++++
.../web/mapper/SessionMapper.java | 2 +-
web/package-lock.json | 4 +-
web/package.json | 2 +-
web/src/app/app.routes.ts | 2 +
.../arc/arc-create/arc-create.component.html | 16 ++
.../arc/arc-create/arc-create.component.scss | 38 ++++
.../arc/arc-create/arc-create.component.ts | 5 +-
.../arc/arc-edit/arc-edit.component.html | 16 ++
.../arc/arc-edit/arc-edit.component.scss | 38 ++++
.../arc/arc-edit/arc-edit.component.ts | 3 +
.../arc/arc-view/arc-view.component.html | 38 +++-
.../arc/arc-view/arc-view.component.scss | 111 +++++++++
.../arc/arc-view/arc-view.component.ts | 46 +++-
web/src/app/campaigns/campaign-tree.helper.ts | 5 +-
.../campaign-detail.component.html | 62 ++----
.../campaign-detail.component.scss | 55 +++++
.../campaign-detail.component.ts | 96 ++++----
.../chapter-edit/chapter-edit.component.html | 16 ++
.../chapter-edit/chapter-edit.component.scss | 51 +++++
.../chapter-edit/chapter-edit.component.ts | 69 ++++--
.../chapter-view/chapter-view.component.html | 27 ++-
.../chapter-view/chapter-view.component.scss | 34 +++
.../chapter-view/chapter-view.component.ts | 22 +-
.../character-edit.component.ts | 12 +-
.../playthrough-detail.component.html | 66 ++++++
.../playthrough-detail.component.scss | 109 +++++++++
.../playthrough-detail.component.ts | 145 ++++++++++++
.../playthrough-flags-page.component.html | 20 ++
.../playthrough-flags-page.component.scss | 24 ++
.../playthrough-flags-page.component.ts | 75 +++++++
.../scene-edit/scene-edit.component.html | 12 +
.../scene/scene-edit/scene-edit.component.ts | 12 +-
.../scene-view/scene-view.component.html | 44 ++++
.../scene-view/scene-view.component.scss | 83 +++++++
.../scene/scene-view/scene-view.component.ts | 5 +
web/src/app/services/campaign-flag.service.ts | 19 ++
web/src/app/services/campaign.model.ts | 105 +++++++++
web/src/app/services/campaign.service.ts | 17 +-
web/src/app/services/character.model.ts | 13 +-
web/src/app/services/character.service.ts | 4 +-
.../app/services/playthrough-flag.service.ts | 31 +++
web/src/app/services/playthrough.service.ts | 44 ++++
.../app/services/quest-progression.service.ts | 28 +++
web/src/app/services/session.model.ts | 2 +-
web/src/app/services/session.service.ts | 27 ++-
.../session-detail.component.html | 5 +-
.../session-detail.component.ts | 16 +-
.../session-reference-panel.component.ts | 8 +-
.../playthrough-flags-manager.component.html | 21 ++
.../playthrough-flags-manager.component.scss | 133 +++++++++++
.../playthrough-flags-manager.component.ts | 70 ++++++
.../prerequisite-editor.component.html | 79 +++++++
.../prerequisite-editor.component.scss | 108 +++++++++
.../prerequisite-editor.component.ts | 99 +++++++++
.../quest-status-badge.component.html | 4 +
.../quest-status-badge.component.scss | 43 ++++
.../quest-status-badge.component.ts | 46 ++++
.../rooms-editor/rooms-editor.component.html | 126 +++++++++++
.../rooms-editor/rooms-editor.component.scss | 159 +++++++++++++
.../rooms-editor/rooms-editor.component.ts | 133 +++++++++++
147 files changed, 5347 insertions(+), 392 deletions(-)
create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java
create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java
create mode 100644 core/src/main/java/com/loremind/application/playcontext/PlaythroughService.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/Prerequisite.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/PrerequisiteEvaluator.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ProgressionStatus.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/QuestStatus.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/Room.java
create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/RoomBranch.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/Playthrough.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughFlagRepository.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughRepository.java
create mode 100644 core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/converter/PrerequisiteListJsonConverter.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/converter/RoomListJsonConverter.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughFlagJpaEntity.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughJpaEntity.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestProgressionJpaEntity.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughFlagJpaRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/PlaythroughJpaRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/QuestProgressionJpaRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughFlagRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/CampaignFlagController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/PlaythroughFlagController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/QuestProgressionController.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/PrerequisiteDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomBranchDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RoomDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/PlaythroughFlagDTO.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/PlaythroughMapper.java
create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/PrerequisiteMapper.java
create mode 100644 web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html
create mode 100644 web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss
create mode 100644 web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts
create mode 100644 web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html
create mode 100644 web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.scss
create mode 100644 web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts
create mode 100644 web/src/app/services/campaign-flag.service.ts
create mode 100644 web/src/app/services/playthrough-flag.service.ts
create mode 100644 web/src/app/services/playthrough.service.ts
create mode 100644 web/src/app/services/quest-progression.service.ts
create mode 100644 web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.html
create mode 100644 web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.scss
create mode 100644 web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.ts
create mode 100644 web/src/app/shared/prerequisite-editor/prerequisite-editor.component.html
create mode 100644 web/src/app/shared/prerequisite-editor/prerequisite-editor.component.scss
create mode 100644 web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts
create mode 100644 web/src/app/shared/quest-status-badge/quest-status-badge.component.html
create mode 100644 web/src/app/shared/quest-status-badge/quest-status-badge.component.scss
create mode 100644 web/src/app/shared/quest-status-badge/quest-status-badge.component.ts
create mode 100644 web/src/app/shared/rooms-editor/rooms-editor.component.html
create mode 100644 web/src/app/shared/rooms-editor/rooms-editor.component.scss
create mode 100644 web/src/app/shared/rooms-editor/rooms-editor.component.ts
diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py
index 68758ac..3f220e3 100644
--- a/brain/app/application/chat.py
+++ b/brain/app/application/chat.py
@@ -23,10 +23,12 @@ from app.domain.models import (
CharacterSummary,
NpcSummary,
GameSystemContext,
+ JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
PageSummary,
+ QuestSummary,
SessionContext,
)
from app.domain.ports import LLMChatProvider
@@ -294,7 +296,8 @@ class ChatUseCase:
else:
for scene in chapter.scenes:
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
- block.append(f" - {scene.name} (scène){sc_hint}")
+ scene_kind = " (lieu explorable)" if scene.rooms else " (scène)"
+ block.append(f" - {scene.name}{scene_kind}{sc_hint}")
if scene.description:
block.append(f" Description : {scene.description}")
for br in scene.branches:
@@ -302,6 +305,19 @@ class ChatUseCase:
block.append(
f' → "{br.label}" vers {br.target_scene_name}{cond}'
)
+ # Pièces du lieu explorable (mode donjon)
+ for room in scene.rooms:
+ floor = f" [étage {room.floor}]" if room.floor is not None else ""
+ block.append(f" ◆ {room.name}{floor}")
+ if room.description:
+ block.append(f" {room.description}")
+ if room.enemies:
+ block.append(f" Ennemis : {room.enemies}")
+ for rb in room.branches:
+ cond = f" (si : {rb.condition})" if rb.condition else ""
+ block.append(
+ f' ↳ "{rb.label}" vers {rb.target_room_name}{cond}'
+ )
return block
@staticmethod
@@ -359,35 +375,42 @@ class ChatUseCase:
@staticmethod
def _format_session(sc: SessionContext) -> str:
- """Bloc journal de la session en cours.
+ """Bloc journal de la session en cours + résumé des sessions précédentes.
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
rappeler un évènement antérieur, ou rebondir sur une action joueur.
+
+ Pour les sessions PRÉCÉDENTES de la même campagne, on ne remonte que
+ les EVENTs (les moments marquants) pour préserver le contexte LLM.
"""
status = "EN COURS" if sc.active else "TERMINÉE"
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
+ previous_block = ChatUseCase._format_previous_events(sc.previous_events)
+ hub_block = ChatUseCase._format_hub_status(sc)
+
if not sc.entries:
- entries_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
+ current_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
else:
lines: list[str] = []
for e in sc.entries:
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
- # Indentation sur les contenus multi-lignes pour préserver la lisibilité
content = e.content.replace("\n", "\n ")
lines.append(f"- {label}{ts} : {content}")
- entries_block = "\n".join(lines)
+ current_block = "\n".join(lines)
return (
"--- SESSION DE JEU EN COURS ---\n"
f"Nom : {sc.session_name}\n"
- f"Statut : {status}{started}\n\n"
- "Journal chronologique (du plus ancien au plus récent) :\n"
- f"{entries_block}\n\n"
+ f"Statut : {status}{started}\n"
+ f"{hub_block}"
+ f"{previous_block}"
+ "\nJournal chronologique de la session courante (du plus ancien au plus récent) :\n"
+ f"{current_block}\n\n"
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
- "- Tenir compte des évènements déjà capturés dans le journal ci-dessus.\n"
+ "- Tenir compte des évènements déjà capturés (sessions précédentes + journal courant).\n"
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
"qui s'enchaînent à ce qui vient de se passer.\n"
@@ -395,6 +418,87 @@ class ChatUseCase:
"il a besoin d'idées immédiatement actionnables."
)
+ @staticmethod
+ def _format_hub_status(sc: SessionContext) -> str:
+ """Bloc Hub : quêtes ouvertes + flags actifs.
+
+ Vide si la campagne n'a aucun Arc HUB (toutes les listes vides côté Core).
+ Les quêtes LOCKED apparaissent par leur TITRE uniquement : l'IA sait
+ qu'elles existent (utile pour les teasers, les rumeurs en jeu) mais ne
+ peut pas spoiler leurs détails.
+ """
+ if (not sc.available_quests
+ and not sc.in_progress_quests
+ and not sc.locked_quest_titles
+ and not sc.active_flags):
+ return ""
+
+ lines = ["", "État du Hub (quêtes parallèles et faits narratifs) :"]
+
+ if sc.in_progress_quests:
+ lines.append(" Quêtes en cours :")
+ lines.extend(ChatUseCase._format_quest_lines(sc.in_progress_quests))
+
+ if sc.available_quests:
+ lines.append(" Quêtes disponibles (non démarrées, prêtes à être lancées) :")
+ lines.extend(ChatUseCase._format_quest_lines(sc.available_quests))
+
+ if sc.locked_quest_titles:
+ titles = ", ".join(f'"{t}"' for t in sc.locked_quest_titles)
+ lines.append(
+ " Quêtes encore verrouillées (existent mais non accessibles — "
+ f"tu peux y faire allusion sous forme de rumeurs sans spoiler leur contenu) : {titles}"
+ )
+
+ if sc.active_flags:
+ lines.append(
+ " Faits actifs : " + ", ".join(f"`{f}`" for f in sc.active_flags)
+ )
+
+ lines.append(
+ " Conseille des actions cohérentes avec ces quêtes ouvertes. Ne fais "
+ "PAS comme si une quête verrouillée était déjà accessible aux PJ."
+ )
+ lines.append("") # séparateur visuel avant le récap des sessions précédentes
+ return "\n".join(lines)
+
+ @staticmethod
+ def _format_quest_lines(quests: list[QuestSummary]) -> list[str]:
+ """Sérialise une liste de QuestSummary en lignes indentées."""
+ out: list[str] = []
+ for q in quests:
+ arc = f" [arc : {q.arc_name}]" if q.arc_name else ""
+ out.append(f" - {q.name}{arc}")
+ if q.description:
+ out.append(f" Synopsis : {q.description}")
+ return out
+
+ @staticmethod
+ def _format_previous_events(events: list[JournalEntrySummary]) -> str:
+ """Bloc "Story so far" : EVENTs marquants des sessions antérieures.
+
+ Vide si la campagne en est à sa première session. On groupe par
+ session source pour aider l'IA à situer chaque évènement temporellement.
+ """
+ if not events:
+ return ""
+
+ # Groupement par session source en préservant l'ordre d'apparition.
+ grouped: dict[str, list[JournalEntrySummary]] = {}
+ for e in events:
+ key = e.source_session_name or "(session inconnue)"
+ grouped.setdefault(key, []).append(e)
+
+ lines = ["\nRécapitulatif des sessions précédentes (évènements marquants uniquement) :"]
+ for session_name, items in grouped.items():
+ lines.append(f" • {session_name} :")
+ for e in items:
+ ts = f" [{e.occurred_at}]" if e.occurred_at else ""
+ content = e.content.replace("\n", "\n ")
+ lines.append(f" - {content}{ts}")
+ lines.append("") # ligne vide avant le bloc journal courant
+ return "\n".join(lines) + "\n"
+
@staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py
index 449a883..fb04aa1 100644
--- a/brain/app/domain/models.py
+++ b/brain/app/domain/models.py
@@ -122,9 +122,29 @@ class SceneBranchHint:
condition: str | None = None
+@dataclass(frozen=True)
+class RoomBranchHint:
+ """Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
+
+ label: str
+ target_room_name: str
+ condition: str | None = None
+
+
+@dataclass(frozen=True)
+class RoomSummary:
+ """Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
+
+ name: str
+ floor: int | None = None
+ description: str | None = None
+ enemies: str | None = None
+ branches: list[RoomBranchHint] = field(default_factory=list)
+
+
@dataclass(frozen=True)
class SceneSummary:
- """Résumé d'une scène : nom + description courte + illustrations + branches."""
+ """Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
name: str
description: str | None
@@ -133,6 +153,8 @@ class SceneSummary:
illustration_count: int = 0
# Connexions narratives sortantes (livre dont vous etes le heros).
branches: list[SceneBranchHint] = field(default_factory=list)
+ # Pièces du lieu explorable (vide = scène classique).
+ rooms: list[RoomSummary] = field(default_factory=list)
@dataclass(frozen=True)
@@ -233,26 +255,49 @@ class GameSystemContext:
@dataclass(frozen=True)
class JournalEntrySummary:
- """Une entrée du journal d'une Session : type + contenu + horodatage."""
+ """Une entrée du journal d'une Session.
+
+ `source_session_name` n'est renseigné que pour les entrées issues de
+ sessions précédentes (option 3 : continuité narrative entre séances).
+ """
type: str
content: str
occurred_at: str | None
+ source_session_name: str | None = None
+
+
+@dataclass(frozen=True)
+class QuestSummary:
+ """Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
+
+ Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
+ dans available_quests / in_progress_quests / locked_quest_titles.
+ """
+
+ name: str
+ arc_name: str
+ description: str | None = None
@dataclass(frozen=True)
class SessionContext:
"""Contexte d'une Session de jeu en cours (Play Context).
- Injecté dans le system prompt pendant qu'une partie est jouée pour que
- l'IA voit le nom de la session, son statut, et un historique chronologique
- des évènements/notes/jets capturés par le MJ.
-
- Le journal a déjà été tronqué côté Core (cap à ~80 entrées récentes)
- pour ne pas saturer le contexte LLM sur les sessions très longues.
+ Combine plusieurs niveaux :
+ - `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
+ - `previous_events` : EVENTs marquants des sessions précédentes (continuité)
+ - `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
+ - `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
+ - `active_flags` : noms des flags de campagne actuellement à true
"""
session_name: str
active: bool
started_at: str | None
entries: list[JournalEntrySummary]
+ previous_events: list[JournalEntrySummary]
+ available_quests: list[QuestSummary] = field(default_factory=list)
+ in_progress_quests: list[QuestSummary] = field(default_factory=list)
+ locked_quest_titles: list[str] = field(default_factory=list)
+ active_flags: list[str] = field(default_factory=list)
diff --git a/brain/app/main.py b/brain/app/main.py
index d87cb4e..cd24348 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -32,6 +32,9 @@ from app.domain.models import (
PageContext,
PageGenerationContext,
PageSummary,
+ QuestSummary,
+ RoomBranchHint,
+ RoomSummary,
SceneBranchHint,
SceneSummary,
SessionContext,
@@ -43,7 +46,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
- version="0.9.0-beta",
+ version="0.9.1-beta",
)
@@ -171,6 +174,24 @@ class SceneBranchHintDTO(BaseModel):
condition: str | None = None
+class RoomBranchHintDTO(BaseModel):
+ """Sortie d'une pièce vers une autre pièce du même lieu (donjon)."""
+
+ label: str
+ target_room_name: str
+ condition: str | None = None
+
+
+class RoomSummaryDTO(BaseModel):
+ """Pièce d'un lieu explorable. Omise par le Core si la scène est classique."""
+
+ name: str
+ floor: int | None = None
+ description: str | None = None
+ enemies: str | None = None
+ branches: list[RoomBranchHintDTO] = Field(default_factory=list)
+
+
class SceneSummaryDTO(BaseModel):
"""Résumé d'une scène : nom + description courte (synopsis)."""
@@ -181,6 +202,8 @@ class SceneSummaryDTO(BaseModel):
illustration_count: int = 0
# Branches narratives sortantes, omises cote Core si vides.
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
+ # Pièces du lieu explorable, omises par Core si scène classique.
+ rooms: list[RoomSummaryDTO] = Field(default_factory=list)
class ChapterSummaryDTO(BaseModel):
@@ -246,24 +269,44 @@ class GameSystemContextDTO(BaseModel):
class JournalEntrySummaryDTO(BaseModel):
- """Une entrée du journal de session — type + contenu + horodatage."""
+ """Une entrée du journal de session.
+
+ `source_session_name` est présent uniquement pour les évènements issus
+ des sessions précédentes — sert à ancrer temporellement dans le prompt.
+ """
type: str
content: str
occurred_at: str | None = None
+ source_session_name: str | None = None
+
+
+class QuestSummaryDTO(BaseModel):
+ """Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine."""
+
+ name: str
+ arc_name: str
+ description: str | None = None
class SessionContextDTO(BaseModel):
"""Contexte d'une Session de jeu en cours (Play Context).
- Injecté par le Core quand le chat est ancré sur une Session.
- Contient le journal chronologique (déjà plafonné côté Core).
+ Combine le journal complet (`entries`), les EVENTs des sessions précédentes
+ (`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes
+ Hub de la campagne (disponibles / en cours / verrouillées) plus les flags
+ narratifs actuellement actifs.
"""
session_name: str
active: bool
started_at: str | None = None
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
+ previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list)
+ available_quests: list[QuestSummaryDTO] = Field(default_factory=list)
+ in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list)
+ locked_quest_titles: list[str] = Field(default_factory=list)
+ active_flags: list[str] = Field(default_factory=list)
class ChatStreamRequestDTO(BaseModel):
@@ -580,6 +623,23 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
)
for br in sc.branches
],
+ rooms=[
+ RoomSummary(
+ name=room.name,
+ floor=room.floor,
+ description=room.description,
+ enemies=room.enemies,
+ branches=[
+ RoomBranchHint(
+ label=rb.label,
+ target_room_name=rb.target_room_name,
+ condition=rb.condition,
+ )
+ for rb in room.branches
+ ],
+ )
+ for room in sc.rooms
+ ],
)
for sc in ch.scenes
],
@@ -903,17 +963,31 @@ def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemConte
def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
if dto is None:
return None
- entries = [
- JournalEntrySummary(
- type=e.type,
- content=e.content,
- occurred_at=e.occurred_at,
- )
- for e in dto.entries
- ]
return SessionContext(
session_name=dto.session_name,
active=dto.active,
started_at=dto.started_at,
- entries=entries,
+ entries=[_to_journal_entry(e) for e in dto.entries],
+ previous_events=[_to_journal_entry(e) for e in dto.previous_events],
+ available_quests=[_to_quest_summary(q) for q in dto.available_quests],
+ in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests],
+ locked_quest_titles=list(dto.locked_quest_titles),
+ active_flags=list(dto.active_flags),
+ )
+
+
+def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary:
+ return QuestSummary(
+ name=dto.name,
+ arc_name=dto.arc_name,
+ description=dto.description,
+ )
+
+
+def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary:
+ return JournalEntrySummary(
+ type=dto.type,
+ content=dto.content,
+ occurred_at=dto.occurred_at,
+ source_session_name=dto.source_session_name,
)
diff --git a/core/pom.xml b/core/pom.xml
index d7dd225..51459af 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
com.loremindloremind-core
- 0.9.0-beta
+ 0.9.1-betaLoreMind CoreBackend Core - Architecture Hexagonale
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java
index 933f06e..972d460 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java
@@ -50,6 +50,15 @@ public class ArcService {
return arcRepository.save(arc);
}
+ /**
+ * Création à partir d'un Arc complet (utilisé par le controller pour faire passer
+ * les nouveaux champs comme type sans démultiplier les paramètres).
+ */
+ public Arc createArc(Arc input) {
+ input.setId(null);
+ return arcRepository.save(input);
+ }
+
public Optional getArcById(String id) {
return arcRepository.findById(id);
}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java
new file mode 100644
index 0000000..e9175db
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsService.java
@@ -0,0 +1,49 @@
+package com.loremind.application.campaigncontext;
+
+import com.loremind.domain.campaigncontext.Arc;
+import com.loremind.domain.campaigncontext.Chapter;
+import com.loremind.domain.campaigncontext.Prerequisite;
+import com.loremind.domain.campaigncontext.ports.ArcRepository;
+import com.loremind.domain.campaigncontext.ports.ChapterRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.TreeSet;
+
+/**
+ * Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet})
+ * référencés par les chapitres d'une Campagne.
+ *
+ *
Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés
+ * globalement. Un fait existe dès qu'au moins une quête le référence dans ses
+ * prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans
+ * la Partie, autocomplete dans l'éditeur de prérequis).
+ */
+@Service
+public class CampaignReferencedFlagsService {
+
+ private final ArcRepository arcRepository;
+ private final ChapterRepository chapterRepository;
+
+ public CampaignReferencedFlagsService(ArcRepository arcRepository,
+ ChapterRepository chapterRepository) {
+ this.arcRepository = arcRepository;
+ this.chapterRepository = chapterRepository;
+ }
+
+ /** Retourne la liste triée alphabétiquement des noms de faits référencés. */
+ public List listForCampaign(String campaignId) {
+ TreeSet unique = new TreeSet<>();
+ for (Arc arc : arcRepository.findByCampaignId(campaignId)) {
+ for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
+ if (chapter.getPrerequisites() == null) continue;
+ for (Prerequisite p : chapter.getPrerequisites()) {
+ if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
+ unique.add(f.flagName());
+ }
+ }
+ }
+ }
+ return List.copyOf(unique);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
index 6cc3cb9..7701c20 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
@@ -1,13 +1,15 @@
package com.loremind.application.campaigncontext;
+import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
-import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
+import com.loremind.domain.playcontext.Playthrough;
+import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,9 +17,11 @@ import java.util.List;
import java.util.Optional;
/**
- * Service d'application pour le contexte Campaign.
- * Orchestre la logique métier en utilisant le Port CampaignRepository.
- * Fait partie de la couche Application de l'Architecture Hexagonale.
+ * Service d'application pour le contexte Campaign (scénario).
+ *
+ *
Depuis Playthrough : les PJ et les sessions ne sont plus rattachés directement
+ * à la Campagne ; ils dépendent d'un Playthrough (Partie). La cascade de suppression
+ * d'une campagne englobe donc ses Playthroughs (qui à leur tour cascadent).
*/
@Service
public class CampaignService {
@@ -26,35 +30,27 @@ public class CampaignService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
- private final CharacterRepository characterRepository;
+ private final PlaythroughRepository playthroughRepository;
+ private final PlaythroughService playthroughService;
public CampaignService(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
- CharacterRepository characterRepository) {
+ PlaythroughRepository playthroughRepository,
+ PlaythroughService playthroughService) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
- this.characterRepository = characterRepository;
+ this.playthroughRepository = playthroughRepository;
+ this.playthroughService = playthroughService;
}
- /**
- * Parameter Object pour la création / mise à jour d'une Campaign.
- * Évite une signature à rallonge et rend les évolutions futures (theme,
- * coverImageUrl, etc.) sans casser les appelants.
- *
- *
{@code loreId} est nullable : une campagne peut exister sans univers associé.
- */
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
- /**
- * Compte des entités qui seront supprimées en cascade si la campagne est effacée.
- * Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
- */
- public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
+ public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder()
@@ -64,7 +60,12 @@ public class CampaignService {
.gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0)
.build();
- return campaignRepository.save(campaign);
+ Campaign saved = campaignRepository.save(campaign);
+
+ // Une campagne sans Partie n'a pas de sens jouable : on crée d'office
+ // une "Partie principale" pour que l'utilisateur puisse jouer immédiatement.
+ playthroughService.create(saved.getId(), "Partie principale", null);
+ return saved;
}
public Optional getCampaignById(String id) {
@@ -89,19 +90,10 @@ public class CampaignService {
return campaignRepository.save(campaign);
}
- /**
- * Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
- * Utile car les payloads JSON peuvent envoyer "" au lieu de null.
- */
private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id;
}
- /**
- * Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
- * scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
- * pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
- */
public DeletionImpact getDeletionImpact(String id) {
List arcs = arcRepository.findByCampaignId(id);
int chapterTotal = 0;
@@ -113,22 +105,21 @@ public class CampaignService {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
}
- int characterTotal = characterRepository.findByCampaignId(id).size();
- return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
+ int playthroughTotal = playthroughRepository.findByCampaignId(id).size();
+ return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, playthroughTotal);
}
- /**
- * Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
- * scènes, plus les personnages). L'opération est transactionnelle : soit
- * tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
- * de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
- */
@Transactional
public void deleteCampaign(String id) {
- List arcs = arcRepository.findByCampaignId(id);
- for (Arc arc : arcs) {
- List chapters = chapterRepository.findByArcId(arc.getId());
- for (Chapter chapter : chapters) {
+ // 1. Cascade des Playthroughs (qui cascadent eux-mêmes sur PJ/sessions/valeurs flags/progressions).
+ for (Playthrough p : playthroughRepository.findByCampaignId(id)) {
+ playthroughService.delete(p.getId());
+ }
+ // 2. Cascade du scénario : arcs → chapitres → scènes
+ // (Pas de déclarations globales de faits : ils existent implicitement via les
+ // prérequis FLAG_SET des chapitres, qui partent avec les chapitres.)
+ for (Arc arc : arcRepository.findByCampaignId(id)) {
+ for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
@@ -136,9 +127,6 @@ public class CampaignService {
}
arcRepository.deleteById(arc.getId());
}
- for (var character : characterRepository.findByCampaignId(id)) {
- characterRepository.deleteById(character.getId());
- }
campaignRepository.deleteById(id);
}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java
index 18fda92..c9a0ab4 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java
@@ -44,6 +44,16 @@ public class ChapterService {
return chapterRepository.save(chapter);
}
+ /**
+ * Création à partir d'un Chapter complet (utilisé par le controller pour faire passer
+ * les nouveaux champs comme progressionStatus / prerequisites sans démultiplier les
+ * paramètres). L'id est forcé à null pour laisser la DB le générer.
+ */
+ public Chapter createChapter(Chapter input) {
+ input.setId(null);
+ return chapterRepository.save(input);
+ }
+
public Optional getChapterById(String id) {
return chapterRepository.findById(id);
}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java
new file mode 100644
index 0000000..eaf6bfe
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterStatusEnricher.java
@@ -0,0 +1,104 @@
+package com.loremind.application.campaigncontext;
+
+import com.loremind.domain.campaigncontext.Chapter;
+import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
+import com.loremind.domain.campaigncontext.ProgressionStatus;
+import com.loremind.domain.campaigncontext.QuestStatus;
+import com.loremind.domain.playcontext.QuestProgression;
+import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
+import com.loremind.domain.playcontext.ports.PlaythroughRepository;
+import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Service applicatif : enrichit des ChapterDTO avec leur {@link QuestStatus} effectif,
+ * relatif à un Playthrough donné.
+ *
+ *
Depuis l'introduction de Playthrough : la progression et les flags vivent au niveau
+ * de la Partie, plus de la Campagne. L'enrichissement nécessite donc un playthroughId.
+ */
+@Service
+public class ChapterStatusEnricher {
+
+ private final PlaythroughRepository playthroughRepository;
+ private final QuestProgressionRepository progressionRepository;
+ private final PlaythroughFlagRepository flagRepository;
+ private final SessionRepository sessionRepository;
+ private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator();
+
+ public ChapterStatusEnricher(PlaythroughRepository playthroughRepository,
+ QuestProgressionRepository progressionRepository,
+ PlaythroughFlagRepository flagRepository,
+ SessionRepository sessionRepository) {
+ this.playthroughRepository = playthroughRepository;
+ this.progressionRepository = progressionRepository;
+ this.flagRepository = flagRepository;
+ this.sessionRepository = sessionRepository;
+ }
+
+ /** Contexte d'évaluation + map chapterId -> ProgressionStatus pour ce Playthrough. */
+ public record PlaythroughEvalSnapshot(
+ PrerequisiteEvaluator.EvaluationContext ctx,
+ Map progressionByChapterId
+ ) {}
+
+ /** Construit le snapshot d'évaluation pour un Playthrough. */
+ public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) {
+ if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) {
+ return new PlaythroughEvalSnapshot(
+ new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()),
+ Collections.emptyMap()
+ );
+ }
+ Map flags = flagRepository.findByPlaythroughId(playthroughId);
+ Set completedQuestIds = progressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
+ int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
+
+ Map progressionMap = new HashMap<>();
+ for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
+ progressionMap.put(qp.getChapterId(), qp.getStatus());
+ }
+
+ return new PlaythroughEvalSnapshot(
+ new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
+ progressionMap
+ );
+ }
+
+ /** Calcule le statut effectif d'un seul chapitre relatif à un Playthrough. */
+ public QuestStatus computeFor(Chapter chapter, String playthroughId) {
+ PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
+ ProgressionStatus progression = snap.progressionByChapterId()
+ .getOrDefault(chapter.getId(), ProgressionStatus.NOT_STARTED);
+ return evaluator.computeStatus(progression, chapter.getPrerequisites(), snap.ctx());
+ }
+
+ /**
+ * Injecte le {@code effectiveStatus} et le {@code progressionStatus} dans une liste de DTOs.
+ * Un seul build du snapshot pour toute la liste (optimal pour les vues qui listent un arc).
+ */
+ public void enrich(List dtos, List domain, String playthroughId) {
+ if (dtos == null || dtos.isEmpty()) return;
+ PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
+ Map byId = domain.stream()
+ .collect(Collectors.toMap(Chapter::getId, c -> c));
+ for (ChapterDTO dto : dtos) {
+ Chapter c = byId.get(dto.getId());
+ if (c == null) continue;
+ ProgressionStatus progression = snap.progressionByChapterId()
+ .getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
+ QuestStatus status = evaluator.computeStatus(progression, c.getPrerequisites(), snap.ctx());
+ dto.setProgressionStatus(progression.name());
+ dto.setEffectiveStatus(status.name());
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java
index 0e42f91..8117096 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java
@@ -11,6 +11,7 @@ import java.util.Optional;
/**
* Service d'application pour les fiches de personnages (PJ).
+ * Les PJ appartiennent désormais à un Playthrough (Partie), pas à la Campagne.
*/
@Service
public class CharacterService {
@@ -21,11 +22,6 @@ public class CharacterService {
this.characterRepository = characterRepository;
}
- /**
- * Parameter Object pour la création / mise à jour d'un Character.
- * `order` est fourni par le controller ; si absent, le service le calcule.
- * Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
- */
public record CharacterData(
String name,
String portraitImageId,
@@ -33,14 +29,14 @@ public class CharacterService {
Map values,
Map> imageValues,
Map> keyValueValues,
- String campaignId,
+ String playthroughId,
Integer order
) {}
public Character createCharacter(CharacterData data) {
int order = data.order() != null
? data.order()
- : nextOrderFor(data.campaignId());
+ : nextOrderFor(data.playthroughId());
Character character = Character.builder()
.name(data.name())
.portraitImageId(data.portraitImageId())
@@ -48,7 +44,7 @@ public class CharacterService {
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
- .campaignId(data.campaignId())
+ .playthroughId(data.playthroughId())
.order(order)
.build();
return characterRepository.save(character);
@@ -58,8 +54,8 @@ public class CharacterService {
return characterRepository.findById(id);
}
- public List getCharactersByCampaignId(String campaignId) {
- return characterRepository.findByCampaignId(campaignId);
+ public List getCharactersByPlaythroughId(String playthroughId) {
+ return characterRepository.findByPlaythroughId(playthroughId);
}
public Character updateCharacter(String id, CharacterData data) {
@@ -74,7 +70,7 @@ public class CharacterService {
if (data.order() != null) {
existing.setOrder(data.order());
}
- // campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP).
+ // playthroughId immuable après création.
return characterRepository.save(existing);
}
@@ -82,9 +78,8 @@ public class CharacterService {
characterRepository.deleteById(id);
}
- /** Renvoie la prochaine position libre — append en fin de liste. */
- private int nextOrderFor(String campaignId) {
- return characterRepository.findByCampaignId(campaignId).stream()
+ private int nextOrderFor(String playthroughId) {
+ return characterRepository.findByPlaythroughId(playthroughId).stream()
.mapToInt(Character::getOrder)
.max()
.orElse(-1) + 1;
diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
index 3569767..53bb13d 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
@@ -18,6 +18,8 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHin
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
+import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomBranchHint;
+import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component;
@@ -66,11 +68,18 @@ public class CampaignStructuralContextBuilder {
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
/**
- * Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
- * nom + description courte à chaque niveau).
- * @throws IllegalArgumentException si la Campagne est introuvable
+ * Construit la carte narrative d'une Campagne. Sans playthroughId, les PJ
+ * sont omis (ils sont propres à une Partie).
*/
public CampaignStructuralContext build(String campaignId) {
+ return build(campaignId, null);
+ }
+
+ /**
+ * Variante avec playthroughId : injecte les PJ de la Partie indiquée.
+ * Les PNJ restent campagne-scope (donnée de scénario).
+ */
+ public CampaignStructuralContext build(String campaignId, String playthroughId) {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId));
@@ -80,10 +89,12 @@ public class CampaignStructuralContextBuilder {
.map(this::toArcSummary)
.collect(Collectors.toList());
- List characters = characterRepository.findByCampaignId(campaignId).stream()
- .sorted(Comparator.comparingInt(Character::getOrder))
- .map(this::toCharacterSummary)
- .collect(Collectors.toList());
+ List characters = (playthroughId == null || playthroughId.isBlank())
+ ? List.of()
+ : characterRepository.findByPlaythroughId(playthroughId).stream()
+ .sorted(Comparator.comparingInt(Character::getOrder))
+ .map(this::toCharacterSummary)
+ .collect(Collectors.toList());
List npcs = npcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Npc::getOrder))
@@ -175,11 +186,42 @@ public class CampaignStructuralContextBuilder {
b.condition()))
.collect(Collectors.toList());
+ List rooms = toRoomSummaries(scene);
+
return new SceneSummary(
scene.getName(),
scene.getDescription(),
countImages(scene.getIllustrationImageIds()),
- hints);
+ hints,
+ rooms);
+ }
+
+ /**
+ * Projette les pièces d'une scène en RoomSummary pour le contexte IA.
+ * Pas de notes MJ, pas de loot ni pièges : le prompt reste lisible. L'IA
+ * connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
+ * suffisant pour proposer de la narration ou anticiper les choix.
+ */
+ private List toRoomSummaries(Scene scene) {
+ if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
+ Map nameById = scene.getRooms().stream()
+ .collect(Collectors.toMap(
+ com.loremind.domain.campaigncontext.Room::getId,
+ com.loremind.domain.campaigncontext.Room::getName,
+ (a, b) -> a));
+ return scene.getRooms().stream()
+ .map(r -> {
+ List hints = r.getBranches() == null
+ ? List.of()
+ : r.getBranches().stream()
+ .map(b -> new RoomBranchHint(
+ b.label(),
+ nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
+ b.condition()))
+ .collect(Collectors.toList());
+ return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints);
+ })
+ .collect(Collectors.toList());
}
/** Helper defensif : compte les illustrations attachees (null-safe). */
diff --git a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
index 311abee..1c600c6 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
@@ -1,42 +1,71 @@
package com.loremind.application.generationcontext;
+import com.loremind.domain.campaigncontext.Arc;
+import com.loremind.domain.campaigncontext.ArcType;
+import com.loremind.domain.campaigncontext.Chapter;
+import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
+import com.loremind.domain.campaigncontext.ProgressionStatus;
+import com.loremind.domain.campaigncontext.QuestStatus;
+import com.loremind.domain.campaigncontext.ports.ArcRepository;
+import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
+import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
+import com.loremind.domain.playcontext.EntryType;
+import com.loremind.domain.playcontext.Playthrough;
+import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
+import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
+import com.loremind.domain.playcontext.ports.PlaythroughRepository;
+import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Component;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
*
- *
Charge la Session + les N dernières entrées du journal et les mappe vers
- * le Value Object {@link SessionContext}. La limite d'entrées évite de saturer
- * la fenêtre de contexte du LLM sur des sessions très longues.
+ *
Depuis la refonte Playthrough : la session connaît son playthroughId ; la progression
+ * et les flags viennent du Playthrough (pas plus de la Campagne ni du Chapter).
*/
@Component
public class SessionStructuralContextBuilder {
- /**
- * Plafond du nombre d'entrées remontées au LLM.
- * Choisi pour rester dans des limites raisonnables (≈ 5-10k tokens max
- * pour des entrées moyennes de 200 chars). Si la session déborde,
- * on garde les entrées les plus récentes (fin de chronologie).
- */
- private static final int MAX_ENTRIES = 80;
+ private static final int MAX_CURRENT_ENTRIES = 80;
+ private static final int MAX_PREVIOUS_EVENTS = 60;
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
+ private final PlaythroughRepository playthroughRepository;
+ private final ArcRepository arcRepository;
+ private final ChapterRepository chapterRepository;
+ private final PlaythroughFlagRepository playthroughFlagRepository;
+ private final QuestProgressionRepository questProgressionRepository;
+ private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
- SessionEntryRepository entryRepository) {
+ SessionEntryRepository entryRepository,
+ PlaythroughRepository playthroughRepository,
+ ArcRepository arcRepository,
+ ChapterRepository chapterRepository,
+ PlaythroughFlagRepository playthroughFlagRepository,
+ QuestProgressionRepository questProgressionRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
+ this.playthroughRepository = playthroughRepository;
+ this.arcRepository = arcRepository;
+ this.chapterRepository = chapterRepository;
+ this.playthroughFlagRepository = playthroughFlagRepository;
+ this.questProgressionRepository = questProgressionRepository;
}
public Optional buildOptional(String sessionId) {
@@ -50,24 +79,155 @@ public class SessionStructuralContextBuilder {
}
private SessionContext toContext(Session session) {
- List allEntries = entryRepository.findBySessionId(session.getId());
- // findBySessionId renvoie en ASC. On garde la fin si la liste dépasse le plafond
- // — c'est l'info récente qui aide le plus l'IA pendant la partie.
- List kept = allEntries.size() <= MAX_ENTRIES
- ? allEntries
- : allEntries.subList(allEntries.size() - MAX_ENTRIES, allEntries.size());
-
- List summaries = kept.stream()
- .map(e -> new JournalEntrySummary(
- e.getType() != null ? e.getType().name() : "NOTE",
- e.getContent(),
- e.getOccurredAt()))
- .collect(Collectors.toList());
+ List currentEntries = loadCurrentEntries(session);
+ List previousEvents = loadPreviousEvents(session);
+ HubStatus hub = computeHubStatus(session.getPlaythroughId());
return new SessionContext(
session.getName(),
session.isActive(),
session.getStartedAt(),
- summaries);
+ currentEntries,
+ previousEvents,
+ hub.available(),
+ hub.inProgress(),
+ hub.lockedTitles(),
+ hub.activeFlags());
+ }
+
+ private List loadCurrentEntries(Session session) {
+ List allEntries = entryRepository.findBySessionId(session.getId());
+ List kept = allEntries.size() <= MAX_CURRENT_ENTRIES
+ ? allEntries
+ : allEntries.subList(allEntries.size() - MAX_CURRENT_ENTRIES, allEntries.size());
+
+ return kept.stream()
+ .map(e -> toSummary(e, null))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * EVENTs des sessions précédentes du MÊME Playthrough (même table).
+ * On ne mélange jamais les EVENTs de tables différentes.
+ */
+ private List loadPreviousEvents(Session current) {
+ if (current.getPlaythroughId() == null) return List.of();
+ List siblingSessions = sessionRepository.findByPlaythroughId(current.getPlaythroughId());
+ List events = new ArrayList<>();
+
+ for (Session past : siblingSessions) {
+ if (past.getId().equals(current.getId())) continue;
+ for (SessionEntry entry : entryRepository.findBySessionId(past.getId())) {
+ if (entry.getType() == EntryType.EVENT) {
+ events.add(toSummary(entry, past.getName()));
+ }
+ }
+ }
+
+ events.sort(Comparator.comparing(
+ JournalEntrySummary::occurredAt,
+ Comparator.nullsLast(Comparator.naturalOrder())));
+
+ if (events.size() > MAX_PREVIOUS_EVENTS) {
+ return events.subList(events.size() - MAX_PREVIOUS_EVENTS, events.size());
+ }
+ return events;
+ }
+
+ private JournalEntrySummary toSummary(SessionEntry entry, String sourceSessionName) {
+ return new JournalEntrySummary(
+ entry.getType() != null ? entry.getType().name() : "NOTE",
+ entry.getContent(),
+ entry.getOccurredAt(),
+ sourceSessionName);
+ }
+
+ /** Agrégat interne des données Hub à injecter dans le SessionContext. */
+ private record HubStatus(
+ List available,
+ List inProgress,
+ List lockedTitles,
+ List activeFlags
+ ) {}
+
+ /**
+ * Calcule l'état des quêtes Hub du Playthrough courant :
+ * - AVAILABLE / IN_PROGRESS → résumé complet
+ * - LOCKED → titre uniquement (anti-spoiler)
+ * - COMPLETED → omis (déjà raconté par les EVENTs)
+ */
+ private HubStatus computeHubStatus(String playthroughId) {
+ if (playthroughId == null) {
+ return new HubStatus(List.of(), List.of(), List.of(), List.of());
+ }
+ Optional maybePlaythrough = playthroughRepository.findById(playthroughId);
+ if (maybePlaythrough.isEmpty()) {
+ return new HubStatus(List.of(), List.of(), List.of(), List.of());
+ }
+ String campaignId = maybePlaythrough.get().getCampaignId();
+
+ Map flags = playthroughFlagRepository.findByPlaythroughId(playthroughId);
+ List activeFlags = buildActiveFlags(flags);
+
+ List arcs = arcRepository.findByCampaignId(campaignId);
+ Map arcsById = arcs.stream()
+ .filter(a -> a.getId() != null)
+ .collect(Collectors.toMap(Arc::getId, a -> a));
+
+ boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
+ if (!anyHub) {
+ return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
+ }
+
+ // Map chapterId -> ProgressionStatus pour ce Playthrough
+ Map progressionByChapter = new HashMap<>();
+ for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
+ progressionByChapter.put(qp.getChapterId(), qp.getStatus());
+ }
+
+ // IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted)
+ var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
+
+ int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
+ PrerequisiteEvaluator.EvaluationContext ctx =
+ new PrerequisiteEvaluator.EvaluationContext(completedIds, sessionCount, flags);
+
+ List available = new ArrayList<>();
+ List inProgress = new ArrayList<>();
+ List lockedTitles = new ArrayList<>();
+
+ for (Arc arc : arcs) {
+ if (arc.getType() != ArcType.HUB) continue;
+ for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
+ ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
+ QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
+ Arc parent = arcsById.get(c.getArcId());
+ String arcName = parent != null ? parent.getName() : null;
+ switch (status) {
+ case AVAILABLE:
+ available.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
+ break;
+ case IN_PROGRESS:
+ inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
+ break;
+ case LOCKED:
+ lockedTitles.add(c.getName());
+ break;
+ case COMPLETED:
+ // Omis (déjà dans le journal des EVENTs).
+ break;
+ }
+ }
+ }
+
+ return new HubStatus(available, inProgress, lockedTitles, activeFlags);
+ }
+
+ private List buildActiveFlags(Map flags) {
+ return flags.entrySet().stream()
+ .filter(Map.Entry::getValue)
+ .map(Map.Entry::getKey)
+ .sorted()
+ .collect(Collectors.toList());
}
}
diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
index dea49ed..0bdd7f8 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForSessionUseCase.java
@@ -12,7 +12,9 @@ import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
+import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
+import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
@@ -39,6 +41,7 @@ import java.util.function.Consumer;
public class StreamChatForSessionUseCase {
private final SessionRepository sessionRepository;
+ private final PlaythroughRepository playthroughRepository;
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
@@ -48,6 +51,7 @@ public class StreamChatForSessionUseCase {
public StreamChatForSessionUseCase(
SessionRepository sessionRepository,
+ PlaythroughRepository playthroughRepository,
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
@@ -55,6 +59,7 @@ public class StreamChatForSessionUseCase {
SessionStructuralContextBuilder sessionContextBuilder,
AiChatProvider aiChatProvider) {
this.sessionRepository = sessionRepository;
+ this.playthroughRepository = playthroughRepository;
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
@@ -74,11 +79,17 @@ public class StreamChatForSessionUseCase {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
- Campaign campaign = campaignRepository.findById(session.getCampaignId())
+ // Chaîne de résolution : Session → Playthrough → Campaign.
+ Playthrough playthrough = playthroughRepository.findById(session.getPlaythroughId())
.orElseThrow(() -> new IllegalArgumentException(
- "Campagne associée à la session introuvable : " + session.getCampaignId()));
+ "Partie associée à la session introuvable : " + session.getPlaythroughId()));
- CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId());
+ Campaign campaign = campaignRepository.findById(playthrough.getCampaignId())
+ .orElseThrow(() -> new IllegalArgumentException(
+ "Campagne associée à la partie introuvable : " + playthrough.getCampaignId()));
+
+ // Le campaign context inclut les PJ de CE Playthrough (les PJ sont par-table).
+ CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId(), playthrough.getId());
LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
SessionContext sessionContext = sessionContextBuilder.build(sessionId);
diff --git a/core/src/main/java/com/loremind/application/playcontext/PlaythroughService.java b/core/src/main/java/com/loremind/application/playcontext/PlaythroughService.java
new file mode 100644
index 0000000..3c726c9
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/playcontext/PlaythroughService.java
@@ -0,0 +1,110 @@
+package com.loremind.application.playcontext;
+
+import com.loremind.domain.campaigncontext.ports.CampaignRepository;
+import com.loremind.domain.campaigncontext.ports.CharacterRepository;
+import com.loremind.domain.playcontext.Playthrough;
+import com.loremind.domain.playcontext.Session;
+import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
+import com.loremind.domain.playcontext.ports.PlaythroughRepository;
+import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
+import com.loremind.domain.playcontext.ports.SessionRepository;
+import org.springframework.beans.BeanUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Service applicatif pour le cycle de vie d'un Playthrough (Partie).
+ *
+ *
Cascade de suppression : un Playthrough supprime ses flags, ses progressions,
+ * ses PJ et ses sessions (avec leurs entrées de journal).
+ */
+@Service
+public class PlaythroughService {
+
+ private final PlaythroughRepository playthroughRepository;
+ private final CampaignRepository campaignRepository;
+ private final PlaythroughFlagRepository flagRepository;
+ private final QuestProgressionRepository progressionRepository;
+ private final CharacterRepository characterRepository;
+ private final SessionRepository sessionRepository;
+ private final SessionService sessionService;
+
+ public PlaythroughService(PlaythroughRepository playthroughRepository,
+ CampaignRepository campaignRepository,
+ PlaythroughFlagRepository flagRepository,
+ QuestProgressionRepository progressionRepository,
+ CharacterRepository characterRepository,
+ SessionRepository sessionRepository,
+ SessionService sessionService) {
+ this.playthroughRepository = playthroughRepository;
+ this.campaignRepository = campaignRepository;
+ this.flagRepository = flagRepository;
+ this.progressionRepository = progressionRepository;
+ this.characterRepository = characterRepository;
+ this.sessionRepository = sessionRepository;
+ this.sessionService = sessionService;
+ }
+
+ /** Compte des entités qui seront supprimées en cascade avec la Partie. */
+ public record DeletionImpact(int sessions, int characters, int flags, int progressions) {}
+
+ public Playthrough create(String campaignId, String name, String description) {
+ if (campaignId == null || campaignId.isBlank()) {
+ throw new IllegalArgumentException("campaignId requis.");
+ }
+ if (!campaignRepository.existsById(campaignId)) {
+ throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
+ }
+ Playthrough p = Playthrough.builder()
+ .campaignId(campaignId)
+ .name((name == null || name.isBlank()) ? "Partie principale" : name.trim())
+ .description(description)
+ .build();
+ return playthroughRepository.save(p);
+ }
+
+ public Playthrough update(String id, Playthrough updated) {
+ Playthrough existing = playthroughRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Partie introuvable : " + id));
+ BeanUtils.copyProperties(updated, existing, "id", "campaignId", "createdAt");
+ return playthroughRepository.save(existing);
+ }
+
+ public Optional getById(String id) {
+ return playthroughRepository.findById(id);
+ }
+
+ public List getByCampaignId(String campaignId) {
+ return playthroughRepository.findByCampaignId(campaignId);
+ }
+
+ public DeletionImpact getDeletionImpact(String id) {
+ int sessions = sessionRepository.findByPlaythroughId(id).size();
+ int characters = characterRepository.findByPlaythroughId(id).size();
+ int flags = flagRepository.findByPlaythroughId(id).size();
+ int progressions = progressionRepository.findByPlaythroughId(id).size();
+ return new DeletionImpact(sessions, characters, flags, progressions);
+ }
+
+ @Transactional
+ public void delete(String id) {
+ if (!playthroughRepository.existsById(id)) {
+ throw new IllegalArgumentException("Partie introuvable : " + id);
+ }
+ // Cascade : sessions (et leurs entries), PJ, flags, progressions
+ for (Session s : sessionRepository.findByPlaythroughId(id)) {
+ sessionService.deleteSession(s.getId());
+ }
+ characterRepository.findByPlaythroughId(id).forEach(c -> characterRepository.deleteById(c.getId()));
+ flagRepository.deleteAllByPlaythroughId(id);
+ progressionRepository.deleteAllByPlaythroughId(id);
+ playthroughRepository.deleteById(id);
+ }
+
+ public boolean exists(String id) {
+ return playthroughRepository.existsById(id);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/playcontext/SessionService.java b/core/src/main/java/com/loremind/application/playcontext/SessionService.java
index 9ba6d6e..2306e45 100644
--- a/core/src/main/java/com/loremind/application/playcontext/SessionService.java
+++ b/core/src/main/java/com/loremind/application/playcontext/SessionService.java
@@ -1,7 +1,7 @@
package com.loremind.application.playcontext;
-import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.playcontext.Session;
+import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
@@ -15,10 +15,9 @@ import java.util.Optional;
/**
* Service d'application pour le Play Context.
* Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
- * Fait partie de la couche Application de l'Architecture Hexagonale.
*
- *
Règle métier : une seule Session peut être active (endedAt null) à la fois
- * dans l'application.
+ *
Règle métier : une seule Session peut être active (endedAt null) à la fois.
+ *
Depuis Playthrough : une Session appartient à un Playthrough (pas directement à une Campaign).
*/
@Service
public class SessionService {
@@ -27,41 +26,43 @@ public class SessionService {
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
- private final CampaignRepository campaignRepository;
+ private final PlaythroughRepository playthroughRepository;
public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
- CampaignRepository campaignRepository) {
+ PlaythroughRepository playthroughRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
- this.campaignRepository = campaignRepository;
+ this.playthroughRepository = playthroughRepository;
}
/**
- * Lance une nouvelle session sur la campagne donnée.
- * Échoue si une session est déjà active ou si la campagne n'existe pas.
+ * Lance une nouvelle session sur le Playthrough donné.
+ * Échoue si une session est déjà active ou si le Playthrough n'existe pas.
*/
- public Session startSession(String campaignId) {
- if (campaignId == null || campaignId.isBlank()) {
- throw new IllegalArgumentException("campaignId est requis pour démarrer une session.");
+ public Session startSession(String playthroughId) {
+ if (playthroughId == null || playthroughId.isBlank()) {
+ throw new IllegalArgumentException("playthroughId est requis pour démarrer une session.");
}
- if (!campaignRepository.existsById(campaignId)) {
- throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
+ if (!playthroughRepository.existsById(playthroughId)) {
+ throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
- sessionRepository.findActive().ifPresent(s -> {
- throw new IllegalStateException("Une session est déjà en cours (id=" + s.getId() + "). Termine-la avant d'en lancer une nouvelle.");
+ // Règle métier : une seule session active par Partie (pas de verrou global cross-Partie).
+ sessionRepository.findActiveByPlaythroughId(playthroughId).ifPresent(s -> {
+ throw new IllegalStateException(
+ "Une session est déjà en cours pour cette Partie (id=" + s.getId() +
+ "). Termine-la avant d'en lancer une nouvelle.");
});
LocalDateTime now = LocalDateTime.now();
Session session = Session.builder()
.name(generateDefaultName(now))
- .campaignId(campaignId)
+ .playthroughId(playthroughId)
.startedAt(now)
.build();
return sessionRepository.save(session);
}
- /** Termine la session active si elle correspond à l'id donné. */
public Session endSession(String id) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
@@ -90,18 +91,18 @@ public class SessionService {
return sessionRepository.findActive();
}
+ public Optional getActiveByPlaythrough(String playthroughId) {
+ return sessionRepository.findActiveByPlaythroughId(playthroughId);
+ }
+
public List getAll() {
return sessionRepository.findAll();
}
- public List getByCampaignId(String campaignId) {
- return sessionRepository.findByCampaignId(campaignId);
+ public List getByPlaythroughId(String playthroughId) {
+ return sessionRepository.findByPlaythroughId(playthroughId);
}
- /**
- * Supprime une session et toutes ses entrées de journal en cascade.
- * Transactionnel : soit tout disparaît, soit rien.
- */
@Transactional
public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) {
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java b/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java
index d5ced15..ef1a6c7 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java
@@ -21,6 +21,14 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne
+ /**
+ * Type structurel de l'arc. Détermine son rendu UI et la sémantique de ses chapitres
+ * (séquence narrative LINEAR vs. quêtes parallèles d'un HUB).
+ * Défaut LINEAR pour rétro-compatibilité avec les arcs existants.
+ */
+ @Builder.Default
+ private ArcType type = ArcType.LINEAR;
+
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java b/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java
new file mode 100644
index 0000000..65ddf10
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ArcType.java
@@ -0,0 +1,14 @@
+package com.loremind.domain.campaigncontext;
+
+/**
+ * Type structurel d'un Arc.
+ * - LINEAR : narration séquentielle classique (chapitres joués dans l'ordre).
+ * - HUB : narration non linéaire ; les chapitres sont des "quêtes" satellites
+ * potentiellement parallèles, soumises à des prérequis pour être débloquées.
+ *
+ * Value Object du domaine (Bounded Context : Campaign).
+ */
+public enum ArcType {
+ LINEAR,
+ HUB
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
index 4d89f91..e6dfee1 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
@@ -6,8 +6,12 @@ import java.time.LocalDateTime;
/**
* Entité de domaine représentant une Campaign.
- * Conteneur global pour organiser la narration d'une campagne.
- * Entité pure du domaine, sans dépendance technique.
+ * Conteneur du SCÉNARIO (générique, ré-utilisable par plusieurs tables).
+ *
+ *
Toute donnée dynamique propre à une table jouée (progression des quêtes,
+ * flags narratifs, sessions, PJ) vit dans un {@link com.loremind.domain.playcontext.Playthrough}.
+ *
+ *
Entité pure du domaine, sans dépendance technique.
*/
@Data
@Builder
@@ -21,17 +25,13 @@ public class Campaign {
private int arcsCount;
/**
- * Référence faible (weak reference) vers un Lore.
- * Nullable : une campagne peut exister sans univers associé (one-shot, test, pitch libre).
- * Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context
- * (respect des Bounded Contexts en DDD).
+ * Référence faible vers un Lore. Nullable.
+ * Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
*/
private String loreId;
/**
- * Référence faible (weak reference) vers un GameSystem.
- * Nullable : une campagne peut être "générique" (pas de système de JDR déclaré).
- * Weak reference pour respecter la séparation des Bounded Contexts.
+ * Référence faible vers un GameSystem. Nullable.
*/
private String gameSystemId;
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java
index 391358d..7eb70a9 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java
@@ -21,6 +21,14 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc
+ /**
+ * Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE.
+ * Pertinent surtout pour les chapitres d'un Arc HUB ; ignoré pour LINEAR.
+ * Donnée de SCÉNARIO — partagée par toutes les Parties de la campagne.
+ */
+ @Builder.Default
+ private List prerequisites = new ArrayList<>();
+
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java
index ab46e87..d351b31 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java
@@ -59,10 +59,14 @@ public class Character {
*/
private Map> keyValueValues;
- /** Référence vers la Campaign parente. */
- private String campaignId;
+ /**
+ * Référence vers le Playthrough (= la partie / table) auquel ce PJ appartient.
+ * Les PJ sont propres à une table jouée, pas au scénario générique de la campagne.
+ * Weak reference cross-context.
+ */
+ private String playthroughId;
- /** Ordre d'affichage dans la liste des PJ de la campagne. */
+ /** Ordre d'affichage dans la liste des PJ de la Partie. */
private int order;
private LocalDateTime createdAt;
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Prerequisite.java b/core/src/main/java/com/loremind/domain/campaigncontext/Prerequisite.java
new file mode 100644
index 0000000..82ce788
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Prerequisite.java
@@ -0,0 +1,26 @@
+package com.loremind.domain.campaigncontext;
+
+/**
+ * Condition de déblocage d'une quête (Chapter dans un Arc HUB).
+ *
+ * Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
+ * un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
+ * {@link PrerequisiteEvaluator}.
+ *
+ * Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
+ * (pas de OR pour le moment).
+ */
+public sealed interface Prerequisite
+ permits Prerequisite.QuestCompleted,
+ Prerequisite.SessionReached,
+ Prerequisite.FlagSet {
+
+ /** La quête référencée par {@code questId} doit être en COMPLETED. */
+ record QuestCompleted(String questId) implements Prerequisite {}
+
+ /** Le compteur de sessions de la campagne doit avoir atteint {@code minSessionNumber}. */
+ record SessionReached(int minSessionNumber) implements Prerequisite {}
+
+ /** Le flag campagne nommé {@code flagName} doit être à true. */
+ record FlagSet(String flagName) implements Prerequisite {}
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/PrerequisiteEvaluator.java b/core/src/main/java/com/loremind/domain/campaigncontext/PrerequisiteEvaluator.java
new file mode 100644
index 0000000..86d3045
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/PrerequisiteEvaluator.java
@@ -0,0 +1,65 @@
+package com.loremind.domain.campaigncontext;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
+ * et en dérive le {@link QuestStatus} effectif.
+ *
+ * NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
+ * switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
+ * compile-time est compensée par le throw final qui fait crasher tout nouvel
+ * implémentant non câblé.
+ */
+public final class PrerequisiteEvaluator {
+
+ /**
+ * Contexte minimal nécessaire à l'évaluation. On ne passe pas la Campaign entière
+ * pour ne pas créer de couplage fort ; juste les faits nécessaires.
+ */
+ public record EvaluationContext(
+ Set completedQuestIds,
+ int currentSessionCount,
+ Map campaignFlags
+ ) {}
+
+ /** True si TOUS les prérequis sont satisfaits (ET logique). Vide => true. */
+ public boolean areAllSatisfied(List prerequisites, EvaluationContext ctx) {
+ if (prerequisites == null || prerequisites.isEmpty()) return true;
+ return prerequisites.stream().allMatch(p -> isSatisfied(p, ctx));
+ }
+
+ /** Évalue un seul prérequis. */
+ public boolean isSatisfied(Prerequisite prereq, EvaluationContext ctx) {
+ if (prereq instanceof Prerequisite.QuestCompleted q) {
+ return ctx.completedQuestIds().contains(q.questId());
+ }
+ if (prereq instanceof Prerequisite.SessionReached s) {
+ return ctx.currentSessionCount() >= s.minSessionNumber();
+ }
+ if (prereq instanceof Prerequisite.FlagSet f) {
+ return Boolean.TRUE.equals(ctx.campaignFlags().get(f.flagName()));
+ }
+ throw new IllegalStateException("Prerequisite non géré : " + prereq.getClass().getName());
+ }
+
+ /** Dérive le statut effectif à partir de la progression manuelle + des prérequis. */
+ public QuestStatus computeStatus(
+ ProgressionStatus progression,
+ List prerequisites,
+ EvaluationContext ctx
+ ) {
+ switch (progression) {
+ case COMPLETED: return QuestStatus.COMPLETED;
+ case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
+ case NOT_STARTED:
+ return areAllSatisfied(prerequisites, ctx)
+ ? QuestStatus.AVAILABLE
+ : QuestStatus.LOCKED;
+ default:
+ throw new IllegalStateException("ProgressionStatus non géré : " + progression);
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ProgressionStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/ProgressionStatus.java
new file mode 100644
index 0000000..1bf1213
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ProgressionStatus.java
@@ -0,0 +1,17 @@
+package com.loremind.domain.campaigncontext;
+
+/**
+ * Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
+ *
+ * NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
+ * selon les prérequis — voir {@link QuestStatus}.
+ * IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
+ * COMPLETED : marquée terminée par le MJ.
+ *
+ * NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
+ */
+public enum ProgressionStatus {
+ NOT_STARTED,
+ IN_PROGRESS,
+ COMPLETED
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/QuestStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/QuestStatus.java
new file mode 100644
index 0000000..6347a1e
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/QuestStatus.java
@@ -0,0 +1,19 @@
+package com.loremind.domain.campaigncontext;
+
+/**
+ * Statut effectif d'une quête tel qu'affiché dans la vue Hub.
+ * DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
+ * de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
+ *
+ * Table de vérité :
+ * NOT_STARTED + prérequis non remplis -> LOCKED
+ * NOT_STARTED + prérequis remplis -> AVAILABLE
+ * IN_PROGRESS -> IN_PROGRESS
+ * COMPLETED -> COMPLETED
+ */
+public enum QuestStatus {
+ LOCKED,
+ AVAILABLE,
+ IN_PROGRESS,
+ COMPLETED
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Room.java b/core/src/main/java/com/loremind/domain/campaigncontext/Room.java
new file mode 100644
index 0000000..f8b0100
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Room.java
@@ -0,0 +1,69 @@
+package com.loremind.domain.campaigncontext;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Pièce d'un lieu explorable (donjon, crypte…) attaché à une Scene.
+ *
+ *
Une Scene devient « explorable » dès qu'elle a au moins une Room. Tant
+ * qu'elle n'en a pas, elle se comporte comme un beat narratif classique.
+ *
+ *
Pas un record Java parce que la liste {@code branches} est mutable côté
+ * builder ; on garde la classe Lombok pour la cohérence avec le reste du
+ * domaine (Arc, Chapter, Scene). L'ID est généré côté front (UUID) au moment
+ * de la création — pas d'auto-increment DB puisque c'est un Value Object
+ * sérialisé en JSONB sur Scene.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class Room {
+
+ /** ID stable (UUID généré côté client). Sert de cible aux {@link RoomBranch}. */
+ private String id;
+
+ /** Nom de la pièce (« Antichambre », « Salle du trône »). */
+ private String name;
+
+ /** Narration / description lue ou résumée aux joueurs en entrant. */
+ private String description;
+
+ /** Énemis, créatures, boss éventuels (markdown libre). */
+ private String enemies;
+
+ /** Loot / récompenses présentes dans la pièce. */
+ private String loot;
+
+ /** Pièges / dangers environnementaux. */
+ private String traps;
+
+ /** Notes privées du MJ (cachées des joueurs). */
+ private String gmNotes;
+
+ /** Étage / niveau de la pièce. 0 = rez-de-chaussée. Nullable = pas d'étage défini. */
+ private Integer floor;
+
+ /** Ordre d'affichage dans la liste (au sein d'un même étage le cas échéant). */
+ private int order;
+
+ /** IDs d'images d'illustration / ambiance. */
+ @Builder.Default
+ private List illustrationImageIds = new ArrayList<>();
+
+ /** ID de l'image « plan » de la pièce (1 image dédiée, schéma tactique). */
+ private String mapImageId;
+
+ /**
+ * Sorties vers d'autres pièces. {@link RoomBranch#targetRoomId()} doit pointer
+ * vers une Room de la même Scene.
+ */
+ @Builder.Default
+ private List branches = new ArrayList<>();
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/RoomBranch.java b/core/src/main/java/com/loremind/domain/campaigncontext/RoomBranch.java
new file mode 100644
index 0000000..ef6cecf
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/RoomBranch.java
@@ -0,0 +1,22 @@
+package com.loremind.domain.campaigncontext;
+
+/**
+ * Sortie d'une pièce vers une autre pièce de la même Scene explorable.
+ * Équivalent inter-pièces de {@link SceneBranch}.
+ *
+ *
Record Java immuable, sérialisé via Jackson dans la liste JSONB
+ * {@code rooms} de la Scene.
+ *
+ *
Règle métier : {@code targetRoomId} doit pointer vers une Room de la
+ * MÊME Scene (validation côté service).
+ *
+ * @param label Libellé visible (« Porte nord », « Trappe au sol »).
+ * @param targetRoomId ID stable de la Room de destination (UUID Room.id).
+ * @param condition Condition optionnelle (« si les PJ ont la clé en argent »).
+ */
+public record RoomBranch(String label, String targetRoomId, String condition) {
+
+ public static RoomBranch of(String label, String targetRoomId) {
+ return new RoomBranch(label, targetRoomId, null);
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java
index 3c7298c..6ea62a3 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java
@@ -72,6 +72,15 @@ public class Scene {
@Builder.Default
private List branches = new ArrayList<>();
+ /**
+ * Pièces du lieu explorable représenté par cette scène (donjon, crypte, manoir…).
+ * Vide => scène classique « beat narratif » (comportement inchangé).
+ * Non vide => la scène devient explorable, l'UI affiche un layout dédié pièce-par-pièce.
+ * Sérialisé en JSONB.
+ */
+ @Builder.Default
+ private List rooms = new ArrayList<>();
+
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java
index a0adcdc..b9c9090 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java
@@ -14,7 +14,7 @@ public interface CharacterRepository {
Optional findById(String id);
- List findByCampaignId(String campaignId);
+ List findByPlaythroughId(String playthroughId);
void deleteById(String id);
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
index a3d2e18..d043788 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
@@ -69,12 +69,13 @@ public record CampaignStructuralContext(
List scenes) {
}
- /** Résumé d'une scène : nom + description courte + branches narratives. */
+ /** Résumé d'une scène : nom + description courte + branches + pièces explorables. */
public record SceneSummary(
String name,
String description,
int illustrationCount,
- List branches) {
+ List branches,
+ List rooms) {
}
/**
@@ -86,4 +87,27 @@ public record CampaignStructuralContext(
*/
public record BranchHint(String label, String targetSceneName, String condition) {
}
+
+ /**
+ * Pièce d'un lieu explorable (donjon, crypte). Projection volontairement plate
+ * pour le prompt IA : pas de notes MJ (jamais leakées dans le contexte campagne),
+ * la narration et les ennemis suffisent à camper la pièce.
+ *
+ * @param name Nom de la pièce.
+ * @param floor Étage (nullable).
+ * @param description Narration courte.
+ * @param enemies Ennemis (texte libre).
+ * @param branches Sorties vers d'autres pièces (noms résolus).
+ */
+ public record RoomSummary(
+ String name,
+ Integer floor,
+ String description,
+ String enemies,
+ List branches) {
+ }
+
+ /** Indice d'une sortie entre pièces ; {@code targetRoomName} déjà résolu. */
+ public record RoomBranchHint(String label, String targetRoomName, String condition) {
+ }
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java b/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
index c7b7bb4..8d41231 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/SessionContext.java
@@ -8,26 +8,51 @@ import java.util.List;
* de l'IA pour qu'elle ait conscience de la partie en cours et de son journal.
*
*
Pendant qu'une session se joue, l'IA reçoit en plus du Lore/Campagne/GameSystem :
- * le nom de la session, son statut (en cours / terminée) et un résumé chronologique
- * des entrées du journal (notes, évènements, jets, actions joueurs).
+ * le nom de la session, son statut, un résumé chronologique du journal,
+ * et — depuis l'ajout du mode Hub — l'état des quêtes ouvertes de la campagne et
+ * les flags actifs.
*
*
Value Object du Generation Context — record Java immutable.
*
- * @param sessionName Nom de la session telle qu'affichée au MJ.
- * @param active True si la session est en cours, false si terminée.
- * @param startedAt Horodatage de démarrage.
- * @param entries Entrées du journal triées chronologiquement (anciennes → récentes).
- * Limité côté builder pour éviter de saturer le contexte LLM.
+ * @param sessionName Nom de la session courante telle qu'affichée au MJ.
+ * @param active True si la session est en cours, false si terminée.
+ * @param startedAt Horodatage de démarrage de la session courante.
+ * @param entries Entrées du journal de la session courante (cap côté builder).
+ * @param previousEvents Évènements marquants des sessions précédentes (continuité narrative).
+ * @param availableQuests Quêtes Hub actuellement débloquées et non démarrées.
+ * @param inProgressQuests Quêtes Hub en cours.
+ * @param lockedQuestTitles Titres des quêtes Hub verrouillées — uniquement le titre
+ * pour signaler leur existence sans spoiler.
+ * @param activeFlags Noms des flags de campagne à true.
*/
public record SessionContext(
String sessionName,
boolean active,
LocalDateTime startedAt,
- List entries) {
+ List entries,
+ List previousEvents,
+ List availableQuests,
+ List inProgressQuests,
+ List lockedQuestTitles,
+ List activeFlags) {
- /** Résumé d'une entrée de journal — type + contenu + horodatage. */
+ /**
+ * Résumé d'une entrée de journal — type + contenu + horodatage + (optionnel) session source.
+ * {@code sourceSessionName} renseigné uniquement pour les évènements issus de sessions
+ * précédentes, pour aider l'IA à les ancrer temporellement.
+ */
public record JournalEntrySummary(
String type,
String content,
- LocalDateTime occurredAt) {}
+ LocalDateTime occurredAt,
+ String sourceSessionName) {}
+
+ /**
+ * Résumé d'une quête (= Chapter dans un Arc HUB) telle qu'exposée à l'IA.
+ * On omet volontairement les notes MJ : pas de fuite côté prompt.
+ */
+ public record QuestSummary(
+ String name,
+ String arcName,
+ String description) {}
}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/Playthrough.java b/core/src/main/java/com/loremind/domain/playcontext/Playthrough.java
new file mode 100644
index 0000000..c38f2e2
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/Playthrough.java
@@ -0,0 +1,36 @@
+package com.loremind.domain.playcontext;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Instance jouée d'une Campagne par une table donnée.
+ *
+ *
Sépare clairement le SCÉNARIO (Campaign : arcs, chapitres, prérequis) de
+ * l'ÉTAT DE JEU d'une table précise (progression des quêtes, flags narratifs,
+ * sessions tenues, PJ). Permet à plusieurs tables de jouer la même campagne
+ * indépendamment.
+ *
+ *
Fait partie du Play Context. Référence la Campagne par weak reference
+ * (campaignId) pour respecter les Bounded Contexts.
+ */
+@Data
+@Builder
+public class Playthrough {
+
+ private String id;
+
+ /** Weak reference vers la Campagne (le scénario joué). */
+ private String campaignId;
+
+ /** Nom donné par le MJ à cette partie (ex. : "Table du vendredi"). */
+ private String name;
+
+ /** Notes libres sur la partie / la table — facultatif. */
+ private String description;
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java b/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java
new file mode 100644
index 0000000..0b79f0b
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/QuestProgression.java
@@ -0,0 +1,29 @@
+package com.loremind.domain.playcontext;
+
+import com.loremind.domain.campaigncontext.ProgressionStatus;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * État de progression d'une quête (Chapter) pour un Playthrough donné.
+ *
+ *
Remplace l'ancien champ {@code Chapter.progressionStatus} qui mélangeait
+ * le scénario et l'état de jeu : ici, la progression est exclusivement
+ * propre à une instance jouée (Playthrough).
+ *
+ *
Référence le Chapter par weak reference (chapterId) pour respecter les
+ * Bounded Contexts. Le type {@link ProgressionStatus} reste défini dans
+ * Campaign Context (c'est un Value Object générique, partageable).
+ *
+ *
Sémantique : l'absence de ligne dans le repo équivaut à NOT_STARTED.
+ * On ne persiste donc que les transitions explicites IN_PROGRESS / COMPLETED.
+ */
+@Data
+@Builder
+public class QuestProgression {
+
+ private String id;
+ private String playthroughId;
+ private String chapterId;
+ private ProgressionStatus status;
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/Session.java b/core/src/main/java/com/loremind/domain/playcontext/Session.java
index d9a1298..2391fd1 100644
--- a/core/src/main/java/com/loremind/domain/playcontext/Session.java
+++ b/core/src/main/java/com/loremind/domain/playcontext/Session.java
@@ -6,14 +6,12 @@ import lombok.Data;
import java.time.LocalDateTime;
/**
- * Entité de domaine représentant une Session de jeu en cours ou passée.
+ * Entité de domaine représentant une Session de jeu (une soirée).
*
- *
Une Session est une instance jouée d'une Campaign. La Campaign reste
- * un scénario générique réutilisable ; la Session capture une partie réelle
- * (date, journal, etc.) sans polluer le scénario d'origine.
- *
- *
Fait partie du Play Context. Référence la Campaign par weak reference
- * (campaignId) pour respecter la séparation des Bounded Contexts.
+ *
Une Session appartient à un {@link Playthrough} (une instance jouée d'une
+ * campagne par une table). Un Playthrough a typiquement plusieurs sessions
+ * dans le temps ; la progression et les flags persistent entre elles via le
+ * Playthrough parent.
*
*
{@code endedAt == null} signifie que la session est en cours.
* Une seule session peut être en cours dans l'application à la fois.
@@ -25,8 +23,8 @@ public class Session {
private String id;
private String name;
- /** Weak reference vers Campaign — pas de dépendance directe inter-contexte. */
- private String campaignId;
+ /** Weak reference vers le Playthrough parent. */
+ private String playthroughId;
private LocalDateTime startedAt;
diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughFlagRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughFlagRepository.java
new file mode 100644
index 0000000..541dd24
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughFlagRepository.java
@@ -0,0 +1,20 @@
+package com.loremind.domain.playcontext.ports;
+
+import java.util.Map;
+
+/**
+ * Port de sortie pour les flags narratifs d'un Playthrough.
+ *
+ *
Anciennement {@code CampaignFlagRepository} : les flags suivent maintenant
+ * une partie (Playthrough), pas un scénario (Campaign).
+ */
+public interface PlaythroughFlagRepository {
+
+ Map findByPlaythroughId(String playthroughId);
+
+ void setFlag(String playthroughId, String name, boolean value);
+
+ void deleteFlag(String playthroughId, String name);
+
+ void deleteAllByPlaythroughId(String playthroughId);
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughRepository.java
new file mode 100644
index 0000000..d195b67
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/ports/PlaythroughRepository.java
@@ -0,0 +1,24 @@
+package com.loremind.domain.playcontext.ports;
+
+import com.loremind.domain.playcontext.Playthrough;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Port de sortie pour la persistance des Playthroughs (parties jouées).
+ */
+public interface PlaythroughRepository {
+
+ Playthrough save(Playthrough playthrough);
+
+ Optional findById(String id);
+
+ List findByCampaignId(String campaignId);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java
new file mode 100644
index 0000000..1409e87
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/playcontext/ports/QuestProgressionRepository.java
@@ -0,0 +1,31 @@
+package com.loremind.domain.playcontext.ports;
+
+import com.loremind.domain.campaigncontext.ProgressionStatus;
+import com.loremind.domain.playcontext.QuestProgression;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Port de sortie pour la persistance des progressions de quêtes d'un Playthrough.
+ *
+ *
Modèle "absence = NOT_STARTED" : on ne stocke que les transitions
+ * explicites IN_PROGRESS / COMPLETED.
+ */
+public interface QuestProgressionRepository {
+
+ /** Liste toutes les progressions explicites d'un Playthrough. */
+ List findByPlaythroughId(String playthroughId);
+
+ /** Set des IDs de chapitres en COMPLETED pour un Playthrough donné (fast path éval). */
+ Set findCompletedChapterIdsByPlaythroughId(String playthroughId);
+
+ /**
+ * Crée ou met à jour le statut d'une quête pour un Playthrough.
+ * Si {@code status == NOT_STARTED}, la ligne est supprimée (sémantique "absence").
+ */
+ void setStatus(String playthroughId, String chapterId, ProgressionStatus status);
+
+ /** Supprime toutes les progressions d'un Playthrough (cascade applicative). */
+ void deleteAllByPlaythroughId(String playthroughId);
+}
diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java
index 2a20908..6eb6a90 100644
--- a/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java
+++ b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java
@@ -7,7 +7,6 @@ import java.util.Optional;
/**
* Port de sortie pour la persistance des Sessions.
- * Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface SessionRepository {
@@ -17,11 +16,14 @@ public interface SessionRepository {
List findAll();
- List findByCampaignId(String campaignId);
+ List findByPlaythroughId(String playthroughId);
- /** Retourne la session en cours (endedAt null) s'il y en a une. */
+ /** Retourne UNE session active dans l'app (sémantique « legacy » — multi-actives possibles). */
Optional findActive();
+ /** Retourne la session active du Playthrough donné, s'il y en a une. */
+ Optional findActiveByPlaythroughId(String playthroughId);
+
void deleteById(String id);
boolean existsById(String id);
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
index cd641c5..3d881b2 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
@@ -16,6 +16,7 @@ import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
+import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
@@ -76,6 +77,40 @@ public class BrainChatPayloadBuilder {
map.put("entries", sc.entries() != null
? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList())
: List.of());
+ // Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session).
+ if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) {
+ map.put("previous_events", sc.previousEvents().stream()
+ .map(this::journalEntryToMap)
+ .collect(Collectors.toList()));
+ }
+ // État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas
+ // saturer le prompt sur les campagnes sans Hub.
+ if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) {
+ map.put("available_quests", sc.availableQuests().stream()
+ .map(this::questSummaryToMap)
+ .collect(Collectors.toList()));
+ }
+ if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) {
+ map.put("in_progress_quests", sc.inProgressQuests().stream()
+ .map(this::questSummaryToMap)
+ .collect(Collectors.toList()));
+ }
+ if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) {
+ map.put("locked_quest_titles", sc.lockedQuestTitles());
+ }
+ if (sc.activeFlags() != null && !sc.activeFlags().isEmpty()) {
+ map.put("active_flags", sc.activeFlags());
+ }
+ return map;
+ }
+
+ private Map questSummaryToMap(QuestSummary q) {
+ Map map = new LinkedHashMap<>();
+ map.put("name", q.name());
+ map.put("arc_name", q.arcName());
+ if (q.description() != null && !q.description().isBlank()) {
+ map.put("description", q.description());
+ }
return map;
}
@@ -86,6 +121,9 @@ public class BrainChatPayloadBuilder {
if (e.occurredAt() != null) {
map.put("occurred_at", e.occurredAt().toString());
}
+ if (e.sourceSessionName() != null && !e.sourceSessionName().isBlank()) {
+ map.put("source_session_name", e.sourceSessionName());
+ }
return map;
}
@@ -243,6 +281,12 @@ public class BrainChatPayloadBuilder {
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
+ // Pièces du lieu explorable : omises si scène classique.
+ if (s.rooms() != null && !s.rooms().isEmpty()) {
+ map.put("rooms", s.rooms().stream()
+ .map(this::roomSummaryToMap)
+ .collect(Collectors.toList()));
+ }
});
}
@@ -256,6 +300,24 @@ public class BrainChatPayloadBuilder {
return map;
}
+ private Map roomSummaryToMap(com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary r) {
+ Map map = new LinkedHashMap<>();
+ map.put("name", r.name());
+ if (r.floor() != null) map.put("floor", r.floor());
+ if (r.description() != null && !r.description().isBlank()) map.put("description", r.description());
+ if (r.enemies() != null && !r.enemies().isBlank()) map.put("enemies", r.enemies());
+ if (r.branches() != null && !r.branches().isEmpty()) {
+ map.put("branches", r.branches().stream().map(b -> {
+ Map bm = new LinkedHashMap<>();
+ bm.put("label", b.label());
+ bm.put("target_room_name", b.targetRoomName());
+ if (b.condition() != null && !b.condition().isBlank()) bm.put("condition", b.condition());
+ return bm;
+ }).collect(Collectors.toList()));
+ }
+ return map;
+ }
+
private Map narrativeEntityToMap(NarrativeEntityContext ne) {
Map map = new LinkedHashMap<>();
map.put("entity_type", ne.entityType());
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/PrerequisiteListJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/PrerequisiteListJsonConverter.java
new file mode 100644
index 0000000..a80ace2
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/PrerequisiteListJsonConverter.java
@@ -0,0 +1,98 @@
+package com.loremind.infrastructure.persistence.converter;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loremind.domain.campaigncontext.Prerequisite;
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Convertit une List (sealed type du domaine) en JSON stocké en base.
+ *
+ * On utilise un discriminant {@code kind} explicite pour éviter de polluer le domaine
+ * avec des annotations Jackson (@JsonTypeInfo / @JsonSubTypes). Le format on-disk est
+ * stable et indépendant des noms de classes Java.
+ *
+ * Format JSON :
+ * [
+ * {"kind": "QUEST_COMPLETED", "questId": "42"},
+ * {"kind": "SESSION_REACHED", "minSessionNumber": 3},
+ * {"kind": "FLAG_SET", "flagName": "forgeron_rencontre"}
+ * ]
+ */
+@Converter
+public class PrerequisiteListJsonConverter implements AttributeConverter, String> {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final String KIND = "kind";
+ private static final String KIND_QUEST_COMPLETED = "QUEST_COMPLETED";
+ private static final String KIND_SESSION_REACHED = "SESSION_REACHED";
+ private static final String KIND_FLAG_SET = "FLAG_SET";
+
+ @Override
+ public String convertToDatabaseColumn(List attribute) {
+ if (attribute == null || attribute.isEmpty()) return "[]";
+ try {
+ List