From 694f687fec3a6385656ac619fa178962f4501593 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Wed, 20 May 2026 14:59:26 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20d'un=20mode=20"jeu"=20(possibilit=C3=A9?= =?UTF-8?q?=20de=20lancer=20des=20sessions=20dans=20une=20campagne).=20Cel?= =?UTF-8?q?a=20permet=20de=20faire=20de=20prendre=20des=20notes=20en=20liv?= =?UTF-8?q?e=20au=20cours=20d'une=20partie=20et=20d'avoir=20plusieurs=20ou?= =?UTF-8?q?tils=20sous=20la=20main=20pour=20aider=20le=20mj=20:=20-=20Poss?= =?UTF-8?q?ibilit=C3=A9=20de=20parler=20=C3=A0=20une=20IA=20pour=20r=C3=A8?= =?UTF-8?q?gle=20de=20jeu=20ou=20=C3=A9l=C3=A9ment=20de=20lore=20/=20campa?= =?UTF-8?q?gne=20au=20cours=20d'une=20partie=20comme=20aide=20m=C3=A9moire?= =?UTF-8?q?=20-=20Onglet=20d=C3=A9di=C3=A9=20aux=20personnages=20de=20la?= =?UTF-8?q?=20campagne=20-=20Onglet=20d=C3=A9di=C3=A9=20aux=20sc=C3=A8nes?= =?UTF-8?q?=20-=20Onglet=20avec=20d=C3=A8s=20pour=20ceux=20qui=20souhaiten?= =?UTF-8?q?t=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.loremind loremind-core - 0.8.7-beta + 0.9.0-beta LoreMind Core Backend 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.

