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