Plusieurs ajouts :
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes. - Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement. - Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes. Passage en 0.9.1-beta
This commit is contained in:
@@ -23,10 +23,12 @@ from app.domain.models import (
|
||||
CharacterSummary,
|
||||
NpcSummary,
|
||||
GameSystemContext,
|
||||
JournalEntrySummary,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
PageSummary,
|
||||
QuestSummary,
|
||||
SessionContext,
|
||||
)
|
||||
from app.domain.ports import LLMChatProvider
|
||||
@@ -294,7 +296,8 @@ class ChatUseCase:
|
||||
else:
|
||||
for scene in chapter.scenes:
|
||||
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
|
||||
block.append(f" - {scene.name} (scène){sc_hint}")
|
||||
scene_kind = " (lieu explorable)" if scene.rooms else " (scène)"
|
||||
block.append(f" - {scene.name}{scene_kind}{sc_hint}")
|
||||
if scene.description:
|
||||
block.append(f" Description : {scene.description}")
|
||||
for br in scene.branches:
|
||||
@@ -302,6 +305,19 @@ class ChatUseCase:
|
||||
block.append(
|
||||
f' → "{br.label}" vers {br.target_scene_name}{cond}'
|
||||
)
|
||||
# Pièces du lieu explorable (mode donjon)
|
||||
for room in scene.rooms:
|
||||
floor = f" [étage {room.floor}]" if room.floor is not None else ""
|
||||
block.append(f" ◆ {room.name}{floor}")
|
||||
if room.description:
|
||||
block.append(f" {room.description}")
|
||||
if room.enemies:
|
||||
block.append(f" Ennemis : {room.enemies}")
|
||||
for rb in room.branches:
|
||||
cond = f" (si : {rb.condition})" if rb.condition else ""
|
||||
block.append(
|
||||
f' ↳ "{rb.label}" vers {rb.target_room_name}{cond}'
|
||||
)
|
||||
return block
|
||||
|
||||
@staticmethod
|
||||
@@ -359,35 +375,42 @@ class ChatUseCase:
|
||||
|
||||
@staticmethod
|
||||
def _format_session(sc: SessionContext) -> str:
|
||||
"""Bloc journal de la session en cours.
|
||||
"""Bloc journal de la session en cours + résumé des sessions précédentes.
|
||||
|
||||
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
|
||||
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
|
||||
rappeler un évènement antérieur, ou rebondir sur une action joueur.
|
||||
|
||||
Pour les sessions PRÉCÉDENTES de la même campagne, on ne remonte que
|
||||
les EVENTs (les moments marquants) pour préserver le contexte LLM.
|
||||
"""
|
||||
status = "EN COURS" if sc.active else "TERMINÉE"
|
||||
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
|
||||
|
||||
previous_block = ChatUseCase._format_previous_events(sc.previous_events)
|
||||
hub_block = ChatUseCase._format_hub_status(sc)
|
||||
|
||||
if not sc.entries:
|
||||
entries_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
|
||||
current_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
|
||||
else:
|
||||
lines: list[str] = []
|
||||
for e in sc.entries:
|
||||
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
|
||||
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
|
||||
# Indentation sur les contenus multi-lignes pour préserver la lisibilité
|
||||
content = e.content.replace("\n", "\n ")
|
||||
lines.append(f"- {label}{ts} : {content}")
|
||||
entries_block = "\n".join(lines)
|
||||
current_block = "\n".join(lines)
|
||||
|
||||
return (
|
||||
"--- SESSION DE JEU EN COURS ---\n"
|
||||
f"Nom : {sc.session_name}\n"
|
||||
f"Statut : {status}{started}\n\n"
|
||||
"Journal chronologique (du plus ancien au plus récent) :\n"
|
||||
f"{entries_block}\n\n"
|
||||
f"Statut : {status}{started}\n"
|
||||
f"{hub_block}"
|
||||
f"{previous_block}"
|
||||
"\nJournal chronologique de la session courante (du plus ancien au plus récent) :\n"
|
||||
f"{current_block}\n\n"
|
||||
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
|
||||
"- Tenir compte des évènements déjà capturés dans le journal ci-dessus.\n"
|
||||
"- Tenir compte des évènements déjà capturés (sessions précédentes + journal courant).\n"
|
||||
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
|
||||
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
|
||||
"qui s'enchaînent à ce qui vient de se passer.\n"
|
||||
@@ -395,6 +418,87 @@ class ChatUseCase:
|
||||
"il a besoin d'idées immédiatement actionnables."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_hub_status(sc: SessionContext) -> str:
|
||||
"""Bloc Hub : quêtes ouvertes + flags actifs.
|
||||
|
||||
Vide si la campagne n'a aucun Arc HUB (toutes les listes vides côté Core).
|
||||
Les quêtes LOCKED apparaissent par leur TITRE uniquement : l'IA sait
|
||||
qu'elles existent (utile pour les teasers, les rumeurs en jeu) mais ne
|
||||
peut pas spoiler leurs détails.
|
||||
"""
|
||||
if (not sc.available_quests
|
||||
and not sc.in_progress_quests
|
||||
and not sc.locked_quest_titles
|
||||
and not sc.active_flags):
|
||||
return ""
|
||||
|
||||
lines = ["", "État du Hub (quêtes parallèles et faits narratifs) :"]
|
||||
|
||||
if sc.in_progress_quests:
|
||||
lines.append(" Quêtes en cours :")
|
||||
lines.extend(ChatUseCase._format_quest_lines(sc.in_progress_quests))
|
||||
|
||||
if sc.available_quests:
|
||||
lines.append(" Quêtes disponibles (non démarrées, prêtes à être lancées) :")
|
||||
lines.extend(ChatUseCase._format_quest_lines(sc.available_quests))
|
||||
|
||||
if sc.locked_quest_titles:
|
||||
titles = ", ".join(f'"{t}"' for t in sc.locked_quest_titles)
|
||||
lines.append(
|
||||
" Quêtes encore verrouillées (existent mais non accessibles — "
|
||||
f"tu peux y faire allusion sous forme de rumeurs sans spoiler leur contenu) : {titles}"
|
||||
)
|
||||
|
||||
if sc.active_flags:
|
||||
lines.append(
|
||||
" Faits actifs : " + ", ".join(f"`{f}`" for f in sc.active_flags)
|
||||
)
|
||||
|
||||
lines.append(
|
||||
" Conseille des actions cohérentes avec ces quêtes ouvertes. Ne fais "
|
||||
"PAS comme si une quête verrouillée était déjà accessible aux PJ."
|
||||
)
|
||||
lines.append("") # séparateur visuel avant le récap des sessions précédentes
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _format_quest_lines(quests: list[QuestSummary]) -> list[str]:
|
||||
"""Sérialise une liste de QuestSummary en lignes indentées."""
|
||||
out: list[str] = []
|
||||
for q in quests:
|
||||
arc = f" [arc : {q.arc_name}]" if q.arc_name else ""
|
||||
out.append(f" - {q.name}{arc}")
|
||||
if q.description:
|
||||
out.append(f" Synopsis : {q.description}")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _format_previous_events(events: list[JournalEntrySummary]) -> str:
|
||||
"""Bloc "Story so far" : EVENTs marquants des sessions antérieures.
|
||||
|
||||
Vide si la campagne en est à sa première session. On groupe par
|
||||
session source pour aider l'IA à situer chaque évènement temporellement.
|
||||
"""
|
||||
if not events:
|
||||
return ""
|
||||
|
||||
# Groupement par session source en préservant l'ordre d'apparition.
|
||||
grouped: dict[str, list[JournalEntrySummary]] = {}
|
||||
for e in events:
|
||||
key = e.source_session_name or "(session inconnue)"
|
||||
grouped.setdefault(key, []).append(e)
|
||||
|
||||
lines = ["\nRécapitulatif des sessions précédentes (évènements marquants uniquement) :"]
|
||||
for session_name, items in grouped.items():
|
||||
lines.append(f" • {session_name} :")
|
||||
for e in items:
|
||||
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
|
||||
content = e.content.replace("\n", "\n ")
|
||||
lines.append(f" - {content}{ts}")
|
||||
lines.append("") # ligne vide avant le bloc journal courant
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
|
||||
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
|
||||
|
||||
@@ -122,9 +122,29 @@ class SceneBranchHint:
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomBranchHint:
|
||||
"""Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
|
||||
|
||||
label: str
|
||||
target_room_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomSummary:
|
||||
"""Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
|
||||
|
||||
name: str
|
||||
floor: int | None = None
|
||||
description: str | None = None
|
||||
enemies: str | None = None
|
||||
branches: list[RoomBranchHint] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneSummary:
|
||||
"""Résumé d'une scène : nom + description courte + illustrations + branches."""
|
||||
"""Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
|
||||
|
||||
name: str
|
||||
description: str | None
|
||||
@@ -133,6 +153,8 @@ class SceneSummary:
|
||||
illustration_count: int = 0
|
||||
# Connexions narratives sortantes (livre dont vous etes le heros).
|
||||
branches: list[SceneBranchHint] = field(default_factory=list)
|
||||
# Pièces du lieu explorable (vide = scène classique).
|
||||
rooms: list[RoomSummary] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -233,26 +255,49 @@ class GameSystemContext:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JournalEntrySummary:
|
||||
"""Une entrée du journal d'une Session : type + contenu + horodatage."""
|
||||
"""Une entrée du journal d'une Session.
|
||||
|
||||
`source_session_name` n'est renseigné que pour les entrées issues de
|
||||
sessions précédentes (option 3 : continuité narrative entre séances).
|
||||
"""
|
||||
|
||||
type: str
|
||||
content: str
|
||||
occurred_at: str | None
|
||||
source_session_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestSummary:
|
||||
"""Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
|
||||
|
||||
Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
|
||||
dans available_quests / in_progress_quests / locked_quest_titles.
|
||||
"""
|
||||
|
||||
name: str
|
||||
arc_name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionContext:
|
||||
"""Contexte d'une Session de jeu en cours (Play Context).
|
||||
|
||||
Injecté dans le system prompt pendant qu'une partie est jouée pour que
|
||||
l'IA voit le nom de la session, son statut, et un historique chronologique
|
||||
des évènements/notes/jets capturés par le MJ.
|
||||
|
||||
Le journal a déjà été tronqué côté Core (cap à ~80 entrées récentes)
|
||||
pour ne pas saturer le contexte LLM sur les sessions très longues.
|
||||
Combine plusieurs niveaux :
|
||||
- `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
|
||||
- `previous_events` : EVENTs marquants des sessions précédentes (continuité)
|
||||
- `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
|
||||
- `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
|
||||
- `active_flags` : noms des flags de campagne actuellement à true
|
||||
"""
|
||||
|
||||
session_name: str
|
||||
active: bool
|
||||
started_at: str | None
|
||||
entries: list[JournalEntrySummary]
|
||||
previous_events: list[JournalEntrySummary]
|
||||
available_quests: list[QuestSummary] = field(default_factory=list)
|
||||
in_progress_quests: list[QuestSummary] = field(default_factory=list)
|
||||
locked_quest_titles: list[str] = field(default_factory=list)
|
||||
active_flags: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -32,6 +32,9 @@ from app.domain.models import (
|
||||
PageContext,
|
||||
PageGenerationContext,
|
||||
PageSummary,
|
||||
QuestSummary,
|
||||
RoomBranchHint,
|
||||
RoomSummary,
|
||||
SceneBranchHint,
|
||||
SceneSummary,
|
||||
SessionContext,
|
||||
@@ -43,7 +46,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.9.0-beta",
|
||||
version="0.9.1-beta",
|
||||
)
|
||||
|
||||
|
||||
@@ -171,6 +174,24 @@ class SceneBranchHintDTO(BaseModel):
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RoomBranchHintDTO(BaseModel):
|
||||
"""Sortie d'une pièce vers une autre pièce du même lieu (donjon)."""
|
||||
|
||||
label: str
|
||||
target_room_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RoomSummaryDTO(BaseModel):
|
||||
"""Pièce d'un lieu explorable. Omise par le Core si la scène est classique."""
|
||||
|
||||
name: str
|
||||
floor: int | None = None
|
||||
description: str | None = None
|
||||
enemies: str | None = None
|
||||
branches: list[RoomBranchHintDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SceneSummaryDTO(BaseModel):
|
||||
"""Résumé d'une scène : nom + description courte (synopsis)."""
|
||||
|
||||
@@ -181,6 +202,8 @@ class SceneSummaryDTO(BaseModel):
|
||||
illustration_count: int = 0
|
||||
# Branches narratives sortantes, omises cote Core si vides.
|
||||
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
|
||||
# Pièces du lieu explorable, omises par Core si scène classique.
|
||||
rooms: list[RoomSummaryDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChapterSummaryDTO(BaseModel):
|
||||
@@ -246,24 +269,44 @@ class GameSystemContextDTO(BaseModel):
|
||||
|
||||
|
||||
class JournalEntrySummaryDTO(BaseModel):
|
||||
"""Une entrée du journal de session — type + contenu + horodatage."""
|
||||
"""Une entrée du journal de session.
|
||||
|
||||
`source_session_name` est présent uniquement pour les évènements issus
|
||||
des sessions précédentes — sert à ancrer temporellement dans le prompt.
|
||||
"""
|
||||
|
||||
type: str
|
||||
content: str
|
||||
occurred_at: str | None = None
|
||||
source_session_name: str | None = None
|
||||
|
||||
|
||||
class QuestSummaryDTO(BaseModel):
|
||||
"""Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine."""
|
||||
|
||||
name: str
|
||||
arc_name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SessionContextDTO(BaseModel):
|
||||
"""Contexte d'une Session de jeu en cours (Play Context).
|
||||
|
||||
Injecté par le Core quand le chat est ancré sur une Session.
|
||||
Contient le journal chronologique (déjà plafonné côté Core).
|
||||
Combine le journal complet (`entries`), les EVENTs des sessions précédentes
|
||||
(`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes
|
||||
Hub de la campagne (disponibles / en cours / verrouillées) plus les flags
|
||||
narratifs actuellement actifs.
|
||||
"""
|
||||
|
||||
session_name: str
|
||||
active: bool
|
||||
started_at: str | None = None
|
||||
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
|
||||
previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list)
|
||||
available_quests: list[QuestSummaryDTO] = Field(default_factory=list)
|
||||
in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list)
|
||||
locked_quest_titles: list[str] = Field(default_factory=list)
|
||||
active_flags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChatStreamRequestDTO(BaseModel):
|
||||
@@ -580,6 +623,23 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
|
||||
)
|
||||
for br in sc.branches
|
||||
],
|
||||
rooms=[
|
||||
RoomSummary(
|
||||
name=room.name,
|
||||
floor=room.floor,
|
||||
description=room.description,
|
||||
enemies=room.enemies,
|
||||
branches=[
|
||||
RoomBranchHint(
|
||||
label=rb.label,
|
||||
target_room_name=rb.target_room_name,
|
||||
condition=rb.condition,
|
||||
)
|
||||
for rb in room.branches
|
||||
],
|
||||
)
|
||||
for room in sc.rooms
|
||||
],
|
||||
)
|
||||
for sc in ch.scenes
|
||||
],
|
||||
@@ -903,17 +963,31 @@ def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemConte
|
||||
def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
entries = [
|
||||
JournalEntrySummary(
|
||||
type=e.type,
|
||||
content=e.content,
|
||||
occurred_at=e.occurred_at,
|
||||
)
|
||||
for e in dto.entries
|
||||
]
|
||||
return SessionContext(
|
||||
session_name=dto.session_name,
|
||||
active=dto.active,
|
||||
started_at=dto.started_at,
|
||||
entries=entries,
|
||||
entries=[_to_journal_entry(e) for e in dto.entries],
|
||||
previous_events=[_to_journal_entry(e) for e in dto.previous_events],
|
||||
available_quests=[_to_quest_summary(q) for q in dto.available_quests],
|
||||
in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests],
|
||||
locked_quest_titles=list(dto.locked_quest_titles),
|
||||
active_flags=list(dto.active_flags),
|
||||
)
|
||||
|
||||
|
||||
def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary:
|
||||
return QuestSummary(
|
||||
name=dto.name,
|
||||
arc_name=dto.arc_name,
|
||||
description=dto.description,
|
||||
)
|
||||
|
||||
|
||||
def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary:
|
||||
return JournalEntrySummary(
|
||||
type=dto.type,
|
||||
content=dto.content,
|
||||
occurred_at=dto.occurred_at,
|
||||
source_session_name=dto.source_session_name,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user