+ */ +@Data +@Builder +public class SessionEntry { + + private String id; + + /** Weak reference vers Session (intra-contexte mais reste découplée). */ + private String sessionId; + + private EntryType type; + + /** Contenu texte brut saisi par le MJ. */ + private String content; + + /** + * Horodatage métier de l'évènement. + * Distinct de {@code createdAt} : utile si le MJ rédige a posteriori + * une note rétroactive sur quelque chose qui s'est passé plus tôt. + */ + private LocalDateTime occurredAt; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/domain/playcontext/ports/SessionEntryRepository.java b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionEntryRepository.java new file mode 100644 index 0000000..ceb4b9e --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionEntryRepository.java @@ -0,0 +1,26 @@ +package com.loremind.domain.playcontext.ports; + +import com.loremind.domain.playcontext.SessionEntry; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des entrées de journal de session. + */ +public interface SessionEntryRepository { + + SessionEntry save(SessionEntry entry); + + Optional findById(String id); + + /** Renvoie les entrées d'une session, triées par occurredAt croissant (chronologique). */ + List findBySessionId(String sessionId); + + void deleteById(String id); + + /** Supprime toutes les entrées d'une session — utilisé pour la cascade à la suppression. */ + void deleteBySessionId(String sessionId); + + boolean existsById(String id); +} 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 new file mode 100644 index 0000000..2a20908 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/playcontext/ports/SessionRepository.java @@ -0,0 +1,28 @@ +package com.loremind.domain.playcontext.ports; + +import com.loremind.domain.playcontext.Session; + +import java.util.List; +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 { + + Session save(Session session); + + Optional findById(String id); + + List findAll(); + + List findByCampaignId(String campaignId); + + /** Retourne la session en cours (endedAt null) s'il y en a une. */ + Optional findActive(); + + 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 14c13fb..cd641c5 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java @@ -14,6 +14,8 @@ import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; 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 org.springframework.stereotype.Component; import java.util.LinkedHashMap; @@ -58,9 +60,35 @@ public class BrainChatPayloadBuilder { if (request.gameSystemContext() != null) { root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext())); } + if (request.sessionContext() != null) { + root.put("session_context", sessionContextToMap(request.sessionContext())); + } return root; } + private Map sessionContextToMap(SessionContext sc) { + Map map = new LinkedHashMap<>(); + map.put("session_name", sc.sessionName()); + map.put("active", sc.active()); + if (sc.startedAt() != null) { + map.put("started_at", sc.startedAt().toString()); + } + map.put("entries", sc.entries() != null + ? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList()) + : List.of()); + return map; + } + + private Map journalEntryToMap(JournalEntrySummary e) { + Map map = new LinkedHashMap<>(); + map.put("type", e.type()); + map.put("content", e.content()); + if (e.occurredAt() != null) { + map.put("occurred_at", e.occurredAt().toString()); + } + return map; + } + private Map gameSystemContextToMap(GameSystemContext gs) { Map map = new LinkedHashMap<>(); map.put("system_name", gs.systemName()); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionEntryJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionEntryJpaEntity.java new file mode 100644 index 0000000..fcdbf1e --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionEntryJpaEntity.java @@ -0,0 +1,60 @@ +package com.loremind.infrastructure.persistence.entity; + +import com.loremind.domain.playcontext.EntryType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Entité JPA pour la persistance des entrées de journal de session. + */ +@Entity +@Table(name = "session_entries", indexes = { + @Index(name = "idx_session_entries_session_id", columnList = "session_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SessionEntryJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Weak reference — pas de FK DB pour rester cohérent avec le reste du projet. */ + @Column(name = "session_id", nullable = false) + private String sessionId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private EntryType type; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Column(name = "occurred_at", nullable = false) + private LocalDateTime occurredAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java new file mode 100644 index 0000000..d3016a9 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java @@ -0,0 +1,61 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Entité JPA pour la persistance des Sessions en PostgreSQL. + * Adaptateur d'infrastructure — n'est PAS dans le domaine. + */ +@Entity +@Table(name = "sessions") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SessionJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + /** + * ID de la Campaign associée. Pas de @ManyToOne / pas de FK : c'est une + * weak reference inter-contexte (Play Context ↔ Campaign Context). + */ + @Column(name = "campaign_id", nullable = false) + private String campaignId; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + /** Null = session en cours. */ + @Column(name = "ended_at") + private LocalDateTime endedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionEntryJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionEntryJpaRepository.java new file mode 100644 index 0000000..88b542d --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionEntryJpaRepository.java @@ -0,0 +1,15 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SessionEntryJpaRepository extends JpaRepository { + + List findBySessionIdOrderByOccurredAtAsc(String sessionId); + + void deleteBySessionId(String sessionId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionJpaRepository.java new file mode 100644 index 0000000..cb4548f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/SessionJpaRepository.java @@ -0,0 +1,19 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.SessionJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * Repository Spring Data JPA pour SessionJpaEntity. + */ +@Repository +public interface SessionJpaRepository extends JpaRepository { + + List findByCampaignIdOrderByStartedAtDesc(String campaignId); + + Optional findFirstByEndedAtIsNull(); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionEntryRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionEntryRepository.java new file mode 100644 index 0000000..70566bc --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionEntryRepository.java @@ -0,0 +1,85 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.playcontext.SessionEntry; +import com.loremind.domain.playcontext.ports.SessionEntryRepository; +import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity; +import com.loremind.infrastructure.persistence.jpa.SessionEntryJpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Adaptateur d'infrastructure : implémente le Port SessionEntryRepository. + */ +@Repository +public class PostgresSessionEntryRepository implements SessionEntryRepository { + + private final SessionEntryJpaRepository jpaRepository; + + public PostgresSessionEntryRepository(SessionEntryJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public SessionEntry save(SessionEntry entry) { + SessionEntryJpaEntity saved = jpaRepository.save(toJpaEntity(entry)); + return toDomain(saved); + } + + @Override + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain); + } + + @Override + public List findBySessionId(String sessionId) { + return jpaRepository.findBySessionIdOrderByOccurredAtAsc(sessionId).stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + /** {@code @Transactional} requis : Spring Data exige une transaction pour les deleteByXxx dérivés. */ + @Override + @Transactional + public void deleteBySessionId(String sessionId) { + jpaRepository.deleteBySessionId(sessionId); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + private SessionEntry toDomain(SessionEntryJpaEntity jpa) { + return SessionEntry.builder() + .id(jpa.getId().toString()) + .sessionId(jpa.getSessionId()) + .type(jpa.getType()) + .content(jpa.getContent()) + .occurredAt(jpa.getOccurredAt()) + .createdAt(jpa.getCreatedAt()) + .updatedAt(jpa.getUpdatedAt()) + .build(); + } + + private SessionEntryJpaEntity toJpaEntity(SessionEntry entry) { + Long id = entry.getId() != null ? Long.parseLong(entry.getId()) : null; + return SessionEntryJpaEntity.builder() + .id(id) + .sessionId(entry.getSessionId()) + .type(entry.getType()) + .content(entry.getContent()) + .occurredAt(entry.getOccurredAt()) + .createdAt(entry.getCreatedAt()) + .updatedAt(entry.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java new file mode 100644 index 0000000..8d5964f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java @@ -0,0 +1,90 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.ports.SessionRepository; +import com.loremind.infrastructure.persistence.entity.SessionJpaEntity; +import com.loremind.infrastructure.persistence.jpa.SessionJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Adaptateur d'infrastructure qui implémente le Port SessionRepository. + * Convertit Session (domaine pur) ↔ SessionJpaEntity (persistance). + */ +@Repository +public class PostgresSessionRepository implements SessionRepository { + + private final SessionJpaRepository jpaRepository; + + public PostgresSessionRepository(SessionJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Session save(Session session) { + SessionJpaEntity saved = jpaRepository.save(toJpaEntity(session)); + return toDomain(saved); + } + + @Override + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain); + } + + @Override + public List findAll() { + return jpaRepository.findAll().stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findByCampaignId(String campaignId) { + return jpaRepository.findByCampaignIdOrderByStartedAtDesc(campaignId).stream() + .map(this::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional findActive() { + return jpaRepository.findFirstByEndedAtIsNull().map(this::toDomain); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + private Session toDomain(SessionJpaEntity jpa) { + return Session.builder() + .id(jpa.getId().toString()) + .name(jpa.getName()) + .campaignId(jpa.getCampaignId()) + .startedAt(jpa.getStartedAt()) + .endedAt(jpa.getEndedAt()) + .createdAt(jpa.getCreatedAt()) + .updatedAt(jpa.getUpdatedAt()) + .build(); + } + + private SessionJpaEntity toJpaEntity(Session session) { + Long id = session.getId() != null ? Long.parseLong(session.getId()) : null; + return SessionJpaEntity.builder() + .id(id) + .name(session.getName()) + .campaignId(session.getCampaignId()) + .startedAt(session.getStartedAt()) + .endedAt(session.getEndedAt()) + .createdAt(session.getCreatedAt()) + .updatedAt(session.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java index a7f30b8..6247c1f 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java @@ -2,11 +2,13 @@ package com.loremind.infrastructure.web.controller; import com.loremind.application.generationcontext.StreamChatForCampaignUseCase; import com.loremind.application.generationcontext.StreamChatForLoreUseCase; +import com.loremind.application.generationcontext.StreamChatForSessionUseCase; import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO; import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO; import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO; +import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.task.TaskExecutor; import org.springframework.http.MediaType; @@ -42,14 +44,17 @@ public class AiChatController { private final StreamChatForLoreUseCase streamChatForLoreUseCase; private final StreamChatForCampaignUseCase streamChatForCampaignUseCase; + private final StreamChatForSessionUseCase streamChatForSessionUseCase; private final TaskExecutor taskExecutor; public AiChatController( StreamChatForLoreUseCase streamChatForLoreUseCase, StreamChatForCampaignUseCase streamChatForCampaignUseCase, + StreamChatForSessionUseCase streamChatForSessionUseCase, @Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) { this.streamChatForLoreUseCase = streamChatForLoreUseCase; this.streamChatForCampaignUseCase = streamChatForCampaignUseCase; + this.streamChatForSessionUseCase = streamChatForSessionUseCase; this.taskExecutor = taskExecutor; } @@ -74,6 +79,19 @@ public class AiChatController { return emitter; } + /** + * Chat IA ancré sur une Session de jeu : récupère automatiquement la + * Campagne / Lore / GameSystem associés + injecte le journal horodaté. + */ + @PostMapping(value = "/chat/stream-session", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter chatStreamSession(@RequestBody ChatStreamSessionRequestDTO body) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + List messages = toDomainMessages(body.getMessages()); + + taskExecutor.execute(() -> runSessionStreaming(emitter, body.getSessionId(), messages)); + return emitter; + } + // --- Exécution du streaming dans un thread dédié ------------------------ private void runLoreStreaming( @@ -111,6 +129,22 @@ public class AiChatController { } } + private void runSessionStreaming( + SseEmitter emitter, + String sessionId, + List messages) { + try { + streamChatForSessionUseCase.execute( + sessionId, messages, + usage -> sendUsage(emitter, usage), + token -> sendToken(emitter, token), + () -> complete(emitter), + error -> fail(emitter, error)); + } catch (Exception e) { + fail(emitter, e); + } + } + // --- Helpers SSE (un seul point d'écriture par type d'événement) -------- private void sendUsage(SseEmitter emitter, ChatUsage usage) { diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java new file mode 100644 index 0000000..b18e061 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionController.java @@ -0,0 +1,82 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.playcontext.SessionService; +import com.loremind.domain.playcontext.Session; +import com.loremind.infrastructure.web.dto.playcontext.SessionDTO; +import com.loremind.infrastructure.web.mapper.SessionMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * REST Controller pour le Play Context. + * Adaptateur d'infrastructure qui expose l'API REST des Sessions. + */ +@RestController +@RequestMapping("/api/sessions") +public class SessionController { + + private final SessionService sessionService; + private final SessionMapper sessionMapper; + + public SessionController(SessionService sessionService, SessionMapper sessionMapper) { + this.sessionService = sessionService; + this.sessionMapper = sessionMapper; + } + + public record StartSessionRequest(String campaignId) {} + + public record RenameSessionRequest(String name) {} + + @PostMapping + public ResponseEntity startSession(@RequestBody StartSessionRequest request) { + Session session = sessionService.startSession(request.campaignId()); + return ResponseEntity.ok(sessionMapper.toDTO(session)); + } + + @GetMapping("/active") + public ResponseEntity getActiveSession() { + return sessionService.getActive() + .map(s -> ResponseEntity.ok(sessionMapper.toDTO(s))) + .orElse(ResponseEntity.noContent().build()); + } + + @GetMapping + public ResponseEntity> getSessions(@RequestParam(value = "campaignId", required = false) String campaignId) { + List sessions = (campaignId == null || campaignId.isBlank()) + ? sessionService.getAll() + : sessionService.getByCampaignId(campaignId); + List dtos = sessions.stream() + .map(sessionMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @GetMapping("/{id}") + public ResponseEntity getSessionById(@PathVariable String id) { + return sessionService.getById(id) + .map(s -> ResponseEntity.ok(sessionMapper.toDTO(s))) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/{id}/end") + public ResponseEntity endSession(@PathVariable String id) { + Session ended = sessionService.endSession(id); + return ResponseEntity.ok(sessionMapper.toDTO(ended)); + } + + @PatchMapping("/{id}") + public ResponseEntity renameSession(@PathVariable String id, + @RequestBody RenameSessionRequest request) { + Session renamed = sessionService.renameSession(id, request.name()); + return ResponseEntity.ok(sessionMapper.toDTO(renamed)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteSession(@PathVariable String id) { + sessionService.deleteSession(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SessionEntryController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionEntryController.java new file mode 100644 index 0000000..e1d75d7 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SessionEntryController.java @@ -0,0 +1,68 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.playcontext.SessionEntryService; +import com.loremind.domain.playcontext.EntryType; +import com.loremind.domain.playcontext.SessionEntry; +import com.loremind.infrastructure.web.dto.playcontext.SessionEntryDTO; +import com.loremind.infrastructure.web.mapper.SessionEntryMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * REST Controller pour les entrées de journal d'une Session. + * Endpoints imbriqués sous /api/sessions/{sessionId}/entries. + */ +@RestController +@RequestMapping("/api/sessions/{sessionId}/entries") +public class SessionEntryController { + + private final SessionEntryService entryService; + private final SessionEntryMapper entryMapper; + + public SessionEntryController(SessionEntryService entryService, SessionEntryMapper entryMapper) { + this.entryService = entryService; + this.entryMapper = entryMapper; + } + + public record EntryRequest(EntryType type, String content, LocalDateTime occurredAt) {} + + @GetMapping + public ResponseEntity> getEntries(@PathVariable String sessionId) { + List dtos = entryService.getBySessionId(sessionId).stream() + .map(entryMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @PostMapping + public ResponseEntity createEntry(@PathVariable String sessionId, + @RequestBody EntryRequest request) { + SessionEntry created = entryService.createEntry( + sessionId, + new SessionEntryService.EntryData(request.type(), request.content(), request.occurredAt()) + ); + return ResponseEntity.ok(entryMapper.toDTO(created)); + } + + @PutMapping("/{entryId}") + public ResponseEntity updateEntry(@PathVariable String sessionId, + @PathVariable String entryId, + @RequestBody EntryRequest request) { + SessionEntry updated = entryService.updateEntry( + entryId, + new SessionEntryService.EntryData(request.type(), request.content(), request.occurredAt()) + ); + return ResponseEntity.ok(entryMapper.toDTO(updated)); + } + + @DeleteMapping("/{entryId}") + public ResponseEntity deleteEntry(@PathVariable String sessionId, + @PathVariable String entryId) { + entryService.deleteEntry(entryId); + return ResponseEntity.noContent().build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/generationcontext/ChatStreamSessionRequestDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/generationcontext/ChatStreamSessionRequestDTO.java new file mode 100644 index 0000000..65d9a77 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/generationcontext/ChatStreamSessionRequestDTO.java @@ -0,0 +1,16 @@ +package com.loremind.infrastructure.web.dto.generationcontext; + +import lombok.Data; + +import java.util.List; + +/** + * DTO de requête pour le chat IA d'une Session de jeu. + * Le contexte (lore, campagne, gamesystem, journal) est dérivé du sessionId + * côté serveur — l'appelant n'a qu'à fournir l'id et les messages. + */ +@Data +public class ChatStreamSessionRequestDTO { + private String sessionId; + private List messages; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java new file mode 100644 index 0000000..a2d2ebc --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionDTO.java @@ -0,0 +1,22 @@ +package com.loremind.infrastructure.web.dto.playcontext; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * DTO pour l'entité Session — objet de transfert de l'API REST. + */ +@Data +public class SessionDTO { + + private String id; + private String name; + private String campaignId; + private LocalDateTime startedAt; + /** Null = session en cours. */ + private LocalDateTime endedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private boolean active; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionEntryDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionEntryDTO.java new file mode 100644 index 0000000..796a04c --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/playcontext/SessionEntryDTO.java @@ -0,0 +1,21 @@ +package com.loremind.infrastructure.web.dto.playcontext; + +import com.loremind.domain.playcontext.EntryType; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * DTO d'une entrée de journal de session. + */ +@Data +public class SessionEntryDTO { + + private String id; + private String sessionId; + private EntryType type; + private String content; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionEntryMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionEntryMapper.java new file mode 100644 index 0000000..b1c2033 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionEntryMapper.java @@ -0,0 +1,22 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.playcontext.SessionEntry; +import com.loremind.infrastructure.web.dto.playcontext.SessionEntryDTO; +import org.springframework.stereotype.Component; + +@Component +public class SessionEntryMapper { + + public SessionEntryDTO toDTO(SessionEntry entry) { + if (entry == null) return null; + SessionEntryDTO dto = new SessionEntryDTO(); + dto.setId(entry.getId()); + dto.setSessionId(entry.getSessionId()); + dto.setType(entry.getType()); + dto.setContent(entry.getContent()); + dto.setOccurredAt(entry.getOccurredAt()); + dto.setCreatedAt(entry.getCreatedAt()); + dto.setUpdatedAt(entry.getUpdatedAt()); + return dto; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java new file mode 100644 index 0000000..b62882b --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SessionMapper.java @@ -0,0 +1,26 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.playcontext.Session; +import com.loremind.infrastructure.web.dto.playcontext.SessionDTO; +import org.springframework.stereotype.Component; + +/** + * Mapper Session (domaine) ↔ SessionDTO (transport REST). + */ +@Component +public class SessionMapper { + + public SessionDTO toDTO(Session session) { + if (session == null) return null; + SessionDTO dto = new SessionDTO(); + dto.setId(session.getId()); + dto.setName(session.getName()); + dto.setCampaignId(session.getCampaignId()); + dto.setStartedAt(session.getStartedAt()); + dto.setEndedAt(session.getEndedAt()); + dto.setCreatedAt(session.getCreatedAt()); + dto.setUpdatedAt(session.getUpdatedAt()); + dto.setActive(session.isActive()); + return dto; + } +} diff --git a/web/package-lock.json b/web/package-lock.json index d1dd6c7..f7648fd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.8.7-beta", + "version": "0.9.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.8.7-beta", + "version": "0.9.0-beta", "dependencies": { "@angular/animations": "^17.0.0", "@angular/common": "^17.0.0", diff --git a/web/package.json b/web/package.json index cda3e9a..3ee1f79 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.8.7-beta", + "version": "0.9.0-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index b23e94e..f17cd2a 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -32,6 +32,7 @@ export const routes: Routes = [ { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) }, + { path: 'sessions/:id', loadComponent: () => import('./sessions/session-detail/session-detail.component').then(m => m.SessionDetailComponent) }, { path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) }, { path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, { path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index 246291d..c45c420 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -196,4 +196,60 @@ + +
+
+

+ + Sessions de jeu +

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

Aucune session de jeu pour le moment.

+
+
+ diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss index fb78672..6b94930 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss @@ -382,3 +382,57 @@ &:hover { background: #5b52e0; } } + +// ─────────────── Sessions de jeu (Play Context) ─────────────── +.sessions-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.session-card { + display: flex; + align-items: center; + gap: 0.75rem; + background: #111827; + border: 1px solid #1f2937; + border-radius: 10px; + padding: 0.9rem 1rem; + cursor: pointer; + transition: border-color 0.2s, transform 0.2s; + + &:hover { border-color: #6c63ff; transform: translateY(-1px); } + + &--active { + border-color: #10b981; + background: linear-gradient(180deg, #0d1f1a 0%, #111827 100%); + } + + .session-icon { color: #6c63ff; flex-shrink: 0; } + + .session-info { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + } + + .session-name { + color: white; + font-size: 0.9rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .session-meta { + color: #6b7280; + font-size: 0.75rem; + } + + .session-status { + color: #10b981; + font-weight: 600; + } +} diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts index 891f55a..041a52e 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check } from 'lucide-angular'; +import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check, Play } from 'lucide-angular'; import { Router, RouterLink } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError, switchMap, filter, map } from 'rxjs/operators'; @@ -12,6 +12,8 @@ import { GameSystemService } from '../../../services/game-system.service'; import { GameSystem } from '../../../services/game-system.model'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { SessionService } from '../../../services/session.service'; +import { Session } from '../../../services/session.model'; import { Character } from '../../../services/character.model'; import { Npc } from '../../../services/npc.model'; import { LayoutService } from '../../../services/layout.service'; @@ -38,6 +40,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { readonly Dices = Dices; readonly Drama = Drama; readonly Check = Check; + readonly Play = Play; campaign: Campaign | null = null; arcs: Arc[] = []; @@ -55,6 +58,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { characters: Character[] = []; /** Fiches de personnages non-joueurs (PNJ) de la campagne. */ npcs: Npc[] = []; + /** Sessions de jeu (passées et en cours) liées à cette campagne. */ + sessions: Session[] = []; + /** + * Session active globale (toutes campagnes confondues). + * Sert à désactiver le bouton "Lancer" si une session tourne déjà ailleurs. + * Null si aucune session active dans l'app. + */ + activeSessionGlobal: Session | null = null; + /** Indicateur de lancement en cours pour éviter les double-clics. */ + startingSession = false; /** Mode édition inline. */ editing = false; @@ -78,6 +91,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { private gameSystemService: GameSystemService, private characterService: CharacterService, private npcService: NpcService, + private sessionService: SessionService, private layoutService: LayoutService, private pageTitleService: PageTitleService, private confirmDialog: ConfirmDialogService @@ -104,6 +118,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.loadLinkedGameSystem(campaign); this.loadCharacters(campaign.id!); this.loadNpcs(campaign.id!); + this.loadSessions(campaign.id!); this.arcs = treeData.arcs; this.chapterCountByArc = this.computeChapterCounts(treeData); this.showLayout(allCampaigns, treeData); @@ -138,6 +153,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.loadLinkedGameSystem(campaign); this.loadCharacters(campaign.id!); this.loadNpcs(campaign.id!); + this.loadSessions(campaign.id!); this.arcs = treeData.arcs; this.chapterCountByArc = this.computeChapterCounts(treeData); this.showLayout(allCampaigns, treeData); @@ -184,6 +200,55 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { ).subscribe(list => this.npcs = list); } + /** + * Charge les sessions de cette campagne ET la session active globale. + * La session globale conditionne si le bouton "Lancer" est activable + * (règle métier : une seule session active simultanément dans l'app). + */ + private loadSessions(campaignId: string): void { + this.sessionService.getSessions(campaignId).pipe( + catchError(() => of([] as Session[])) + ).subscribe(list => this.sessions = list); + + this.sessionService.getActiveSession().pipe( + catchError(() => of(null)) + ).subscribe(active => this.activeSessionGlobal = active); + } + + /** True si une session est active mais sur une AUTRE campagne (lancement bloqué). */ + get isLaunchBlockedByOtherCampaign(): boolean { + return !!this.activeSessionGlobal + && !!this.campaign + && this.activeSessionGlobal.campaignId !== this.campaign.id; + } + + /** Session active sur la campagne courante (le MJ joue déjà ici). */ + get activeSessionOnCurrentCampaign(): Session | null { + if (!this.activeSessionGlobal || !this.campaign) return null; + return this.activeSessionGlobal.campaignId === this.campaign.id + ? this.activeSessionGlobal + : null; + } + + startSession(): void { + if (!this.campaign || this.startingSession || this.isLaunchBlockedByOtherCampaign) return; + this.startingSession = true; + this.sessionService.startSession(this.campaign.id!).subscribe({ + next: session => { + this.startingSession = false; + this.router.navigate(['/sessions', session.id]); + }, + error: () => { + this.startingSession = false; + console.error('Erreur lors du lancement de la session'); + } + }); + } + + openSession(session: Session): void { + this.router.navigate(['/sessions', session.id]); + } + createCharacter(): void { if (!this.campaign) return; this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']); diff --git a/web/src/app/services/ai-chat.service.ts b/web/src/app/services/ai-chat.service.ts index 75dd0a6..c5c1143 100644 --- a/web/src/app/services/ai-chat.service.ts +++ b/web/src/app/services/ai-chat.service.ts @@ -47,6 +47,7 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'n export class AiChatService { private readonly loreEndpoint = '/api/ai/chat/stream'; private readonly campaignEndpoint = '/api/ai/chat/stream-campaign'; + private readonly sessionEndpoint = '/api/ai/chat/stream-session'; /** * Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore). @@ -89,7 +90,16 @@ export class AiChatService { return this.streamSse(this.campaignEndpoint, body); } - /** Plumbing SSE mutualisé entre les 2 endpoints (Lore et Campaign). */ + /** + * Streame la réponse de l'IA pour un chat pendant une Session de jeu. + * Le backend reconstitue automatiquement le contexte complet (lore + + * campagne + système de JDR + journal de session). + */ + streamChatForSession(sessionId: string, messages: ChatMessage[]): Observable { + return this.streamSse(this.sessionEndpoint, { sessionId, messages }); + } + + /** Plumbing SSE mutualisé entre les endpoints (Lore / Campaign / Session). */ private streamSse(endpoint: string, body: Record): Observable { return new Observable((subscriber) => { const controller = new AbortController(); diff --git a/web/src/app/services/session-entry.model.ts b/web/src/app/services/session-entry.model.ts new file mode 100644 index 0000000..5aa0212 --- /dev/null +++ b/web/src/app/services/session-entry.model.ts @@ -0,0 +1,35 @@ +/** Type d'une entrée de journal de session — miroir de l'enum Java EntryType. */ +export type EntryType = 'NOTE' | 'EVENT' | 'DICE_ROLL' | 'PLAYER_ACTION'; + +/** Entrée du journal d'une Session (note, évènement, jet, action joueur). */ +export interface SessionEntry { + id: string; + sessionId: string; + type: EntryType; + content: string; + occurredAt: string; + createdAt: string; + updatedAt: string; +} + +/** Payload de création/édition d'une entrée. */ +export interface SessionEntryInput { + type: EntryType; + content: string; + /** Optionnel : si absent, le backend utilisera "maintenant". */ + occurredAt?: string; +} + +/** Métadonnées d'affichage par type — utilisées par la timeline. */ +export interface EntryTypeMeta { + label: string; + icon: 'StickyNote' | 'Sparkles' | 'Dices' | 'UserCheck'; + color: string; +} + +export const ENTRY_TYPE_META: Record = { + NOTE: { label: 'Note', icon: 'StickyNote', color: '#9ca3af' }, + EVENT: { label: 'Évènement', icon: 'Sparkles', color: '#f59e0b' }, + DICE_ROLL: { label: 'Jet de dés', icon: 'Dices', color: '#6c63ff' }, + PLAYER_ACTION: { label: 'Action joueur', icon: 'UserCheck', color: '#10b981' }, +}; diff --git a/web/src/app/services/session-entry.service.ts b/web/src/app/services/session-entry.service.ts new file mode 100644 index 0000000..b036281 --- /dev/null +++ b/web/src/app/services/session-entry.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { SessionEntry, SessionEntryInput } from './session-entry.model'; + +/** + * Service HTTP pour le journal d'une Session. + * Endpoints imbriqués : /api/sessions/{sessionId}/entries. + */ +@Injectable({ + providedIn: 'root' +}) +export class SessionEntryService { + private base(sessionId: string): string { + return `/api/sessions/${sessionId}/entries`; + } + + constructor(private http: HttpClient) {} + + getEntries(sessionId: string): Observable { + return this.http.get(this.base(sessionId)); + } + + createEntry(sessionId: string, input: SessionEntryInput): Observable { + return this.http.post(this.base(sessionId), input); + } + + updateEntry(sessionId: string, entryId: string, input: SessionEntryInput): Observable { + return this.http.put(`${this.base(sessionId)}/${entryId}`, input); + } + + deleteEntry(sessionId: string, entryId: string): Observable { + return this.http.delete(`${this.base(sessionId)}/${entryId}`); + } +} diff --git a/web/src/app/services/session.model.ts b/web/src/app/services/session.model.ts new file mode 100644 index 0000000..05a52c4 --- /dev/null +++ b/web/src/app/services/session.model.ts @@ -0,0 +1,15 @@ +/** + * Modèle Session côté Frontend. + * Miroir du SessionDTO Java exposé par /api/sessions. + */ +export interface Session { + id: string; + name: string; + campaignId: string; + startedAt: string; + /** Null/undefined = session en cours. */ + endedAt: string | null; + createdAt: string; + updatedAt: string; + active: boolean; +} diff --git a/web/src/app/services/session.service.ts b/web/src/app/services/session.service.ts new file mode 100644 index 0000000..00de128 --- /dev/null +++ b/web/src/app/services/session.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Session } from './session.model'; + +/** + * Service HTTP pour le Play Context (gestion des Sessions de jeu). + * Port de sortie vers le Backend Java (Architecture Hexagonale). + */ +@Injectable({ + providedIn: 'root' +}) +export class SessionService { + private apiUrl = '/api/sessions'; + + constructor(private http: HttpClient) {} + + /** Lance une nouvelle session sur la campagne donnée. */ + startSession(campaignId: string): Observable { + return this.http.post(this.apiUrl, { campaignId }); + } + + /** Récupère la session active (204 No Content si aucune). */ + getActiveSession(): Observable { + return this.http.get(`${this.apiUrl}/active`, { observe: 'body' }); + } + + getSessions(campaignId?: string): Observable { + let params = new HttpParams(); + if (campaignId) { + params = params.set('campaignId', campaignId); + } + return this.http.get(this.apiUrl, { params }); + } + + getSessionById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + endSession(id: string): Observable { + return this.http.post(`${this.apiUrl}/${id}/end`, {}); + } + + renameSession(id: string, name: string): Observable { + return this.http.patch(`${this.apiUrl}/${id}`, { name }); + } + + deleteSession(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } +} diff --git a/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.html b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.html new file mode 100644 index 0000000..e358ffb --- /dev/null +++ b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.html @@ -0,0 +1,72 @@ +
+ +
+ +
+ +

Pose une question à l'IA pendant la partie.

+

+ Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal. +

+
+ +
+
{{ m.content }}
+ +
+ + +
+
{{ currentAssistantText }}
+
+ +

{{ error }}

+
+ +
+ + +
+ + + + + +
+
+ +
diff --git a/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.scss b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.scss new file mode 100644 index 0000000..913b5cc --- /dev/null +++ b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.scss @@ -0,0 +1,168 @@ +.ai-chat-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + gap: 0.5rem; +} + +.messages-area { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-right: 0.25rem; +} + +.welcome-hint { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + color: #9ca3af; + font-size: 0.85rem; + text-align: center; + padding: 1rem 0.5rem; + + p { margin: 0; } + .welcome-sub { font-size: 0.75rem; color: #6b7280; font-style: italic; } +} + +.msg { + display: flex; + flex-direction: column; + gap: 0.3rem; + padding: 0.55rem 0.7rem; + border-radius: 8px; + font-size: 0.85rem; + line-height: 1.45; + word-break: break-word; + white-space: pre-wrap; +} + +.msg--user { + align-self: flex-end; + max-width: 90%; + background: #1e3a5f; + color: #dbeafe; +} + +.msg--assistant { + align-self: flex-start; + max-width: 95%; + background: #111827; + border: 1px solid #1f2937; + color: #e5e7eb; +} + +.msg--streaming { + opacity: 0.95; +} + +.msg-content { + white-space: pre-wrap; +} + +.msg-action { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 0.3rem; + background: transparent; + border: 1px dashed #374151; + color: #9ca3af; + font-size: 0.7rem; + padding: 0.25rem 0.55rem; + border-radius: 999px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; + + &:hover:not(:disabled) { + border-color: #6c63ff; + color: #c4bdff; + background: rgba(108, 99, 255, 0.08); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.cursor { + color: #6c63ff; + animation: blink 1s steps(2) infinite; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +.error-hint { + color: #f87171; + font-size: 0.8rem; + font-style: italic; + margin: 0.25rem 0; +} + +// ─────────────── Composer ─────────────── +.composer { + display: flex; + flex-direction: column; + gap: 0.4rem; + border-top: 1px solid #1f2937; + padding-top: 0.5rem; +} + +.composer-input { + width: 100%; + background: #111827; + border: 1px solid #1f2937; + color: #e5e7eb; + font-family: inherit; + font-size: 0.85rem; + padding: 0.55rem 0.7rem; + border-radius: 6px; + resize: vertical; + min-height: 50px; + + &:focus { outline: none; border-color: #6c63ff; } + &:disabled { opacity: 0.7; } +} + +.composer-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.45rem; +} + +.btn-primary, +.btn-secondary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: none; + padding: 0.4rem 0.75rem; + border-radius: 6px; + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; +} + +.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } &:disabled { opacity: 0.5; cursor: not-allowed; } } +.btn-secondary{ background: #374151; color: #e5e7eb; &:hover { background: #4b5563; } } + +.btn-link { + background: transparent; + border: none; + color: #6b7280; + padding: 0; + cursor: pointer; + margin-right: auto; + + &:hover:not(:disabled) { color: #e5e7eb; } + &:disabled { opacity: 0.4; cursor: not-allowed; } +} diff --git a/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.ts b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.ts new file mode 100644 index 0000000..5e89822 --- /dev/null +++ b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.ts @@ -0,0 +1,147 @@ +import { + Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, + ElementRef, ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + LucideAngularModule, Send, Sparkles, Trash2, BookmarkPlus, Square +} from 'lucide-angular'; +import { Subscription } from 'rxjs'; +import { AiChatService, ChatMessage } from '../../services/ai-chat.service'; + +/** + * Panneau de chat IA pour le mode jeu. + * + *

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é.

+ */ +@Component({ + selector: 'app-session-ai-chat-panel', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './session-ai-chat-panel.component.html', + styleUrls: ['./session-ai-chat-panel.component.scss'] +}) +export class SessionAiChatPanelComponent implements OnChanges, OnDestroy { + readonly Send = Send; + readonly Sparkles = Sparkles; + readonly Trash2 = Trash2; + readonly BookmarkPlus = BookmarkPlus; + readonly Square = Square; + + @Input() sessionId!: string; + @Input() canSaveToJournal = true; + + /** Émis quand le MJ clique "Ajouter au journal" sur une réponse. */ + @Output() saveToJournal = new EventEmitter(); + + @ViewChild('messagesContainer') messagesContainer?: ElementRef; + + messages: ChatMessage[] = []; + currentAssistantText = ''; + input = ''; + isStreaming = false; + error: string | null = null; + + private streamSub: Subscription | null = null; + + constructor(private aiChat: AiChatService) {} + + ngOnChanges(changes: SimpleChanges): void { + // Reset complet si on change de session (changement d'instance jouée). + if (changes['sessionId'] && !changes['sessionId'].firstChange) { + this.cancelStream(); + this.messages = []; + this.currentAssistantText = ''; + this.error = null; + } + } + + send(): void { + const text = this.input.trim(); + if (!text || this.isStreaming || !this.sessionId) return; + + this.messages = [...this.messages, { role: 'user', content: text }]; + this.input = ''; + this.error = null; + this.startStream(); + } + + private startStream(): void { + this.isStreaming = true; + this.currentAssistantText = ''; + this.scrollToBottomSoon(); + + this.streamSub = this.aiChat.streamChatForSession(this.sessionId, this.messages).subscribe({ + next: (event) => { + if (event.type === 'token') { + this.currentAssistantText += event.value; + this.scrollToBottomSoon(); + } else if (event.type === 'done') { + this.finishAssistantMessage(); + } + }, + error: (err: unknown) => { + const message = err instanceof Error ? err.message : 'Erreur inconnue'; + this.error = `Erreur IA : ${message}`; + this.isStreaming = false; + this.streamSub = null; + }, + complete: () => { + this.finishAssistantMessage(); + } + }); + } + + private finishAssistantMessage(): void { + if (this.currentAssistantText.trim()) { + this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText }]; + } + this.currentAssistantText = ''; + this.isStreaming = false; + this.streamSub = null; + this.scrollToBottomSoon(); + } + + cancelStream(): void { + if (this.streamSub) { + this.streamSub.unsubscribe(); + this.streamSub = null; + } + // On garde ce qui a déjà été streamé : utile si l'IA partait dans le mur. + if (this.currentAssistantText.trim()) { + this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText + ' [interrompu]' }]; + } + this.currentAssistantText = ''; + this.isStreaming = false; + } + + clearConversation(): void { + this.cancelStream(); + this.messages = []; + this.error = null; + } + + onSaveToJournal(content: string): void { + if (!this.canSaveToJournal) return; + this.saveToJournal.emit(content); + } + + /** Scroll vers le bas après cycle de change detection — preuve d'affichage du dernier token. */ + private scrollToBottomSoon(): void { + queueMicrotask(() => { + const el = this.messagesContainer?.nativeElement; + if (el) el.scrollTop = el.scrollHeight; + }); + } + + ngOnDestroy(): void { + this.cancelStream(); + } +} diff --git a/web/src/app/sessions/session-detail/session-detail.component.html b/web/src/app/sessions/session-detail/session-detail.component.html new file mode 100644 index 0000000..1f23ee5 --- /dev/null +++ b/web/src/app/sessions/session-detail/session-detail.component.html @@ -0,0 +1,188 @@ +
+ + + + Retour à la campagne + + +
+
+
+

+ + {{ session.name }} +

+ +
+ +
+ + + +
+ +
+ + {{ session.active ? 'En cours' : 'Terminée' }} + + Démarrée le {{ session.startedAt | date:'dd/MM/yyyy HH:mm' }} + + Terminée le {{ session.endedAt | date:'dd/MM/yyyy HH:mm' }} + +
+
+ +
+ + +
+
+ + +
+ + +
+ + +
+
+ +
+ + + +
+ Ctrl + Entrée pour ajouter + +
+
+ + +
+

Journal de session

+ +
+

Aucune entrée pour le moment.

+

+ Saisis une note, un évènement ou un jet ci-dessus pour commencer le journal. +

+
+ +
    +
  • + +
    + +
    + +
    + + +
    + {{ entryTypeMeta[entry.type].label }} + {{ entry.occurredAt | date:'HH:mm — dd/MM/yyyy' }} +
    + + +
    +
    +

    {{ entry.content }}

    +
    + + + +
    + +
    + +
    + + +
    +
    +
    + +
  • +
+
+
+ + + + +
+ +
diff --git a/web/src/app/sessions/session-detail/session-detail.component.scss b/web/src/app/sessions/session-detail/session-detail.component.scss new file mode 100644 index 0000000..0de122b --- /dev/null +++ b/web/src/app/sessions/session-detail/session-detail.component.scss @@ -0,0 +1,346 @@ +.session-detail { + padding: 2.5rem 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + max-width: 1500px; + margin: 0 auto; +} + +// ─────────────── Layout mode jeu (2 colonnes) ─────────────── +.play-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 1.5rem; + align-items: start; +} + +.play-main { + display: flex; + flex-direction: column; + gap: 1.5rem; + min-width: 0; +} + +/* + * Panneau latéral sticky pour garder dés + références visibles pendant + * que le MJ scroll dans le journal. Hauteur = viewport - padding pour ne + * pas déborder ; le panneau gère son propre scroll interne (.ref-content). + */ +.play-aside { + position: sticky; + top: 1rem; + height: calc(100vh - 3rem); + min-height: 0; +} + +@media (max-width: 1024px) { + .play-grid { + grid-template-columns: 1fr; + } + .play-aside { + position: static; + height: auto; + } +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: #9ca3af; + font-size: 0.85rem; + text-decoration: none; + width: fit-content; + + &:hover { color: #e5e7eb; } +} + +// ─────────────── Header ─────────────── +.detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1.5rem; + flex-wrap: wrap; + + .header-texts { flex: 1; min-width: 0; } + + h1 { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 1.75rem; + font-weight: 700; + color: white; + margin: 0; + } + + .title-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + + &.edit-mode input { + background: #0d1117; + border: 1px solid #374151; + color: white; + font-size: 1.25rem; + font-weight: 700; + padding: 0.4rem 0.75rem; + border-radius: 6px; + min-width: 280px; + } + } + + .meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + } + + .badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: #1f2937; + color: #9ca3af; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.75rem; + border-radius: 999px; + } + + .badge-active { background: #064e3b; color: #6ee7b7; } + .badge-muted { background: #1f2937; color: #9ca3af; } +} + +.header-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +// ─────────────── Boutons ─────────────── +.btn-primary, +.btn-secondary, +.btn-danger { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: none; + padding: 0.55rem 1rem; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } } +.btn-secondary{ background: #374151; color: #e5e7eb; &:hover:not(:disabled) { background: #4b5563; } } +.btn-danger { background: #7f1d1d; color: #fecaca; &:hover:not(:disabled) { background: #991b1b; } } + +.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.75rem; } + +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + background: transparent; + border: 1px solid #374151; + color: #9ca3af; + padding: 0.3rem 0.45rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.75rem; + transition: background 0.15s ease, color 0.15s ease; + + &:hover:not(:disabled) { background: #1f2937; color: #e5e7eb; } + &:disabled { opacity: 0.5; cursor: not-allowed; } + + &--danger:hover:not(:disabled) { background: #7f1d1d; color: #fecaca; border-color: #7f1d1d; } +} + +// ─────────────── Sections / cards ─────────────── +.detail-section { + background: #0d1117; + border: 1px solid #1f2937; + border-radius: 12px; + padding: 1.5rem 1.75rem; +} + +.timeline-section h2 { + margin: 0 0 1.25rem 0; + font-size: 1.1rem; + color: #e5e7eb; +} + +.empty-state { + text-align: center; + padding: 1.5rem 0; + color: #6b7280; + + p { margin: 0.25rem 0; } + .hint { font-size: 0.8rem; font-style: italic; } +} + +// ─────────────── Form "Ajouter une entrée" ─────────────── +.add-entry-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.type-selector { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + + &--compact { margin-bottom: 0.5rem; } +} + +/* + * Variable CSS --type-color injectée par le template depuis ENTRY_TYPE_META. + * Permet à chaque puce d'avoir sa propre teinte sans dupliquer la règle. + */ +.type-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: transparent; + border: 1px solid #374151; + color: #9ca3af; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.78rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; + + &:hover { border-color: var(--type-color, #6c63ff); color: #e5e7eb; } + + &--active { + background: color-mix(in srgb, var(--type-color, #6c63ff) 18%, transparent); + border-color: var(--type-color, #6c63ff); + color: var(--type-color, #6c63ff); + font-weight: 600; + } +} + +.entry-input { + width: 100%; + background: #111827; + border: 1px solid #1f2937; + color: #e5e7eb; + font-family: inherit; + font-size: 0.9rem; + padding: 0.65rem 0.85rem; + border-radius: 8px; + resize: vertical; + min-height: 70px; + + &:focus { outline: none; border-color: #6c63ff; } +} + +.entry-input-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + + .hint { color: #6b7280; font-size: 0.75rem; } +} + +// ─────────────── Timeline ─────────────── +.timeline { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1rem; + position: relative; + + // Ligne verticale qui relie les markers + &::before { + content: ''; + position: absolute; + left: 14px; + top: 8px; + bottom: 8px; + width: 2px; + background: #1f2937; + } +} + +.timeline-entry { + display: grid; + grid-template-columns: 30px 1fr; + gap: 0.85rem; + align-items: flex-start; +} + +.entry-marker { + width: 30px; + height: 30px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: #0d1117; + border: 2px solid var(--type-color, #6c63ff); + color: var(--type-color, #6c63ff); + flex-shrink: 0; + z-index: 1; +} + +.entry-body { + background: #111827; + border: 1px solid #1f2937; + border-radius: 10px; + padding: 0.75rem 1rem; + min-width: 0; +} + +.entry-header { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.4rem; + + .entry-type { + color: var(--type-color, #6c63ff); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + } + + .entry-time { + color: #6b7280; + font-size: 0.75rem; + } + + .entry-actions { + margin-left: auto; + display: flex; + gap: 0.3rem; + } +} + +.entry-content { + color: #e5e7eb; + font-size: 0.9rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} diff --git a/web/src/app/sessions/session-detail/session-detail.component.ts b/web/src/app/sessions/session-detail/session-detail.component.ts new file mode 100644 index 0000000..25e7f17 --- /dev/null +++ b/web/src/app/sessions/session-detail/session-detail.component.ts @@ -0,0 +1,276 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { + LucideAngularModule, LucideIconData, + Dices, ArrowLeft, Square, Trash2, Pencil, Check, + StickyNote, Sparkles, UserCheck, Plus, X +} from 'lucide-angular'; +import { catchError, switchMap, filter, map } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { SessionService } from '../../services/session.service'; +import { Session } from '../../services/session.model'; +import { + SessionEntry, SessionEntryInput, EntryType, ENTRY_TYPE_META +} from '../../services/session-entry.model'; +import { SessionEntryService } from '../../services/session-entry.service'; +import { LayoutService } from '../../services/layout.service'; +import { PageTitleService } from '../../services/page-title.service'; +import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service'; +import { SessionReferencePanelComponent } from '../session-reference-panel/session-reference-panel.component'; +import { DiceRollResult } from '../session-dice-panel/session-dice-panel.component'; + +/** + * Vue détail d'une Session avec journal horodaté. + * Form de saisie en haut, timeline en dessous (plus récent en premier). + * Le layout dédié "mode jeu" sera ajouté en Phase 4. + */ +@Component({ + selector: 'app-session-detail', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink, SessionReferencePanelComponent], + templateUrl: './session-detail.component.html', + styleUrls: ['./session-detail.component.scss'] +}) +export class SessionDetailComponent implements OnInit, OnDestroy { + readonly Dices = Dices; + readonly ArrowLeft = ArrowLeft; + readonly Square = Square; + readonly Trash2 = Trash2; + readonly Pencil = Pencil; + readonly Check = Check; + readonly Plus = Plus; + readonly X = X; + + /** Mapping enum → composant Lucide pour le rendu des icônes par type. */ + readonly typeIcons: Record = { + NOTE: StickyNote, + EVENT: Sparkles, + DICE_ROLL: Dices, + PLAYER_ACTION: UserCheck, + }; + readonly entryTypes: EntryType[] = ['NOTE', 'EVENT', 'DICE_ROLL', 'PLAYER_ACTION']; + readonly entryTypeMeta = ENTRY_TYPE_META; + + session: Session | null = null; + /** Timeline triée du plus récent au plus ancien (DESC) pour l'UX en partie. */ + entries: SessionEntry[] = []; + + editingName = false; + editName = ''; + + /** State de la zone "Ajouter une entrée". */ + newEntryType: EntryType = 'NOTE'; + newEntryContent = ''; + submittingEntry = false; + + /** Id de l'entrée en cours d'édition (null si aucune). */ + editingEntryId: string | null = null; + editEntryType: EntryType = 'NOTE'; + editEntryContent = ''; + + constructor( + private route: ActivatedRoute, + private router: Router, + private sessionService: SessionService, + private entryService: SessionEntryService, + private layoutService: LayoutService, + private pageTitleService: PageTitleService, + private confirmDialog: ConfirmDialogService + ) {} + + ngOnInit(): void { + this.layoutService.hide(); + this.route.paramMap.pipe( + map(pm => pm.get('id')), + filter((id): id is string => !!id), + switchMap(id => this.sessionService.getSessionById(id).pipe( + catchError(() => of(null)) + )) + ).subscribe(session => { + this.session = session; + if (session) { + this.pageTitleService.set(session.name); + this.loadEntries(session.id); + } + }); + } + + private loadEntries(sessionId: string): void { + this.entryService.getEntries(sessionId).pipe( + catchError(() => of([] as SessionEntry[])) + ).subscribe(list => { + this.entries = list.slice().sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); + }); + } + + // ─────────────── Renommage de la Session ─────────────── + + startRename(): void { + if (!this.session) return; + this.editName = this.session.name; + this.editingName = true; + } + + cancelRename(): void { + this.editingName = false; + this.editName = ''; + } + + saveRename(): void { + if (!this.session || !this.editName.trim()) return; + this.sessionService.renameSession(this.session.id, this.editName.trim()).subscribe({ + next: updated => { + this.session = updated; + this.editingName = false; + this.pageTitleService.set(updated.name); + }, + error: () => console.error('Erreur lors du renommage de la session') + }); + } + + // ─────────────── Fin / suppression de Session ─────────────── + + endSession(): void { + if (!this.session || !this.session.active) return; + const session = this.session; + this.confirmDialog.confirm({ + title: 'Terminer la session ?', + message: `Marquer la session "${session.name}" comme terminée ?`, + details: ['Tu pourras toujours consulter son contenu après.'], + confirmLabel: 'Terminer', + variant: 'warning' + }).then(ok => { + if (!ok) return; + this.sessionService.endSession(session.id).subscribe({ + next: updated => this.session = updated, + error: () => console.error('Erreur lors de la fin de session') + }); + }); + } + + deleteSession(): void { + if (!this.session) return; + const session = this.session; + const entryCount = this.entries.length; + const details = [ + entryCount > 0 + ? `${entryCount} entrée${entryCount > 1 ? 's' : ''} de journal sera également supprimée.` + : 'Aucune entrée de journal pour cette session.', + 'Cette action est irréversible.' + ]; + this.confirmDialog.confirm({ + title: 'Supprimer la session ?', + message: `Supprimer définitivement la session "${session.name}" ?`, + details, + confirmLabel: 'Supprimer', + variant: 'danger' + }).then(ok => { + if (!ok) return; + const campaignId = session.campaignId; + this.sessionService.deleteSession(session.id).subscribe({ + next: () => this.router.navigate(['/campaigns', campaignId]), + error: () => console.error('Erreur lors de la suppression de la session') + }); + }); + } + + // ─────────────── Ajout d'entrée ─────────────── + + submitNewEntry(): void { + if (!this.session || this.submittingEntry) return; + const content = this.newEntryContent.trim(); + if (!content) return; + this.submittingEntry = true; + const input: SessionEntryInput = { type: this.newEntryType, content }; + this.entryService.createEntry(this.session.id, input).subscribe({ + next: created => { + this.submittingEntry = false; + this.entries = [created, ...this.entries]; + this.newEntryContent = ''; + }, + error: () => { + this.submittingEntry = false; + console.error('Erreur lors de l\'ajout de l\'entrée'); + } + }); + } + + // ─────────────── Édition d'entrée ─────────────── + + startEditEntry(entry: SessionEntry): void { + this.editingEntryId = entry.id; + this.editEntryType = entry.type; + this.editEntryContent = entry.content; + } + + cancelEditEntry(): void { + this.editingEntryId = null; + this.editEntryContent = ''; + } + + saveEditEntry(entry: SessionEntry): void { + if (!this.session) return; + const content = this.editEntryContent.trim(); + if (!content) return; + const input: SessionEntryInput = { type: this.editEntryType, content }; + this.entryService.updateEntry(this.session.id, entry.id, input).subscribe({ + next: updated => { + this.entries = this.entries.map(e => e.id === updated.id ? updated : e); + this.editingEntryId = null; + }, + error: () => console.error('Erreur lors de la mise à jour de l\'entrée') + }); + } + + /** + * Réception d'un jet de dés depuis le panneau latéral. + * On crée une entrée DICE_ROLL dans le journal avec le résumé formaté. + */ + onDiceRolled(result: DiceRollResult): void { + if (!this.session || !this.session.active) return; + const input: SessionEntryInput = { type: 'DICE_ROLL', content: result.summary }; + this.entryService.createEntry(this.session.id, input).subscribe({ + next: created => this.entries = [created, ...this.entries], + error: () => console.error('Erreur lors de l\'ajout du jet au journal') + }); + } + + /** + * Réception d'une réponse IA à sauvegarder dans le journal. + * Type NOTE par défaut car c'est le MJ qui choisit de capter une suggestion + * comme repère — pas un évènement de partie en lui-même. + */ + onAiReplyToJournal(content: string): void { + if (!this.session || !this.session.active) return; + const trimmed = content.trim(); + if (!trimmed) return; + const input: SessionEntryInput = { type: 'NOTE', content: '💡 ' + trimmed }; + this.entryService.createEntry(this.session.id, input).subscribe({ + next: created => this.entries = [created, ...this.entries], + error: () => console.error('Erreur lors de l\'ajout de la suggestion IA au journal') + }); + } + + deleteEntry(entry: SessionEntry): void { + if (!this.session) return; + const session = this.session; + this.confirmDialog.confirm({ + title: 'Supprimer cette entrée ?', + message: 'Cette entrée du journal sera définitivement supprimée.', + confirmLabel: 'Supprimer', + variant: 'danger' + }).then(ok => { + if (!ok) return; + this.entryService.deleteEntry(session.id, entry.id).subscribe({ + next: () => this.entries = this.entries.filter(e => e.id !== entry.id), + error: () => console.error('Erreur lors de la suppression de l\'entrée') + }); + }); + } + + ngOnDestroy(): void { + this.layoutService.hide(); + } +} diff --git a/web/src/app/sessions/session-dice-panel/session-dice-panel.component.html b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.html new file mode 100644 index 0000000..76663c4 --- /dev/null +++ b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.html @@ -0,0 +1,61 @@ +
+ +
+
+ +
+ +
+ + +
+ + +
+ +
+
+ Derniers jets + +
+ +
    +
  • +
    + {{ r.notation }} + [{{ r.rolls.join(', ') }}] + = {{ r.total }} +
    + +
  • +
+
+ +

+ Choisis un dé et lance. +

+ +
diff --git a/web/src/app/sessions/session-dice-panel/session-dice-panel.component.scss b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.scss new file mode 100644 index 0000000..fc90812 --- /dev/null +++ b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.scss @@ -0,0 +1,172 @@ +.dice-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.dice-controls { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.face-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.4rem; +} + +.face-chip { + background: #111827; + border: 1px solid #1f2937; + color: #9ca3af; + font-size: 0.85rem; + font-weight: 600; + padding: 0.45rem 0.5rem; + border-radius: 6px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; + + &:hover { border-color: #6c63ff; color: #e5e7eb; } + + &--active { + background: rgba(108, 99, 255, 0.18); + border-color: #6c63ff; + color: #c4bdff; + } +} + +.dice-inputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 0.25rem; + + span { + color: #9ca3af; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.4px; + } + + input { + background: #111827; + border: 1px solid #1f2937; + color: #e5e7eb; + padding: 0.4rem 0.55rem; + border-radius: 6px; + font-size: 0.85rem; + width: 100%; + + &:focus { outline: none; border-color: #6c63ff; } + } +} + +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + background: #6c63ff; + color: white; + border: none; + padding: 0.6rem 1rem; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + + &:hover:not(:disabled) { background: #5b52e0; } + &:disabled { opacity: 0.5; cursor: not-allowed; } +} + +.btn-roll { width: 100%; } + +// ─────────────── Historique ─────────────── +.dice-history { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.history-header { + display: flex; + justify-content: space-between; + align-items: center; + color: #9ca3af; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.history-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + background: #111827; + border: 1px solid #1f2937; + border-radius: 6px; + padding: 0.4rem 0.55rem; + font-size: 0.8rem; +} + +.history-text { + display: flex; + align-items: baseline; + gap: 0.4rem; + min-width: 0; + flex-wrap: wrap; + + .history-notation { color: #c4bdff; font-weight: 600; } + .history-detail { color: #6b7280; font-size: 0.72rem; } + .history-total { color: white; font-weight: 700; margin-left: auto; } +} + +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid #374151; + color: #9ca3af; + padding: 0.25rem 0.35rem; + border-radius: 5px; + cursor: pointer; + + &:hover:not(:disabled) { background: #1f2937; color: #e5e7eb; } + &:disabled { opacity: 0.4; cursor: not-allowed; } +} + +.btn-link { + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + padding: 0; + + &:hover { color: #e5e7eb; } +} + +.placeholder-hint { + color: #6b7280; + font-size: 0.8rem; + font-style: italic; + text-align: center; + margin: 0; +} diff --git a/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts new file mode 100644 index 0000000..eee5ab3 --- /dev/null +++ b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts @@ -0,0 +1,92 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { LucideAngularModule, Dices, BookmarkPlus, Trash2 } from 'lucide-angular'; + +/** Faces de dés supportées par le roller. */ +const DICE_FACES = [4, 6, 8, 10, 12, 20, 100] as const; +type DiceFace = typeof DICE_FACES[number]; + +/** Résultat d'un jet, exposé au parent pour ajout au journal. */ +export interface DiceRollResult { + /** Notation lisible, ex: "2d6+3". */ + notation: string; + /** Détail des dés individuels. */ + rolls: number[]; + modifier: number; + total: number; + /** Formatage textuel prêt à être écrit dans le journal. */ + summary: string; +} + +/** + * Panneau de jet de dés pour une session. + * Composant isolé : choix face/quantité/modificateur, jet, historique local court, + * et émission d'un événement vers le parent pour ajout au journal. + */ +@Component({ + selector: 'app-session-dice-panel', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './session-dice-panel.component.html', + styleUrls: ['./session-dice-panel.component.scss'] +}) +export class SessionDicePanelComponent { + readonly Dices = Dices; + readonly BookmarkPlus = BookmarkPlus; + readonly Trash2 = Trash2; + + readonly faces: readonly DiceFace[] = DICE_FACES; + + /** Désactive le bouton "Ajouter au journal" si la session est terminée. */ + @Input() canAddToJournal = true; + + @Output() rolled = new EventEmitter(); + + selectedFace: DiceFace = 20; + count = 1; + modifier = 0; + + /** Historique local (max 8 entrées) pour permettre de retrouver un jet récent. */ + history: DiceRollResult[] = []; + + roll(): void { + const safeCount = Math.max(1, Math.min(20, Math.floor(this.count))); + const rolls: number[] = []; + for (let i = 0; i < safeCount; i++) { + rolls.push(this.randomFace(this.selectedFace)); + } + const sumRolls = rolls.reduce((s, n) => s + n, 0); + const total = sumRolls + this.modifier; + const modPart = this.modifier === 0 ? '' : (this.modifier > 0 ? `+${this.modifier}` : `${this.modifier}`); + const notation = `${safeCount}d${this.selectedFace}${modPart}`; + const detailsPart = rolls.length > 1 ? ` [${rolls.join(', ')}]` : ''; + const summary = `🎲 ${notation}${detailsPart} = ${total}`; + + const result: DiceRollResult = { notation, rolls, modifier: this.modifier, total, summary }; + this.history = [result, ...this.history].slice(0, 8); + } + + /** Émet vers le parent pour qu'il insère le jet comme entrée DICE_ROLL. */ + addToJournal(result: DiceRollResult): void { + if (!this.canAddToJournal) return; + this.rolled.emit(result); + } + + clearHistory(): void { + this.history = []; + } + + /** + * crypto.getRandomValues si dispo, fallback Math.random sinon. + * Pas critique pour du JDR mais évite le biais Math.random sur les très petites distributions. + */ + private randomFace(face: DiceFace): number { + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + const buf = new Uint32Array(1); + crypto.getRandomValues(buf); + return (buf[0] % face) + 1; + } + return Math.floor(Math.random() * face) + 1; + } +} diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html new file mode 100644 index 0000000..10cd13c --- /dev/null +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html @@ -0,0 +1,120 @@ +
+ + + +
+ + + + + + + + + + +
+

Chargement…

+ +
+
+

+ + Personnages joueurs +

+ +
+ +
+

+ + Personnages non-joueurs +

+ +
+ +

+ Aucun personnage dans cette campagne. +

+
+
+ + +
+

Chargement…

+ + +

+ Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici. +

+ +
+

+ + {{ arc.name }} +

+ +
+ {{ chapter.name }} + +
+
+
+
+ +
+
diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.scss b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.scss new file mode 100644 index 0000000..b3d18f4 --- /dev/null +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.scss @@ -0,0 +1,143 @@ +.reference-panel { + display: flex; + flex-direction: column; + background: #0d1117; + border: 1px solid #1f2937; + border-radius: 12px; + overflow: hidden; + height: 100%; + min-height: 0; +} + +// ─────────────── Tabs ─────────────── +.ref-tabs { + display: flex; + background: #111827; + border-bottom: 1px solid #1f2937; +} + +.ref-tab { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + background: transparent; + color: #9ca3af; + border: none; + padding: 0.7rem 0.5rem; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s, background 0.15s; + + &:hover { color: #e5e7eb; background: rgba(108, 99, 255, 0.06); } + + &--active { + color: #c4bdff; + border-bottom-color: #6c63ff; + background: rgba(108, 99, 255, 0.1); + } +} + +.ref-content { + padding: 1rem; + overflow-y: auto; + flex: 1; + min-height: 0; +} + +/* + * Onglet IA : on retire l'overflow du conteneur (le panneau de chat gère + * son propre scroll interne pour les messages) et on force display flex + * pour que l'enfant prenne toute la hauteur. + */ +.ref-content--fill { + display: flex; + overflow: hidden; + + > app-session-ai-chat-panel { + flex: 1; + min-height: 0; + display: flex; + } +} + +// ─────────────── Listes (PJ/PNJ/Scènes) ─────────────── +.ref-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.ref-group { + display: flex; + flex-direction: column; + gap: 0.35rem; + + h4 { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: #9ca3af; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.4px; + margin: 0 0 0.25rem 0; + } +} + +.ref-subgroup { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding-left: 0.25rem; + margin-top: 0.35rem; + + .ref-subgroup-title { + color: #6b7280; + font-size: 0.72rem; + margin-bottom: 0.15rem; + } +} + +.ref-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + background: #111827; + border: 1px solid #1f2937; + color: #e5e7eb; + font-size: 0.82rem; + text-align: left; + padding: 0.45rem 0.65rem; + border-radius: 6px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + + &:hover { border-color: #6c63ff; background: #131c2e; } + + &--nested { padding-left: 0.9rem; } + + .ref-item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ref-item-icon { + color: #6b7280; + flex-shrink: 0; + } +} + +.loading-hint, +.empty-hint { + color: #6b7280; + font-size: 0.8rem; + font-style: italic; + text-align: center; + margin: 0.5rem 0; +} diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts new file mode 100644 index 0000000..f59899f --- /dev/null +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts @@ -0,0 +1,138 @@ +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles } from 'lucide-angular'; +import { catchError, of } from 'rxjs'; +import { CampaignService } from '../../services/campaign.service'; +import { CharacterService } from '../../services/character.service'; +import { NpcService } from '../../services/npc.service'; +import { Character } from '../../services/character.model'; +import { Npc } from '../../services/npc.model'; +import { Arc, Chapter, Scene } from '../../services/campaign.model'; +import { loadCampaignTreeData, CampaignTreeData } from '../../campaigns/campaign-tree.helper'; +import { + SessionDicePanelComponent, DiceRollResult +} from '../session-dice-panel/session-dice-panel.component'; +import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-panel.component'; + +type TabId = 'dice' | 'characters' | 'scenes' | 'ai'; + +/** + * Panneau latéral du mode jeu : référence rapide en lecture seule. + * + *

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); + } +}