diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py index 0f0c72e..3d1144b 100644 --- a/brain/app/application/chat.py +++ b/brain/app/application/chat.py @@ -20,6 +20,8 @@ from app.domain.models import ( CampaignStructuralContext, ChatMessage, ChapterSummary, + CharacterSummary, + GameSystemContext, LoreStructuralContext, NarrativeEntityContext, PageContext, @@ -63,16 +65,17 @@ class ChatUseCase: page_context: PageContext | None = None, campaign_context: CampaignStructuralContext | None = None, narrative_entity: NarrativeEntityContext | None = None, + game_system_context: GameSystemContext | None = None, ) -> AsyncIterator[str]: """Streame les tokens de la réponse assistant pour le dernier message user. - Les 4 contextes sont tous optionnels, mais au moins l'un des deux + Les contextes sont tous optionnels, mais au moins l'un des deux "niveaux haut" (lore_context ou campaign_context) doit être fourni pour que le prompt ait du sens. Le controller (main.py) applique cette règle à la frontière HTTP. """ system_prompt = self._build_system_prompt( - lore_context, page_context, campaign_context, narrative_entity + lore_context, page_context, campaign_context, narrative_entity, game_system_context ) async for token in self._llm.stream_chat( messages, @@ -87,12 +90,13 @@ class ChatUseCase: page_context: PageContext | None = None, campaign_context: CampaignStructuralContext | None = None, narrative_entity: NarrativeEntityContext | None = None, + game_system_context: GameSystemContext | None = None, ) -> str: """Version publique — utilisée par le controller HTTP pour compter les tokens du system prompt avant de streamer (jauge de contexte). """ return self._build_system_prompt( - lore_context, page_context, campaign_context, narrative_entity + lore_context, page_context, campaign_context, narrative_entity, game_system_context ) # --- Construction du system prompt -------------------------------------- @@ -103,12 +107,15 @@ class ChatUseCase: page: PageContext | None, campaign: CampaignStructuralContext | None, narrative: NarrativeEntityContext | None, + game_system: GameSystemContext | None = None, ) -> str: sections = [_BASE_SYSTEM] if lore is not None: sections.append(self._format_lore(lore)) if campaign is not None: sections.append(self._format_campaign(campaign, lore_present=lore is not None)) + if game_system is not None: + sections.append(self._format_game_system(game_system)) if page is not None: sections.append(self._format_page(page)) if narrative is not None: @@ -190,14 +197,40 @@ class ChatUseCase: if lore_present else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)" ) + characters_block = ChatUseCase._format_characters(ctx.characters) return ( "--- CAMPAGNE COURANTE ---\n" - f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n" + f"Nom : {ctx.campaign_name}{desc}{lore_note}\n" + f"{characters_block}\n" "Structure narrative (les flèches → indiquent des transitions de scène " "déclenchées par un choix des joueurs) :\n" f"{arcs_block}" ) + @staticmethod + def _format_characters(characters: list[CharacterSummary]) -> str: + """Bloc PJ — liste nom + snippet. Rappel anti-hallucination IA. + + Si la campagne n'a aucun PJ, on le signale explicitement : l'IA ne + doit pas inventer "les héros" ou leurs noms dans ses suggestions. + """ + if not characters: + return ( + "\nPersonnages joueurs : aucune fiche pour l'instant. Ne suppose " + "ni noms ni classes pour les PJ tant que le MJ ne les a pas créés.\n" + ) + lines = ["\nPersonnages joueurs (PJ) :"] + for c in characters: + if c.snippet: + lines.append(f"- **{c.name}** — {c.snippet}") + else: + lines.append(f"- **{c.name}** (fiche vide)") + lines.append( + "Pour une fiche complète (stats, backstory), n'invente rien : " + "demande au MJ d'ouvrir l'éditeur du PJ pour te donner les détails." + ) + return "\n".join(lines) + "\n" + @staticmethod def _format_arcs(arcs: list[ArcSummary]) -> str: if not arcs: @@ -248,12 +281,46 @@ class ChatUseCase: noun = "illustration" if count == 1 else "illustrations" return f" [{count} {noun}]" + # --- Bloc Système de JDR ------------------------------------------------ + + @staticmethod + def _format_game_system(gs: GameSystemContext) -> str: + """Bloc des règles du système de JDR de la campagne. + + Les sections ont été filtrées côté Core selon l'intent (combat, + classes, lore...). Si aucune section n'a matché, on affiche juste + le nom du système comme rappel de cadre. + """ + desc = f"\nDescription : {gs.system_description}" if gs.system_description else "" + if not gs.sections: + return ( + "--- SYSTÈME DE JDR ---\n" + f"Nom : {gs.system_name}{desc}\n" + "(Aucune section de règles pertinente pour ce type de génération — " + "reste cohérent avec l'univers et les conventions du système.)" + ) + sections_block = "\n\n".join( + f"### {title}\n{content}" for title, content in gs.sections.items() + ) + return ( + "--- SYSTÈME DE JDR ---\n" + f"Nom : {gs.system_name}{desc}\n\n" + "Respecte scrupuleusement les règles et conventions ci-dessous quand " + "tu proposes des stats, classes, rencontres, mécaniques ou éléments " + "d'ambiance. Les noms propres (classes, sorts, monstres) doivent " + "venir de ces règles — n'en invente pas d'autres.\n\n" + f"{sections_block}" + ) + @staticmethod def _format_narrative_entity(ne: NarrativeEntityContext) -> str: """Bloc équivalent à _format_page mais pour Arc/Chapter/Scene.""" - type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get( - ne.entity_type.lower(), ne.entity_type.upper() - ) + type_label = { + "arc": "ARC", + "chapter": "CHAPITRE", + "scene": "SCÈNE", + "character": "FICHE DE PERSONNAGE", + }.get(ne.entity_type.lower(), ne.entity_type.upper()) if ne.fields: fields_block = "\n".join( f'- "{key}" : {value or "(vide)"}' diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py index ec17322..cfbbb96 100644 --- a/brain/app/domain/models.py +++ b/brain/app/domain/models.py @@ -169,6 +169,20 @@ class CampaignStructuralContext: campaign_name: str campaign_description: str | None arcs: list[ArcSummary] + characters: list["CharacterSummary"] = field(default_factory=list) + + +@dataclass(frozen=True) +class CharacterSummary: + """Résumé d'un PJ : nom + snippet court extrait du markdown de la fiche. + + La fiche complète n'est JAMAIS dans ce résumé — elle n'arrive que si le PJ + est l'entité focus (via NarrativeEntityContext entity_type="character"). + Ça plafonne le coût token à ~40 tokens/PJ quel que soit le détail des fiches. + """ + + name: str + snippet: str @dataclass(frozen=True) @@ -184,3 +198,20 @@ class NarrativeEntityContext: entity_type: str title: str fields: dict[str, str] + + +@dataclass(frozen=True) +class GameSystemContext: + """Règles d'un système de JDR (D&D, Nimble, homebrew...) injectées + dans le system prompt pour que l'IA respecte les mécaniques du jeu. + + Les sections ont été présélectionnées côté Core selon l'intent + (SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions, + GENERIC → toutes). Indexées par titre H2 original. + + Campagne uniquement au MVP : jamais présent sur un chat Lore. + """ + + system_name: str + system_description: str | None + sections: dict[str, str] diff --git a/brain/app/main.py b/brain/app/main.py index 36fc68c..af0d422 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -22,7 +22,9 @@ from app.domain.models import ( ArcSummary, CampaignStructuralContext, ChapterSummary, + CharacterSummary, ChatMessage, + GameSystemContext, LoreStructuralContext, NarrativeEntityContext, PageContext, @@ -196,22 +198,42 @@ class ArcSummaryDTO(BaseModel): illustration_count: int = 0 +class CharacterSummaryDTO(BaseModel): + """Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé.""" + + name: str + snippet: str = "" + + class CampaignContextDTO(BaseModel): """Carte narrative enrichie : arcs → chapitres → scènes avec synopsis.""" campaign_name: str campaign_description: str | None = None arcs: list[ArcSummaryDTO] = Field(default_factory=list) + characters: list[CharacterSummaryDTO] = Field(default_factory=list) class NarrativeEntityDTO(BaseModel): - """Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel.""" + """Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel.""" - entity_type: str = Field(pattern="^(arc|chapter|scene)$") + entity_type: str = Field(pattern="^(arc|chapter|scene|character)$") title: str fields: dict[str, str] = Field(default_factory=dict) +class GameSystemContextDTO(BaseModel): + """Règles de JDR présélectionnées par le Core (filtrées par intent). + + Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être + vides si aucune section ne matchait l'intent de génération courant. + """ + + system_name: str + system_description: str | None = None + sections: dict[str, str] = Field(default_factory=dict) + + class ChatStreamRequestDTO(BaseModel): """Requête de chat streamé : historique + contextes structurels. @@ -226,6 +248,7 @@ class ChatStreamRequestDTO(BaseModel): page_context: PageContextDTO | None = None campaign_context: CampaignContextDTO | None = None narrative_entity: NarrativeEntityDTO | None = None + game_system_context: GameSystemContextDTO | None = None def has_scope(self) -> bool: """Vrai si au moins un contexte racine (Lore ou Campagne) est fourni.""" @@ -352,6 +375,7 @@ async def chat_stream( page_context = _to_page_context(body.page_context) campaign_context = _to_campaign_context(body.campaign_context) narrative_entity = _to_narrative_entity(body.narrative_entity) + game_system_context = _to_game_system_context(body.game_system_context) # --- Comptage tokens pour la jauge de contexte frontend --- # On construit le system prompt une fois ici pour le compter — le use case @@ -363,6 +387,7 @@ async def chat_stream( page_context=page_context, campaign_context=campaign_context, narrative_entity=narrative_entity, + game_system_context=game_system_context, ) # Dernier message = "current" (souvent user), le reste = historique accumulé. current_msg = messages[-1] if messages else None @@ -386,6 +411,7 @@ async def chat_stream( page_context=page_context, campaign_context=campaign_context, narrative_entity=narrative_entity, + game_system_context=game_system_context, ): # json.dumps avec ensure_ascii=False pour préserver les accents yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" @@ -523,10 +549,15 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo ) for arc in dto.arcs ] + characters = [ + CharacterSummary(name=c.name, snippet=c.snippet) + for c in dto.characters + ] return CampaignStructuralContext( campaign_name=dto.campaign_name, campaign_description=dto.campaign_description, arcs=arcs, + characters=characters, ) @@ -742,3 +773,13 @@ def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityConte title=dto.title, fields=dict(dto.fields), ) + + +def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None: + if dto is None: + return None + return GameSystemContext( + system_name=dto.system_name, + system_description=dto.system_description, + sections=dict(dto.sections), + ) diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java index 48dbcac..dc58d4b 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java @@ -28,13 +28,14 @@ public class CampaignService { * *

