Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.

Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
This commit is contained in:
2026-04-22 11:58:50 +02:00
parent bf38b6695f
commit 8f4dd3e9d6
63 changed files with 2840 additions and 36 deletions

View File

@@ -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)"}'

View File

@@ -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]

View File

@@ -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),
)

View File

@@ -28,13 +28,14 @@ public class CampaignService {
*
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
*/
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) {

View File

@@ -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<Character> getCharacterById(String id) {
return characterRepository.findById(id);
}
public List<Character> 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;
}
}

View File

@@ -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.
* <p>
* Pipeline :
* 1. Charge le GameSystem (retourne Optional.empty si introuvable — dégradation gracieuse).
* 2. Parse le markdown par titres H2 (## Section) → Map<Titre, Contenu>.
* 3. Filtre les sections selon l'intent via les alias {@link GenerationIntent#getSectionAliases()}.
* GENERIC = pas de filtre.
* <p>
* 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<GameSystemContext> 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<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
Map<String, String> 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<String, String> parseH2Sections(String markdown) {
Map<String, String> 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<String, String> filterByIntent(Map<String, String> sections, GenerationIntent intent) {
if (intent.matchesAllSections()) return sections;
Map<String, String> filtered = new LinkedHashMap<>();
for (Map.Entry<String, String> 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;
}
}

View File

@@ -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<GameSystem> getGameSystemById(String id) {
return gameSystemRepository.findById(id);
}
public List<GameSystem> 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<GameSystem> 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;
}
}

View File

@@ -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<CharacterSummary> 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<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder))

View File

@@ -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<String, String> 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<String, String> target, String key, String value) {
target.put(key, value == null ? "" : value);

View File

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

View File

@@ -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();
}
}

View File

@@ -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.
* <p>
* 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).
* <p>
* 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;
}

View File

@@ -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<Character> findById(String id);
List<Character> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -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;

View File

@@ -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).
* <p>
* 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).
* <p>
* {@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;
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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<String> sectionAliases;
GenerationIntent(Set<String> sectionAliases) {
this.sectionAliases = sectionAliases;
}
public Set<String> 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;
};
}
}

View File

@@ -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<GameSystem> findById(String id);
List<GameSystem> findAll();
void deleteById(String id);
boolean existsById(String id);
List<GameSystem> searchByName(String query);
}

View File

@@ -30,6 +30,22 @@ public class CampaignStructuralContext {
String campaignName;
String campaignDescription;
@Singular List<ArcSummary> arcs;
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
@Singular List<CharacterSummary> 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

View File

@@ -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;
}

View File

@@ -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.
* <p>
* 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<String, String> sections;
}

View File

@@ -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<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> 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<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> 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<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.getName());
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
map.put("snippet", c.getSnippet());
}
return map;
}

View File

