diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py
index 0f0c72e..3d1144b 100644
--- a/brain/app/application/chat.py
+++ b/brain/app/application/chat.py
@@ -20,6 +20,8 @@ from app.domain.models import (
CampaignStructuralContext,
ChatMessage,
ChapterSummary,
+ CharacterSummary,
+ GameSystemContext,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
@@ -63,16 +65,17 @@ class ChatUseCase:
page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
+ game_system_context: GameSystemContext | None = None,
) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user.
- Les 4 contextes sont tous optionnels, mais au moins l'un des deux
+ Les contextes sont tous optionnels, mais au moins l'un des deux
"niveaux haut" (lore_context ou campaign_context) doit être fourni
pour que le prompt ait du sens. Le controller (main.py) applique
cette règle à la frontière HTTP.
"""
system_prompt = self._build_system_prompt(
- lore_context, page_context, campaign_context, narrative_entity
+ lore_context, page_context, campaign_context, narrative_entity, game_system_context
)
async for token in self._llm.stream_chat(
messages,
@@ -87,12 +90,13 @@ class ChatUseCase:
page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
+ game_system_context: GameSystemContext | 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
+ lore_context, page_context, campaign_context, narrative_entity, game_system_context
)
# --- Construction du system prompt --------------------------------------
@@ -103,12 +107,15 @@ class ChatUseCase:
page: PageContext | None,
campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None,
+ game_system: GameSystemContext | None = None,
) -> str:
sections = [_BASE_SYSTEM]
if lore is not None:
sections.append(self._format_lore(lore))
if campaign is not None:
sections.append(self._format_campaign(campaign, lore_present=lore is not None))
+ if game_system is not None:
+ sections.append(self._format_game_system(game_system))
if page is not None:
sections.append(self._format_page(page))
if narrative is not None:
@@ -190,14 +197,40 @@ class ChatUseCase:
if lore_present
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
)
+ characters_block = ChatUseCase._format_characters(ctx.characters)
return (
"--- CAMPAGNE COURANTE ---\n"
- f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n"
+ f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
+ f"{characters_block}\n"
"Structure narrative (les flèches → indiquent des transitions de scène "
"déclenchées par un choix des joueurs) :\n"
f"{arcs_block}"
)
+ @staticmethod
+ def _format_characters(characters: list[CharacterSummary]) -> str:
+ """Bloc PJ — liste nom + snippet. Rappel anti-hallucination IA.
+
+ Si la campagne n'a aucun PJ, on le signale explicitement : l'IA ne
+ doit pas inventer "les héros" ou leurs noms dans ses suggestions.
+ """
+ if not characters:
+ return (
+ "\nPersonnages joueurs : aucune fiche pour l'instant. Ne suppose "
+ "ni noms ni classes pour les PJ tant que le MJ ne les a pas créés.\n"
+ )
+ lines = ["\nPersonnages joueurs (PJ) :"]
+ for c in characters:
+ if c.snippet:
+ lines.append(f"- **{c.name}** — {c.snippet}")
+ else:
+ lines.append(f"- **{c.name}** (fiche vide)")
+ lines.append(
+ "Pour une fiche complète (stats, backstory), n'invente rien : "
+ "demande au MJ d'ouvrir l'éditeur du PJ pour te donner les détails."
+ )
+ return "\n".join(lines) + "\n"
+
@staticmethod
def _format_arcs(arcs: list[ArcSummary]) -> str:
if not arcs:
@@ -248,12 +281,46 @@ class ChatUseCase:
noun = "illustration" if count == 1 else "illustrations"
return f" [{count} {noun}]"
+ # --- Bloc Système de JDR ------------------------------------------------
+
+ @staticmethod
+ def _format_game_system(gs: GameSystemContext) -> str:
+ """Bloc des règles du système de JDR de la campagne.
+
+ Les sections ont été filtrées côté Core selon l'intent (combat,
+ classes, lore...). Si aucune section n'a matché, on affiche juste
+ le nom du système comme rappel de cadre.
+ """
+ desc = f"\nDescription : {gs.system_description}" if gs.system_description else ""
+ if not gs.sections:
+ return (
+ "--- SYSTÈME DE JDR ---\n"
+ f"Nom : {gs.system_name}{desc}\n"
+ "(Aucune section de règles pertinente pour ce type de génération — "
+ "reste cohérent avec l'univers et les conventions du système.)"
+ )
+ sections_block = "\n\n".join(
+ f"### {title}\n{content}" for title, content in gs.sections.items()
+ )
+ return (
+ "--- SYSTÈME DE JDR ---\n"
+ f"Nom : {gs.system_name}{desc}\n\n"
+ "Respecte scrupuleusement les règles et conventions ci-dessous quand "
+ "tu proposes des stats, classes, rencontres, mécaniques ou éléments "
+ "d'ambiance. Les noms propres (classes, sorts, monstres) doivent "
+ "venir de ces règles — n'en invente pas d'autres.\n\n"
+ f"{sections_block}"
+ )
+
@staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
- type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get(
- ne.entity_type.lower(), ne.entity_type.upper()
- )
+ type_label = {
+ "arc": "ARC",
+ "chapter": "CHAPITRE",
+ "scene": "SCÈNE",
+ "character": "FICHE DE PERSONNAGE",
+ }.get(ne.entity_type.lower(), ne.entity_type.upper())
if ne.fields:
fields_block = "\n".join(
f'- "{key}" : {value or "(vide)"}'
diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py
index ec17322..cfbbb96 100644
--- a/brain/app/domain/models.py
+++ b/brain/app/domain/models.py
@@ -169,6 +169,20 @@ class CampaignStructuralContext:
campaign_name: str
campaign_description: str | None
arcs: list[ArcSummary]
+ characters: list["CharacterSummary"] = field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class CharacterSummary:
+ """Résumé d'un PJ : nom + snippet court extrait du markdown de la fiche.
+
+ La fiche complète n'est JAMAIS dans ce résumé — elle n'arrive que si le PJ
+ est l'entité focus (via NarrativeEntityContext entity_type="character").
+ Ça plafonne le coût token à ~40 tokens/PJ quel que soit le détail des fiches.
+ """
+
+ name: str
+ snippet: str
@dataclass(frozen=True)
@@ -184,3 +198,20 @@ class NarrativeEntityContext:
entity_type: str
title: str
fields: dict[str, str]
+
+
+@dataclass(frozen=True)
+class GameSystemContext:
+ """Règles d'un système de JDR (D&D, Nimble, homebrew...) injectées
+ dans le system prompt pour que l'IA respecte les mécaniques du jeu.
+
+ Les sections ont été présélectionnées côté Core selon l'intent
+ (SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions,
+ GENERIC → toutes). Indexées par titre H2 original.
+
+ Campagne uniquement au MVP : jamais présent sur un chat Lore.
+ """
+
+ system_name: str
+ system_description: str | None
+ sections: dict[str, str]
diff --git a/brain/app/main.py b/brain/app/main.py
index 36fc68c..af0d422 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -22,7 +22,9 @@ from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
+ CharacterSummary,
ChatMessage,
+ GameSystemContext,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
@@ -196,22 +198,42 @@ class ArcSummaryDTO(BaseModel):
illustration_count: int = 0
+class CharacterSummaryDTO(BaseModel):
+ """Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
+
+ name: str
+ snippet: str = ""
+
+
class CampaignContextDTO(BaseModel):
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
campaign_name: str
campaign_description: str | None = None
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
+ characters: list[CharacterSummaryDTO] = Field(default_factory=list)
class NarrativeEntityDTO(BaseModel):
- """Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel."""
+ """Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
- entity_type: str = Field(pattern="^(arc|chapter|scene)$")
+ entity_type: str = Field(pattern="^(arc|chapter|scene|character)$")
title: str
fields: dict[str, str] = Field(default_factory=dict)
+class GameSystemContextDTO(BaseModel):
+ """Règles de JDR présélectionnées par le Core (filtrées par intent).
+
+ Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
+ vides si aucune section ne matchait l'intent de génération courant.
+ """
+
+ system_name: str
+ system_description: str | None = None
+ sections: dict[str, str] = Field(default_factory=dict)
+
+
class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels.
@@ -226,6 +248,7 @@ class ChatStreamRequestDTO(BaseModel):
page_context: PageContextDTO | None = None
campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None
+ game_system_context: GameSystemContextDTO | None = None
def has_scope(self) -> bool:
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
@@ -352,6 +375,7 @@ async def chat_stream(
page_context = _to_page_context(body.page_context)
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)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
@@ -363,6 +387,7 @@ async def chat_stream(
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
+ game_system_context=game_system_context,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
@@ -386,6 +411,7 @@ async def chat_stream(
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
+ game_system_context=game_system_context,
):
# json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
@@ -523,10 +549,15 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
)
for arc in dto.arcs
]
+ characters = [
+ CharacterSummary(name=c.name, snippet=c.snippet)
+ for c in dto.characters
+ ]
return CampaignStructuralContext(
campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description,
arcs=arcs,
+ characters=characters,
)
@@ -742,3 +773,13 @@ def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityConte
title=dto.title,
fields=dict(dto.fields),
)
+
+
+def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
+ if dto is None:
+ return None
+ return GameSystemContext(
+ system_name=dto.system_name,
+ system_description=dto.system_description,
+ sections=dict(dto.sections),
+ )
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
index 48dbcac..dc58d4b 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
@@ -28,13 +28,14 @@ public class CampaignService {
*
*
{@code loreId} est nullable : une campagne peut exister sans univers associé.
*/
- public record CampaignData(String name, String description, String loreId) {}
+ public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder()
.name(data.name())
.description(data.description())
- .loreId(normalizeLoreId(data.loreId()))
+ .loreId(normalizeId(data.loreId()))
+ .gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0)
.build();
return campaignRepository.save(campaign);
@@ -57,16 +58,17 @@ public class CampaignService {
Campaign campaign = existingCampaign.get();
campaign.setName(data.name());
campaign.setDescription(data.description());
- campaign.setLoreId(normalizeLoreId(data.loreId()));
+ campaign.setLoreId(normalizeId(data.loreId()));
+ campaign.setGameSystemId(normalizeId(data.gameSystemId()));
return campaignRepository.save(campaign);
}
/**
- * Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien".
+ * Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
*/
- private String normalizeLoreId(String loreId) {
- return (loreId == null || loreId.isBlank()) ? null : loreId;
+ private String normalizeId(String id) {
+ return (id == null || id.isBlank()) ? null : id;
}
public void deleteCampaign(String id) {
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java
new file mode 100644
index 0000000..27344be
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java
@@ -0,0 +1,72 @@
+package com.loremind.application.campaigncontext;
+
+import com.loremind.domain.campaigncontext.Character;
+import com.loremind.domain.campaigncontext.ports.CharacterRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Service d'application pour les fiches de personnages (PJ).
+ */
+@Service
+public class CharacterService {
+
+ private final CharacterRepository characterRepository;
+
+ public CharacterService(CharacterRepository characterRepository) {
+ this.characterRepository = characterRepository;
+ }
+
+ /**
+ * Parameter Object pour la création / mise à jour d'un Character.
+ * `order` est fourni par le controller ; si absent, le service le calcule.
+ */
+ public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
+
+ public Character createCharacter(CharacterData data) {
+ int order = data.order() != null
+ ? data.order()
+ : nextOrderFor(data.campaignId());
+ Character character = Character.builder()
+ .name(data.name())
+ .markdownContent(data.markdownContent())
+ .campaignId(data.campaignId())
+ .order(order)
+ .build();
+ return characterRepository.save(character);
+ }
+
+ public Optional getCharacterById(String id) {
+ return characterRepository.findById(id);
+ }
+
+ public List getCharactersByCampaignId(String campaignId) {
+ return characterRepository.findByCampaignId(campaignId);
+ }
+
+ public Character updateCharacter(String id, CharacterData data) {
+ Character existing = characterRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
+ existing.setName(data.name());
+ existing.setMarkdownContent(data.markdownContent());
+ if (data.order() != null) {
+ existing.setOrder(data.order());
+ }
+ // campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP).
+ return characterRepository.save(existing);
+ }
+
+ public void deleteCharacter(String id) {
+ characterRepository.deleteById(id);
+ }
+
+ /** Renvoie la prochaine position libre — append en fin de liste. */
+ private int nextOrderFor(String campaignId) {
+ return characterRepository.findByCampaignId(campaignId).stream()
+ .mapToInt(Character::getOrder)
+ .max()
+ .orElse(-1) + 1;
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java
new file mode 100644
index 0000000..ba959f3
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java
@@ -0,0 +1,92 @@
+package com.loremind.application.gamesystemcontext;
+
+import com.loremind.domain.gamesystemcontext.GameSystem;
+import com.loremind.domain.gamesystemcontext.GenerationIntent;
+import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
+import com.loremind.domain.generationcontext.GameSystemContext;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Construit un {@link GameSystemContext} à partir d'un gameSystemId et d'un intent.
+ *
+ * Pipeline :
+ * 1. Charge le GameSystem (retourne Optional.empty si introuvable — dégradation gracieuse).
+ * 2. Parse le markdown par titres H2 (## Section) → Map.
+ * 3. Filtre les sections selon l'intent via les alias {@link GenerationIntent#getSectionAliases()}.
+ * GENERIC = pas de filtre.
+ *
+ * Parsing à la volée (pas de cache) : les règles d'un système font
+ * typiquement 5-20kB, le coût de parsing est négligeable devant l'appel LLM.
+ */
+@Service
+public class GameSystemContextBuilder {
+
+ /** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
+ private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
+
+ private final GameSystemRepository gameSystemRepository;
+
+ public GameSystemContextBuilder(GameSystemRepository gameSystemRepository) {
+ this.gameSystemRepository = gameSystemRepository;
+ }
+
+ public Optional buildOptional(String gameSystemId, GenerationIntent intent) {
+ if (gameSystemId == null || gameSystemId.isBlank()) return Optional.empty();
+ return gameSystemRepository.findById(gameSystemId)
+ .map(gs -> build(gs, intent));
+ }
+
+ private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
+ Map allSections = parseH2Sections(gs.getRulesMarkdown());
+ Map filtered = filterByIntent(allSections, intent);
+ return GameSystemContext.builder()
+ .systemName(gs.getName())
+ .systemDescription(gs.getDescription())
+ .sections(filtered)
+ .build();
+ }
+
+ /**
+ * Découpe le markdown par titres H2. Préserve l'ordre d'apparition (LinkedHashMap).
+ * Le contenu avant le premier H2 est ignoré (préambule libre).
+ */
+ Map parseH2Sections(String markdown) {
+ Map sections = new LinkedHashMap<>();
+ if (markdown == null || markdown.isBlank()) return sections;
+
+ Matcher m = H2_HEADER.matcher(markdown);
+ String currentTitle = null;
+ int currentContentStart = -1;
+
+ while (m.find()) {
+ if (currentTitle != null) {
+ sections.put(currentTitle, markdown.substring(currentContentStart, m.start()).strip());
+ }
+ currentTitle = m.group(1).trim();
+ currentContentStart = m.end();
+ }
+ if (currentTitle != null) {
+ sections.put(currentTitle, markdown.substring(currentContentStart).strip());
+ }
+ return sections;
+ }
+
+ private Map filterByIntent(Map sections, GenerationIntent intent) {
+ if (intent.matchesAllSections()) return sections;
+ Map filtered = new LinkedHashMap<>();
+ for (Map.Entry e : sections.entrySet()) {
+ String titleLower = e.getKey().toLowerCase();
+ boolean match = intent.getSectionAliases().stream().anyMatch(titleLower::contains);
+ if (match) {
+ filtered.put(e.getKey(), e.getValue());
+ }
+ }
+ return filtered;
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java
new file mode 100644
index 0000000..6f4af23
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java
@@ -0,0 +1,76 @@
+package com.loremind.application.gamesystemcontext;
+
+import com.loremind.domain.gamesystemcontext.GameSystem;
+import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Optional;
+
+@Service
+public class GameSystemService {
+
+ private final GameSystemRepository gameSystemRepository;
+
+ public GameSystemService(GameSystemRepository gameSystemRepository) {
+ this.gameSystemRepository = gameSystemRepository;
+ }
+
+ /**
+ * Parameter Object pour la création / mise à jour d'un GameSystem.
+ */
+ public record GameSystemData(
+ String name,
+ String description,
+ String rulesMarkdown,
+ String author,
+ boolean isPublic
+ ) {}
+
+ public GameSystem createGameSystem(GameSystemData data) {
+ GameSystem gameSystem = GameSystem.builder()
+ .name(data.name())
+ .description(data.description())
+ .rulesMarkdown(data.rulesMarkdown())
+ .author(normalize(data.author()))
+ .isPublic(data.isPublic())
+ .build();
+ return gameSystemRepository.save(gameSystem);
+ }
+
+ public Optional getGameSystemById(String id) {
+ return gameSystemRepository.findById(id);
+ }
+
+ public List getAllGameSystems() {
+ return gameSystemRepository.findAll();
+ }
+
+ public GameSystem updateGameSystem(String id, GameSystemData data) {
+ GameSystem existing = gameSystemRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id));
+ existing.setName(data.name());
+ existing.setDescription(data.description());
+ existing.setRulesMarkdown(data.rulesMarkdown());
+ existing.setAuthor(normalize(data.author()));
+ existing.setPublic(data.isPublic());
+ return gameSystemRepository.save(existing);
+ }
+
+ public void deleteGameSystem(String id) {
+ gameSystemRepository.deleteById(id);
+ }
+
+ public boolean gameSystemExists(String id) {
+ return gameSystemRepository.existsById(id);
+ }
+
+ public List searchGameSystems(String query) {
+ if (query == null || query.isBlank()) return List.of();
+ return gameSystemRepository.searchByName(query.trim());
+ }
+
+ private String normalize(String value) {
+ return (value == null || value.isBlank()) ? null : value;
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
index 3ad07e2..8e919d4 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
@@ -3,15 +3,18 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
+import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
+import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
+import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component;
@@ -38,18 +41,24 @@ public class CampaignStructuralContextBuilder {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
+ private final CharacterRepository characterRepository;
public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
- SceneRepository sceneRepository) {
+ SceneRepository sceneRepository,
+ CharacterRepository characterRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
+ this.characterRepository = characterRepository;
}
+ /** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
+ private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
+
/**
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
* nom + description courte à chaque niveau).
@@ -65,13 +74,42 @@ public class CampaignStructuralContextBuilder {
.map(this::toArcSummary)
.collect(Collectors.toList());
+ List characters = characterRepository.findByCampaignId(campaignId).stream()
+ .sorted(Comparator.comparingInt(Character::getOrder))
+ .map(this::toCharacterSummary)
+ .collect(Collectors.toList());
+
return CampaignStructuralContext.builder()
.campaignName(campaign.getName())
.campaignDescription(campaign.getDescription())
.arcs(arcs)
+ .characters(characters)
.build();
}
+ /**
+ * Projette un PJ vers un résumé court : nom + 1re ligne "signifiante" du
+ * markdown (ni vide, ni un titre). Permet à l'IA de savoir "qui est Thorin"
+ * sans injecter toute sa fiche.
+ */
+ private CharacterSummary toCharacterSummary(Character c) {
+ return CharacterSummary.builder()
+ .name(c.getName())
+ .snippet(extractSnippet(c.getMarkdownContent()))
+ .build();
+ }
+
+ private static String extractSnippet(String markdown) {
+ if (markdown == null || markdown.isBlank()) return "";
+ String firstLine = markdown.lines()
+ .map(String::strip)
+ .filter(l -> !l.isEmpty() && !l.startsWith("#"))
+ .findFirst()
+ .orElse("");
+ if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
+ return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
+ }
+
private ArcSummary toArcSummary(Arc arc) {
List chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder))
diff --git a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java
index f437d08..149e6f5 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java
@@ -2,9 +2,11 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
+import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
+import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.springframework.stereotype.Component;
@@ -26,20 +28,23 @@ public class NarrativeEntityContextBuilder {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
+ private final CharacterRepository characterRepository;
public NarrativeEntityContextBuilder(
ArcRepository arcRepository,
ChapterRepository chapterRepository,
- SceneRepository sceneRepository) {
+ SceneRepository sceneRepository,
+ CharacterRepository characterRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
+ this.characterRepository = characterRepository;
}
/**
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
*
- * @param entityType "arc", "chapter" ou "scene" (insensible à la casse)
+ * @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse)
* @param entityId l'ID de l'entité
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
*/
@@ -49,6 +54,7 @@ public class NarrativeEntityContextBuilder {
case "arc" -> fromArc(loadArc(entityId));
case "chapter" -> fromChapter(loadChapter(entityId));
case "scene" -> fromScene(loadScene(entityId));
+ case "character" -> fromCharacter(loadCharacter(entityId));
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
};
}
@@ -70,6 +76,11 @@ public class NarrativeEntityContextBuilder {
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
}
+ private Character loadCharacter(String id) {
+ return characterRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
+ }
+
// --- Mapping entité → VO ------------------------------------------------
private NarrativeEntityContext fromArc(Arc a) {
@@ -118,6 +129,16 @@ public class NarrativeEntityContextBuilder {
.build();
}
+ private NarrativeEntityContext fromCharacter(Character c) {
+ Map fields = new LinkedHashMap<>();
+ putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
+ return NarrativeEntityContext.builder()
+ .entityType("character")
+ .title(c.getName())
+ .fields(fields)
+ .build();
+ }
+
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
private static void putField(Map target, String key, String value) {
target.put(key, value == null ? "" : value);
diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java
index 9618a4b..db46444 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java
@@ -1,11 +1,14 @@
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.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -34,6 +37,7 @@ public class StreamChatForCampaignUseCase {
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final NarrativeEntityContextBuilder narrativeEntityContextBuilder;
+ private final GameSystemContextBuilder gameSystemContextBuilder;
private final AiChatProvider aiChatProvider;
public StreamChatForCampaignUseCase(
@@ -41,11 +45,13 @@ public class StreamChatForCampaignUseCase {
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
NarrativeEntityContextBuilder narrativeEntityContextBuilder,
+ GameSystemContextBuilder gameSystemContextBuilder,
AiChatProvider aiChatProvider) {
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.narrativeEntityContextBuilder = narrativeEntityContextBuilder;
+ this.gameSystemContextBuilder = gameSystemContextBuilder;
this.aiChatProvider = aiChatProvider;
}
@@ -78,12 +84,14 @@ public class StreamChatForCampaignUseCase {
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId);
LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign);
NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId);
+ GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign, entityType);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.campaignContext(campaignContext)
.narrativeEntity(narrativeEntity)
+ .gameSystemContext(gameSystemContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
@@ -104,4 +112,16 @@ public class StreamChatForCampaignUseCase {
if (entityId == null || entityId.isBlank()) return null;
return narrativeEntityContextBuilder.build(entityType, entityId);
}
+
+ /**
+ * Charge le GameSystemContext si la campagne est liée à un GameSystem.
+ * L'entityType détermine quelles sections de règles sont injectées
+ * (SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions, autre → toutes).
+ * Retourne null en cas de GameSystem introuvable (dégradation gracieuse).
+ */
+ private GameSystemContext loadGameSystemContextOrNull(Campaign campaign, String entityType) {
+ if (!campaign.isLinkedToGameSystem()) return null;
+ GenerationIntent intent = GenerationIntent.fromNarrativeEntityType(entityType);
+ return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), intent).orElse(null);
+ }
}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
index 41b7c41..4d89f91 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
@@ -28,7 +28,18 @@ public class Campaign {
*/
private String loreId;
+ /**
+ * Référence faible (weak reference) vers un GameSystem.
+ * Nullable : une campagne peut être "générique" (pas de système de JDR déclaré).
+ * Weak reference pour respecter la séparation des Bounded Contexts.
+ */
+ private String gameSystemId;
+
public boolean isLinkedToLore() {
return this.loreId != null && !this.loreId.isBlank();
}
+
+ public boolean isLinkedToGameSystem() {
+ return this.gameSystemId != null && !this.gameSystemId.isBlank();
+ }
}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java
new file mode 100644
index 0000000..e9e13b5
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java
@@ -0,0 +1,40 @@
+package com.loremind.domain.campaigncontext;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Fiche de personnage joueur (PJ) d'une campagne.
+ *
+ * MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
+ * backstory, équipement). Évolution prévue vers un système templaté par
+ * GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
+ *
+ * Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
+ * dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
+ * campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
+ */
+@Data
+@Builder
+public class Character {
+
+ private String id;
+ private String name;
+
+ /**
+ * Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
+ * renseigné progressivement par le MJ.
+ */
+ private String markdownContent;
+
+ /** Référence vers la Campaign parente. */
+ private String campaignId;
+
+ /** Ordre d'affichage dans la liste des PJ de la campagne. */
+ private int order;
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java
new file mode 100644
index 0000000..a0adcdc
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java
@@ -0,0 +1,22 @@
+package com.loremind.domain.campaigncontext.ports;
+
+import com.loremind.domain.campaigncontext.Character;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Port de sortie pour la persistance des fiches de personnages (PJ).
+ */
+public interface CharacterRepository {
+
+ Character save(Character character);
+
+ Optional findById(String id);
+
+ List findByCampaignId(String campaignId);
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+}
diff --git a/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java b/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java
index b8102c9..1e2a4db 100644
--- a/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java
+++ b/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java
@@ -37,7 +37,7 @@ public class Conversation {
/**
* Type d'entite focus, null si la conversation est ancree au niveau
* Lore/Campagne racine (pas sur une page/scene precise).
- * Valeurs : "page", "arc", "chapter", "scene".
+ * Valeurs : "page", "arc", "chapter", "scene", "character".
*/
private String entityType;
private String entityId;
diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java
new file mode 100644
index 0000000..edee045
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java
@@ -0,0 +1,38 @@
+package com.loremind.domain.gamesystemcontext;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Entité de domaine représentant un GameSystem (système de JDR).
+ *
+ * Porte les règles d'un système (D&D, Nimble, Pathfinder, homebrew...) sous forme
+ * d'un markdown monolithique structuré par titres H2. Les sections sont extraites
+ * à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
+ *
+ * {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
+ * de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
+ * éviter une migration ultérieure.
+ */
+@Data
+@Builder
+public class GameSystem {
+
+ private String id;
+ private String name;
+ private String description;
+
+ /** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
+ private String rulesMarkdown;
+
+ /** Auteur déclaré — futur marketplace. Nullable. */
+ private String author;
+
+ /** Flag de partage — futur marketplace. False par défaut. */
+ private boolean isPublic;
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/GenerationIntent.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/GenerationIntent.java
new file mode 100644
index 0000000..c102882
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/GenerationIntent.java
@@ -0,0 +1,57 @@
+package com.loremind.domain.gamesystemcontext;
+
+import java.util.Set;
+
+/**
+ * Intent de génération utilisé pour sélectionner les sections d'un GameSystem
+ * à injecter dans le prompt IA.
+ *
+ * Chaque intent porte une liste d'alias (case-insensitive, comparaison par
+ * {@code contains}) utilisée pour matcher les titres H2 du markdown de règles.
+ *
+ * MVP : mapping codé en dur. Évoluera vers un mapping configurable par
+ * l'utilisateur dans l'éditeur de GameSystem (futur marketplace).
+ */
+public enum GenerationIntent {
+
+ /** Scène (combat / rencontre) : règles de résolution + format de stat block. */
+ SCENE(Set.of("combat", "monstre", "monster")),
+
+ /** Chapitre (segment narratif) : règles de combat + archétypes pour PNJ. */
+ CHAPTER(Set.of("combat", "classe", "class")),
+
+ /** Arc (structure narrative longue) : pas de règles spécifiques — toutes. */
+ ARC(Set.of()),
+
+ /** Fallback : toutes les sections (intent inconnu). */
+ GENERIC(Set.of());
+
+ private final Set sectionAliases;
+
+ GenerationIntent(Set sectionAliases) {
+ this.sectionAliases = sectionAliases;
+ }
+
+ public Set getSectionAliases() {
+ return sectionAliases;
+ }
+
+ /** True si l'intent veut toutes les sections (pas de filtre). */
+ public boolean matchesAllSections() {
+ return sectionAliases.isEmpty();
+ }
+
+ /**
+ * Mappe un entityType de NarrativeEntityContext ("arc"/"chapter"/"scene")
+ * vers l'intent correspondant. Tout le reste (null, inconnu) tombe sur GENERIC.
+ */
+ public static GenerationIntent fromNarrativeEntityType(String entityType) {
+ if (entityType == null) return GENERIC;
+ return switch (entityType.toLowerCase()) {
+ case "scene" -> SCENE;
+ case "chapter" -> CHAPTER;
+ case "arc" -> ARC;
+ default -> GENERIC;
+ };
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/GameSystemRepository.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/GameSystemRepository.java
new file mode 100644
index 0000000..0748d95
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/GameSystemRepository.java
@@ -0,0 +1,24 @@
+package com.loremind.domain.gamesystemcontext.ports;
+
+import com.loremind.domain.gamesystemcontext.GameSystem;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Port de sortie pour la persistance des GameSystems.
+ */
+public interface GameSystemRepository {
+
+ GameSystem save(GameSystem gameSystem);
+
+ Optional findById(String id);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+
+ List searchByName(String query);
+}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
index a0e552b..f6fbbe7 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
@@ -30,6 +30,22 @@ public class CampaignStructuralContext {
String campaignName;
String campaignDescription;
@Singular List arcs;
+ /** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
+ @Singular List characters;
+
+ /**
+ * Résumé d'un PJ : nom + snippet court du markdown.
+ * Pas le markdown complet pour maîtriser le coût token (chaque campagne
+ * peut avoir 4-6 PJ × potentiellement 1-2k tokens/fiche = trop lourd).
+ * La fiche complète n'est injectée que si le PJ est l'entité focus
+ * (via NarrativeEntityContext, entity_type="character").
+ */
+ @Value
+ @Builder
+ public static class CharacterSummary {
+ String name;
+ String snippet;
+ }
/** Résumé d'un arc : nom + description courte + ses chapitres. */
@Value
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 ea5091d..ce002ab 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
@@ -39,4 +39,10 @@ public class ChatRequest {
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
NarrativeEntityContext narrativeEntity;
+
+ /**
+ * Optionnel : règles du système de JDR de la campagne (filtrées par intent).
+ * Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
+ */
+ GameSystemContext gameSystemContext;
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java b/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java
new file mode 100644
index 0000000..8e66836
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java
@@ -0,0 +1,30 @@
+package com.loremind.domain.generationcontext;
+
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.Map;
+
+/**
+ * Value Object représentant les règles de JDR injectées dans un prompt IA.
+ *
+ * Contient uniquement les sections pertinentes pour l'intent de génération
+ * en cours (sélection effectuée par GameSystemContextBuilder). Les sections
+ * sont indexées par leur titre H2 original (ex : "Combat", "Classes").
+ */
+@Value
+@Builder
+public class GameSystemContext {
+
+ /** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
+ String systemName;
+
+ /** Description courte du système (nullable). */
+ String systemDescription;
+
+ /**
+ * Sections de règles pertinentes, indexées par titre H2.
+ * Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
+ */
+ Map sections;
+}
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 0e83160..8d8781f 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
@@ -4,9 +4,11 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
+import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
+import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -52,9 +54,22 @@ public class BrainChatPayloadBuilder {
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
}
+ if (request.getGameSystemContext() != null) {
+ root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
+ }
return root;
}
+ private Map gameSystemContextToMap(GameSystemContext gs) {
+ Map map = new LinkedHashMap<>();
+ map.put("system_name", gs.getSystemName());
+ if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
+ map.put("system_description", gs.getSystemDescription());
+ }
+ map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
+ return map;
+ }
+
private Map messageToMap(ChatMessage m) {
Map map = new LinkedHashMap<>();
map.put("role", m.role());
@@ -111,6 +126,21 @@ public class BrainChatPayloadBuilder {
map.put("arcs", ctx.getArcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
+ // Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
+ if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
+ map.put("characters", ctx.getCharacters().stream()
+ .map(this::characterSummaryToMap)
+ .collect(Collectors.toList()));
+ }
+ return map;
+ }
+
+ private Map characterSummaryToMap(CharacterSummary c) {
+ Map map = new LinkedHashMap<>();
+ map.put("name", c.getName());
+ if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
+ map.put("snippet", c.getSnippet());
+ }
return map;
}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java
new file mode 100644
index 0000000..c0b1216
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java
@@ -0,0 +1,143 @@
+package com.loremind.infrastructure.persistence;
+
+import com.loremind.domain.gamesystemcontext.GameSystem;
+import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Seed 3 rulesets libres au premier démarrage (si la table game_systems est vide).
+ *
+ * Objectif : donner à l'utilisateur un point de départ pour comprendre le format
+ * attendu (markdown structuré par titres H2) et permettre une démo "out of the box"
+ * sans devoir taper ses propres règles.
+ *
+ * Les rulesets fournis sont des extraits libres (Nimble, SRD 5.1 extrait,
+ * homebrew exemple) — pas des règles officielles complètes. L'utilisateur est
+ * libre de les éditer, supprimer, ou les utiliser comme template.
+ *
+ * Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
+ * il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
+ */
+@Component
+public class GameSystemSeeder {
+
+ private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class);
+
+ private final GameSystemRepository gameSystemRepository;
+
+ public GameSystemSeeder(GameSystemRepository gameSystemRepository) {
+ this.gameSystemRepository = gameSystemRepository;
+ }
+
+ @EventListener(ApplicationReadyEvent.class)
+ public void seedIfEmpty() {
+ if (!gameSystemRepository.findAll().isEmpty()) {
+ log.debug("GameSystem seed skipped — table non vide.");
+ return;
+ }
+ log.info("Seed initial des GameSystems (table vide)...");
+ for (GameSystem gs : defaultSystems()) {
+ gameSystemRepository.save(gs);
+ }
+ log.info("GameSystems seedés : {}", defaultSystems().size());
+ }
+
+ private List defaultSystems() {
+ return List.of(
+ GameSystem.builder()
+ .name("Nimble (extrait)")
+ .description("Système léger et narratif, résolution rapide des combats.")
+ .author("LoreMind seed")
+ .isPublic(false)
+ .rulesMarkdown(NIMBLE_RULES)
+ .build(),
+ GameSystem.builder()
+ .name("D&D 5e SRD (extrait)")
+ .description("Extrait libre des bases du System Reference Document 5.1.")
+ .author("LoreMind seed")
+ .isPublic(false)
+ .rulesMarkdown(DND_SRD_RULES)
+ .build(),
+ GameSystem.builder()
+ .name("Homebrew Exemple")
+ .description("Template minimaliste à dupliquer pour créer votre propre système.")
+ .author("LoreMind seed")
+ .isPublic(false)
+ .rulesMarkdown(HOMEBREW_EXAMPLE)
+ .build()
+ );
+ }
+
+ private static final String NIMBLE_RULES = """
+ Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
+
+ ## Combat
+ - Initiative libre : les joueurs décrivent leur action dans l'ordre qu'ils veulent, le MJ joue les ennemis quand la fiction l'exige.
+ - Résolution : 1d20 + mod, difficulté 10/15/20 (facile/normal/dur). 20 naturel = critique (double dégâts).
+ - Dégâts : arme légère 1d6, arme lourde 1d10, projectile 1d8. Pas de table d'armure, l'armure augmente la difficulté à toucher.
+ - Blessures : un PJ peut encaisser 3 blessures graves avant de tomber. Pas de PV fins — on raconte les coups.
+
+ ## Classes
+ - **Guerrier** : +2 en combat, peut relancer un dé de dégât 1×/scène.
+ - **Explorateur** : +2 en perception/survie, ignore la première blessure d'une scène.
+ - **Mage** : peut lancer un effet de magie par scène, nécessite une composante racontée.
+ - **Barde** : +2 en social, peut inspirer un allié (relance de dé).
+
+ ## Monstres
+ Les monstres ont 3 stats : Menace (difficulté à toucher), Dégâts (dé de dégât), Résistance (nombre de blessures).
+ Exemples : Gobelin (Menace 10, 1d6, 1), Ogre (Menace 13, 1d10, 3), Dragon adulte (Menace 18, 2d10, 6).
+ """;
+
+ private static final String DND_SRD_RULES = """
+ Extrait libre du SRD 5.1 (Open Game License). Pour les règles complètes, consulter le SRD officiel.
+
+ ## Combat
+ - Initiative : 1d20 + mod Dex au début du combat, ordre fixe par round.
+ - Action par tour : une action, une action bonus (si classe le permet), une réaction, mouvement jusqu'à la vitesse.
+ - Attaque : 1d20 + mod caractéristique + bonus maîtrise vs CA de la cible.
+ - Dégâts : dé de l'arme + mod caractéristique. Critique sur 20 naturel (double les dés de dégâts).
+ - Avantage/Désavantage : lancer 2d20 et garder le meilleur / pire.
+
+ ## Classes
+ - **Barbare** : d12 PV, rage (+dégâts, résistance). Caractéristique principale : Force.
+ - **Barde** : d8 PV, sorts + inspiration bardique. Caractéristique : Charisme.
+ - **Clerc** : d8 PV, sorts divins, canalise la divinité. Caractéristique : Sagesse.
+ - **Druide** : d8 PV, sorts nature + forme animale. Caractéristique : Sagesse.
+ - **Ensorceleur** : d6 PV, sorts innés + métamagie. Caractéristique : Charisme.
+ - **Guerrier** : d10 PV, maîtrise martiale, second souffle. Caractéristique : Force ou Dextérité.
+ - **Magicien** : d6 PV, livre de sorts, grande flexibilité. Caractéristique : Intelligence.
+ - **Moine** : d8 PV, arts martiaux + ki. Caractéristique : Dextérité + Sagesse.
+ - **Paladin** : d10 PV, sorts + serment + imposition des mains. Caractéristique : Force + Charisme.
+ - **Rôdeur** : d10 PV, ennemi juré + explorateur + sorts. Caractéristique : Dextérité + Sagesse.
+ - **Roublard** : d8 PV, attaque sournoise + expertise. Caractéristique : Dextérité.
+
+ ## Monstres
+ Stat block standard : CA, PV, Vitesse, For/Dex/Con/Int/Sag/Cha, jets de sauvegarde, compétences, sens, langues, Facteur de Puissance (FP).
+ Exemples : Gobelin (FP 1/4, CA 15, 7 PV), Ogre (FP 2, CA 11, 59 PV), Dragon rouge adulte (FP 17, CA 19, 256 PV).
+ """;
+
+ private static final String HOMEBREW_EXAMPLE = """
+ Template vide à dupliquer et remplir pour créer votre propre système.
+
+ ## Combat
+ (Décrivez ici comment se résout un combat : initiative, jet d'attaque, dégâts, points de vie, critiques...)
+
+ ## Classes
+ (Listez les archétypes jouables : nom, stats de base, capacités signature.)
+
+ ## Monstres
+ (Format de stat block pour vos créatures : stats, capacités spéciales, FP/niveau.)
+
+ ## Magie
+ (Si votre système a un système de magie : écoles, coût, composantes, listes de sorts/pouvoirs.)
+
+ ## Progression
+ (Comment les PJ montent en puissance : XP, niveaux, acquisitions par niveau.)
+ """;
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java
index b856901..886fcdf 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java
@@ -45,6 +45,13 @@ public class CampaignJpaEntity {
@Column(name = "lore_id")
private String loreId;
+ /**
+ * ID du GameSystem associé (nullable).
+ * Weak reference inter-contexte — pas de @ManyToOne / pas de FK DB.
+ */
+ @Column(name = "game_system_id")
+ private String gameSystemId;
+
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java
new file mode 100644
index 0000000..39aeab0
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java
@@ -0,0 +1,56 @@
+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 les fiches de personnages (PJ) d'une campagne.
+ * Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte :
+ * on reste dans le Campaign Context, mais l'agrégat Character est autonome).
+ */
+@Entity
+@Table(name = "characters")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CharacterJpaEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(name = "markdown_content", columnDefinition = "TEXT")
+ private String markdownContent;
+
+ @Column(name = "campaign_id", nullable = false)
+ private Long campaignId;
+
+ @Column(name = "\"order\"", nullable = false)
+ private int order;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ @PrePersist
+ protected void onCreate() {
+ createdAt = LocalDateTime.now();
+ updatedAt = LocalDateTime.now();
+ }
+
+ @PreUpdate
+ protected void onUpdate() {
+ updatedAt = LocalDateTime.now();
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java
new file mode 100644
index 0000000..3185e9c
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java
@@ -0,0 +1,57 @@
+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 GameSystems (systèmes de JDR).
+ */
+@Entity
+@Table(name = "game_systems")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class GameSystemJpaEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(columnDefinition = "TEXT")
+ private String description;
+
+ @Column(name = "rules_markdown", columnDefinition = "TEXT")
+ private String rulesMarkdown;
+
+ @Column
+ private String author;
+
+ @Column(name = "is_public", nullable = false)
+ private boolean isPublic;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ @PrePersist
+ protected void onCreate() {
+ createdAt = LocalDateTime.now();
+ updatedAt = LocalDateTime.now();
+ }
+
+ @PreUpdate
+ protected void onUpdate() {
+ updatedAt = LocalDateTime.now();
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java
new file mode 100644
index 0000000..76db4e5
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java
@@ -0,0 +1,13 @@
+package com.loremind.infrastructure.persistence.jpa;
+
+import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface CharacterJpaRepository extends JpaRepository {
+
+ List findByCampaignIdOrderByOrderAsc(Long campaignId);
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/GameSystemJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/GameSystemJpaRepository.java
new file mode 100644
index 0000000..81b9228
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/GameSystemJpaRepository.java
@@ -0,0 +1,16 @@
+package com.loremind.infrastructure.persistence.jpa;
+
+import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface GameSystemJpaRepository extends JpaRepository {
+
+ @Query("SELECT g FROM GameSystemJpaEntity g WHERE LOWER(g.name) LIKE LOWER(CONCAT('%', :query, '%'))")
+ List findByNameContainingIgnoreCase(@Param("query") String query);
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java
index 86aa974..0b2d039 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java
@@ -71,6 +71,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.updatedAt(jpaEntity.getUpdatedAt())
.arcsCount(jpaEntity.getArcsCount())
.loreId(jpaEntity.getLoreId())
+ .gameSystemId(jpaEntity.getGameSystemId())
.build();
}
@@ -84,6 +85,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.updatedAt(campaign.getUpdatedAt())
.arcsCount(campaign.getArcsCount())
.loreId(campaign.getLoreId())
+ .gameSystemId(campaign.getGameSystemId())
.build();
}
}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java
new file mode 100644
index 0000000..411b107
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java
@@ -0,0 +1,75 @@
+package com.loremind.infrastructure.persistence.postgres;
+
+import com.loremind.domain.campaigncontext.Character;
+import com.loremind.domain.campaigncontext.ports.CharacterRepository;
+import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
+import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@Repository
+public class PostgresCharacterRepository implements CharacterRepository {
+
+ private final CharacterJpaRepository jpaRepository;
+
+ public PostgresCharacterRepository(CharacterJpaRepository jpaRepository) {
+ this.jpaRepository = jpaRepository;
+ }
+
+ @Override
+ public Character save(Character character) {
+ CharacterJpaEntity entity = toJpaEntity(character);
+ CharacterJpaEntity saved = jpaRepository.save(entity);
+ return toDomainEntity(saved);
+ }
+
+ @Override
+ public Optional findById(String id) {
+ return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
+ }
+
+ @Override
+ public List findByCampaignId(String campaignId) {
+ return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
+ .map(this::toDomainEntity)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public void deleteById(String id) {
+ jpaRepository.deleteById(Long.parseLong(id));
+ }
+
+ @Override
+ public boolean existsById(String id) {
+ return jpaRepository.existsById(Long.parseLong(id));
+ }
+
+ private Character toDomainEntity(CharacterJpaEntity e) {
+ return Character.builder()
+ .id(e.getId().toString())
+ .name(e.getName())
+ .markdownContent(e.getMarkdownContent())
+ .campaignId(e.getCampaignId().toString())
+ .order(e.getOrder())
+ .createdAt(e.getCreatedAt())
+ .updatedAt(e.getUpdatedAt())
+ .build();
+ }
+
+ private CharacterJpaEntity toJpaEntity(Character c) {
+ Long id = c.getId() != null ? Long.parseLong(c.getId()) : null;
+ return CharacterJpaEntity.builder()
+ .id(id)
+ .name(c.getName())
+ .markdownContent(c.getMarkdownContent())
+ .campaignId(Long.parseLong(c.getCampaignId()))
+ .order(c.getOrder())
+ .createdAt(c.getCreatedAt())
+ .updatedAt(c.getUpdatedAt())
+ .build();
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java
new file mode 100644
index 0000000..c32b816
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java
@@ -0,0 +1,84 @@
+package com.loremind.infrastructure.persistence.postgres;
+
+import com.loremind.domain.gamesystemcontext.GameSystem;
+import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
+import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
+import com.loremind.infrastructure.persistence.jpa.GameSystemJpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@Repository
+public class PostgresGameSystemRepository implements GameSystemRepository {
+
+ private final GameSystemJpaRepository jpaRepository;
+
+ public PostgresGameSystemRepository(GameSystemJpaRepository jpaRepository) {
+ this.jpaRepository = jpaRepository;
+ }
+
+ @Override
+ public GameSystem save(GameSystem gameSystem) {
+ GameSystemJpaEntity entity = toJpaEntity(gameSystem);
+ GameSystemJpaEntity saved = jpaRepository.save(entity);
+ return toDomainEntity(saved);
+ }
+
+ @Override
+ public Optional findById(String id) {
+ return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
+ }
+
+ @Override
+ public List findAll() {
+ return jpaRepository.findAll().stream()
+ .map(this::toDomainEntity)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public void deleteById(String id) {
+ jpaRepository.deleteById(Long.parseLong(id));
+ }
+
+ @Override
+ public boolean existsById(String id) {
+ return jpaRepository.existsById(Long.parseLong(id));
+ }
+
+ @Override
+ public List searchByName(String query) {
+ return jpaRepository.findByNameContainingIgnoreCase(query).stream()
+ .map(this::toDomainEntity)
+ .collect(Collectors.toList());
+ }
+
+ private GameSystem toDomainEntity(GameSystemJpaEntity e) {
+ return GameSystem.builder()
+ .id(e.getId().toString())
+ .name(e.getName())
+ .description(e.getDescription())
+ .rulesMarkdown(e.getRulesMarkdown())
+ .author(e.getAuthor())
+ .isPublic(e.isPublic())
+ .createdAt(e.getCreatedAt())
+ .updatedAt(e.getUpdatedAt())
+ .build();
+ }
+
+ private GameSystemJpaEntity toJpaEntity(GameSystem g) {
+ Long id = g.getId() != null ? Long.parseLong(g.getId()) : null;
+ return GameSystemJpaEntity.builder()
+ .id(id)
+ .name(g.getName())
+ .description(g.getDescription())
+ .rulesMarkdown(g.getRulesMarkdown())
+ .author(g.getAuthor())
+ .isPublic(g.isPublic())
+ .createdAt(g.getCreatedAt())
+ .updatedAt(g.getUpdatedAt())
+ .build();
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java
index a36ed46..962a72d 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java
@@ -31,7 +31,7 @@ public class CampaignController {
public ResponseEntity createCampaign(@RequestBody CampaignDTO campaignDTO) {
Campaign campaign = campaignMapper.toDomain(campaignDTO);
Campaign createdCampaign = campaignService.createCampaign(
- new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId())
+ new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId())
);
return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign));
}
@@ -64,7 +64,7 @@ public class CampaignController {
public ResponseEntity updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
Campaign updatedCampaign = campaignService.updateCampaign(
id,
- new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId())
+ new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId())
);
return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign));
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java
new file mode 100644
index 0000000..92e8dfb
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java
@@ -0,0 +1,62 @@
+package com.loremind.infrastructure.web.controller;
+
+import com.loremind.application.campaigncontext.CharacterService;
+import com.loremind.domain.campaigncontext.Character;
+import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
+import com.loremind.infrastructure.web.mapper.CharacterMapper;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/characters")
+public class CharacterController {
+
+ private final CharacterService characterService;
+ private final CharacterMapper characterMapper;
+
+ public CharacterController(CharacterService characterService, CharacterMapper characterMapper) {
+ this.characterService = characterService;
+ this.characterMapper = characterMapper;
+ }
+
+ @PostMapping
+ public ResponseEntity createCharacter(@RequestBody CharacterDTO dto) {
+ Character created = characterService.createCharacter(
+ new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
+ );
+ return ResponseEntity.ok(characterMapper.toDTO(created));
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getCharacterById(@PathVariable String id) {
+ return characterService.getCharacterById(id)
+ .map(c -> ResponseEntity.ok(characterMapper.toDTO(c)))
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ @GetMapping("/campaign/{campaignId}")
+ public ResponseEntity> getCharactersByCampaign(@PathVariable String campaignId) {
+ List dtos = characterService.getCharactersByCampaignId(campaignId).stream()
+ .map(characterMapper::toDTO)
+ .collect(Collectors.toList());
+ return ResponseEntity.ok(dtos);
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
+ Character updated = characterService.updateCharacter(
+ id,
+ new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
+ );
+ return ResponseEntity.ok(characterMapper.toDTO(updated));
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteCharacter(@PathVariable String id) {
+ characterService.deleteCharacter(id);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java
new file mode 100644
index 0000000..a243423
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java
@@ -0,0 +1,75 @@
+package com.loremind.infrastructure.web.controller;
+
+import com.loremind.application.gamesystemcontext.GameSystemService;
+import com.loremind.domain.gamesystemcontext.GameSystem;
+import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
+import com.loremind.infrastructure.web.mapper.GameSystemMapper;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/game-systems")
+public class GameSystemController {
+
+ private final GameSystemService gameSystemService;
+ private final GameSystemMapper gameSystemMapper;
+
+ public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) {
+ this.gameSystemService = gameSystemService;
+ this.gameSystemMapper = gameSystemMapper;
+ }
+
+ @PostMapping
+ public ResponseEntity createGameSystem(@RequestBody GameSystemDTO dto) {
+ GameSystem created = gameSystemService.createGameSystem(toData(dto));
+ return ResponseEntity.ok(gameSystemMapper.toDTO(created));
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getGameSystemById(@PathVariable String id) {
+ return gameSystemService.getGameSystemById(id)
+ .map(g -> ResponseEntity.ok(gameSystemMapper.toDTO(g)))
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ @GetMapping
+ public ResponseEntity> getAllGameSystems() {
+ List dtos = gameSystemService.getAllGameSystems().stream()
+ .map(gameSystemMapper::toDTO)
+ .collect(Collectors.toList());
+ return ResponseEntity.ok(dtos);
+ }
+
+ @GetMapping("/search")
+ public ResponseEntity> searchGameSystems(@RequestParam("q") String query) {
+ List dtos = gameSystemService.searchGameSystems(query).stream()
+ .map(gameSystemMapper::toDTO)
+ .collect(Collectors.toList());
+ return ResponseEntity.ok(dtos);
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity updateGameSystem(@PathVariable String id, @RequestBody GameSystemDTO dto) {
+ GameSystem updated = gameSystemService.updateGameSystem(id, toData(dto));
+ return ResponseEntity.ok(gameSystemMapper.toDTO(updated));
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteGameSystem(@PathVariable String id) {
+ gameSystemService.deleteGameSystem(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
+ return new GameSystemService.GameSystemData(
+ dto.getName(),
+ dto.getDescription(),
+ dto.getRulesMarkdown(),
+ dto.getAuthor(),
+ dto.isPublic()
+ );
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java
index 3c6c997..6be48c4 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java
@@ -15,4 +15,6 @@ public class CampaignDTO {
private int arcsCount;
/** Nullable : campagne sans univers associé. */
private String loreId;
+ /** Nullable : campagne sans système de JDR associé (générique). */
+ private String gameSystemId;
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java
new file mode 100644
index 0000000..201a936
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java
@@ -0,0 +1,16 @@
+package com.loremind.infrastructure.web.dto.campaigncontext;
+
+import lombok.Data;
+
+/**
+ * DTO pour les fiches de personnages (PJ) d'une campagne.
+ */
+@Data
+public class CharacterDTO {
+
+ private String id;
+ private String name;
+ private String markdownContent;
+ private String campaignId;
+ private int order;
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java
new file mode 100644
index 0000000..e745b4e
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java
@@ -0,0 +1,17 @@
+package com.loremind.infrastructure.web.dto.gamesystemcontext;
+
+import lombok.Data;
+
+/**
+ * DTO pour l'entité GameSystem (système de JDR).
+ */
+@Data
+public class GameSystemDTO {
+
+ private String id;
+ private String name;
+ private String description;
+ private String rulesMarkdown;
+ private String author;
+ private boolean isPublic;
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java
index 393a655..c16a87b 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java
@@ -21,6 +21,7 @@ public class CampaignMapper {
dto.setDescription(campaign.getDescription());
dto.setArcsCount(campaign.getArcsCount());
dto.setLoreId(campaign.getLoreId());
+ dto.setGameSystemId(campaign.getGameSystemId());
return dto;
}
@@ -35,6 +36,7 @@ public class CampaignMapper {
.description(dto.getDescription())
.arcsCount(dto.getArcsCount())
.loreId(dto.getLoreId())
+ .gameSystemId(dto.getGameSystemId())
.build();
}
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java
new file mode 100644
index 0000000..86bc49d
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java
@@ -0,0 +1,31 @@
+package com.loremind.infrastructure.web.mapper;
+
+import com.loremind.domain.campaigncontext.Character;
+import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
+import org.springframework.stereotype.Component;
+
+@Component
+public class CharacterMapper {
+
+ public CharacterDTO toDTO(Character c) {
+ if (c == null) return null;
+ CharacterDTO dto = new CharacterDTO();
+ dto.setId(c.getId());
+ dto.setName(c.getName());
+ dto.setMarkdownContent(c.getMarkdownContent());
+ dto.setCampaignId(c.getCampaignId());
+ dto.setOrder(c.getOrder());
+ return dto;
+ }
+
+ public Character toDomain(CharacterDTO dto) {
+ if (dto == null) return null;
+ return Character.builder()
+ .id(dto.getId())
+ .name(dto.getName())
+ .markdownContent(dto.getMarkdownContent())
+ .campaignId(dto.getCampaignId())
+ .order(dto.getOrder())
+ .build();
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java
new file mode 100644
index 0000000..901baa6
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java
@@ -0,0 +1,33 @@
+package com.loremind.infrastructure.web.mapper;
+
+import com.loremind.domain.gamesystemcontext.GameSystem;
+import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
+import org.springframework.stereotype.Component;
+
+@Component
+public class GameSystemMapper {
+
+ public GameSystemDTO toDTO(GameSystem g) {
+ if (g == null) return null;
+ GameSystemDTO dto = new GameSystemDTO();
+ dto.setId(g.getId());
+ dto.setName(g.getName());
+ dto.setDescription(g.getDescription());
+ dto.setRulesMarkdown(g.getRulesMarkdown());
+ dto.setAuthor(g.getAuthor());
+ dto.setPublic(g.isPublic());
+ return dto;
+ }
+
+ public GameSystem toDomain(GameSystemDTO dto) {
+ if (dto == null) return null;
+ return GameSystem.builder()
+ .id(dto.getId())
+ .name(dto.getName())
+ .description(dto.getDescription())
+ .rulesMarkdown(dto.getRulesMarkdown())
+ .author(dto.getAuthor())
+ .isPublic(dto.isPublic())
+ .build();
+ }
+}
diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts
index 5c1eda5..8477770 100644
--- a/web/src/app/app.routes.ts
+++ b/web/src/app/app.routes.ts
@@ -14,6 +14,8 @@ export const routes: Routes = [
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
+ { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
+ { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
@@ -24,6 +26,9 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
+ { 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) },
{ path: 'settings', loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
];
diff --git a/web/src/app/campaigns/campaign-create/campaign-create.component.html b/web/src/app/campaigns/campaign-create/campaign-create.component.html
index e0d94f9..4e30fc7 100644
--- a/web/src/app/campaigns/campaign-create/campaign-create.component.html
+++ b/web/src/app/campaigns/campaign-create/campaign-create.component.html
@@ -46,6 +46,18 @@
+
+
+
+
+ Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
+ dans ses suggestions pour respecter les mécaniques du JDR.
+
+
+
💡 Organisation : Votre campagne sera structurée en :
diff --git a/web/src/app/campaigns/campaign-create/campaign-create.component.ts b/web/src/app/campaigns/campaign-create/campaign-create.component.ts
index 9861668..1af4b9b 100644
--- a/web/src/app/campaigns/campaign-create/campaign-create.component.ts
+++ b/web/src/app/campaigns/campaign-create/campaign-create.component.ts
@@ -4,16 +4,19 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { Lore } from '../../services/lore.model';
+import { GameSystemService } from '../../services/game-system.service';
+import { GameSystem } from '../../services/game-system.model';
/**
* Payload émis vers le parent à la création d'une campagne.
- * `loreId` est optionnel (null = campagne sans univers associé).
+ * `loreId` et `gameSystemId` sont optionnels (null = non associé).
*/
export interface CampaignCreatePayload {
name: string;
description: string;
playerCount: number;
loreId: string | null;
+ gameSystemId: string | null;
}
@Component({
@@ -33,15 +36,20 @@ export class CampaignCreateComponent implements OnInit {
form: FormGroup;
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
availableLores: Lore[] = [];
+ /** GameSystems disponibles pour association. */
+ availableGameSystems: GameSystem[] = [];
- constructor(private fb: FormBuilder, private loreService: LoreService) {
+ constructor(
+ private fb: FormBuilder,
+ private loreService: LoreService,
+ private gameSystemService: GameSystemService
+ ) {
this.form = this.fb.group({
- name: ['', Validators.required],
- description: [''],
- playerCount: [4, [Validators.required, Validators.min(1)]],
- // Valeur par défaut : chaîne vide = "— Aucun lore associé —".
- // Le service normalise ensuite ""/null en null côté backend.
- loreId: ['']
+ name: ['', Validators.required],
+ description: [''],
+ playerCount: [4, [Validators.required, Validators.min(1)]],
+ loreId: [''],
+ gameSystemId: ['']
});
}
@@ -50,6 +58,10 @@ export class CampaignCreateComponent implements OnInit {
next: (lores) => this.availableLores = lores,
error: () => this.availableLores = []
});
+ this.gameSystemService.getAll().subscribe({
+ next: (gs) => this.availableGameSystems = gs,
+ error: () => this.availableGameSystems = []
+ });
}
submit(): void {
@@ -59,7 +71,8 @@ export class CampaignCreateComponent implements OnInit {
name: raw.name,
description: raw.description,
playerCount: raw.playerCount,
- loreId: raw.loreId ? raw.loreId : null
+ loreId: raw.loreId ? raw.loreId : null,
+ gameSystemId: raw.gameSystemId ? raw.gameSystemId : null
});
}
diff --git a/web/src/app/campaigns/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign-detail/campaign-detail.component.html
index 06d7d51..9ae9f62 100644
--- a/web/src/app/campaigns/campaign-detail/campaign-detail.component.html
+++ b/web/src/app/campaigns/campaign-detail/campaign-detail.component.html
@@ -53,6 +53,13 @@
+ Tout en markdown libre : stats, classe, backstory, équipement, objectifs personnels…
+ L'IA lira ces infos pour rester cohérente quand vous générez des scènes impliquant ce PJ.
+
+
+ {{ id ? 'Éditer le système' : 'Nouveau système de JDR' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Règles du système
+
+ Une section = un thème. L'IA injectera automatiquement les sections pertinentes
+ selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous.
+
💡 Astuce : organisez vos règles par sections avec des titres ## Combat, ## Classes, ## Lore… Le système injectera les sections pertinentes selon ce que l'IA doit générer.