{@code loreId} est nullable : une campagne peut exister sans univers associé.

*/ - public record CampaignData(String name, String description, String loreId) {} + public record CampaignData(String name, String description, String loreId, String gameSystemId) {} public Campaign createCampaign(CampaignData data) { Campaign campaign = Campaign.builder() .name(data.name()) .description(data.description()) - .loreId(normalizeLoreId(data.loreId())) + .loreId(normalizeId(data.loreId())) + .gameSystemId(normalizeId(data.gameSystemId())) .arcsCount(0) .build(); return campaignRepository.save(campaign); @@ -57,16 +58,17 @@ public class CampaignService { Campaign campaign = existingCampaign.get(); campaign.setName(data.name()); campaign.setDescription(data.description()); - campaign.setLoreId(normalizeLoreId(data.loreId())); + campaign.setLoreId(normalizeId(data.loreId())); + campaign.setGameSystemId(normalizeId(data.gameSystemId())); return campaignRepository.save(campaign); } /** - * Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien". + * Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien". * Utile car les payloads JSON peuvent envoyer "" au lieu de null. */ - private String normalizeLoreId(String loreId) { - return (loreId == null || loreId.isBlank()) ? null : loreId; + private String normalizeId(String id) { + return (id == null || id.isBlank()) ? null : id; } public void deleteCampaign(String id) { diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java new file mode 100644 index 0000000..27344be --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java @@ -0,0 +1,72 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Character; +import com.loremind.domain.campaigncontext.ports.CharacterRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * Service d'application pour les fiches de personnages (PJ). + */ +@Service +public class CharacterService { + + private final CharacterRepository characterRepository; + + public CharacterService(CharacterRepository characterRepository) { + this.characterRepository = characterRepository; + } + + /** + * Parameter Object pour la création / mise à jour d'un Character. + * `order` est fourni par le controller ; si absent, le service le calcule. + */ + public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {} + + public Character createCharacter(CharacterData data) { + int order = data.order() != null + ? data.order() + : nextOrderFor(data.campaignId()); + Character character = Character.builder() + .name(data.name()) + .markdownContent(data.markdownContent()) + .campaignId(data.campaignId()) + .order(order) + .build(); + return characterRepository.save(character); + } + + public Optional getCharacterById(String id) { + return characterRepository.findById(id); + } + + public List getCharactersByCampaignId(String campaignId) { + return characterRepository.findByCampaignId(campaignId); + } + + public Character updateCharacter(String id, CharacterData data) { + Character existing = characterRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id)); + existing.setName(data.name()); + existing.setMarkdownContent(data.markdownContent()); + if (data.order() != null) { + existing.setOrder(data.order()); + } + // campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP). + return characterRepository.save(existing); + } + + public void deleteCharacter(String id) { + characterRepository.deleteById(id); + } + + /** Renvoie la prochaine position libre — append en fin de liste. */ + private int nextOrderFor(String campaignId) { + return characterRepository.findByCampaignId(campaignId).stream() + .mapToInt(Character::getOrder) + .max() + .orElse(-1) + 1; + } +} diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java new file mode 100644 index 0000000..ba959f3 --- /dev/null +++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java @@ -0,0 +1,92 @@ +package com.loremind.application.gamesystemcontext; + +import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.domain.gamesystemcontext.GenerationIntent; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import com.loremind.domain.generationcontext.GameSystemContext; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Construit un {@link GameSystemContext} à partir d'un gameSystemId et d'un intent. + *

+ * Pipeline : + * 1. Charge le GameSystem (retourne Optional.empty si introuvable — dégradation gracieuse). + * 2. Parse le markdown par titres H2 (## Section) → Map. + * 3. Filtre les sections selon l'intent via les alias {@link GenerationIntent#getSectionAliases()}. + * GENERIC = pas de filtre. + *

+ * Parsing à la volée (pas de cache) : les règles d'un système font + * typiquement 5-20kB, le coût de parsing est négligeable devant l'appel LLM. + */ +@Service +public class GameSystemContextBuilder { + + /** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */ + private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$"); + + private final GameSystemRepository gameSystemRepository; + + public GameSystemContextBuilder(GameSystemRepository gameSystemRepository) { + this.gameSystemRepository = gameSystemRepository; + } + + public Optional buildOptional(String gameSystemId, GenerationIntent intent) { + if (gameSystemId == null || gameSystemId.isBlank()) return Optional.empty(); + return gameSystemRepository.findById(gameSystemId) + .map(gs -> build(gs, intent)); + } + + private GameSystemContext build(GameSystem gs, GenerationIntent intent) { + Map allSections = parseH2Sections(gs.getRulesMarkdown()); + Map filtered = filterByIntent(allSections, intent); + return GameSystemContext.builder() + .systemName(gs.getName()) + .systemDescription(gs.getDescription()) + .sections(filtered) + .build(); + } + + /** + * Découpe le markdown par titres H2. Préserve l'ordre d'apparition (LinkedHashMap). + * Le contenu avant le premier H2 est ignoré (préambule libre). + */ + Map parseH2Sections(String markdown) { + Map sections = new LinkedHashMap<>(); + if (markdown == null || markdown.isBlank()) return sections; + + Matcher m = H2_HEADER.matcher(markdown); + String currentTitle = null; + int currentContentStart = -1; + + while (m.find()) { + if (currentTitle != null) { + sections.put(currentTitle, markdown.substring(currentContentStart, m.start()).strip()); + } + currentTitle = m.group(1).trim(); + currentContentStart = m.end(); + } + if (currentTitle != null) { + sections.put(currentTitle, markdown.substring(currentContentStart).strip()); + } + return sections; + } + + private Map filterByIntent(Map sections, GenerationIntent intent) { + if (intent.matchesAllSections()) return sections; + Map filtered = new LinkedHashMap<>(); + for (Map.Entry e : sections.entrySet()) { + String titleLower = e.getKey().toLowerCase(); + boolean match = intent.getSectionAliases().stream().anyMatch(titleLower::contains); + if (match) { + filtered.put(e.getKey(), e.getValue()); + } + } + return filtered; + } +} diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java new file mode 100644 index 0000000..6f4af23 --- /dev/null +++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java @@ -0,0 +1,76 @@ +package com.loremind.application.gamesystemcontext; + +import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class GameSystemService { + + private final GameSystemRepository gameSystemRepository; + + public GameSystemService(GameSystemRepository gameSystemRepository) { + this.gameSystemRepository = gameSystemRepository; + } + + /** + * Parameter Object pour la création / mise à jour d'un GameSystem. + */ + public record GameSystemData( + String name, + String description, + String rulesMarkdown, + String author, + boolean isPublic + ) {} + + public GameSystem createGameSystem(GameSystemData data) { + GameSystem gameSystem = GameSystem.builder() + .name(data.name()) + .description(data.description()) + .rulesMarkdown(data.rulesMarkdown()) + .author(normalize(data.author())) + .isPublic(data.isPublic()) + .build(); + return gameSystemRepository.save(gameSystem); + } + + public Optional getGameSystemById(String id) { + return gameSystemRepository.findById(id); + } + + public List getAllGameSystems() { + return gameSystemRepository.findAll(); + } + + public GameSystem updateGameSystem(String id, GameSystemData data) { + GameSystem existing = gameSystemRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id)); + existing.setName(data.name()); + existing.setDescription(data.description()); + existing.setRulesMarkdown(data.rulesMarkdown()); + existing.setAuthor(normalize(data.author())); + existing.setPublic(data.isPublic()); + return gameSystemRepository.save(existing); + } + + public void deleteGameSystem(String id) { + gameSystemRepository.deleteById(id); + } + + public boolean gameSystemExists(String id) { + return gameSystemRepository.existsById(id); + } + + public List searchGameSystems(String query) { + if (query == null || query.isBlank()) return List.of(); + return gameSystemRepository.searchByName(query.trim()); + } + + private String normalize(String value) { + return (value == null || value.isBlank()) ? null : value; + } +} diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java index 3ad07e2..8e919d4 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java @@ -3,15 +3,18 @@ package com.loremind.application.generationcontext; import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint; import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import org.springframework.stereotype.Component; @@ -38,18 +41,24 @@ public class CampaignStructuralContextBuilder { private final ArcRepository arcRepository; private final ChapterRepository chapterRepository; private final SceneRepository sceneRepository; + private final CharacterRepository characterRepository; public CampaignStructuralContextBuilder( CampaignRepository campaignRepository, ArcRepository arcRepository, ChapterRepository chapterRepository, - SceneRepository sceneRepository) { + SceneRepository sceneRepository, + CharacterRepository characterRepository) { this.campaignRepository = campaignRepository; this.arcRepository = arcRepository; this.chapterRepository = chapterRepository; this.sceneRepository = sceneRepository; + this.characterRepository = characterRepository; } + /** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */ + private static final int CHARACTER_SNIPPET_MAX_LEN = 160; + /** * Construit la carte narrative d'une Campagne (arcs → chapitres → scènes, * nom + description courte à chaque niveau). @@ -65,13 +74,42 @@ public class CampaignStructuralContextBuilder { .map(this::toArcSummary) .collect(Collectors.toList()); + List characters = characterRepository.findByCampaignId(campaignId).stream() + .sorted(Comparator.comparingInt(Character::getOrder)) + .map(this::toCharacterSummary) + .collect(Collectors.toList()); + return CampaignStructuralContext.builder() .campaignName(campaign.getName()) .campaignDescription(campaign.getDescription()) .arcs(arcs) + .characters(characters) .build(); } + /** + * Projette un PJ vers un résumé court : nom + 1re ligne "signifiante" du + * markdown (ni vide, ni un titre). Permet à l'IA de savoir "qui est Thorin" + * sans injecter toute sa fiche. + */ + private CharacterSummary toCharacterSummary(Character c) { + return CharacterSummary.builder() + .name(c.getName()) + .snippet(extractSnippet(c.getMarkdownContent())) + .build(); + } + + private static String extractSnippet(String markdown) { + if (markdown == null || markdown.isBlank()) return ""; + String firstLine = markdown.lines() + .map(String::strip) + .filter(l -> !l.isEmpty() && !l.startsWith("#")) + .findFirst() + .orElse(""); + if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine; + return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…"; + } + private ArcSummary toArcSummary(Arc arc) { List chapters = chapterRepository.findByArcId(arc.getId()).stream() .sorted(Comparator.comparingInt(Chapter::getOrder)) diff --git a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java index f437d08..149e6f5 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java @@ -2,9 +2,11 @@ package com.loremind.application.generationcontext; import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.generationcontext.NarrativeEntityContext; import org.springframework.stereotype.Component; @@ -26,20 +28,23 @@ public class NarrativeEntityContextBuilder { private final ArcRepository arcRepository; private final ChapterRepository chapterRepository; private final SceneRepository sceneRepository; + private final CharacterRepository characterRepository; public NarrativeEntityContextBuilder( ArcRepository arcRepository, ChapterRepository chapterRepository, - SceneRepository sceneRepository) { + SceneRepository sceneRepository, + CharacterRepository characterRepository) { this.arcRepository = arcRepository; this.chapterRepository = chapterRepository; this.sceneRepository = sceneRepository; + this.characterRepository = characterRepository; } /** * Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext. * - * @param entityType "arc", "chapter" ou "scene" (insensible à la casse) + * @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse) * @param entityId l'ID de l'entité * @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable */ @@ -49,6 +54,7 @@ public class NarrativeEntityContextBuilder { case "arc" -> fromArc(loadArc(entityId)); case "chapter" -> fromChapter(loadChapter(entityId)); case "scene" -> fromScene(loadScene(entityId)); + case "character" -> fromCharacter(loadCharacter(entityId)); default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType); }; } @@ -70,6 +76,11 @@ public class NarrativeEntityContextBuilder { .orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id)); } + private Character loadCharacter(String id) { + return characterRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id)); + } + // --- Mapping entité → VO ------------------------------------------------ private NarrativeEntityContext fromArc(Arc a) { @@ -118,6 +129,16 @@ public class NarrativeEntityContextBuilder { .build(); } + private NarrativeEntityContext fromCharacter(Character c) { + Map fields = new LinkedHashMap<>(); + putField(fields, "fiche complète (markdown)", c.getMarkdownContent()); + return NarrativeEntityContext.builder() + .entityType("character") + .title(c.getName()) + .fields(fields) + .build(); + } + /** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */ private static void putField(Map target, String key, String value) { target.put(key, value == null ? "" : value); diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java index 9618a4b..db46444 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java +++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForCampaignUseCase.java @@ -1,11 +1,14 @@ package com.loremind.application.generationcontext; +import com.loremind.application.gamesystemcontext.GameSystemContextBuilder; import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.gamesystemcontext.GenerationIntent; import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatUsage; +import com.loremind.domain.generationcontext.GameSystemContext; import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.ports.AiChatProvider; @@ -34,6 +37,7 @@ public class StreamChatForCampaignUseCase { private final CampaignStructuralContextBuilder campaignContextBuilder; private final LoreStructuralContextBuilder loreContextBuilder; private final NarrativeEntityContextBuilder narrativeEntityContextBuilder; + private final GameSystemContextBuilder gameSystemContextBuilder; private final AiChatProvider aiChatProvider; public StreamChatForCampaignUseCase( @@ -41,11 +45,13 @@ public class StreamChatForCampaignUseCase { CampaignStructuralContextBuilder campaignContextBuilder, LoreStructuralContextBuilder loreContextBuilder, NarrativeEntityContextBuilder narrativeEntityContextBuilder, + GameSystemContextBuilder gameSystemContextBuilder, AiChatProvider aiChatProvider) { this.campaignRepository = campaignRepository; this.campaignContextBuilder = campaignContextBuilder; this.loreContextBuilder = loreContextBuilder; this.narrativeEntityContextBuilder = narrativeEntityContextBuilder; + this.gameSystemContextBuilder = gameSystemContextBuilder; this.aiChatProvider = aiChatProvider; } @@ -78,12 +84,14 @@ public class StreamChatForCampaignUseCase { CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId); LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign); NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId); + GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign, entityType); ChatRequest request = ChatRequest.builder() .messages(messages) .loreContext(loreContext) .campaignContext(campaignContext) .narrativeEntity(narrativeEntity) + .gameSystemContext(gameSystemContext) .build(); aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError); @@ -104,4 +112,16 @@ public class StreamChatForCampaignUseCase { if (entityId == null || entityId.isBlank()) return null; return narrativeEntityContextBuilder.build(entityType, entityId); } + + /** + * Charge le GameSystemContext si la campagne est liée à un GameSystem. + * L'entityType détermine quelles sections de règles sont injectées + * (SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions, autre → toutes). + * Retourne null en cas de GameSystem introuvable (dégradation gracieuse). + */ + private GameSystemContext loadGameSystemContextOrNull(Campaign campaign, String entityType) { + if (!campaign.isLinkedToGameSystem()) return null; + GenerationIntent intent = GenerationIntent.fromNarrativeEntityType(entityType); + return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), intent).orElse(null); + } } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java index 41b7c41..4d89f91 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java @@ -28,7 +28,18 @@ public class Campaign { */ private String loreId; + /** + * Référence faible (weak reference) vers un GameSystem. + * Nullable : une campagne peut être "générique" (pas de système de JDR déclaré). + * Weak reference pour respecter la séparation des Bounded Contexts. + */ + private String gameSystemId; + public boolean isLinkedToLore() { return this.loreId != null && !this.loreId.isBlank(); } + + public boolean isLinkedToGameSystem() { + return this.gameSystemId != null && !this.gameSystemId.isBlank(); + } } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java new file mode 100644 index 0000000..e9e13b5 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java @@ -0,0 +1,40 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Fiche de personnage joueur (PJ) d'une campagne. + *

+ * MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats, + * backstory, équipement). Évolution prévue vers un système templaté par + * GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D). + *

+ * Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou + * dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une + * campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ). + */ +@Data +@Builder +public class Character { + + private String id; + private String name; + + /** + * Contenu libre en markdown — stats + backstory + notes. Nullable à la création, + * renseigné progressivement par le MJ. + */ + private String markdownContent; + + /** Référence vers la Campaign parente. */ + private String campaignId; + + /** Ordre d'affichage dans la liste des PJ de la campagne. */ + private int order; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java new file mode 100644 index 0000000..a0adcdc --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CharacterRepository.java @@ -0,0 +1,22 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.Character; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des fiches de personnages (PJ). + */ +public interface CharacterRepository { + + Character save(Character character); + + Optional findById(String id); + + List findByCampaignId(String campaignId); + + void deleteById(String id); + + boolean existsById(String id); +} diff --git a/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java b/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java index b8102c9..1e2a4db 100644 --- a/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java +++ b/core/src/main/java/com/loremind/domain/conversationcontext/Conversation.java @@ -37,7 +37,7 @@ public class Conversation { /** * Type d'entite focus, null si la conversation est ancree au niveau * Lore/Campagne racine (pas sur une page/scene precise). - * Valeurs : "page", "arc", "chapter", "scene". + * Valeurs : "page", "arc", "chapter", "scene", "character". */ private String entityType; private String entityId; diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java new file mode 100644 index 0000000..edee045 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java @@ -0,0 +1,38 @@ +package com.loremind.domain.gamesystemcontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Entité de domaine représentant un GameSystem (système de JDR). + *

+ * Porte les règles d'un système (D&D, Nimble, Pathfinder, homebrew...) sous forme + * d'un markdown monolithique structuré par titres H2. Les sections sont extraites + * à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector). + *

+ * {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace + * de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour + * éviter une migration ultérieure. + */ +@Data +@Builder +public class GameSystem { + + private String id; + private String name; + private String description; + + /** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */ + private String rulesMarkdown; + + /** Auteur déclaré — futur marketplace. Nullable. */ + private String author; + + /** Flag de partage — futur marketplace. False par défaut. */ + private boolean isPublic; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/GenerationIntent.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/GenerationIntent.java new file mode 100644 index 0000000..c102882 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/GenerationIntent.java @@ -0,0 +1,57 @@ +package com.loremind.domain.gamesystemcontext; + +import java.util.Set; + +/** + * Intent de génération utilisé pour sélectionner les sections d'un GameSystem + * à injecter dans le prompt IA. + *

+ * Chaque intent porte une liste d'alias (case-insensitive, comparaison par + * {@code contains}) utilisée pour matcher les titres H2 du markdown de règles. + *

+ * MVP : mapping codé en dur. Évoluera vers un mapping configurable par + * l'utilisateur dans l'éditeur de GameSystem (futur marketplace). + */ +public enum GenerationIntent { + + /** Scène (combat / rencontre) : règles de résolution + format de stat block. */ + SCENE(Set.of("combat", "monstre", "monster")), + + /** Chapitre (segment narratif) : règles de combat + archétypes pour PNJ. */ + CHAPTER(Set.of("combat", "classe", "class")), + + /** Arc (structure narrative longue) : pas de règles spécifiques — toutes. */ + ARC(Set.of()), + + /** Fallback : toutes les sections (intent inconnu). */ + GENERIC(Set.of()); + + private final Set sectionAliases; + + GenerationIntent(Set sectionAliases) { + this.sectionAliases = sectionAliases; + } + + public Set getSectionAliases() { + return sectionAliases; + } + + /** True si l'intent veut toutes les sections (pas de filtre). */ + public boolean matchesAllSections() { + return sectionAliases.isEmpty(); + } + + /** + * Mappe un entityType de NarrativeEntityContext ("arc"/"chapter"/"scene") + * vers l'intent correspondant. Tout le reste (null, inconnu) tombe sur GENERIC. + */ + public static GenerationIntent fromNarrativeEntityType(String entityType) { + if (entityType == null) return GENERIC; + return switch (entityType.toLowerCase()) { + case "scene" -> SCENE; + case "chapter" -> CHAPTER; + case "arc" -> ARC; + default -> GENERIC; + }; + } +} diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/GameSystemRepository.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/GameSystemRepository.java new file mode 100644 index 0000000..0748d95 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/GameSystemRepository.java @@ -0,0 +1,24 @@ +package com.loremind.domain.gamesystemcontext.ports; + +import com.loremind.domain.gamesystemcontext.GameSystem; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des GameSystems. + */ +public interface GameSystemRepository { + + GameSystem save(GameSystem gameSystem); + + Optional findById(String id); + + List findAll(); + + void deleteById(String id); + + boolean existsById(String id); + + List searchByName(String query); +} diff --git a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java index a0e552b..f6fbbe7 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java @@ -30,6 +30,22 @@ public class CampaignStructuralContext { String campaignName; String campaignDescription; @Singular List arcs; + /** Personnages joueurs (PJ) de la campagne. Vide si aucun. */ + @Singular List characters; + + /** + * Résumé d'un PJ : nom + snippet court du markdown. + * Pas le markdown complet pour maîtriser le coût token (chaque campagne + * peut avoir 4-6 PJ × potentiellement 1-2k tokens/fiche = trop lourd). + * La fiche complète n'est injectée que si le PJ est l'entité focus + * (via NarrativeEntityContext, entity_type="character"). + */ + @Value + @Builder + public static class CharacterSummary { + String name; + String snippet; + } /** Résumé d'un arc : nom + description courte + ses chapitres. */ @Value diff --git a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java index ea5091d..ce002ab 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java @@ -39,4 +39,10 @@ public class ChatRequest { /** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */ NarrativeEntityContext narrativeEntity; + + /** + * Optionnel : règles du système de JDR de la campagne (filtrées par intent). + * Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP. + */ + GameSystemContext gameSystemContext; } diff --git a/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java b/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java new file mode 100644 index 0000000..8e66836 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java @@ -0,0 +1,30 @@ +package com.loremind.domain.generationcontext; + +import lombok.Builder; +import lombok.Value; + +import java.util.Map; + +/** + * Value Object représentant les règles de JDR injectées dans un prompt IA. + *

+ * Contient uniquement les sections pertinentes pour l'intent de génération + * en cours (sélection effectuée par GameSystemContextBuilder). Les sections + * sont indexées par leur titre H2 original (ex : "Combat", "Classes"). + */ +@Value +@Builder +public class GameSystemContext { + + /** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */ + String systemName; + + /** Description courte du système (nullable). */ + String systemDescription; + + /** + * Sections de règles pertinentes, indexées par titre H2. + * Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent. + */ + Map sections; +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java index 0e83160..8d8781f 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java @@ -4,9 +4,11 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint; import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatRequest; +import com.loremind.domain.generationcontext.GameSystemContext; import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; import com.loremind.domain.generationcontext.NarrativeEntityContext; @@ -52,9 +54,22 @@ public class BrainChatPayloadBuilder { if (request.getNarrativeEntity() != null) { root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity())); } + if (request.getGameSystemContext() != null) { + root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext())); + } return root; } + private Map gameSystemContextToMap(GameSystemContext gs) { + Map map = new LinkedHashMap<>(); + map.put("system_name", gs.getSystemName()); + if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) { + map.put("system_description", gs.getSystemDescription()); + } + map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of()); + return map; + } + private Map messageToMap(ChatMessage m) { Map map = new LinkedHashMap<>(); map.put("role", m.role()); @@ -111,6 +126,21 @@ public class BrainChatPayloadBuilder { map.put("arcs", ctx.getArcs().stream() .map(this::arcSummaryToMap) .collect(Collectors.toList())); + // Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches. + if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) { + map.put("characters", ctx.getCharacters().stream() + .map(this::characterSummaryToMap) + .collect(Collectors.toList())); + } + return map; + } + + private Map characterSummaryToMap(CharacterSummary c) { + Map map = new LinkedHashMap<>(); + map.put("name", c.getName()); + if (c.getSnippet() != null && !c.getSnippet().isBlank()) { + map.put("snippet", c.getSnippet()); + } return map; } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java new file mode 100644 index 0000000..c0b1216 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java @@ -0,0 +1,143 @@ +package com.loremind.infrastructure.persistence; + +import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Seed 3 rulesets libres au premier démarrage (si la table game_systems est vide). + *

+ * Objectif : donner à l'utilisateur un point de départ pour comprendre le format + * attendu (markdown structuré par titres H2) et permettre une démo "out of the box" + * sans devoir taper ses propres règles. + *

+ * Les rulesets fournis sont des extraits libres (Nimble, SRD 5.1 extrait, + * homebrew exemple) — pas des règles officielles complètes. L'utilisateur est + * libre de les éditer, supprimer, ou les utiliser comme template. + *

+ * Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé, + * il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur). + */ +@Component +public class GameSystemSeeder { + + private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class); + + private final GameSystemRepository gameSystemRepository; + + public GameSystemSeeder(GameSystemRepository gameSystemRepository) { + this.gameSystemRepository = gameSystemRepository; + } + + @EventListener(ApplicationReadyEvent.class) + public void seedIfEmpty() { + if (!gameSystemRepository.findAll().isEmpty()) { + log.debug("GameSystem seed skipped — table non vide."); + return; + } + log.info("Seed initial des GameSystems (table vide)..."); + for (GameSystem gs : defaultSystems()) { + gameSystemRepository.save(gs); + } + log.info("GameSystems seedés : {}", defaultSystems().size()); + } + + private List defaultSystems() { + return List.of( + GameSystem.builder() + .name("Nimble (extrait)") + .description("Système léger et narratif, résolution rapide des combats.") + .author("LoreMind seed") + .isPublic(false) + .rulesMarkdown(NIMBLE_RULES) + .build(), + GameSystem.builder() + .name("D&D 5e SRD (extrait)") + .description("Extrait libre des bases du System Reference Document 5.1.") + .author("LoreMind seed") + .isPublic(false) + .rulesMarkdown(DND_SRD_RULES) + .build(), + GameSystem.builder() + .name("Homebrew Exemple") + .description("Template minimaliste à dupliquer pour créer votre propre système.") + .author("LoreMind seed") + .isPublic(false) + .rulesMarkdown(HOMEBREW_EXAMPLE) + .build() + ); + } + + private static final String NIMBLE_RULES = """ + Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé). + + ## Combat + - Initiative libre : les joueurs décrivent leur action dans l'ordre qu'ils veulent, le MJ joue les ennemis quand la fiction l'exige. + - Résolution : 1d20 + mod, difficulté 10/15/20 (facile/normal/dur). 20 naturel = critique (double dégâts). + - Dégâts : arme légère 1d6, arme lourde 1d10, projectile 1d8. Pas de table d'armure, l'armure augmente la difficulté à toucher. + - Blessures : un PJ peut encaisser 3 blessures graves avant de tomber. Pas de PV fins — on raconte les coups. + + ## Classes + - **Guerrier** : +2 en combat, peut relancer un dé de dégât 1×/scène. + - **Explorateur** : +2 en perception/survie, ignore la première blessure d'une scène. + - **Mage** : peut lancer un effet de magie par scène, nécessite une composante racontée. + - **Barde** : +2 en social, peut inspirer un allié (relance de dé). + + ## Monstres + Les monstres ont 3 stats : Menace (difficulté à toucher), Dégâts (dé de dégât), Résistance (nombre de blessures). + Exemples : Gobelin (Menace 10, 1d6, 1), Ogre (Menace 13, 1d10, 3), Dragon adulte (Menace 18, 2d10, 6). + """; + + private static final String DND_SRD_RULES = """ + Extrait libre du SRD 5.1 (Open Game License). Pour les règles complètes, consulter le SRD officiel. + + ## Combat + - Initiative : 1d20 + mod Dex au début du combat, ordre fixe par round. + - Action par tour : une action, une action bonus (si classe le permet), une réaction, mouvement jusqu'à la vitesse. + - Attaque : 1d20 + mod caractéristique + bonus maîtrise vs CA de la cible. + - Dégâts : dé de l'arme + mod caractéristique. Critique sur 20 naturel (double les dés de dégâts). + - Avantage/Désavantage : lancer 2d20 et garder le meilleur / pire. + + ## Classes + - **Barbare** : d12 PV, rage (+dégâts, résistance). Caractéristique principale : Force. + - **Barde** : d8 PV, sorts + inspiration bardique. Caractéristique : Charisme. + - **Clerc** : d8 PV, sorts divins, canalise la divinité. Caractéristique : Sagesse. + - **Druide** : d8 PV, sorts nature + forme animale. Caractéristique : Sagesse. + - **Ensorceleur** : d6 PV, sorts innés + métamagie. Caractéristique : Charisme. + - **Guerrier** : d10 PV, maîtrise martiale, second souffle. Caractéristique : Force ou Dextérité. + - **Magicien** : d6 PV, livre de sorts, grande flexibilité. Caractéristique : Intelligence. + - **Moine** : d8 PV, arts martiaux + ki. Caractéristique : Dextérité + Sagesse. + - **Paladin** : d10 PV, sorts + serment + imposition des mains. Caractéristique : Force + Charisme. + - **Rôdeur** : d10 PV, ennemi juré + explorateur + sorts. Caractéristique : Dextérité + Sagesse. + - **Roublard** : d8 PV, attaque sournoise + expertise. Caractéristique : Dextérité. + + ## Monstres + Stat block standard : CA, PV, Vitesse, For/Dex/Con/Int/Sag/Cha, jets de sauvegarde, compétences, sens, langues, Facteur de Puissance (FP). + Exemples : Gobelin (FP 1/4, CA 15, 7 PV), Ogre (FP 2, CA 11, 59 PV), Dragon rouge adulte (FP 17, CA 19, 256 PV). + """; + + private static final String HOMEBREW_EXAMPLE = """ + Template vide à dupliquer et remplir pour créer votre propre système. + + ## Combat + (Décrivez ici comment se résout un combat : initiative, jet d'attaque, dégâts, points de vie, critiques...) + + ## Classes + (Listez les archétypes jouables : nom, stats de base, capacités signature.) + + ## Monstres + (Format de stat block pour vos créatures : stats, capacités spéciales, FP/niveau.) + + ## Magie + (Si votre système a un système de magie : écoles, coût, composantes, listes de sorts/pouvoirs.) + + ## Progression + (Comment les PJ montent en puissance : XP, niveaux, acquisitions par niveau.) + """; +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java index b856901..886fcdf 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java @@ -45,6 +45,13 @@ public class CampaignJpaEntity { @Column(name = "lore_id") private String loreId; + /** + * ID du GameSystem associé (nullable). + * Weak reference inter-contexte — pas de @ManyToOne / pas de FK DB. + */ + @Column(name = "game_system_id") + private String gameSystemId; + @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java new file mode 100644 index 0000000..39aeab0 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java @@ -0,0 +1,56 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Entité JPA pour les fiches de personnages (PJ) d'une campagne. + * Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte : + * on reste dans le Campaign Context, mais l'agrégat Character est autonome). + */ +@Entity +@Table(name = "characters") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CharacterJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(name = "markdown_content", columnDefinition = "TEXT") + private String markdownContent; + + @Column(name = "campaign_id", nullable = false) + private Long campaignId; + + @Column(name = "\"order\"", nullable = false) + private int order; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java new file mode 100644 index 0000000..3185e9c --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java @@ -0,0 +1,57 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Entité JPA pour la persistance des GameSystems (systèmes de JDR). + */ +@Entity +@Table(name = "game_systems") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GameSystemJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "rules_markdown", columnDefinition = "TEXT") + private String rulesMarkdown; + + @Column + private String author; + + @Column(name = "is_public", nullable = false) + private boolean isPublic; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java new file mode 100644 index 0000000..76db4e5 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/CharacterJpaRepository.java @@ -0,0 +1,13 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CharacterJpaRepository extends JpaRepository { + + List findByCampaignIdOrderByOrderAsc(Long campaignId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/GameSystemJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/GameSystemJpaRepository.java new file mode 100644 index 0000000..81b9228 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/GameSystemJpaRepository.java @@ -0,0 +1,16 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface GameSystemJpaRepository extends JpaRepository { + + @Query("SELECT g FROM GameSystemJpaEntity g WHERE LOWER(g.name) LIKE LOWER(CONCAT('%', :query, '%'))") + List findByNameContainingIgnoreCase(@Param("query") String query); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java index 86aa974..0b2d039 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java @@ -71,6 +71,7 @@ public class PostgresCampaignRepository implements CampaignRepository { .updatedAt(jpaEntity.getUpdatedAt()) .arcsCount(jpaEntity.getArcsCount()) .loreId(jpaEntity.getLoreId()) + .gameSystemId(jpaEntity.getGameSystemId()) .build(); } @@ -84,6 +85,7 @@ public class PostgresCampaignRepository implements CampaignRepository { .updatedAt(campaign.getUpdatedAt()) .arcsCount(campaign.getArcsCount()) .loreId(campaign.getLoreId()) + .gameSystemId(campaign.getGameSystemId()) .build(); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java new file mode 100644 index 0000000..411b107 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java @@ -0,0 +1,75 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.campaigncontext.Character; +import com.loremind.domain.campaigncontext.ports.CharacterRepository; +import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity; +import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +public class PostgresCharacterRepository implements CharacterRepository { + + private final CharacterJpaRepository jpaRepository; + + public PostgresCharacterRepository(CharacterJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Character save(Character character) { + CharacterJpaEntity entity = toJpaEntity(character); + CharacterJpaEntity saved = jpaRepository.save(entity); + return toDomainEntity(saved); + } + + @Override + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity); + } + + @Override + public List findByCampaignId(String campaignId) { + return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + private Character toDomainEntity(CharacterJpaEntity e) { + return Character.builder() + .id(e.getId().toString()) + .name(e.getName()) + .markdownContent(e.getMarkdownContent()) + .campaignId(e.getCampaignId().toString()) + .order(e.getOrder()) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } + + private CharacterJpaEntity toJpaEntity(Character c) { + Long id = c.getId() != null ? Long.parseLong(c.getId()) : null; + return CharacterJpaEntity.builder() + .id(id) + .name(c.getName()) + .markdownContent(c.getMarkdownContent()) + .campaignId(Long.parseLong(c.getCampaignId())) + .order(c.getOrder()) + .createdAt(c.getCreatedAt()) + .updatedAt(c.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java new file mode 100644 index 0000000..c32b816 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java @@ -0,0 +1,84 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity; +import com.loremind.infrastructure.persistence.jpa.GameSystemJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +public class PostgresGameSystemRepository implements GameSystemRepository { + + private final GameSystemJpaRepository jpaRepository; + + public PostgresGameSystemRepository(GameSystemJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public GameSystem save(GameSystem gameSystem) { + GameSystemJpaEntity entity = toJpaEntity(gameSystem); + GameSystemJpaEntity saved = jpaRepository.save(entity); + return toDomainEntity(saved); + } + + @Override + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity); + } + + @Override + public List findAll() { + return jpaRepository.findAll().stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + @Override + public List searchByName(String query) { + return jpaRepository.findByNameContainingIgnoreCase(query).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + + private GameSystem toDomainEntity(GameSystemJpaEntity e) { + return GameSystem.builder() + .id(e.getId().toString()) + .name(e.getName()) + .description(e.getDescription()) + .rulesMarkdown(e.getRulesMarkdown()) + .author(e.getAuthor()) + .isPublic(e.isPublic()) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } + + private GameSystemJpaEntity toJpaEntity(GameSystem g) { + Long id = g.getId() != null ? Long.parseLong(g.getId()) : null; + return GameSystemJpaEntity.builder() + .id(id) + .name(g.getName()) + .description(g.getDescription()) + .rulesMarkdown(g.getRulesMarkdown()) + .author(g.getAuthor()) + .isPublic(g.isPublic()) + .createdAt(g.getCreatedAt()) + .updatedAt(g.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java index a36ed46..962a72d 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java @@ -31,7 +31,7 @@ public class CampaignController { public ResponseEntity createCampaign(@RequestBody CampaignDTO campaignDTO) { Campaign campaign = campaignMapper.toDomain(campaignDTO); Campaign createdCampaign = campaignService.createCampaign( - new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId()) + new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId()) ); return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign)); } @@ -64,7 +64,7 @@ public class CampaignController { public ResponseEntity updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) { Campaign updatedCampaign = campaignService.updateCampaign( id, - new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId()) + new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId()) ); return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign)); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java new file mode 100644 index 0000000..92e8dfb --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java @@ -0,0 +1,62 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.CharacterService; +import com.loremind.domain.campaigncontext.Character; +import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO; +import com.loremind.infrastructure.web.mapper.CharacterMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/characters") +public class CharacterController { + + private final CharacterService characterService; + private final CharacterMapper characterMapper; + + public CharacterController(CharacterService characterService, CharacterMapper characterMapper) { + this.characterService = characterService; + this.characterMapper = characterMapper; + } + + @PostMapping + public ResponseEntity createCharacter(@RequestBody CharacterDTO dto) { + Character created = characterService.createCharacter( + new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null) + ); + return ResponseEntity.ok(characterMapper.toDTO(created)); + } + + @GetMapping("/{id}") + public ResponseEntity getCharacterById(@PathVariable String id) { + return characterService.getCharacterById(id) + .map(c -> ResponseEntity.ok(characterMapper.toDTO(c))) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/campaign/{campaignId}") + public ResponseEntity> getCharactersByCampaign(@PathVariable String campaignId) { + List dtos = characterService.getCharactersByCampaignId(campaignId).stream() + .map(characterMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @PutMapping("/{id}") + public ResponseEntity updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) { + Character updated = characterService.updateCharacter( + id, + new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder()) + ); + return ResponseEntity.ok(characterMapper.toDTO(updated)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCharacter(@PathVariable String id) { + characterService.deleteCharacter(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java new file mode 100644 index 0000000..a243423 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java @@ -0,0 +1,75 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.gamesystemcontext.GameSystemService; +import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; +import com.loremind.infrastructure.web.mapper.GameSystemMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/game-systems") +public class GameSystemController { + + private final GameSystemService gameSystemService; + private final GameSystemMapper gameSystemMapper; + + public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) { + this.gameSystemService = gameSystemService; + this.gameSystemMapper = gameSystemMapper; + } + + @PostMapping + public ResponseEntity createGameSystem(@RequestBody GameSystemDTO dto) { + GameSystem created = gameSystemService.createGameSystem(toData(dto)); + return ResponseEntity.ok(gameSystemMapper.toDTO(created)); + } + + @GetMapping("/{id}") + public ResponseEntity getGameSystemById(@PathVariable String id) { + return gameSystemService.getGameSystemById(id) + .map(g -> ResponseEntity.ok(gameSystemMapper.toDTO(g))) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping + public ResponseEntity> getAllGameSystems() { + List dtos = gameSystemService.getAllGameSystems().stream() + .map(gameSystemMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @GetMapping("/search") + public ResponseEntity> searchGameSystems(@RequestParam("q") String query) { + List dtos = gameSystemService.searchGameSystems(query).stream() + .map(gameSystemMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @PutMapping("/{id}") + public ResponseEntity updateGameSystem(@PathVariable String id, @RequestBody GameSystemDTO dto) { + GameSystem updated = gameSystemService.updateGameSystem(id, toData(dto)); + return ResponseEntity.ok(gameSystemMapper.toDTO(updated)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteGameSystem(@PathVariable String id) { + gameSystemService.deleteGameSystem(id); + return ResponseEntity.noContent().build(); + } + + private GameSystemService.GameSystemData toData(GameSystemDTO dto) { + return new GameSystemService.GameSystemData( + dto.getName(), + dto.getDescription(), + dto.getRulesMarkdown(), + dto.getAuthor(), + dto.isPublic() + ); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java index 3c6c997..6be48c4 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java @@ -15,4 +15,6 @@ public class CampaignDTO { private int arcsCount; /** Nullable : campagne sans univers associé. */ private String loreId; + /** Nullable : campagne sans système de JDR associé (générique). */ + private String gameSystemId; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java new file mode 100644 index 0000000..201a936 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java @@ -0,0 +1,16 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; + +/** + * DTO pour les fiches de personnages (PJ) d'une campagne. + */ +@Data +public class CharacterDTO { + + private String id; + private String name; + private String markdownContent; + private String campaignId; + private int order; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java new file mode 100644 index 0000000..e745b4e --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java @@ -0,0 +1,17 @@ +package com.loremind.infrastructure.web.dto.gamesystemcontext; + +import lombok.Data; + +/** + * DTO pour l'entité GameSystem (système de JDR). + */ +@Data +public class GameSystemDTO { + + private String id; + private String name; + private String description; + private String rulesMarkdown; + private String author; + private boolean isPublic; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java index 393a655..c16a87b 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java @@ -21,6 +21,7 @@ public class CampaignMapper { dto.setDescription(campaign.getDescription()); dto.setArcsCount(campaign.getArcsCount()); dto.setLoreId(campaign.getLoreId()); + dto.setGameSystemId(campaign.getGameSystemId()); return dto; } @@ -35,6 +36,7 @@ public class CampaignMapper { .description(dto.getDescription()) .arcsCount(dto.getArcsCount()) .loreId(dto.getLoreId()) + .gameSystemId(dto.getGameSystemId()) .build(); } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java new file mode 100644 index 0000000..86bc49d --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java @@ -0,0 +1,31 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.campaigncontext.Character; +import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO; +import org.springframework.stereotype.Component; + +@Component +public class CharacterMapper { + + public CharacterDTO toDTO(Character c) { + if (c == null) return null; + CharacterDTO dto = new CharacterDTO(); + dto.setId(c.getId()); + dto.setName(c.getName()); + dto.setMarkdownContent(c.getMarkdownContent()); + dto.setCampaignId(c.getCampaignId()); + dto.setOrder(c.getOrder()); + return dto; + } + + public Character toDomain(CharacterDTO dto) { + if (dto == null) return null; + return Character.builder() + .id(dto.getId()) + .name(dto.getName()) + .markdownContent(dto.getMarkdownContent()) + .campaignId(dto.getCampaignId()) + .order(dto.getOrder()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java new file mode 100644 index 0000000..901baa6 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java @@ -0,0 +1,33 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; +import org.springframework.stereotype.Component; + +@Component +public class GameSystemMapper { + + public GameSystemDTO toDTO(GameSystem g) { + if (g == null) return null; + GameSystemDTO dto = new GameSystemDTO(); + dto.setId(g.getId()); + dto.setName(g.getName()); + dto.setDescription(g.getDescription()); + dto.setRulesMarkdown(g.getRulesMarkdown()); + dto.setAuthor(g.getAuthor()); + dto.setPublic(g.isPublic()); + return dto; + } + + public GameSystem toDomain(GameSystemDTO dto) { + if (dto == null) return null; + return GameSystem.builder() + .id(dto.getId()) + .name(dto.getName()) + .description(dto.getDescription()) + .rulesMarkdown(dto.getRulesMarkdown()) + .author(dto.getAuthor()) + .isPublic(dto.isPublic()) + .build(); + } +} diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 5c1eda5..8477770 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -14,6 +14,8 @@ export const routes: Routes = [ { path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) }, { path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, + { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, + { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, @@ -24,6 +26,9 @@ export const routes: Routes = [ { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) }, + { path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) }, + { path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, + { path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, { path: 'settings', loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) }, { path: '', redirectTo: '/lore', pathMatch: 'full' } ]; diff --git a/web/src/app/campaigns/campaign-create/campaign-create.component.html b/web/src/app/campaigns/campaign-create/campaign-create.component.html index e0d94f9..4e30fc7 100644 --- a/web/src/app/campaigns/campaign-create/campaign-create.component.html +++ b/web/src/app/campaigns/campaign-create/campaign-create.component.html @@ -46,6 +46,18 @@

+
+ + +

+ Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...) + dans ses suggestions pour respecter les mécaniques du JDR. +

+
+

💡 Organisation : Votre campagne sera structurée en :

    diff --git a/web/src/app/campaigns/campaign-create/campaign-create.component.ts b/web/src/app/campaigns/campaign-create/campaign-create.component.ts index 9861668..1af4b9b 100644 --- a/web/src/app/campaigns/campaign-create/campaign-create.component.ts +++ b/web/src/app/campaigns/campaign-create/campaign-create.component.ts @@ -4,16 +4,19 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula import { LucideAngularModule, BookCopy, X } from 'lucide-angular'; import { LoreService } from '../../services/lore.service'; import { Lore } from '../../services/lore.model'; +import { GameSystemService } from '../../services/game-system.service'; +import { GameSystem } from '../../services/game-system.model'; /** * Payload émis vers le parent à la création d'une campagne. - * `loreId` est optionnel (null = campagne sans univers associé). + * `loreId` et `gameSystemId` sont optionnels (null = non associé). */ export interface CampaignCreatePayload { name: string; description: string; playerCount: number; loreId: string | null; + gameSystemId: string | null; } @Component({ @@ -33,15 +36,20 @@ export class CampaignCreateComponent implements OnInit { form: FormGroup; /** Lores disponibles pour association. Chargés à l'ouverture de la modal. */ availableLores: Lore[] = []; + /** GameSystems disponibles pour association. */ + availableGameSystems: GameSystem[] = []; - constructor(private fb: FormBuilder, private loreService: LoreService) { + constructor( + private fb: FormBuilder, + private loreService: LoreService, + private gameSystemService: GameSystemService + ) { this.form = this.fb.group({ - name: ['', Validators.required], - description: [''], - playerCount: [4, [Validators.required, Validators.min(1)]], - // Valeur par défaut : chaîne vide = "— Aucun lore associé —". - // Le service normalise ensuite ""/null en null côté backend. - loreId: [''] + name: ['', Validators.required], + description: [''], + playerCount: [4, [Validators.required, Validators.min(1)]], + loreId: [''], + gameSystemId: [''] }); } @@ -50,6 +58,10 @@ export class CampaignCreateComponent implements OnInit { next: (lores) => this.availableLores = lores, error: () => this.availableLores = [] }); + this.gameSystemService.getAll().subscribe({ + next: (gs) => this.availableGameSystems = gs, + error: () => this.availableGameSystems = [] + }); } submit(): void { @@ -59,7 +71,8 @@ export class CampaignCreateComponent implements OnInit { name: raw.name, description: raw.description, playerCount: raw.playerCount, - loreId: raw.loreId ? raw.loreId : null + loreId: raw.loreId ? raw.loreId : null, + gameSystemId: raw.gameSystemId ? raw.gameSystemId : null }); } diff --git a/web/src/app/campaigns/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign-detail/campaign-detail.component.html index 06d7d51..9ae9f62 100644 --- a/web/src/app/campaigns/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign-detail/campaign-detail.component.html @@ -53,6 +53,13 @@
+
+ + +
+
+
+

Personnages joueurs

+ +
+ +
+
+ +
+ {{ character.name }} + {{ characterSnippet(character) }} +
+
+
+ +
+ +

Aucun personnage joueur pour le moment.

+ +
+
+

Arcs narratifs

diff --git a/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss index f01bc75..b02cf68 100644 --- a/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss @@ -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; diff --git a/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts index 0c65eb3..b6cf157 100644 --- a/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign-detail/campaign-detail.component.ts @@ -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; diff --git a/web/src/app/campaigns/character-edit/character-edit.component.html b/web/src/app/campaigns/character-edit/character-edit.component.html new file mode 100644 index 0000000..eb3c41b --- /dev/null +++ b/web/src/app/campaigns/character-edit/character-edit.component.html @@ -0,0 +1,82 @@ +
+ +
+ +
+

+ + {{ characterId ? 'Éditer la fiche' : 'Nouveau personnage' }} +

+ +
+
+ +
+ +
+ + +
+ +
+ +

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

+ +
+ +
+ + + + +
+ +
+ +
+ + + diff --git a/web/src/app/campaigns/character-edit/character-edit.component.scss b/web/src/app/campaigns/character-edit/character-edit.component.scss new file mode 100644 index 0000000..7371a67 --- /dev/null +++ b/web/src/app/campaigns/character-edit/character-edit.component.scss @@ -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); + } +} diff --git a/web/src/app/campaigns/character-edit/character-edit.component.ts b/web/src/app/campaigns/character-edit/character-edit.component.ts new file mode 100644 index 0000000..208c7b1 --- /dev/null +++ b/web/src/app/campaigns/character-edit/character-edit.component.ts @@ -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']); + } + } +} diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html new file mode 100644 index 0000000..8ee5e88 --- /dev/null +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html @@ -0,0 +1,103 @@ +
+ +
+ +

+ + {{ id ? 'Éditer le système' : 'Nouveau système de JDR' }} +

+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+

Règles du système

+

+ Une section = un thème. L'IA injectera automatiquement les sections pertinentes + selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions). +

+ +
+
+ +
+ + + +
+ + + +
+ +
+ Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous. +
+
+ +
+ Ajouter une section : + + +
+
+ +
+ + +
+ +
+ +
diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss b/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss new file mode 100644 index 0000000..04f5c6d --- /dev/null +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss @@ -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; } +} diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts new file mode 100644 index 0000000..b22f9f8 --- /dev/null +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts @@ -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'); + } +} diff --git a/web/src/app/game-systems/game-systems.component.html b/web/src/app/game-systems/game-systems.component.html new file mode 100644 index 0000000..4bc73a0 --- /dev/null +++ b/web/src/app/game-systems/game-systems.component.html @@ -0,0 +1,39 @@ +
+ +
+ +

Systèmes de JDR

+

Les règles que l'IA connaîtra pour vos campagnes

+
+ +
+ +
+
+

{{ gs.name }}

+
+ +
+
+

{{ gs.description || '(Pas de description)' }}

+ +
+ +
+
+ +
+

Nouveau système

+

Saisir les règles d'un JDR (markdown)

+
+ +
+ +

💡 Astuce : organisez vos règles par sections avec des titres ## Combat, ## Classes, ## Lore… Le système injectera les sections pertinentes selon ce que l'IA doit générer.

+ +
diff --git a/web/src/app/game-systems/game-systems.component.scss b/web/src/app/game-systems/game-systems.component.scss new file mode 100644 index 0000000..5f182f9 --- /dev/null +++ b/web/src/app/game-systems/game-systems.component.scss @@ -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; + } +} diff --git a/web/src/app/game-systems/game-systems.component.ts b/web/src/app/game-systems/game-systems.component.ts new file mode 100644 index 0000000..7d09d20 --- /dev/null +++ b/web/src/app/game-systems/game-systems.component.ts @@ -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') + }); + } +} diff --git a/web/src/app/services/ai-chat.service.ts b/web/src/app/services/ai-chat.service.ts index af85b31..cbd3aee 100644 --- a/web/src/app/services/ai-chat.service.ts +++ b/web/src/app/services/ai-chat.service.ts @@ -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 { diff --git a/web/src/app/services/campaign.model.ts b/web/src/app/services/campaign.model.ts index 3178dd5..5be4e1d 100644 --- a/web/src/app/services/campaign.model.ts +++ b/web/src/app/services/campaign.model.ts @@ -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 { diff --git a/web/src/app/services/character.model.ts b/web/src/app/services/character.model.ts new file mode 100644 index 0000000..c154810 --- /dev/null +++ b/web/src/app/services/character.model.ts @@ -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; +} diff --git a/web/src/app/services/character.service.ts b/web/src/app/services/character.service.ts new file mode 100644 index 0000000..9f5e793 --- /dev/null +++ b/web/src/app/services/character.service.ts @@ -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 { + return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); + } + + getById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + create(payload: CharacterCreate): Observable { + return this.http.post(this.apiUrl, payload); + } + + update(id: string, payload: Character): Observable { + return this.http.put(`${this.apiUrl}/${id}`, payload); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } +} diff --git a/web/src/app/services/conversation.model.ts b/web/src/app/services/conversation.model.ts index 5b6bb50..84d283f 100644 --- a/web/src/app/services/conversation.model.ts +++ b/web/src/app/services/conversation.model.ts @@ -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; } diff --git a/web/src/app/services/game-system.model.ts b/web/src/app/services/game-system.model.ts new file mode 100644 index 0000000..6e3bf25 --- /dev/null +++ b/web/src/app/services/game-system.model.ts @@ -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; +} diff --git a/web/src/app/services/game-system.service.ts b/web/src/app/services/game-system.service.ts new file mode 100644 index 0000000..4aca7a5 --- /dev/null +++ b/web/src/app/services/game-system.service.ts @@ -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 { + return this.http.get(this.apiUrl); + } + + getById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + create(payload: GameSystemCreate): Observable { + return this.http.post(this.apiUrl, payload); + } + + update(id: string, payload: GameSystemCreate): Observable { + return this.http.put(`${this.apiUrl}/${id}`, payload); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } + + search(q: string): Observable { + const params = new HttpParams().set('q', q); + return this.http.get(`${this.apiUrl}/search`, { params }); + } +} diff --git a/web/src/app/sidebar/sidebar.component.html b/web/src/app/sidebar/sidebar.component.html index fe2d771..0c60208 100644 --- a/web/src/app/sidebar/sidebar.component.html +++ b/web/src/app/sidebar/sidebar.component.html @@ -49,6 +49,10 @@ Recherche globale Ctrl+K +