Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
This commit is contained in:
@@ -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)"}'
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -28,13 +28,14 @@ public class CampaignService {
|
||||
*
|
||||
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
|
||||
*/
|
||||
public record CampaignData(String name, String description, String loreId) {}
|
||||
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
|
||||
|
||||
public Campaign createCampaign(CampaignData data) {
|
||||
Campaign campaign = Campaign.builder()
|
||||
.name(data.name())
|
||||
.description(data.description())
|
||||
.loreId(normalizeLoreId(data.loreId()))
|
||||
.loreId(normalizeId(data.loreId()))
|
||||
.gameSystemId(normalizeId(data.gameSystemId()))
|
||||
.arcsCount(0)
|
||||
.build();
|
||||
return campaignRepository.save(campaign);
|
||||
@@ -57,16 +58,17 @@ public class CampaignService {
|
||||
Campaign campaign = existingCampaign.get();
|
||||
campaign.setName(data.name());
|
||||
campaign.setDescription(data.description());
|
||||
campaign.setLoreId(normalizeLoreId(data.loreId()));
|
||||
campaign.setLoreId(normalizeId(data.loreId()));
|
||||
campaign.setGameSystemId(normalizeId(data.gameSystemId()));
|
||||
return campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien".
|
||||
* Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
|
||||
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
|
||||
*/
|
||||
private String normalizeLoreId(String loreId) {
|
||||
return (loreId == null || loreId.isBlank()) ? null : loreId;
|
||||
private String normalizeId(String id) {
|
||||
return (id == null || id.isBlank()) ? null : id;
|
||||
}
|
||||
|
||||
public void deleteCampaign(String id) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour les fiches de personnages (PJ).
|
||||
*/
|
||||
@Service
|
||||
public class CharacterService {
|
||||
|
||||
private final CharacterRepository characterRepository;
|
||||
|
||||
public CharacterService(CharacterRepository characterRepository) {
|
||||
this.characterRepository = characterRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un Character.
|
||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||
*/
|
||||
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
|
||||
|
||||
public Character createCharacter(CharacterData data) {
|
||||
int order = data.order() != null
|
||||
? data.order()
|
||||
: nextOrderFor(data.campaignId());
|
||||
Character character = Character.builder()
|
||||
.name(data.name())
|
||||
.markdownContent(data.markdownContent())
|
||||
.campaignId(data.campaignId())
|
||||
.order(order)
|
||||
.build();
|
||||
return characterRepository.save(character);
|
||||
}
|
||||
|
||||
public Optional<Character> getCharacterById(String id) {
|
||||
return characterRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Character> getCharactersByCampaignId(String campaignId) {
|
||||
return characterRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public Character updateCharacter(String id, CharacterData data) {
|
||||
Character existing = characterRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setMarkdownContent(data.markdownContent());
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
// campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP).
|
||||
return characterRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteCharacter(String id) {
|
||||
characterRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/** Renvoie la prochaine position libre — append en fin de liste. */
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return characterRepository.findByCampaignId(campaignId).stream()
|
||||
.mapToInt(Character::getOrder)
|
||||
.max()
|
||||
.orElse(-1) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.loremind.application.gamesystemcontext;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.GenerationIntent;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Construit un {@link GameSystemContext} à partir d'un gameSystemId et d'un intent.
|
||||
* <p>
|
||||
* Pipeline :
|
||||
* 1. Charge le GameSystem (retourne Optional.empty si introuvable — dégradation gracieuse).
|
||||
* 2. Parse le markdown par titres H2 (## Section) → Map<Titre, Contenu>.
|
||||
* 3. Filtre les sections selon l'intent via les alias {@link GenerationIntent#getSectionAliases()}.
|
||||
* GENERIC = pas de filtre.
|
||||
* <p>
|
||||
* Parsing à la volée (pas de cache) : les règles d'un système font
|
||||
* typiquement 5-20kB, le coût de parsing est négligeable devant l'appel LLM.
|
||||
*/
|
||||
@Service
|
||||
public class GameSystemContextBuilder {
|
||||
|
||||
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
|
||||
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
|
||||
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
|
||||
public GameSystemContextBuilder(GameSystemRepository gameSystemRepository) {
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
public Optional<GameSystemContext> buildOptional(String gameSystemId, GenerationIntent intent) {
|
||||
if (gameSystemId == null || gameSystemId.isBlank()) return Optional.empty();
|
||||
return gameSystemRepository.findById(gameSystemId)
|
||||
.map(gs -> build(gs, intent));
|
||||
}
|
||||
|
||||
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
|
||||
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
|
||||
Map<String, String> filtered = filterByIntent(allSections, intent);
|
||||
return GameSystemContext.builder()
|
||||
.systemName(gs.getName())
|
||||
.systemDescription(gs.getDescription())
|
||||
.sections(filtered)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe le markdown par titres H2. Préserve l'ordre d'apparition (LinkedHashMap).
|
||||
* Le contenu avant le premier H2 est ignoré (préambule libre).
|
||||
*/
|
||||
Map<String, String> parseH2Sections(String markdown) {
|
||||
Map<String, String> sections = new LinkedHashMap<>();
|
||||
if (markdown == null || markdown.isBlank()) return sections;
|
||||
|
||||
Matcher m = H2_HEADER.matcher(markdown);
|
||||
String currentTitle = null;
|
||||
int currentContentStart = -1;
|
||||
|
||||
while (m.find()) {
|
||||
if (currentTitle != null) {
|
||||
sections.put(currentTitle, markdown.substring(currentContentStart, m.start()).strip());
|
||||
}
|
||||
currentTitle = m.group(1).trim();
|
||||
currentContentStart = m.end();
|
||||
}
|
||||
if (currentTitle != null) {
|
||||
sections.put(currentTitle, markdown.substring(currentContentStart).strip());
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
private Map<String, String> filterByIntent(Map<String, String> sections, GenerationIntent intent) {
|
||||
if (intent.matchesAllSections()) return sections;
|
||||
Map<String, String> filtered = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> e : sections.entrySet()) {
|
||||
String titleLower = e.getKey().toLowerCase();
|
||||
boolean match = intent.getSectionAliases().stream().anyMatch(titleLower::contains);
|
||||
if (match) {
|
||||
filtered.put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.loremind.application.gamesystemcontext;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class GameSystemService {
|
||||
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
|
||||
public GameSystemService(GameSystemRepository gameSystemRepository) {
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
||||
*/
|
||||
public record GameSystemData(
|
||||
String name,
|
||||
String description,
|
||||
String rulesMarkdown,
|
||||
String author,
|
||||
boolean isPublic
|
||||
) {}
|
||||
|
||||
public GameSystem createGameSystem(GameSystemData data) {
|
||||
GameSystem gameSystem = GameSystem.builder()
|
||||
.name(data.name())
|
||||
.description(data.description())
|
||||
.rulesMarkdown(data.rulesMarkdown())
|
||||
.author(normalize(data.author()))
|
||||
.isPublic(data.isPublic())
|
||||
.build();
|
||||
return gameSystemRepository.save(gameSystem);
|
||||
}
|
||||
|
||||
public Optional<GameSystem> getGameSystemById(String id) {
|
||||
return gameSystemRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<GameSystem> getAllGameSystems() {
|
||||
return gameSystemRepository.findAll();
|
||||
}
|
||||
|
||||
public GameSystem updateGameSystem(String id, GameSystemData data) {
|
||||
GameSystem existing = gameSystemRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setDescription(data.description());
|
||||
existing.setRulesMarkdown(data.rulesMarkdown());
|
||||
existing.setAuthor(normalize(data.author()));
|
||||
existing.setPublic(data.isPublic());
|
||||
return gameSystemRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteGameSystem(String id) {
|
||||
gameSystemRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public boolean gameSystemExists(String id) {
|
||||
return gameSystemRepository.existsById(id);
|
||||
}
|
||||
|
||||
public List<GameSystem> searchGameSystems(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return gameSystemRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return (value == null || value.isBlank()) ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,18 @@ package com.loremind.application.generationcontext;
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -38,18 +41,24 @@ public class CampaignStructuralContextBuilder {
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
|
||||
public CampaignStructuralContextBuilder(
|
||||
CampaignRepository campaignRepository,
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository) {
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
}
|
||||
|
||||
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
|
||||
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
|
||||
|
||||
/**
|
||||
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
|
||||
* nom + description courte à chaque niveau).
|
||||
@@ -65,13 +74,42 @@ public class CampaignStructuralContextBuilder {
|
||||
.map(this::toArcSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream()
|
||||
.sorted(Comparator.comparingInt(Character::getOrder))
|
||||
.map(this::toCharacterSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return CampaignStructuralContext.builder()
|
||||
.campaignName(campaign.getName())
|
||||
.campaignDescription(campaign.getDescription())
|
||||
.arcs(arcs)
|
||||
.characters(characters)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Projette un PJ vers un résumé court : nom + 1re ligne "signifiante" du
|
||||
* markdown (ni vide, ni un titre). Permet à l'IA de savoir "qui est Thorin"
|
||||
* sans injecter toute sa fiche.
|
||||
*/
|
||||
private CharacterSummary toCharacterSummary(Character c) {
|
||||
return CharacterSummary.builder()
|
||||
.name(c.getName())
|
||||
.snippet(extractSnippet(c.getMarkdownContent()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String extractSnippet(String markdown) {
|
||||
if (markdown == null || markdown.isBlank()) return "";
|
||||
String firstLine = markdown.lines()
|
||||
.map(String::strip)
|
||||
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
||||
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
||||
}
|
||||
|
||||
private ArcSummary toArcSummary(Arc arc) {
|
||||
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||
|
||||
@@ -2,9 +2,11 @@ package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -26,20 +28,23 @@ public class NarrativeEntityContextBuilder {
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
|
||||
public NarrativeEntityContextBuilder(
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository) {
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
||||
*
|
||||
* @param entityType "arc", "chapter" ou "scene" (insensible à la casse)
|
||||
* @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse)
|
||||
* @param entityId l'ID de l'entité
|
||||
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
||||
*/
|
||||
@@ -49,6 +54,7 @@ public class NarrativeEntityContextBuilder {
|
||||
case "arc" -> fromArc(loadArc(entityId));
|
||||
case "chapter" -> fromChapter(loadChapter(entityId));
|
||||
case "scene" -> fromScene(loadScene(entityId));
|
||||
case "character" -> fromCharacter(loadCharacter(entityId));
|
||||
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
||||
};
|
||||
}
|
||||
@@ -70,6 +76,11 @@ public class NarrativeEntityContextBuilder {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
|
||||
}
|
||||
|
||||
private Character loadCharacter(String id) {
|
||||
return characterRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
|
||||
}
|
||||
|
||||
// --- Mapping entité → VO ------------------------------------------------
|
||||
|
||||
private NarrativeEntityContext fromArc(Arc a) {
|
||||
@@ -118,6 +129,16 @@ public class NarrativeEntityContextBuilder {
|
||||
.build();
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromCharacter(Character c) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("character")
|
||||
.title(c.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
||||
private static void putField(Map<String, String> target, String key, String value) {
|
||||
target.put(key, value == null ? "" : value);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||
* <p>
|
||||
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
|
||||
* backstory, équipement). Évolution prévue vers un système templaté par
|
||||
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
|
||||
* <p>
|
||||
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
|
||||
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
|
||||
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Character {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
|
||||
* renseigné progressivement par le MJ.
|
||||
*/
|
||||
private String markdownContent;
|
||||
|
||||
/** Référence vers la Campaign parente. */
|
||||
private String campaignId;
|
||||
|
||||
/** Ordre d'affichage dans la liste des PJ de la campagne. */
|
||||
private int order;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des fiches de personnages (PJ).
|
||||
*/
|
||||
public interface CharacterRepository {
|
||||
|
||||
Character save(Character character);
|
||||
|
||||
Optional<Character> findById(String id);
|
||||
|
||||
List<Character> findByCampaignId(String campaignId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.loremind.domain.gamesystemcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant un GameSystem (système de JDR).
|
||||
* <p>
|
||||
* Porte les règles d'un système (D&D, Nimble, Pathfinder, homebrew...) sous forme
|
||||
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
|
||||
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
|
||||
* <p>
|
||||
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
|
||||
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
|
||||
* éviter une migration ultérieure.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class GameSystem {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String description;
|
||||
|
||||
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
|
||||
private String rulesMarkdown;
|
||||
|
||||
/** Auteur déclaré — futur marketplace. Nullable. */
|
||||
private String author;
|
||||
|
||||
/** Flag de partage — futur marketplace. False par défaut. */
|
||||
private boolean isPublic;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.loremind.domain.gamesystemcontext;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Intent de génération utilisé pour sélectionner les sections d'un GameSystem
|
||||
* à injecter dans le prompt IA.
|
||||
* <p>
|
||||
* Chaque intent porte une liste d'alias (case-insensitive, comparaison par
|
||||
* {@code contains}) utilisée pour matcher les titres H2 du markdown de règles.
|
||||
* <p>
|
||||
* MVP : mapping codé en dur. Évoluera vers un mapping configurable par
|
||||
* l'utilisateur dans l'éditeur de GameSystem (futur marketplace).
|
||||
*/
|
||||
public enum GenerationIntent {
|
||||
|
||||
/** Scène (combat / rencontre) : règles de résolution + format de stat block. */
|
||||
SCENE(Set.of("combat", "monstre", "monster")),
|
||||
|
||||
/** Chapitre (segment narratif) : règles de combat + archétypes pour PNJ. */
|
||||
CHAPTER(Set.of("combat", "classe", "class")),
|
||||
|
||||
/** Arc (structure narrative longue) : pas de règles spécifiques — toutes. */
|
||||
ARC(Set.of()),
|
||||
|
||||
/** Fallback : toutes les sections (intent inconnu). */
|
||||
GENERIC(Set.of());
|
||||
|
||||
private final Set<String> sectionAliases;
|
||||
|
||||
GenerationIntent(Set<String> sectionAliases) {
|
||||
this.sectionAliases = sectionAliases;
|
||||
}
|
||||
|
||||
public Set<String> getSectionAliases() {
|
||||
return sectionAliases;
|
||||
}
|
||||
|
||||
/** True si l'intent veut toutes les sections (pas de filtre). */
|
||||
public boolean matchesAllSections() {
|
||||
return sectionAliases.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe un entityType de NarrativeEntityContext ("arc"/"chapter"/"scene")
|
||||
* vers l'intent correspondant. Tout le reste (null, inconnu) tombe sur GENERIC.
|
||||
*/
|
||||
public static GenerationIntent fromNarrativeEntityType(String entityType) {
|
||||
if (entityType == null) return GENERIC;
|
||||
return switch (entityType.toLowerCase()) {
|
||||
case "scene" -> SCENE;
|
||||
case "chapter" -> CHAPTER;
|
||||
case "arc" -> ARC;
|
||||
default -> GENERIC;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.domain.gamesystemcontext.ports;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des GameSystems.
|
||||
*/
|
||||
public interface GameSystemRepository {
|
||||
|
||||
GameSystem save(GameSystem gameSystem);
|
||||
|
||||
Optional<GameSystem> findById(String id);
|
||||
|
||||
List<GameSystem> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
List<GameSystem> searchByName(String query);
|
||||
}
|
||||
@@ -30,6 +30,22 @@ public class CampaignStructuralContext {
|
||||
String campaignName;
|
||||
String campaignDescription;
|
||||
@Singular List<ArcSummary> arcs;
|
||||
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
|
||||
@Singular List<CharacterSummary> characters;
|
||||
|
||||
/**
|
||||
* Résumé d'un PJ : nom + snippet court du markdown.
|
||||
* Pas le markdown complet pour maîtriser le coût token (chaque campagne
|
||||
* peut avoir 4-6 PJ × potentiellement 1-2k tokens/fiche = trop lourd).
|
||||
* La fiche complète n'est injectée que si le PJ est l'entité focus
|
||||
* (via NarrativeEntityContext, entity_type="character").
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public static class CharacterSummary {
|
||||
String name;
|
||||
String snippet;
|
||||
}
|
||||
|
||||
/** Résumé d'un arc : nom + description courte + ses chapitres. */
|
||||
@Value
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Value Object représentant les règles de JDR injectées dans un prompt IA.
|
||||
* <p>
|
||||
* Contient uniquement les sections pertinentes pour l'intent de génération
|
||||
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
|
||||
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class GameSystemContext {
|
||||
|
||||
/** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
|
||||
String systemName;
|
||||
|
||||
/** Description courte du système (nullable). */
|
||||
String systemDescription;
|
||||
|
||||
/**
|
||||
* Sections de règles pertinentes, indexées par titre H2.
|
||||
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
|
||||
*/
|
||||
Map<String, String> sections;
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
@@ -52,9 +54,22 @@ public class BrainChatPayloadBuilder {
|
||||
if (request.getNarrativeEntity() != null) {
|
||||
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
|
||||
}
|
||||
if (request.getGameSystemContext() != null) {
|
||||
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("system_name", gs.getSystemName());
|
||||
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
|
||||
map.put("system_description", gs.getSystemDescription());
|
||||
}
|
||||
map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> messageToMap(ChatMessage m) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("role", m.role());
|
||||
@@ -111,6 +126,21 @@ public class BrainChatPayloadBuilder {
|
||||
map.put("arcs", ctx.getArcs().stream()
|
||||
.map(this::arcSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
||||
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
|
||||
map.put("characters", ctx.getCharacters().stream()
|
||||
.map(this::characterSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", c.getName());
|
||||
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
|
||||
map.put("snippet", c.getSnippet());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.loremind.infrastructure.persistence;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Seed 3 rulesets libres au premier démarrage (si la table game_systems est vide).
|
||||
* <p>
|
||||
* Objectif : donner à l'utilisateur un point de départ pour comprendre le format
|
||||
* attendu (markdown structuré par titres H2) et permettre une démo "out of the box"
|
||||
* sans devoir taper ses propres règles.
|
||||
* <p>
|
||||
* Les rulesets fournis sont des <b>extraits libres</b> (Nimble, SRD 5.1 extrait,
|
||||
* homebrew exemple) — pas des règles officielles complètes. L'utilisateur est
|
||||
* libre de les éditer, supprimer, ou les utiliser comme template.
|
||||
* <p>
|
||||
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
|
||||
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
|
||||
*/
|
||||
@Component
|
||||
public class GameSystemSeeder {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class);
|
||||
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
|
||||
public GameSystemSeeder(GameSystemRepository gameSystemRepository) {
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void seedIfEmpty() {
|
||||
if (!gameSystemRepository.findAll().isEmpty()) {
|
||||
log.debug("GameSystem seed skipped — table non vide.");
|
||||
return;
|
||||
}
|
||||
log.info("Seed initial des GameSystems (table vide)...");
|
||||
for (GameSystem gs : defaultSystems()) {
|
||||
gameSystemRepository.save(gs);
|
||||
}
|
||||
log.info("GameSystems seedés : {}", defaultSystems().size());
|
||||
}
|
||||
|
||||
private List<GameSystem> defaultSystems() {
|
||||
return List.of(
|
||||
GameSystem.builder()
|
||||
.name("Nimble (extrait)")
|
||||
.description("Système léger et narratif, résolution rapide des combats.")
|
||||
.author("LoreMind seed")
|
||||
.isPublic(false)
|
||||
.rulesMarkdown(NIMBLE_RULES)
|
||||
.build(),
|
||||
GameSystem.builder()
|
||||
.name("D&D 5e SRD (extrait)")
|
||||
.description("Extrait libre des bases du System Reference Document 5.1.")
|
||||
.author("LoreMind seed")
|
||||
.isPublic(false)
|
||||
.rulesMarkdown(DND_SRD_RULES)
|
||||
.build(),
|
||||
GameSystem.builder()
|
||||
.name("Homebrew Exemple")
|
||||
.description("Template minimaliste à dupliquer pour créer votre propre système.")
|
||||
.author("LoreMind seed")
|
||||
.isPublic(false)
|
||||
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
private static final String NIMBLE_RULES = """
|
||||
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
||||
|
||||
## Combat
|
||||
- Initiative libre : les joueurs décrivent leur action dans l'ordre qu'ils veulent, le MJ joue les ennemis quand la fiction l'exige.
|
||||
- Résolution : 1d20 + mod, difficulté 10/15/20 (facile/normal/dur). 20 naturel = critique (double dégâts).
|
||||
- Dégâts : arme légère 1d6, arme lourde 1d10, projectile 1d8. Pas de table d'armure, l'armure augmente la difficulté à toucher.
|
||||
- Blessures : un PJ peut encaisser 3 blessures graves avant de tomber. Pas de PV fins — on raconte les coups.
|
||||
|
||||
## Classes
|
||||
- **Guerrier** : +2 en combat, peut relancer un dé de dégât 1×/scène.
|
||||
- **Explorateur** : +2 en perception/survie, ignore la première blessure d'une scène.
|
||||
- **Mage** : peut lancer un effet de magie par scène, nécessite une composante racontée.
|
||||
- **Barde** : +2 en social, peut inspirer un allié (relance de dé).
|
||||
|
||||
## Monstres
|
||||
Les monstres ont 3 stats : Menace (difficulté à toucher), Dégâts (dé de dégât), Résistance (nombre de blessures).
|
||||
Exemples : Gobelin (Menace 10, 1d6, 1), Ogre (Menace 13, 1d10, 3), Dragon adulte (Menace 18, 2d10, 6).
|
||||
""";
|
||||
|
||||
private static final String DND_SRD_RULES = """
|
||||
Extrait libre du SRD 5.1 (Open Game License). Pour les règles complètes, consulter le SRD officiel.
|
||||
|
||||
## Combat
|
||||
- Initiative : 1d20 + mod Dex au début du combat, ordre fixe par round.
|
||||
- Action par tour : une action, une action bonus (si classe le permet), une réaction, mouvement jusqu'à la vitesse.
|
||||
- Attaque : 1d20 + mod caractéristique + bonus maîtrise vs CA de la cible.
|
||||
- Dégâts : dé de l'arme + mod caractéristique. Critique sur 20 naturel (double les dés de dégâts).
|
||||
- Avantage/Désavantage : lancer 2d20 et garder le meilleur / pire.
|
||||
|
||||
## Classes
|
||||
- **Barbare** : d12 PV, rage (+dégâts, résistance). Caractéristique principale : Force.
|
||||
- **Barde** : d8 PV, sorts + inspiration bardique. Caractéristique : Charisme.
|
||||
- **Clerc** : d8 PV, sorts divins, canalise la divinité. Caractéristique : Sagesse.
|
||||
- **Druide** : d8 PV, sorts nature + forme animale. Caractéristique : Sagesse.
|
||||
- **Ensorceleur** : d6 PV, sorts innés + métamagie. Caractéristique : Charisme.
|
||||
- **Guerrier** : d10 PV, maîtrise martiale, second souffle. Caractéristique : Force ou Dextérité.
|
||||
- **Magicien** : d6 PV, livre de sorts, grande flexibilité. Caractéristique : Intelligence.
|
||||
- **Moine** : d8 PV, arts martiaux + ki. Caractéristique : Dextérité + Sagesse.
|
||||
- **Paladin** : d10 PV, sorts + serment + imposition des mains. Caractéristique : Force + Charisme.
|
||||
- **Rôdeur** : d10 PV, ennemi juré + explorateur + sorts. Caractéristique : Dextérité + Sagesse.
|
||||
- **Roublard** : d8 PV, attaque sournoise + expertise. Caractéristique : Dextérité.
|
||||
|
||||
## Monstres
|
||||
Stat block standard : CA, PV, Vitesse, For/Dex/Con/Int/Sag/Cha, jets de sauvegarde, compétences, sens, langues, Facteur de Puissance (FP).
|
||||
Exemples : Gobelin (FP 1/4, CA 15, 7 PV), Ogre (FP 2, CA 11, 59 PV), Dragon rouge adulte (FP 17, CA 19, 256 PV).
|
||||
""";
|
||||
|
||||
private static final String HOMEBREW_EXAMPLE = """
|
||||
Template vide à dupliquer et remplir pour créer votre propre système.
|
||||
|
||||
## Combat
|
||||
(Décrivez ici comment se résout un combat : initiative, jet d'attaque, dégâts, points de vie, critiques...)
|
||||
|
||||
## Classes
|
||||
(Listez les archétypes jouables : nom, stats de base, capacités signature.)
|
||||
|
||||
## Monstres
|
||||
(Format de stat block pour vos créatures : stats, capacités spéciales, FP/niveau.)
|
||||
|
||||
## Magie
|
||||
(Si votre système a un système de magie : écoles, coût, composantes, listes de sorts/pouvoirs.)
|
||||
|
||||
## Progression
|
||||
(Comment les PJ montent en puissance : XP, niveaux, acquisitions par niveau.)
|
||||
""";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
|
||||
|
||||
List<CharacterJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface GameSystemJpaRepository extends JpaRepository<GameSystemJpaEntity, Long> {
|
||||
|
||||
@Query("SELECT g FROM GameSystemJpaEntity g WHERE LOWER(g.name) LIKE LOWER(CONCAT('%', :query, '%'))")
|
||||
List<GameSystemJpaEntity> findByNameContainingIgnoreCase(@Param("query") String query);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresCharacterRepository implements CharacterRepository {
|
||||
|
||||
private final CharacterJpaRepository jpaRepository;
|
||||
|
||||
public PostgresCharacterRepository(CharacterJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Character save(Character character) {
|
||||
CharacterJpaEntity entity = toJpaEntity(character);
|
||||
CharacterJpaEntity saved = jpaRepository.save(entity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Character> findById(String id) {
|
||||
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Character> findByCampaignId(String campaignId) {
|
||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
jpaRepository.deleteById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
return jpaRepository.existsById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
private Character toDomainEntity(CharacterJpaEntity e) {
|
||||
return Character.builder()
|
||||
.id(e.getId().toString())
|
||||
.name(e.getName())
|
||||
.markdownContent(e.getMarkdownContent())
|
||||
.campaignId(e.getCampaignId().toString())
|
||||
.order(e.getOrder())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private CharacterJpaEntity toJpaEntity(Character c) {
|
||||
Long id = c.getId() != null ? Long.parseLong(c.getId()) : null;
|
||||
return CharacterJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(c.getName())
|
||||
.markdownContent(c.getMarkdownContent())
|
||||
.campaignId(Long.parseLong(c.getCampaignId()))
|
||||
.order(c.getOrder())
|
||||
.createdAt(c.getCreatedAt())
|
||||
.updatedAt(c.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.GameSystemJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||
|
||||
private final GameSystemJpaRepository jpaRepository;
|
||||
|
||||
public PostgresGameSystemRepository(GameSystemJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameSystem save(GameSystem gameSystem) {
|
||||
GameSystemJpaEntity entity = toJpaEntity(gameSystem);
|
||||
GameSystemJpaEntity saved = jpaRepository.save(entity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<GameSystem> findById(String id) {
|
||||
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GameSystem> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
jpaRepository.deleteById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
return jpaRepository.existsById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GameSystem> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private GameSystem toDomainEntity(GameSystemJpaEntity e) {
|
||||
return GameSystem.builder()
|
||||
.id(e.getId().toString())
|
||||
.name(e.getName())
|
||||
.description(e.getDescription())
|
||||
.rulesMarkdown(e.getRulesMarkdown())
|
||||
.author(e.getAuthor())
|
||||
.isPublic(e.isPublic())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GameSystemJpaEntity toJpaEntity(GameSystem g) {
|
||||
Long id = g.getId() != null ? Long.parseLong(g.getId()) : null;
|
||||
return GameSystemJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(g.getName())
|
||||
.description(g.getDescription())
|
||||
.rulesMarkdown(g.getRulesMarkdown())
|
||||
.author(g.getAuthor())
|
||||
.isPublic(g.isPublic())
|
||||
.createdAt(g.getCreatedAt())
|
||||
.updatedAt(g.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ public class CampaignController {
|
||||
public ResponseEntity<CampaignDTO> createCampaign(@RequestBody CampaignDTO campaignDTO) {
|
||||
Campaign campaign = campaignMapper.toDomain(campaignDTO);
|
||||
Campaign createdCampaign = campaignService.createCampaign(
|
||||
new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId())
|
||||
new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId())
|
||||
);
|
||||
return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign));
|
||||
}
|
||||
@@ -64,7 +64,7 @@ public class CampaignController {
|
||||
public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
|
||||
Campaign updatedCampaign = campaignService.updateCampaign(
|
||||
id,
|
||||
new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId())
|
||||
new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId())
|
||||
);
|
||||
return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.campaigncontext.CharacterService;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
|
||||
import com.loremind.infrastructure.web.mapper.CharacterMapper;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/characters")
|
||||
public class CharacterController {
|
||||
|
||||
private final CharacterService characterService;
|
||||
private final CharacterMapper characterMapper;
|
||||
|
||||
public CharacterController(CharacterService characterService, CharacterMapper characterMapper) {
|
||||
this.characterService = characterService;
|
||||
this.characterMapper = characterMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
|
||||
Character created = characterService.createCharacter(
|
||||
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
||||
);
|
||||
return ResponseEntity.ok(characterMapper.toDTO(created));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<CharacterDTO> getCharacterById(@PathVariable String id) {
|
||||
return characterService.getCharacterById(id)
|
||||
.map(c -> ResponseEntity.ok(characterMapper.toDTO(c)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/campaign/{campaignId}")
|
||||
public ResponseEntity<List<CharacterDTO>> getCharactersByCampaign(@PathVariable String campaignId) {
|
||||
List<CharacterDTO> dtos = characterService.getCharactersByCampaignId(campaignId).stream()
|
||||
.map(characterMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
||||
Character updated = characterService.updateCharacter(
|
||||
id,
|
||||
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
|
||||
);
|
||||
return ResponseEntity.ok(characterMapper.toDTO(updated));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteCharacter(@PathVariable String id) {
|
||||
characterService.deleteCharacter(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.gamesystemcontext.GameSystemService;
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/game-systems")
|
||||
public class GameSystemController {
|
||||
|
||||
private final GameSystemService gameSystemService;
|
||||
private final GameSystemMapper gameSystemMapper;
|
||||
|
||||
public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) {
|
||||
this.gameSystemService = gameSystemService;
|
||||
this.gameSystemMapper = gameSystemMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<GameSystemDTO> createGameSystem(@RequestBody GameSystemDTO dto) {
|
||||
GameSystem created = gameSystemService.createGameSystem(toData(dto));
|
||||
return ResponseEntity.ok(gameSystemMapper.toDTO(created));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<GameSystemDTO> getGameSystemById(@PathVariable String id) {
|
||||
return gameSystemService.getGameSystemById(id)
|
||||
.map(g -> ResponseEntity.ok(gameSystemMapper.toDTO(g)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<GameSystemDTO>> getAllGameSystems() {
|
||||
List<GameSystemDTO> dtos = gameSystemService.getAllGameSystems().stream()
|
||||
.map(gameSystemMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<GameSystemDTO>> searchGameSystems(@RequestParam("q") String query) {
|
||||
List<GameSystemDTO> dtos = gameSystemService.searchGameSystems(query).stream()
|
||||
.map(gameSystemMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<GameSystemDTO> updateGameSystem(@PathVariable String id, @RequestBody GameSystemDTO dto) {
|
||||
GameSystem updated = gameSystemService.updateGameSystem(id, toData(dto));
|
||||
return ResponseEntity.ok(gameSystemMapper.toDTO(updated));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteGameSystem(@PathVariable String id) {
|
||||
gameSystemService.deleteGameSystem(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
||||
return new GameSystemService.GameSystemData(
|
||||
dto.getName(),
|
||||
dto.getDescription(),
|
||||
dto.getRulesMarkdown(),
|
||||
dto.getAuthor(),
|
||||
dto.isPublic()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -46,6 +46,18 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Système de JDR</label>
|
||||
<select formControlName="gameSystemId">
|
||||
<option value="">— Aucun (campagne générique) —</option>
|
||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||
</select>
|
||||
<p class="hint">
|
||||
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
|
||||
dans ses suggestions pour respecter les mécaniques du JDR.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
|
||||
<ul>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Système de JDR</label>
|
||||
<select [(ngModel)]="editGameSystemId" name="editGameSystemId">
|
||||
<option value="">— Aucun (générique) —</option>
|
||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||
Sauvegarder
|
||||
@@ -63,6 +70,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="characters-section">
|
||||
<div class="section-header">
|
||||
<h2>Personnages joueurs</h2>
|
||||
<button class="btn-add" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PJ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="characters-grid" *ngIf="characters.length > 0">
|
||||
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)">
|
||||
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ character.name }}</span>
|
||||
<span class="character-snippet">{{ characterSnippet(character) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="characters.length === 0">
|
||||
<lucide-icon [img]="User" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<p>Aucun personnage joueur pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier PJ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arcs-section">
|
||||
<div class="section-header">
|
||||
<h2>Arcs narratifs</h2>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<div class="ce-page">
|
||||
|
||||
<div class="ce-header">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour à la campagne
|
||||
</button>
|
||||
<div class="header-row">
|
||||
<h1>
|
||||
<lucide-icon [img]="User" [size]="22"></lucide-icon>
|
||||
{{ characterId ? 'Éditer la fiche' : 'Nouveau personnage' }}
|
||||
</h1>
|
||||
<button
|
||||
*ngIf="characterId"
|
||||
type="button"
|
||||
class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PJ">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ce-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du personnage *</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
placeholder="Ex: Thorin le Grand-Hache, Lyra l'Errante..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field content-field">
|
||||
<label>Fiche (markdown)</label>
|
||||
<p class="hint">
|
||||
Tout en markdown libre : stats, classe, backstory, équipement, objectifs personnels…
|
||||
L'IA lira ces infos pour rester cohérente quand vous générez des scènes impliquant ce PJ.
|
||||
</p>
|
||||
<textarea
|
||||
[(ngModel)]="markdownContent"
|
||||
name="markdownContent"
|
||||
rows="22"
|
||||
placeholder="# Thorin Grand-Hache **Race :** Nain **Classe :** Guerrier niveau 4 **PV :** 35 / 35 ## Stats - Force : 16 - Dextérité : 12 ... ## Backstory Originaire des montagnes du Nord, Thorin a fui son clan après..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
{{ characterId ? 'Enregistrer' : 'Créer' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
*ngIf="characterId"
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
(click)="deleteCharacter()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<app-ai-chat-drawer
|
||||
*ngIf="characterId && campaignId"
|
||||
[campaignId]="campaignId"
|
||||
entityType="character"
|
||||
[entityId]="characterId"
|
||||
[isOpen]="chatOpen"
|
||||
welcomeMessage="Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels."
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
(close)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
110
web/src/app/campaigns/character-edit/character-edit.component.ts
Normal file
110
web/src/app/campaigns/character-edit/character-edit.component.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { Character } from '../../services/character.model';
|
||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
|
||||
/**
|
||||
* Éditeur plein écran d'une fiche de personnage (PJ).
|
||||
* Double rôle création/édition :
|
||||
* - `/campaigns/:campaignId/characters/create` → POST
|
||||
* - `/campaigns/:campaignId/characters/:characterId/edit` → PUT
|
||||
*
|
||||
* MVP : name + markdown libre. Évolution prévue vers un template dérivé
|
||||
* du GameSystem de la campagne (stats structurées).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-character-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
|
||||
templateUrl: './character-edit.component.html',
|
||||
styleUrls: ['./character-edit.component.scss']
|
||||
})
|
||||
export class CharacterEditComponent implements OnInit {
|
||||
readonly Save = Save;
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly User = User;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
/** État drawer chat IA focalisé sur ce PJ. */
|
||||
chatOpen = false;
|
||||
readonly chatQuickSuggestions = [
|
||||
'Propose une backstory cohérente avec l\'univers',
|
||||
'Suggère 3 objectifs personnels pour ce personnage',
|
||||
'Aide-moi à équilibrer les stats de combat'
|
||||
];
|
||||
|
||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||
|
||||
campaignId: string | null = null;
|
||||
characterId: string | null = null;
|
||||
|
||||
name = '';
|
||||
markdownContent = '';
|
||||
private order = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: CharacterService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
this.campaignId = params.get('campaignId');
|
||||
this.characterId = params.get('characterId');
|
||||
|
||||
if (this.characterId) {
|
||||
this.service.getById(this.characterId).subscribe({
|
||||
next: (c) => {
|
||||
this.name = c.name;
|
||||
this.markdownContent = c.markdownContent ?? '';
|
||||
this.order = c.order ?? 0;
|
||||
},
|
||||
error: () => this.back()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
const req = this.characterId
|
||||
? this.service.update(this.characterId, {
|
||||
id: this.characterId,
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId,
|
||||
order: this.order
|
||||
})
|
||||
: this.service.create({
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId
|
||||
});
|
||||
req.subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur sauvegarde Character')
|
||||
});
|
||||
}
|
||||
|
||||
deleteCharacter(): void {
|
||||
if (!this.characterId) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
|
||||
this.service.delete(this.characterId).subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur suppression Character')
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
if (this.campaignId) {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
} else {
|
||||
this.router.navigate(['/campaigns']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<div class="gse-page">
|
||||
|
||||
<div class="gse-header">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour à la liste
|
||||
</button>
|
||||
<h1>
|
||||
<lucide-icon [img]="Dices" [size]="22"></lucide-icon>
|
||||
{{ id ? 'Éditer le système' : 'Nouveau système de JDR' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="gse-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom *</label>
|
||||
<input type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description courte</label>
|
||||
<textarea [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Auteur</label>
|
||||
<input type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
|
||||
</div>
|
||||
|
||||
<!-- Sections de règles -->
|
||||
<div class="sections-area">
|
||||
<h2 class="sections-title">Règles du système</h2>
|
||||
<p class="sections-hint">
|
||||
Une section = un thème. L'IA injectera automatiquement les sections pertinentes
|
||||
selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions).
|
||||
</p>
|
||||
|
||||
<div class="section-list">
|
||||
<div class="section-card" *ngFor="let section of sections; let i = index" [class.collapsed]="section.collapsed">
|
||||
|
||||
<div class="section-head">
|
||||
<button type="button" class="btn-collapse" (click)="toggleCollapse(section)">
|
||||
<lucide-icon [img]="section.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
class="section-title-input"
|
||||
[(ngModel)]="section.title"
|
||||
[name]="'title-' + i"
|
||||
placeholder="Nom de la section (ex: Combat)"
|
||||
/>
|
||||
<button type="button" class="btn-remove" (click)="removeSection(i)" title="Supprimer cette section">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
*ngIf="!section.collapsed"
|
||||
class="section-content"
|
||||
[(ngModel)]="section.content"
|
||||
[name]="'content-' + i"
|
||||
rows="6"
|
||||
placeholder="Décrivez les règles de cette section..."
|
||||
></textarea>
|
||||
|
||||
</div>
|
||||
|
||||
<div *ngIf="sections.length === 0" class="empty-hint">
|
||||
Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="add-row">
|
||||
<span class="add-label">Ajouter une section :</span>
|
||||
<button
|
||||
type="button"
|
||||
*ngFor="let name of suggestedSections"
|
||||
class="chip"
|
||||
[class.disabled]="isSectionUsed(name)"
|
||||
(click)="addSuggested(name)"
|
||||
[disabled]="isSectionUsed(name)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
{{ name }}
|
||||
</button>
|
||||
<button type="button" class="chip chip-custom" (click)="addBlank()">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
Autre…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
{{ id ? 'Enregistrer' : 'Créer' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
39
web/src/app/game-systems/game-systems.component.html
Normal file
39
web/src/app/game-systems/game-systems.component.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<div class="gs-page">
|
||||
|
||||
<div class="gs-hero">
|
||||
<lucide-icon [img]="Dices" [size]="56" class="hero-icon"></lucide-icon>
|
||||
<h1>Systèmes de JDR</h1>
|
||||
<p class="hero-subtitle">Les règles que l'IA connaîtra pour vos campagnes</p>
|
||||
</div>
|
||||
|
||||
<div class="gs-grid">
|
||||
|
||||
<div class="gs-card" *ngFor="let gs of gameSystems" (click)="edit(gs.id!)">
|
||||
<div class="card-header">
|
||||
<h2>{{ gs.name }}</h2>
|
||||
<div class="card-actions">
|
||||
<button class="icon-btn" (click)="delete(gs, $event)" title="Supprimer">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-description">{{ gs.description || '(Pas de description)' }}</p>
|
||||
<div class="card-footer">
|
||||
<span *ngIf="gs.author" class="author">par {{ gs.author }}</span>
|
||||
<span *ngIf="gs.isPublic" class="badge-public">public</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gs-card card-new" (click)="create()">
|
||||
<div class="new-icon">
|
||||
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
|
||||
</div>
|
||||
<h2>Nouveau système</h2>
|
||||
<p class="card-description">Saisir les règles d'un JDR (markdown)</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="tip">💡 Astuce : organisez vos règles par sections avec des titres <code>## Combat</code>, <code>## Classes</code>, <code>## Lore</code>… Le système injectera les sections pertinentes selon ce que l'IA doit générer.</p>
|
||||
|
||||
</div>
|
||||
111
web/src/app/game-systems/game-systems.component.scss
Normal file
111
web/src/app/game-systems/game-systems.component.scss
Normal file
@@ -0,0 +1,111 @@
|
||||
.gs-page {
|
||||
padding: 2rem 3rem;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.gs-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
|
||||
.hero-icon { color: #a78bfa; margin-bottom: 0.75rem; }
|
||||
h1 { font-size: 2rem; margin: 0.5rem 0 0.25rem; color: white; }
|
||||
.hero-subtitle { color: #9ca3af; margin: 0; }
|
||||
}
|
||||
|
||||
.gs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.gs-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #a78bfa;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
h2 { color: white; font-size: 1.1rem; margin: 0; }
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
min-height: 2.5em;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
|
||||
.author { color: #6b7280; }
|
||||
.badge-public {
|
||||
background: #1e3a8a;
|
||||
color: #bfdbfe;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
|
||||
&:hover { color: #f87171; background: rgba(248, 113, 113, 0.1); }
|
||||
}
|
||||
|
||||
.card-new {
|
||||
border-style: dashed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
.new-icon {
|
||||
background: rgba(167, 139, 250, 0.1);
|
||||
color: #a78bfa;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
h2 { margin: 0 0 0.25rem; color: white; }
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 2rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
|
||||
code {
|
||||
background: #1f2937;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
color: #a78bfa;
|
||||
}
|
||||
}
|
||||
56
web/src/app/game-systems/game-systems.component.ts
Normal file
56
web/src/app/game-systems/game-systems.component.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { GameSystemService } from '../services/game-system.service';
|
||||
import { GameSystem } from '../services/game-system.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-systems',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './game-systems.component.html',
|
||||
styleUrls: ['./game-systems.component.scss']
|
||||
})
|
||||
export class GameSystemsComponent implements OnInit {
|
||||
readonly Dices = Dices;
|
||||
readonly Plus = Plus;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
gameSystems: GameSystem[] = [];
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private gameSystemService: GameSystemService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.gameSystemService.getAll().subscribe({
|
||||
next: (data) => this.gameSystems = data,
|
||||
error: () => this.gameSystems = []
|
||||
});
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.router.navigate(['/game-systems/create']);
|
||||
}
|
||||
|
||||
edit(id: string): void {
|
||||
this.router.navigate(['/game-systems', id, 'edit']);
|
||||
}
|
||||
|
||||
delete(system: GameSystem, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
if (!system.id) return;
|
||||
if (!confirm(`Supprimer le système "${system.name}" ? Les campagnes qui l'utilisent ne seront plus associées à aucun système.`)) return;
|
||||
this.gameSystemService.delete(system.id).subscribe({
|
||||
next: () => this.load(),
|
||||
error: () => console.error('Erreur suppression GameSystem')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
18
web/src/app/services/character.model.ts
Normal file
18
web/src/app/services/character.model.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||
* MVP : markdownContent libre. Évolution prévue vers des fiches templatées
|
||||
* par GameSystem (stats structurées selon le JDR joué).
|
||||
*/
|
||||
export interface Character {
|
||||
id?: string;
|
||||
name: string;
|
||||
markdownContent?: string | null;
|
||||
campaignId: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface CharacterCreate {
|
||||
name: string;
|
||||
markdownContent?: string | null;
|
||||
campaignId: string;
|
||||
}
|
||||
34
web/src/app/services/character.service.ts
Normal file
34
web/src/app/services/character.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Character, CharacterCreate } from './character.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour les fiches de personnages (PJ) d'une campagne.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CharacterService {
|
||||
private apiUrl = 'http://localhost:8080/api/characters';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getByCampaign(campaignId: string): Observable<Character[]> {
|
||||
return this.http.get<Character[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Character> {
|
||||
return this.http.get<Character>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(payload: CharacterCreate): Observable<Character> {
|
||||
return this.http.post<Character>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: Character): Observable<Character> {
|
||||
return this.http.put<Character>(`${this.apiUrl}/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
24
web/src/app/services/game-system.model.ts
Normal file
24
web/src/app/services/game-system.model.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
|
||||
*
|
||||
* rulesMarkdown : markdown monolithique, sections découpées par titres H2
|
||||
* (## Combat, ## Classes, etc.). Le découpage et la sélection des sections
|
||||
* à injecter dans le prompt IA sont faits côté backend Java.
|
||||
*/
|
||||
export interface GameSystem {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rulesMarkdown?: string | null;
|
||||
author?: string | null;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/** Payload de création/mise à jour (sans id). */
|
||||
export interface GameSystemCreate {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rulesMarkdown?: string | null;
|
||||
author?: string | null;
|
||||
isPublic: boolean;
|
||||
}
|
||||
39
web/src/app/services/game-system.service.ts
Normal file
39
web/src/app/services/game-system.service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { GameSystem, GameSystemCreate } from './game-system.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour les GameSystems (systèmes de JDR).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GameSystemService {
|
||||
private apiUrl = 'http://localhost:8080/api/game-systems';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getAll(): Observable<GameSystem[]> {
|
||||
return this.http.get<GameSystem[]>(this.apiUrl);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<GameSystem> {
|
||||
return this.http.get<GameSystem>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(payload: GameSystemCreate): Observable<GameSystem> {
|
||||
return this.http.post<GameSystem>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: GameSystemCreate): Observable<GameSystem> {
|
||||
return this.http.put<GameSystem>(`${this.apiUrl}/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
search(q: string): Observable<GameSystem[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<GameSystem[]>(`${this.apiUrl}/search`, { params });
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,10 @@
|
||||
<span>Recherche globale</span>
|
||||
<span class="tool-kbd">Ctrl+K</span>
|
||||
</button>
|
||||
<button class="tool-btn" [class.active]="currentRoute.startsWith('/game-systems')" (click)="navigateTo('/game-systems')">
|
||||
<lucide-icon [img]="Dices" [size]="16"></lucide-icon>
|
||||
<span>Systèmes de JDR</span>
|
||||
</button>
|
||||
<button class="tool-btn">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Export VTT</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { AsyncPipe, NgIf, NgFor } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, Search, Download, Settings, ArrowLeft } from 'lucide-angular';
|
||||
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { GlobalSearchService } from '../services/global-search.service';
|
||||
|
||||
@@ -19,6 +19,7 @@ export class SidebarComponent {
|
||||
readonly Download = Download;
|
||||
readonly Settings = Settings;
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Dices = Dices;
|
||||
|
||||
readonly layoutConfig$ = this.layoutService.secondarySidebar$;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user