@@ -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).
* <p>
* 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.
* <p>
* Les rulesets fournis sont des <b>extraits libres</b> (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.
* <p>
* 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<GameSystem> 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.)
""";
}

View File

@@ -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();

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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<CharacterJpaEntity, Long> {
List<CharacterJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -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<GameSystemJpaEntity, Long> {
@Query("SELECT g FROM GameSystemJpaEntity g WHERE LOWER(g.name) LIKE LOWER(CONCAT('%', :query, '%'))")
List<GameSystemJpaEntity> findByNameContainingIgnoreCase(@Param("query") String query);
}

View File

@@ -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();
}
}

View File

@@ -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<Character> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<Character> 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();
}
}

View File

@@ -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<GameSystem> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<GameSystem> 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<GameSystem> 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();
}
}

View File

@@ -31,7 +31,7 @@ public class CampaignController {
public ResponseEntity<CampaignDTO> 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<CampaignDTO> 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));
}

View File

@@ -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<CharacterDTO> 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<CharacterDTO> getCharacterById(@PathVariable String id) {
return characterService.getCharacterById(id)
.map(c -> ResponseEntity.ok(characterMapper.toDTO(c)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<CharacterDTO>> getCharactersByCampaign(@PathVariable String campaignId) {
List<CharacterDTO> dtos = characterService.getCharactersByCampaignId(campaignId).stream()
.map(characterMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<CharacterDTO> 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<Void> deleteCharacter(@PathVariable String id) {
characterService.deleteCharacter(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -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<GameSystemDTO> createGameSystem(@RequestBody GameSystemDTO dto) {
GameSystem created = gameSystemService.createGameSystem(toData(dto));
return ResponseEntity.ok(gameSystemMapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<GameSystemDTO> getGameSystemById(@PathVariable String id) {
return gameSystemService.getGameSystemById(id)
.map(g -> ResponseEntity.ok(gameSystemMapper.toDTO(g)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<List<GameSystemDTO>> getAllGameSystems() {
List<GameSystemDTO> dtos = gameSystemService.getAllGameSystems().stream()
.map(gameSystemMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@GetMapping("/search")
public ResponseEntity<List<GameSystemDTO>> searchGameSystems(@RequestParam("q") String query) {
List<GameSystemDTO> dtos = gameSystemService.searchGameSystems(query).stream()
.map(gameSystemMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<GameSystemDTO> updateGameSystem(@PathVariable String id, @RequestBody GameSystemDTO dto) {
GameSystem updated = gameSystemService.updateGameSystem(id, toData(dto));
return ResponseEntity.ok(gameSystemMapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> 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()
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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' }
];

View File

@@ -46,6 +46,18 @@
</p>
</div>
<div class="field">
<label>Système de JDR</label>
<select formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
</select>
<p class="hint">
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.
</p>
</div>
<div class="info-box">
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
<ul>

View File

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

View File

@@ -53,6 +53,13 @@
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
</div>
<div class="field">
<label>Système de JDR</label>
<select [(ngModel)]="editGameSystemId" name="editGameSystemId">
<option value="">— Aucun (générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
</select>
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
Sauvegarder
@@ -63,6 +70,35 @@
</div>
</div>
<div class="characters-section">
<div class="section-header">
<h2>Personnages joueurs</h2>
<button class="btn-add" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PJ
</button>
</div>
<div class="characters-grid" *ngIf="characters.length > 0">
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)">
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ character.name }}</span>
<span class="character-snippet">{{ characterSnippet(character) }}</span>
</div>
</div>
</div>
<div class="empty-state" *ngIf="characters.length === 0">
<lucide-icon [img]="User" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun personnage joueur pour le moment.</p>
<button class="btn-add-first" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PJ
</button>
</div>
</div>
<div class="arcs-section">
<div class="section-header">
<h2>Arcs narratifs</h2>

View File

@@ -173,6 +173,46 @@
.arc-meta { color: #6b7280; font-size: 0.75rem; }
}
.characters-section {
margin-bottom: 2.5rem;
}
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
}
.character-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 0.9rem 1rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover { border-color: #a78bfa; transform: translateY(-2px); }
.character-icon { color: #a78bfa; flex-shrink: 0; margin-top: 2px; }
.character-info {
display: flex;
flex-direction: column;
min-width: 0;
gap: 0.2rem;
}
.character-name { color: white; font-size: 0.95rem; font-weight: 600; }
.character-snippet {
color: #6b7280;
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.empty-state {
display: flex;
flex-direction: column;

View File

@@ -2,12 +2,16 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2 } from 'lucide-angular';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices } from 'lucide-angular';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
import { CampaignService } from '../../services/campaign.service';
import { LoreService } from '../../services/lore.service';
import { GameSystemService } from '../../services/game-system.service';
import { GameSystem } from '../../services/game-system.model';
import { CharacterService } from '../../services/character.service';
import { Character } from '../../services/character.model';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Arc } from '../../services/campaign.model';
@@ -27,6 +31,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Globe = Globe;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly User = User;
readonly Dices = Dices;
campaign: Campaign | null = null;
arcs: Arc[] = [];
@@ -34,18 +40,27 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
linkedLore: Lore | null = null;
/** Lores disponibles pour changer l'association en mode édition. */
availableLores: Lore[] = [];
/** GameSystems disponibles pour changer l'association en mode édition. */
availableGameSystems: GameSystem[] = [];
/** GameSystem associé si `campaign.gameSystemId` est renseigné ; sinon null. */
linkedGameSystem: GameSystem | null = null;
/** Fiches de personnages (PJ) de la campagne. */
characters: Character[] = [];
/** Mode édition inline. */
editing = false;
editName = '';
editDescription = '';
editLoreId = '';
editGameSystemId = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private loreService: LoreService,
private gameSystemService: GameSystemService,
private characterService: CharacterService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
@@ -68,6 +83,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.campaign = campaign;
this.editing = false;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.arcs = treeData.arcs;
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
@@ -90,6 +107,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.campaign = campaign;
this.editing = false;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.arcs = treeData.arcs;
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
@@ -110,6 +129,47 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
).subscribe(lore => this.linkedLore = lore);
}
/** Même logique pour le GameSystem associé : dégradation si supprimé. */
private loadLinkedGameSystem(campaign: Campaign): void {
if (!campaign.gameSystemId) {
this.linkedGameSystem = null;
return;
}
this.gameSystemService.getById(campaign.gameSystemId).pipe(
catchError(() => of(null))
).subscribe(gs => this.linkedGameSystem = gs);
}
/** Charge les fiches de personnages (PJ) de la campagne. */
private loadCharacters(campaignId: string): void {
this.characterService.getByCampaign(campaignId).pipe(
catchError(() => of([] as Character[]))
).subscribe(list => this.characters = list);
}
createCharacter(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
}
editCharacter(character: Character): void {
if (!this.campaign || !character.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
}
/** Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). */
characterSnippet(c: Character): string {
if (!c.markdownContent) return '(Fiche vide)';
const firstMeaningful = c.markdownContent
.split('\n')
.map(l => l.trim())
.find(l => l && !l.startsWith('#'));
if (!firstMeaningful) return '(Fiche vide)';
return firstMeaningful.length > 80
? firstMeaningful.substring(0, 77) + '…'
: firstMeaningful;
}
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
const campaignId = this.campaign!.id!;
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
@@ -138,11 +198,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.editName = this.campaign.name;
this.editDescription = this.campaign.description ?? '';
this.editLoreId = this.campaign.loreId ?? '';
// On charge les Lores disponibles pour le select uniquement à l'entrée en mode édition.
this.editGameSystemId = this.campaign.gameSystemId ?? '';
// On charge les Lores et GameSystems disponibles uniquement à l'entrée en mode édition.
this.loreService.getAllLores().subscribe({
next: (lores) => this.availableLores = lores,
error: () => this.availableLores = []
});
this.gameSystemService.getAll().subscribe({
next: (gs) => this.availableGameSystems = gs,
error: () => this.availableGameSystems = []
});
this.editing = true;
}
@@ -156,7 +221,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
name: this.editName.trim(),
description: this.editDescription,
playerCount: this.campaign.playerCount ?? 0,
loreId: this.editLoreId ? this.editLoreId : null
loreId: this.editLoreId ? this.editLoreId : null,
gameSystemId: this.editGameSystemId ? this.editGameSystemId : null
}).subscribe({
next: (updated) => {
this.campaign = updated;

View File

@@ -0,0 +1,82 @@
<div class="ce-page">
<div class="ce-header">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
</button>
<div class="header-row">
<h1>
<lucide-icon [img]="User" [size]="22"></lucide-icon>
{{ characterId ? 'Éditer la fiche' : 'Nouveau personnage' }}
</h1>
<button
*ngIf="characterId"
type="button"
class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PJ">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
</div>
</div>
<div class="ce-form">
<div class="field">
<label>Nom du personnage *</label>
<input
type="text"
[(ngModel)]="name"
name="name"
placeholder="Ex: Thorin le Grand-Hache, Lyra l'Errante..."
/>
</div>
<div class="field content-field">
<label>Fiche (markdown)</label>
<p class="hint">
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.
</p>
<textarea
[(ngModel)]="markdownContent"
name="markdownContent"
rows="22"
placeholder="# Thorin Grand-Hache&#10;&#10;**Race :** Nain&#10;**Classe :** Guerrier niveau 4&#10;**PV :** 35 / 35&#10;&#10;## Stats&#10;- Force : 16&#10;- Dextérité : 12&#10;...&#10;&#10;## Backstory&#10;Originaire des montagnes du Nord, Thorin a fui son clan après..."
></textarea>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ characterId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
<button
*ngIf="characterId"
type="button"
class="btn-danger"
(click)="deleteCharacter()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
</div>
<app-ai-chat-drawer
*ngIf="characterId && campaignId"
[campaignId]="campaignId"
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
welcomeMessage="Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,157 @@
.ce-page {
padding: 2rem 3rem;
color: #e5e7eb;
max-width: 1000px;
margin: 0 auto;
}
.ce-header {
margin-bottom: 2rem;
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
h1 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.75rem;
color: white;
margin: 0.75rem 0 0;
}
}
.btn-ai {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: rgba(167, 139, 250, 0.08);
border: 1px solid rgba(167, 139, 250, 0.4);
color: #a78bfa;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.15s;
&:hover { background: rgba(167, 139, 250, 0.15); border-color: #a78bfa; }
&.active { background: #a78bfa; color: #0b1220; }
}
.btn-back {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0;
font-size: 0.85rem;
&:hover { color: #e5e7eb; }
}
.ce-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
label {
color: #e5e7eb;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.4rem;
}
.hint {
color: #6b7280;
font-size: 0.8rem;
margin: 0.4rem 0 0.5rem;
}
input[type="text"], textarea {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
color: #e5e7eb;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #a78bfa;
}
}
}
.content-field textarea {
font-family: 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.85rem;
line-height: 1.5;
resize: vertical;
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
align-items: center;
.spacer { flex: 1; }
}
.btn-primary {
background: #a78bfa;
color: #0b1220;
border: none;
padding: 0.6rem 1.25rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
&:disabled { opacity: 0.5; cursor: not-allowed; }
&:hover:not(:disabled) { background: #c4b5fd; }
}
.btn-secondary {
background: transparent;
border: 1px solid #1f2937;
color: #9ca3af;
padding: 0.6rem 1.25rem;
border-radius: 8px;
cursor: pointer;
&:hover { border-color: #374151; color: #e5e7eb; }
}
.btn-danger {
background: transparent;
border: 1px solid rgba(248, 113, 113, 0.3);
color: #f87171;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
&:hover {
border-color: #f87171;
background: rgba(248, 113, 113, 0.08);
}
}

View File

@@ -0,0 +1,110 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../services/character.service';
import { Character } from '../../services/character.model';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Éditeur plein écran d'une fiche de personnage (PJ).
* Double rôle création/édition :
* - `/campaigns/:campaignId/characters/create` → POST
* - `/campaigns/:campaignId/characters/:characterId/edit` → PUT
*
* MVP : name + markdown libre. Évolution prévue vers un template dérivé
* du GameSystem de la campagne (stats structurées).
*/
@Component({
selector: 'app-character-edit',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
templateUrl: './character-edit.component.html',
styleUrls: ['./character-edit.component.scss']
})
export class CharacterEditComponent implements OnInit {
readonly Save = Save;
readonly ArrowLeft = ArrowLeft;
readonly User = User;
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
/** État drawer chat IA focalisé sur ce PJ. */
chatOpen = false;
readonly chatQuickSuggestions = [
'Propose une backstory cohérente avec l\'univers',
'Suggère 3 objectifs personnels pour ce personnage',
'Aide-moi à équilibrer les stats de combat'
];
toggleChat(): void { this.chatOpen = !this.chatOpen; }
campaignId: string | null = null;
characterId: string | null = null;
name = '';
markdownContent = '';
private order = 0;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CharacterService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.characterId = params.get('characterId');
if (this.characterId) {
this.service.getById(this.characterId).subscribe({
next: (c) => {
this.name = c.name;
this.markdownContent = c.markdownContent ?? '';
this.order = c.order ?? 0;
},
error: () => this.back()
});
}
}
submit(): void {
if (!this.name.trim() || !this.campaignId) return;
const req = this.characterId
? this.service.update(this.characterId, {
id: this.characterId,
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId,
order: this.order
})
: this.service.create({
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId
});
req.subscribe({
next: () => this.back(),
error: () => console.error('Erreur sauvegarde Character')
});
}
deleteCharacter(): void {
if (!this.characterId) return;
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
this.service.delete(this.characterId).subscribe({
next: () => this.back(),
error: () => console.error('Erreur suppression Character')
});
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -0,0 +1,103 @@
<div class="gse-page">
<div class="gse-header">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la liste
</button>
<h1>
<lucide-icon [img]="Dices" [size]="22"></lucide-icon>
{{ id ? 'Éditer le système' : 'Nouveau système de JDR' }}
</h1>
</div>
<div class="gse-form">
<div class="field">
<label>Nom *</label>
<input type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
</div>
<div class="field">
<label>Description courte</label>
<textarea [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
</div>
<div class="field">
<label>Auteur</label>
<input type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
</div>
<!-- Sections de règles -->
<div class="sections-area">
<h2 class="sections-title">Règles du système</h2>
<p class="sections-hint">
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).
</p>
<div class="section-list">
<div class="section-card" *ngFor="let section of sections; let i = index" [class.collapsed]="section.collapsed">
<div class="section-head">
<button type="button" class="btn-collapse" (click)="toggleCollapse(section)">
<lucide-icon [img]="section.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
</button>
<input
type="text"
class="section-title-input"
[(ngModel)]="section.title"
[name]="'title-' + i"
placeholder="Nom de la section (ex: Combat)"
/>
<button type="button" class="btn-remove" (click)="removeSection(i)" title="Supprimer cette section">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<textarea
*ngIf="!section.collapsed"
class="section-content"
[(ngModel)]="section.content"
[name]="'content-' + i"
rows="6"
placeholder="Décrivez les règles de cette section..."
></textarea>
</div>
<div *ngIf="sections.length === 0" class="empty-hint">
Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous.
</div>
</div>
<div class="add-row">
<span class="add-label">Ajouter une section :</span>
<button
type="button"
*ngFor="let name of suggestedSections"
class="chip"
[class.disabled]="isSectionUsed(name)"
(click)="addSuggested(name)"
[disabled]="isSectionUsed(name)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ name }}
</button>
<button type="button" class="chip chip-custom" (click)="addBlank()">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
Autre…
</button>
</div>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ id ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,256 @@
.gse-page {
padding: 2rem 3rem;
color: #e5e7eb;
max-width: 1000px;
margin: 0 auto;
}
.gse-header {
margin-bottom: 2rem;
h1 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.75rem;
color: white;
margin: 0.75rem 0 0;
}
}
.btn-back {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0;
font-size: 0.85rem;
&:hover { color: #e5e7eb; }
}
.gse-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
label {
color: #e5e7eb;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.4rem;
}
input[type="text"], textarea {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
color: #e5e7eb;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #a78bfa;
}
}
textarea { resize: vertical; }
}
/* ─── Sections ──────────────────────────────────────────────── */
.sections-area {
border-top: 1px solid #1f2937;
padding-top: 1.5rem;
margin-top: 0.5rem;
}
.sections-title {
font-size: 1rem;
font-weight: 600;
color: white;
margin: 0 0 0.25rem;
}
.sections-hint {
color: #6b7280;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.section-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.section-card {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 10px;
overflow: hidden;
transition: border-color 0.15s;
&:focus-within { border-color: #374151; }
&.collapsed { background: #0a0f1a; }
}
.section-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0.5rem 0.25rem;
}
.btn-collapse {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 4px;
display: flex;
border-radius: 4px;
&:hover { color: #e5e7eb; }
}
.section-title-input {
flex: 1;
background: transparent;
border: none;
color: white;
font-size: 1rem;
font-weight: 500;
padding: 0.3rem 0.25rem;
&:focus { outline: none; }
&::placeholder { color: #4b5563; font-weight: 400; font-style: italic; }
}
.btn-remove {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
display: flex;
&:hover { color: #f87171; background: rgba(248, 113, 113, 0.08); }
}
.section-content {
width: 100%;
background: transparent;
border: none;
border-top: 1px solid #1f2937;
color: #d1d5db;
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-family: inherit;
line-height: 1.5;
resize: vertical;
box-sizing: border-box;
&:focus { outline: none; }
}
.empty-hint {
color: #6b7280;
font-size: 0.85rem;
font-style: italic;
text-align: center;
padding: 1.5rem;
border: 1px dashed #1f2937;
border-radius: 10px;
}
/* ─── Chips d'ajout ─────────────────────────────────────────── */
.add-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.add-label {
color: #9ca3af;
font-size: 0.85rem;
margin-right: 0.25rem;
}
.chip {
background: #111827;
border: 1px solid #1f2937;
color: #d1d5db;
padding: 0.3rem 0.7rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.8rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
transition: all 0.15s;
&:hover:not(:disabled) {
border-color: #a78bfa;
color: white;
}
&.disabled, &:disabled {
opacity: 0.35;
cursor: not-allowed;
}
&.chip-custom {
border-style: dashed;
color: #a78bfa;
}
}
/* ─── Actions ──────────────────────────────────────────────── */
.actions {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
}
.btn-primary {
background: #a78bfa;
color: #0b1220;
border: none;
padding: 0.6rem 1.25rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
&:disabled { opacity: 0.5; cursor: not-allowed; }
&:hover:not(:disabled) { background: #c4b5fd; }
}
.btn-secondary {
background: transparent;
border: 1px solid #1f2937;
color: #9ca3af;
padding: 0.6rem 1.25rem;
border-radius: 8px;
cursor: pointer;
&:hover { border-color: #374151; color: #e5e7eb; }
}

View File

@@ -0,0 +1,170 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
import { GameSystemService } from '../../services/game-system.service';
/**
* Éditeur plein écran d'un GameSystem. Rôle double création/édition :
* - `/game-systems/create` → POST au submit
* - `/game-systems/:id/edit` → PUT au submit
*
* UI par sections : au lieu d'un gros textarea de markdown, on présente
* une liste de cartes repliables (une par section H2). Au load on parse
* le markdown existant, au submit on le reconstruit. Les noms suggérés
* (Combat, Classes…) guident sans imposer — c'est le même mapping que
* le backend utilise pour filtrer les sections à injecter dans l'IA.
*/
interface RuleSection {
title: string;
content: string;
collapsed: boolean;
}
/**
* Sections suggérées : mécaniques de jeu uniquement. Lore/factions/PNJ
* appartiennent au module Lore de LoreMind (décrit l'univers de l'utilisateur),
* pas au GameSystem (décrit les règles). Séparation volontaire.
*/
const SUGGESTED_SECTIONS = [
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
];
@Component({
selector: 'app-game-system-edit',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './game-system-edit.component.html',
styleUrls: ['./game-system-edit.component.scss']
})
export class GameSystemEditComponent implements OnInit {
readonly Save = Save;
readonly ArrowLeft = ArrowLeft;
readonly Dices = Dices;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
id: string | null = null;
name = '';
description = '';
author = '';
sections: RuleSection[] = [];
readonly suggestedSections = SUGGESTED_SECTIONS;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: GameSystemService
) {}
ngOnInit(): void {
this.id = this.route.snapshot.paramMap.get('id');
if (this.id) {
this.service.getById(this.id).subscribe({
next: (gs) => {
this.name = gs.name;
this.description = gs.description ?? '';
this.author = gs.author ?? '';
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
},
error: () => this.back()
});
}
}
/** Noms de section déjà utilisés — pour désactiver les suggestions en doublon. */
isSectionUsed(title: string): boolean {
const lower = title.toLowerCase();
return this.sections.some(s => s.title.toLowerCase() === lower);
}
/** Ajoute une section avec un nom suggéré. */
addSuggested(title: string): void {
if (this.isSectionUsed(title)) return;
this.sections.push({ title, content: '', collapsed: false });
}
/** Ajoute une section vierge (titre à remplir). */
addBlank(): void {
this.sections.push({ title: '', content: '', collapsed: false });
}
removeSection(index: number): void {
this.sections.splice(index, 1);
}
toggleCollapse(section: RuleSection): void {
section.collapsed = !section.collapsed;
}
submit(): void {
if (!this.name.trim()) return;
const payload = {
name: this.name.trim(),
description: this.description.trim() || null,
author: this.author.trim() || null,
rulesMarkdown: this.serializeMarkdown(),
isPublic: false
};
const req = this.id
? this.service.update(this.id, payload)
: this.service.create(payload);
req.subscribe({
next: () => this.back(),
error: () => console.error('Erreur sauvegarde GameSystem')
});
}
back(): void {
this.router.navigate(['/game-systems']);
}
// --- Parse / Serialize markdown ------------------------------------------
/**
* Découpe un markdown par titres H2 en sections. Doit rester cohérent avec
* le parser Java côté backend (regex `^##\s+…$`). Le texte avant le premier
* H2 est volontairement perdu — le backend l'ignore aussi pour l'injection IA.
*/
private parseMarkdown(markdown: string): RuleSection[] {
if (!markdown) return [];
const sections: RuleSection[] = [];
const lines = markdown.split('\n');
let current: RuleSection | null = null;
const buffer: string[] = [];
const flush = () => {
if (current) {
current.content = buffer.join('\n').trim();
sections.push(current);
}
buffer.length = 0;
};
for (const line of lines) {
const match = line.match(/^##\s+(.+?)\s*$/);
if (match) {
flush();
current = { title: match[1].trim(), content: '', collapsed: false };
} else if (current) {
buffer.push(line);
}
}
flush();
return sections;
}
/** Reconstruit le markdown à partir des sections (ignore les sections vides). */
private serializeMarkdown(): string | null {
const valid = this.sections.filter(s => s.title.trim() || s.content.trim());
if (valid.length === 0) return null;
return valid
.map(s => `## ${s.title.trim() || 'Sans titre'}\n${s.content.trim()}`)
.join('\n\n');
}
}

View File

@@ -0,0 +1,39 @@
<div class="gs-page">
<div class="gs-hero">
<lucide-icon [img]="Dices" [size]="56" class="hero-icon"></lucide-icon>
<h1>Systèmes de JDR</h1>
<p class="hero-subtitle">Les règles que l'IA connaîtra pour vos campagnes</p>
</div>
<div class="gs-grid">
<div class="gs-card" *ngFor="let gs of gameSystems" (click)="edit(gs.id!)">
<div class="card-header">
<h2>{{ gs.name }}</h2>
<div class="card-actions">
<button class="icon-btn" (click)="delete(gs, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
</div>
<p class="card-description">{{ gs.description || '(Pas de description)' }}</p>
<div class="card-footer">
<span *ngIf="gs.author" class="author">par {{ gs.author }}</span>
<span *ngIf="gs.isPublic" class="badge-public">public</span>
</div>
</div>
<div class="gs-card card-new" (click)="create()">
<div class="new-icon">
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
</div>
<h2>Nouveau système</h2>
<p class="card-description">Saisir les règles d'un JDR (markdown)</p>
</div>
</div>
<p class="tip">💡 Astuce : organisez vos règles par sections avec des titres <code>## Combat</code>, <code>## Classes</code>, <code>## Lore</code>… Le système injectera les sections pertinentes selon ce que l'IA doit générer.</p>
</div>

View File

@@ -0,0 +1,111 @@
.gs-page {
padding: 2rem 3rem;
color: #e5e7eb;
}
.gs-hero {
text-align: center;
margin-bottom: 2.5rem;
.hero-icon { color: #a78bfa; margin-bottom: 0.75rem; }
h1 { font-size: 2rem; margin: 0.5rem 0 0.25rem; color: white; }
.hero-subtitle { color: #9ca3af; margin: 0; }
}
.gs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.gs-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1.25rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover {
border-color: #a78bfa;
transform: translateY(-2px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
h2 { color: white; font-size: 1.1rem; margin: 0; }
}
.card-description {
color: #9ca3af;
font-size: 0.9rem;
min-height: 2.5em;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
font-size: 0.8rem;
.author { color: #6b7280; }
.badge-public {
background: #1e3a8a;
color: #bfdbfe;
padding: 2px 8px;
border-radius: 10px;
}
}
}
.icon-btn {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
&:hover { color: #f87171; background: rgba(248, 113, 113, 0.1); }
}
.card-new {
border-style: dashed;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
.new-icon {
background: rgba(167, 139, 250, 0.1);
color: #a78bfa;
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
}
h2 { margin: 0 0 0.25rem; color: white; }
}
.tip {
margin-top: 2rem;
color: #6b7280;
font-size: 0.85rem;
code {
background: #1f2937;
padding: 1px 6px;
border-radius: 4px;
color: #a78bfa;
}
}

View File

@@ -0,0 +1,56 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
import { GameSystemService } from '../services/game-system.service';
import { GameSystem } from '../services/game-system.model';
@Component({
selector: 'app-game-systems',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './game-systems.component.html',
styleUrls: ['./game-systems.component.scss']
})
export class GameSystemsComponent implements OnInit {
readonly Dices = Dices;
readonly Plus = Plus;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
gameSystems: GameSystem[] = [];
constructor(
private router: Router,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.gameSystemService.getAll().subscribe({
next: (data) => this.gameSystems = data,
error: () => this.gameSystems = []
});
}
create(): void {
this.router.navigate(['/game-systems/create']);
}
edit(id: string): void {
this.router.navigate(['/game-systems', id, 'edit']);
}
delete(system: GameSystem, event: MouseEvent): void {
event.stopPropagation();
if (!system.id) return;
if (!confirm(`Supprimer le système "${system.name}" ? Les campagnes qui l'utilisent ne seront plus associées à aucun système.`)) return;
this.gameSystemService.delete(system.id).subscribe({
next: () => this.load(),
error: () => console.error('Erreur suppression GameSystem')
});
}
}

View File

@@ -41,7 +41,7 @@ export type ChatStreamEvent =
* décode ligne par ligne pour extraire les événements SSE.
*/
/** Type d'entité narrative focus pour le chat Campagne. */
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene';
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character';
@Injectable({ providedIn: 'root' })
export class AiChatService {

View File

@@ -8,6 +8,8 @@ export interface Campaign {
chapterCount?: number;
/** ID du Lore associé (weak reference cross-context). `null` = pas d'univers lié. */
loreId?: string | null;
/** ID du GameSystem associé (weak reference cross-context). `null` = campagne générique. */
gameSystemId?: string | null;
}
// Interface pour la création de Campaign (sans id)
@@ -16,6 +18,7 @@ export interface CampaignCreate {
description: string;
playerCount: number;
loreId?: string | null;
gameSystemId?: string | null;
}
export interface Arc {

View File

@@ -0,0 +1,18 @@
/**
* Fiche de personnage joueur (PJ) d'une campagne.
* MVP : markdownContent libre. Évolution prévue vers des fiches templatées
* par GameSystem (stats structurées selon le JDR joué).
*/
export interface Character {
id?: string;
name: string;
markdownContent?: string | null;
campaignId: string;
order?: number;
}
export interface CharacterCreate {
name: string;
markdownContent?: string | null;
campaignId: string;
}

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Character, CharacterCreate } from './character.model';
/**
* Service HTTP pour les fiches de personnages (PJ) d'une campagne.
*/
@Injectable({ providedIn: 'root' })
export class CharacterService {
private apiUrl = 'http://localhost:8080/api/characters';
constructor(private http: HttpClient) {}
getByCampaign(campaignId: string): Observable<Character[]> {
return this.http.get<Character[]>(`${this.apiUrl}/campaign/${campaignId}`);
}
getById(id: string): Observable<Character> {
return this.http.get<Character>(`${this.apiUrl}/${id}`);
}
create(payload: CharacterCreate): Observable<Character> {
return this.http.post<Character>(this.apiUrl, payload);
}
update(id: string, payload: Character): Observable<Character> {
return this.http.put<Character>(`${this.apiUrl}/${id}`, payload);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}

View File

@@ -26,7 +26,7 @@ export interface Conversation {
export interface ConversationContext {
loreId?: string | null;
campaignId?: string | null;
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | null;
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | 'character' | null;
entityId?: string | null;
}

View File

@@ -0,0 +1,24 @@
/**
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
*
* rulesMarkdown : markdown monolithique, sections découpées par titres H2
* (## Combat, ## Classes, etc.). Le découpage et la sélection des sections
* à injecter dans le prompt IA sont faits côté backend Java.
*/
export interface GameSystem {
id?: string;
name: string;
description?: string | null;
rulesMarkdown?: string | null;
author?: string | null;
isPublic?: boolean;
}
/** Payload de création/mise à jour (sans id). */
export interface GameSystemCreate {
name: string;
description?: string | null;
rulesMarkdown?: string | null;
author?: string | null;
isPublic: boolean;
}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { GameSystem, GameSystemCreate } from './game-system.model';
/**
* Service HTTP pour les GameSystems (systèmes de JDR).
*/
@Injectable({ providedIn: 'root' })
export class GameSystemService {
private apiUrl = 'http://localhost:8080/api/game-systems';
constructor(private http: HttpClient) {}
getAll(): Observable<GameSystem[]> {
return this.http.get<GameSystem[]>(this.apiUrl);
}
getById(id: string): Observable<GameSystem> {
return this.http.get<GameSystem>(`${this.apiUrl}/${id}`);
}
create(payload: GameSystemCreate): Observable<GameSystem> {
return this.http.post<GameSystem>(this.apiUrl, payload);
}
update(id: string, payload: GameSystemCreate): Observable<GameSystem> {
return this.http.put<GameSystem>(`${this.apiUrl}/${id}`, payload);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
search(q: string): Observable<GameSystem[]> {
const params = new HttpParams().set('q', q);
return this.http.get<GameSystem[]>(`${this.apiUrl}/search`, { params });
}
}

View File

@@ -49,6 +49,10 @@
<span>Recherche globale</span>
<span class="tool-kbd">Ctrl+K</span>
</button>
<button class="tool-btn" [class.active]="currentRoute.startsWith('/game-systems')" (click)="navigateTo('/game-systems')">
<lucide-icon [img]="Dices" [size]="16"></lucide-icon>
<span>Systèmes de JDR</span>
</button>
<button class="tool-btn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Export VTT</span>

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
import { AsyncPipe, NgIf, NgFor } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, Search, Download, Settings, ArrowLeft } from 'lucide-angular';
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular';
import { LayoutService } from '../services/layout.service';
import { GlobalSearchService } from '../services/global-search.service';
@@ -19,6 +19,7 @@ export class SidebarComponent {
readonly Download = Download;
readonly Settings = Settings;
readonly ArrowLeft = ArrowLeft;
readonly Dices = Dices;
readonly layoutConfig$ = this.layoutService.secondarySidebar$;