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

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

View File

@@ -28,13 +28,14 @@ public class CampaignService {
*
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
*/
public record CampaignData(String name, String description, String loreId) {}
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder()
.name(data.name())
.description(data.description())
.loreId(normalizeLoreId(data.loreId()))
.loreId(normalizeId(data.loreId()))
.gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0)
.build();
return campaignRepository.save(campaign);
@@ -57,16 +58,17 @@ public class CampaignService {
Campaign campaign = existingCampaign.get();
campaign.setName(data.name());
campaign.setDescription(data.description());
campaign.setLoreId(normalizeLoreId(data.loreId()));
campaign.setLoreId(normalizeId(data.loreId()));
campaign.setGameSystemId(normalizeId(data.gameSystemId()));
return campaignRepository.save(campaign);
}
/**
* Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien".
* Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
*/
private String normalizeLoreId(String loreId) {
return (loreId == null || loreId.isBlank()) ? null : loreId;
private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id;
}
public void deleteCampaign(String id) {

View File

@@ -0,0 +1,72 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour les fiches de personnages (PJ).
*/
@Service
public class CharacterService {
private final CharacterRepository characterRepository;
public CharacterService(CharacterRepository characterRepository) {
this.characterRepository = characterRepository;
}
/**
* Parameter Object pour la création / mise à jour d'un Character.
* `order` est fourni par le controller ; si absent, le service le calcule.
*/
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
public Character createCharacter(CharacterData data) {
int order = data.order() != null
? data.order()
: nextOrderFor(data.campaignId());
Character character = Character.builder()
.name(data.name())
.markdownContent(data.markdownContent())
.campaignId(data.campaignId())
.order(order)
.build();
return characterRepository.save(character);
}
public Optional<Character> getCharacterById(String id) {
return characterRepository.findById(id);
}
public List<Character> getCharactersByCampaignId(String campaignId) {
return characterRepository.findByCampaignId(campaignId);
}
public Character updateCharacter(String id, CharacterData data) {
Character existing = characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setMarkdownContent(data.markdownContent());
if (data.order() != null) {
existing.setOrder(data.order());
}
// campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP).
return characterRepository.save(existing);
}
public void deleteCharacter(String id) {
characterRepository.deleteById(id);
}
/** Renvoie la prochaine position libre — append en fin de liste. */
private int nextOrderFor(String campaignId) {
return characterRepository.findByCampaignId(campaignId).stream()
.mapToInt(Character::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -0,0 +1,92 @@
package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.generationcontext.GameSystemContext;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Construit un {@link GameSystemContext} à partir d'un gameSystemId et d'un intent.
* <p>
* Pipeline :
* 1. Charge le GameSystem (retourne Optional.empty si introuvable — dégradation gracieuse).
* 2. Parse le markdown par titres H2 (## Section) → Map<Titre, Contenu>.
* 3. Filtre les sections selon l'intent via les alias {@link GenerationIntent#getSectionAliases()}.
* GENERIC = pas de filtre.
* <p>
* Parsing à la volée (pas de cache) : les règles d'un système font
* typiquement 5-20kB, le coût de parsing est négligeable devant l'appel LLM.
*/
@Service
public class GameSystemContextBuilder {
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
private final GameSystemRepository gameSystemRepository;
public GameSystemContextBuilder(GameSystemRepository gameSystemRepository) {
this.gameSystemRepository = gameSystemRepository;
}
public Optional<GameSystemContext> buildOptional(String gameSystemId, GenerationIntent intent) {
if (gameSystemId == null || gameSystemId.isBlank()) return Optional.empty();
return gameSystemRepository.findById(gameSystemId)
.map(gs -> build(gs, intent));
}
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
Map<String, String> filtered = filterByIntent(allSections, intent);
return GameSystemContext.builder()
.systemName(gs.getName())
.systemDescription(gs.getDescription())
.sections(filtered)
.build();
}
/**
* Découpe le markdown par titres H2. Préserve l'ordre d'apparition (LinkedHashMap).
* Le contenu avant le premier H2 est ignoré (préambule libre).
*/
Map<String, String> parseH2Sections(String markdown) {
Map<String, String> sections = new LinkedHashMap<>();
if (markdown == null || markdown.isBlank()) return sections;
Matcher m = H2_HEADER.matcher(markdown);
String currentTitle = null;
int currentContentStart = -1;
while (m.find()) {
if (currentTitle != null) {
sections.put(currentTitle, markdown.substring(currentContentStart, m.start()).strip());
}
currentTitle = m.group(1).trim();
currentContentStart = m.end();
}
if (currentTitle != null) {
sections.put(currentTitle, markdown.substring(currentContentStart).strip());
}
return sections;
}
private Map<String, String> filterByIntent(Map<String, String> sections, GenerationIntent intent) {
if (intent.matchesAllSections()) return sections;
Map<String, String> filtered = new LinkedHashMap<>();
for (Map.Entry<String, String> e : sections.entrySet()) {
String titleLower = e.getKey().toLowerCase();
boolean match = intent.getSectionAliases().stream().anyMatch(titleLower::contains);
if (match) {
filtered.put(e.getKey(), e.getValue());
}
}
return filtered;
}
}

View File

@@ -0,0 +1,76 @@
package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class GameSystemService {
private final GameSystemRepository gameSystemRepository;
public GameSystemService(GameSystemRepository gameSystemRepository) {
this.gameSystemRepository = gameSystemRepository;
}
/**
* Parameter Object pour la création / mise à jour d'un GameSystem.
*/
public record GameSystemData(
String name,
String description,
String rulesMarkdown,
String author,
boolean isPublic
) {}
public GameSystem createGameSystem(GameSystemData data) {
GameSystem gameSystem = GameSystem.builder()
.name(data.name())
.description(data.description())
.rulesMarkdown(data.rulesMarkdown())
.author(normalize(data.author()))
.isPublic(data.isPublic())
.build();
return gameSystemRepository.save(gameSystem);
}
public Optional<GameSystem> getGameSystemById(String id) {
return gameSystemRepository.findById(id);
}
public List<GameSystem> getAllGameSystems() {
return gameSystemRepository.findAll();
}
public GameSystem updateGameSystem(String id, GameSystemData data) {
GameSystem existing = gameSystemRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setDescription(data.description());
existing.setRulesMarkdown(data.rulesMarkdown());
existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing);
}
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
}
public boolean gameSystemExists(String id) {
return gameSystemRepository.existsById(id);
}
public List<GameSystem> searchGameSystems(String query) {
if (query == null || query.isBlank()) return List.of();
return gameSystemRepository.searchByName(query.trim());
}
private String normalize(String value) {
return (value == null || value.isBlank()) ? null : value;
}
}

View File

@@ -3,15 +3,18 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component;
@@ -38,18 +41,24 @@ public class CampaignStructuralContextBuilder {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
}
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
/**
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
* nom + description courte à chaque niveau).
@@ -65,13 +74,42 @@ public class CampaignStructuralContextBuilder {
.map(this::toArcSummary)
.collect(Collectors.toList());
List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Character::getOrder))
.map(this::toCharacterSummary)
.collect(Collectors.toList());
return CampaignStructuralContext.builder()
.campaignName(campaign.getName())
.campaignDescription(campaign.getDescription())
.arcs(arcs)
.characters(characters)
.build();
}
/**
* Projette un PJ vers un résumé court : nom + 1re ligne "signifiante" du
* markdown (ni vide, ni un titre). Permet à l'IA de savoir "qui est Thorin"
* sans injecter toute sa fiche.
*/
private CharacterSummary toCharacterSummary(Character c) {
return CharacterSummary.builder()
.name(c.getName())
.snippet(extractSnippet(c.getMarkdownContent()))
.build();
}
private static String extractSnippet(String markdown) {
if (markdown == null || markdown.isBlank()) return "";
String firstLine = markdown.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
}
private ArcSummary toArcSummary(Arc arc) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder))

View File

@@ -2,9 +2,11 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.springframework.stereotype.Component;
@@ -26,20 +28,23 @@ public class NarrativeEntityContextBuilder {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
public NarrativeEntityContextBuilder(
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
}
/**
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
*
* @param entityType "arc", "chapter" ou "scene" (insensible à la casse)
* @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse)
* @param entityId l'ID de l'entité
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
*/
@@ -49,6 +54,7 @@ public class NarrativeEntityContextBuilder {
case "arc" -> fromArc(loadArc(entityId));
case "chapter" -> fromChapter(loadChapter(entityId));
case "scene" -> fromScene(loadScene(entityId));
case "character" -> fromCharacter(loadCharacter(entityId));
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
};
}
@@ -70,6 +76,11 @@ public class NarrativeEntityContextBuilder {
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
}
private Character loadCharacter(String id) {
return characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
}
// --- Mapping entité → VO ------------------------------------------------
private NarrativeEntityContext fromArc(Arc a) {
@@ -118,6 +129,16 @@ public class NarrativeEntityContextBuilder {
.build();
}
private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
return NarrativeEntityContext.builder()
.entityType("character")
.title(c.getName())
.fields(fields)
.build();
}
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
private static void putField(Map<String, String> target, String key, String value) {
target.put(key, value == null ? "" : value);

View File

@@ -1,11 +1,14 @@
package com.loremind.application.generationcontext;
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -34,6 +37,7 @@ public class StreamChatForCampaignUseCase {
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final NarrativeEntityContextBuilder narrativeEntityContextBuilder;
private final GameSystemContextBuilder gameSystemContextBuilder;
private final AiChatProvider aiChatProvider;
public StreamChatForCampaignUseCase(
@@ -41,11 +45,13 @@ public class StreamChatForCampaignUseCase {
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
NarrativeEntityContextBuilder narrativeEntityContextBuilder,
GameSystemContextBuilder gameSystemContextBuilder,
AiChatProvider aiChatProvider) {
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.narrativeEntityContextBuilder = narrativeEntityContextBuilder;
this.gameSystemContextBuilder = gameSystemContextBuilder;
this.aiChatProvider = aiChatProvider;
}
@@ -78,12 +84,14 @@ public class StreamChatForCampaignUseCase {
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId);
LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign);
NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId);
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign, entityType);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.campaignContext(campaignContext)
.narrativeEntity(narrativeEntity)
.gameSystemContext(gameSystemContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
@@ -104,4 +112,16 @@ public class StreamChatForCampaignUseCase {
if (entityId == null || entityId.isBlank()) return null;
return narrativeEntityContextBuilder.build(entityType, entityId);
}
/**
* Charge le GameSystemContext si la campagne est liée à un GameSystem.
* L'entityType détermine quelles sections de règles sont injectées
* (SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions, autre → toutes).
* Retourne null en cas de GameSystem introuvable (dégradation gracieuse).
*/
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign, String entityType) {
if (!campaign.isLinkedToGameSystem()) return null;
GenerationIntent intent = GenerationIntent.fromNarrativeEntityType(entityType);
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), intent).orElse(null);
}
}

View File

@@ -28,7 +28,18 @@ public class Campaign {
*/
private String loreId;
/**
* Référence faible (weak reference) vers un GameSystem.
* Nullable : une campagne peut être "générique" (pas de système de JDR déclaré).
* Weak reference pour respecter la séparation des Bounded Contexts.
*/
private String gameSystemId;
public boolean isLinkedToLore() {
return this.loreId != null && !this.loreId.isBlank();
}
public boolean isLinkedToGameSystem() {
return this.gameSystemId != null && !this.gameSystemId.isBlank();
}
}

View File

@@ -0,0 +1,40 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Fiche de personnage joueur (PJ) d'une campagne.
* <p>
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
* backstory, équipement). Évolution prévue vers un système templaté par
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
* <p>
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
*/
@Data
@Builder
public class Character {
private String id;
private String name;
/**
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
* renseigné progressivement par le MJ.
*/
private String markdownContent;
/** Référence vers la Campaign parente. */
private String campaignId;
/** Ordre d'affichage dans la liste des PJ de la campagne. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Character;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des fiches de personnages (PJ).
*/
public interface CharacterRepository {
Character save(Character character);
Optional<Character> findById(String id);
List<Character> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -37,7 +37,7 @@ public class Conversation {
/**
* Type d'entite focus, null si la conversation est ancree au niveau
* Lore/Campagne racine (pas sur une page/scene precise).
* Valeurs : "page", "arc", "chapter", "scene".
* Valeurs : "page", "arc", "chapter", "scene", "character".
*/
private String entityType;
private String entityId;

View File

@@ -0,0 +1,38 @@
package com.loremind.domain.gamesystemcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entité de domaine représentant un GameSystem (système de JDR).
* <p>
* Porte les règles d'un système (D&D, Nimble, Pathfinder, homebrew...) sous forme
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
* <p>
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
* éviter une migration ultérieure.
*/
@Data
@Builder
public class GameSystem {
private String id;
private String name;
private String description;
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
private String rulesMarkdown;
/** Auteur déclaré — futur marketplace. Nullable. */
private String author;
/** Flag de partage — futur marketplace. False par défaut. */
private boolean isPublic;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,57 @@
package com.loremind.domain.gamesystemcontext;
import java.util.Set;
/**
* Intent de génération utilisé pour sélectionner les sections d'un GameSystem
* à injecter dans le prompt IA.
* <p>
* Chaque intent porte une liste d'alias (case-insensitive, comparaison par
* {@code contains}) utilisée pour matcher les titres H2 du markdown de règles.
* <p>
* MVP : mapping codé en dur. Évoluera vers un mapping configurable par
* l'utilisateur dans l'éditeur de GameSystem (futur marketplace).
*/
public enum GenerationIntent {
/** Scène (combat / rencontre) : règles de résolution + format de stat block. */
SCENE(Set.of("combat", "monstre", "monster")),
/** Chapitre (segment narratif) : règles de combat + archétypes pour PNJ. */
CHAPTER(Set.of("combat", "classe", "class")),
/** Arc (structure narrative longue) : pas de règles spécifiques — toutes. */
ARC(Set.of()),
/** Fallback : toutes les sections (intent inconnu). */
GENERIC(Set.of());
private final Set<String> sectionAliases;
GenerationIntent(Set<String> sectionAliases) {
this.sectionAliases = sectionAliases;
}
public Set<String> getSectionAliases() {
return sectionAliases;
}
/** True si l'intent veut toutes les sections (pas de filtre). */
public boolean matchesAllSections() {
return sectionAliases.isEmpty();
}
/**
* Mappe un entityType de NarrativeEntityContext ("arc"/"chapter"/"scene")
* vers l'intent correspondant. Tout le reste (null, inconnu) tombe sur GENERIC.
*/
public static GenerationIntent fromNarrativeEntityType(String entityType) {
if (entityType == null) return GENERIC;
return switch (entityType.toLowerCase()) {
case "scene" -> SCENE;
case "chapter" -> CHAPTER;
case "arc" -> ARC;
default -> GENERIC;
};
}
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.gamesystemcontext.ports;
import com.loremind.domain.gamesystemcontext.GameSystem;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des GameSystems.
*/
public interface GameSystemRepository {
GameSystem save(GameSystem gameSystem);
Optional<GameSystem> findById(String id);
List<GameSystem> findAll();
void deleteById(String id);
boolean existsById(String id);
List<GameSystem> searchByName(String query);
}

View File

@@ -30,6 +30,22 @@ public class CampaignStructuralContext {
String campaignName;
String campaignDescription;
@Singular List<ArcSummary> arcs;
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
@Singular List<CharacterSummary> characters;
/**
* Résumé d'un PJ : nom + snippet court du markdown.
* Pas le markdown complet pour maîtriser le coût token (chaque campagne
* peut avoir 4-6 PJ × potentiellement 1-2k tokens/fiche = trop lourd).
* La fiche complète n'est injectée que si le PJ est l'entité focus
* (via NarrativeEntityContext, entity_type="character").
*/
@Value
@Builder
public static class CharacterSummary {
String name;
String snippet;
}
/** Résumé d'un arc : nom + description courte + ses chapitres. */
@Value

View File

@@ -39,4 +39,10 @@ public class ChatRequest {
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
NarrativeEntityContext narrativeEntity;
/**
* Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
*/
GameSystemContext gameSystemContext;
}

View File

@@ -0,0 +1,30 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map;
/**
* Value Object représentant les règles de JDR injectées dans un prompt IA.
* <p>
* Contient uniquement les sections pertinentes pour l'intent de génération
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
*/
@Value
@Builder
public class GameSystemContext {
/** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
String systemName;
/** Description courte du système (nullable). */
String systemDescription;
/**
* Sections de règles pertinentes, indexées par titre H2.
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
*/
Map<String, String> sections;
}

View File

@@ -4,9 +4,11 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -52,9 +54,22 @@ public class BrainChatPayloadBuilder {
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
}
if (request.getGameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
}
return root;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.getSystemName());
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
map.put("system_description", gs.getSystemDescription());
}
map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
return map;
}
private Map<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("role", m.role());
@@ -111,6 +126,21 @@ public class BrainChatPayloadBuilder {
map.put("arcs", ctx.getArcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
map.put("characters", ctx.getCharacters().stream()
.map(this::characterSummaryToMap)
.collect(Collectors.toList()));
}
return map;
}
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.getName());
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
map.put("snippet", c.getSnippet());
}
return map;
}

View File

@@ -0,0 +1,143 @@
package com.loremind.infrastructure.persistence;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Seed 3 rulesets libres au premier démarrage (si la table game_systems est vide).
* <p>
* Objectif : donner à l'utilisateur un point de départ pour comprendre le format
* attendu (markdown structuré par titres H2) et permettre une démo "out of the box"
* sans devoir taper ses propres règles.
* <p>
* Les rulesets fournis sont des <b>extraits libres</b> (Nimble, SRD 5.1 extrait,
* homebrew exemple) — pas des règles officielles complètes. L'utilisateur est
* libre de les éditer, supprimer, ou les utiliser comme template.
* <p>
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
*/
@Component
public class GameSystemSeeder {
private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class);
private final GameSystemRepository gameSystemRepository;
public GameSystemSeeder(GameSystemRepository gameSystemRepository) {
this.gameSystemRepository = gameSystemRepository;
}
@EventListener(ApplicationReadyEvent.class)
public void seedIfEmpty() {
if (!gameSystemRepository.findAll().isEmpty()) {
log.debug("GameSystem seed skipped — table non vide.");
return;
}
log.info("Seed initial des GameSystems (table vide)...");
for (GameSystem gs : defaultSystems()) {
gameSystemRepository.save(gs);
}
log.info("GameSystems seedés : {}", defaultSystems().size());
}
private List<GameSystem> defaultSystems() {
return List.of(
GameSystem.builder()
.name("Nimble (extrait)")
.description("Système léger et narratif, résolution rapide des combats.")
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(NIMBLE_RULES)
.build(),
GameSystem.builder()
.name("D&D 5e SRD (extrait)")
.description("Extrait libre des bases du System Reference Document 5.1.")
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(DND_SRD_RULES)
.build(),
GameSystem.builder()
.name("Homebrew Exemple")
.description("Template minimaliste à dupliquer pour créer votre propre système.")
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(HOMEBREW_EXAMPLE)
.build()
);
}
private static final String NIMBLE_RULES = """
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
## Combat
- Initiative libre : les joueurs décrivent leur action dans l'ordre qu'ils veulent, le MJ joue les ennemis quand la fiction l'exige.
- Résolution : 1d20 + mod, difficulté 10/15/20 (facile/normal/dur). 20 naturel = critique (double dégâts).
- Dégâts : arme légère 1d6, arme lourde 1d10, projectile 1d8. Pas de table d'armure, l'armure augmente la difficulté à toucher.
- Blessures : un PJ peut encaisser 3 blessures graves avant de tomber. Pas de PV fins — on raconte les coups.
## Classes
- **Guerrier** : +2 en combat, peut relancer un dé de dégât 1×/scène.
- **Explorateur** : +2 en perception/survie, ignore la première blessure d'une scène.
- **Mage** : peut lancer un effet de magie par scène, nécessite une composante racontée.
- **Barde** : +2 en social, peut inspirer un allié (relance de dé).
## Monstres
Les monstres ont 3 stats : Menace (difficulté à toucher), Dégâts (dé de dégât), Résistance (nombre de blessures).
Exemples : Gobelin (Menace 10, 1d6, 1), Ogre (Menace 13, 1d10, 3), Dragon adulte (Menace 18, 2d10, 6).
""";
private static final String DND_SRD_RULES = """
Extrait libre du SRD 5.1 (Open Game License). Pour les règles complètes, consulter le SRD officiel.
## Combat
- Initiative : 1d20 + mod Dex au début du combat, ordre fixe par round.
- Action par tour : une action, une action bonus (si classe le permet), une réaction, mouvement jusqu'à la vitesse.
- Attaque : 1d20 + mod caractéristique + bonus maîtrise vs CA de la cible.
- Dégâts : dé de l'arme + mod caractéristique. Critique sur 20 naturel (double les dés de dégâts).
- Avantage/Désavantage : lancer 2d20 et garder le meilleur / pire.
## Classes
- **Barbare** : d12 PV, rage (+dégâts, résistance). Caractéristique principale : Force.
- **Barde** : d8 PV, sorts + inspiration bardique. Caractéristique : Charisme.
- **Clerc** : d8 PV, sorts divins, canalise la divinité. Caractéristique : Sagesse.
- **Druide** : d8 PV, sorts nature + forme animale. Caractéristique : Sagesse.
- **Ensorceleur** : d6 PV, sorts innés + métamagie. Caractéristique : Charisme.
- **Guerrier** : d10 PV, maîtrise martiale, second souffle. Caractéristique : Force ou Dextérité.
- **Magicien** : d6 PV, livre de sorts, grande flexibilité. Caractéristique : Intelligence.
- **Moine** : d8 PV, arts martiaux + ki. Caractéristique : Dextérité + Sagesse.
- **Paladin** : d10 PV, sorts + serment + imposition des mains. Caractéristique : Force + Charisme.
- **Rôdeur** : d10 PV, ennemi juré + explorateur + sorts. Caractéristique : Dextérité + Sagesse.
- **Roublard** : d8 PV, attaque sournoise + expertise. Caractéristique : Dextérité.
## Monstres
Stat block standard : CA, PV, Vitesse, For/Dex/Con/Int/Sag/Cha, jets de sauvegarde, compétences, sens, langues, Facteur de Puissance (FP).
Exemples : Gobelin (FP 1/4, CA 15, 7 PV), Ogre (FP 2, CA 11, 59 PV), Dragon rouge adulte (FP 17, CA 19, 256 PV).
""";
private static final String HOMEBREW_EXAMPLE = """
Template vide à dupliquer et remplir pour créer votre propre système.
## Combat
(Décrivez ici comment se résout un combat : initiative, jet d'attaque, dégâts, points de vie, critiques...)
## Classes
(Listez les archétypes jouables : nom, stats de base, capacités signature.)
## Monstres
(Format de stat block pour vos créatures : stats, capacités spéciales, FP/niveau.)
## Magie
(Si votre système a un système de magie : écoles, coût, composantes, listes de sorts/pouvoirs.)
## Progression
(Comment les PJ montent en puissance : XP, niveaux, acquisitions par niveau.)
""";
}

View File

@@ -45,6 +45,13 @@ public class CampaignJpaEntity {
@Column(name = "lore_id")
private String loreId;
/**
* ID du GameSystem associé (nullable).
* Weak reference inter-contexte — pas de @ManyToOne / pas de FK DB.
*/
@Column(name = "game_system_id")
private String gameSystemId;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();

View File

@@ -0,0 +1,56 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour les fiches de personnages (PJ) d'une campagne.
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte :
* on reste dans le Campaign Context, mais l'agrégat Character est autonome).
*/
@Entity
@Table(name = "characters")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CharacterJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT")
private String markdownContent;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,57 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
*/
@Entity
@Table(name = "game_systems")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GameSystemJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "rules_markdown", columnDefinition = "TEXT")
private String rulesMarkdown;
@Column
private String author;
@Column(name = "is_public", nullable = false)
private boolean isPublic;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
List<CharacterJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface GameSystemJpaRepository extends JpaRepository<GameSystemJpaEntity, Long> {
@Query("SELECT g FROM GameSystemJpaEntity g WHERE LOWER(g.name) LIKE LOWER(CONCAT('%', :query, '%'))")
List<GameSystemJpaEntity> findByNameContainingIgnoreCase(@Param("query") String query);
}

View File

@@ -71,6 +71,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.updatedAt(jpaEntity.getUpdatedAt())
.arcsCount(jpaEntity.getArcsCount())
.loreId(jpaEntity.getLoreId())
.gameSystemId(jpaEntity.getGameSystemId())
.build();
}
@@ -84,6 +85,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.updatedAt(campaign.getUpdatedAt())
.arcsCount(campaign.getArcsCount())
.loreId(campaign.getLoreId())
.gameSystemId(campaign.getGameSystemId())
.build();
}
}

View File

@@ -0,0 +1,75 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresCharacterRepository implements CharacterRepository {
private final CharacterJpaRepository jpaRepository;
public PostgresCharacterRepository(CharacterJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Character save(Character character) {
CharacterJpaEntity entity = toJpaEntity(character);
CharacterJpaEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
@Override
public Optional<Character> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<Character> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private Character toDomainEntity(CharacterJpaEntity e) {
return Character.builder()
.id(e.getId().toString())
.name(e.getName())
.markdownContent(e.getMarkdownContent())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private CharacterJpaEntity toJpaEntity(Character c) {
Long id = c.getId() != null ? Long.parseLong(c.getId()) : null;
return CharacterJpaEntity.builder()
.id(id)
.name(c.getName())
.markdownContent(c.getMarkdownContent())
.campaignId(Long.parseLong(c.getCampaignId()))
.order(c.getOrder())
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,84 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
import com.loremind.infrastructure.persistence.jpa.GameSystemJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresGameSystemRepository implements GameSystemRepository {
private final GameSystemJpaRepository jpaRepository;
public PostgresGameSystemRepository(GameSystemJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public GameSystem save(GameSystem gameSystem) {
GameSystemJpaEntity entity = toJpaEntity(gameSystem);
GameSystemJpaEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
@Override
public Optional<GameSystem> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<GameSystem> findAll() {
return jpaRepository.findAll().stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
@Override
public List<GameSystem> searchByName(String query) {
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private GameSystem toDomainEntity(GameSystemJpaEntity e) {
return GameSystem.builder()
.id(e.getId().toString())
.name(e.getName())
.description(e.getDescription())
.rulesMarkdown(e.getRulesMarkdown())
.author(e.getAuthor())
.isPublic(e.isPublic())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private GameSystemJpaEntity toJpaEntity(GameSystem g) {
Long id = g.getId() != null ? Long.parseLong(g.getId()) : null;
return GameSystemJpaEntity.builder()
.id(id)
.name(g.getName())
.description(g.getDescription())
.rulesMarkdown(g.getRulesMarkdown())
.author(g.getAuthor())
.isPublic(g.isPublic())
.createdAt(g.getCreatedAt())
.updatedAt(g.getUpdatedAt())
.build();
}
}

View File

@@ -31,7 +31,7 @@ public class CampaignController {
public ResponseEntity<CampaignDTO> createCampaign(@RequestBody CampaignDTO campaignDTO) {
Campaign campaign = campaignMapper.toDomain(campaignDTO);
Campaign createdCampaign = campaignService.createCampaign(
new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId())
new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId())
);
return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign));
}
@@ -64,7 +64,7 @@ public class CampaignController {
public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
Campaign updatedCampaign = campaignService.updateCampaign(
id,
new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId())
new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId())
);
return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign));
}

View File

@@ -0,0 +1,62 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.CharacterService;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
import com.loremind.infrastructure.web.mapper.CharacterMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/characters")
public class CharacterController {
private final CharacterService characterService;
private final CharacterMapper characterMapper;
public CharacterController(CharacterService characterService, CharacterMapper characterMapper) {
this.characterService = characterService;
this.characterMapper = characterMapper;
}
@PostMapping
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
Character created = characterService.createCharacter(
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
);
return ResponseEntity.ok(characterMapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<CharacterDTO> getCharacterById(@PathVariable String id) {
return characterService.getCharacterById(id)
.map(c -> ResponseEntity.ok(characterMapper.toDTO(c)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<CharacterDTO>> getCharactersByCampaign(@PathVariable String campaignId) {
List<CharacterDTO> dtos = characterService.getCharactersByCampaignId(campaignId).stream()
.map(characterMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
Character updated = characterService.updateCharacter(
id,
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
);
return ResponseEntity.ok(characterMapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteCharacter(@PathVariable String id) {
characterService.deleteCharacter(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,75 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/game-systems")
public class GameSystemController {
private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper;
public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) {
this.gameSystemService = gameSystemService;
this.gameSystemMapper = gameSystemMapper;
}
@PostMapping
public ResponseEntity<GameSystemDTO> createGameSystem(@RequestBody GameSystemDTO dto) {
GameSystem created = gameSystemService.createGameSystem(toData(dto));
return ResponseEntity.ok(gameSystemMapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<GameSystemDTO> getGameSystemById(@PathVariable String id) {
return gameSystemService.getGameSystemById(id)
.map(g -> ResponseEntity.ok(gameSystemMapper.toDTO(g)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<List<GameSystemDTO>> getAllGameSystems() {
List<GameSystemDTO> dtos = gameSystemService.getAllGameSystems().stream()
.map(gameSystemMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@GetMapping("/search")
public ResponseEntity<List<GameSystemDTO>> searchGameSystems(@RequestParam("q") String query) {
List<GameSystemDTO> dtos = gameSystemService.searchGameSystems(query).stream()
.map(gameSystemMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<GameSystemDTO> updateGameSystem(@PathVariable String id, @RequestBody GameSystemDTO dto) {
GameSystem updated = gameSystemService.updateGameSystem(id, toData(dto));
return ResponseEntity.ok(gameSystemMapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteGameSystem(@PathVariable String id) {
gameSystemService.deleteGameSystem(id);
return ResponseEntity.noContent().build();
}
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
return new GameSystemService.GameSystemData(
dto.getName(),
dto.getDescription(),
dto.getRulesMarkdown(),
dto.getAuthor(),
dto.isPublic()
);
}
}

View File

@@ -15,4 +15,6 @@ public class CampaignDTO {
private int arcsCount;
/** Nullable : campagne sans univers associé. */
private String loreId;
/** Nullable : campagne sans système de JDR associé (générique). */
private String gameSystemId;
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
/**
* DTO pour les fiches de personnages (PJ) d'une campagne.
*/
@Data
public class CharacterDTO {
private String id;
private String name;
private String markdownContent;
private String campaignId;
private int order;
}

View File

@@ -0,0 +1,17 @@
package com.loremind.infrastructure.web.dto.gamesystemcontext;
import lombok.Data;
/**
* DTO pour l'entité GameSystem (système de JDR).
*/
@Data
public class GameSystemDTO {
private String id;
private String name;
private String description;
private String rulesMarkdown;
private String author;
private boolean isPublic;
}

View File

@@ -21,6 +21,7 @@ public class CampaignMapper {
dto.setDescription(campaign.getDescription());
dto.setArcsCount(campaign.getArcsCount());
dto.setLoreId(campaign.getLoreId());
dto.setGameSystemId(campaign.getGameSystemId());
return dto;
}
@@ -35,6 +36,7 @@ public class CampaignMapper {
.description(dto.getDescription())
.arcsCount(dto.getArcsCount())
.loreId(dto.getLoreId())
.gameSystemId(dto.getGameSystemId())
.build();
}
}

View File

@@ -0,0 +1,31 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
import org.springframework.stereotype.Component;
@Component
public class CharacterMapper {
public CharacterDTO toDTO(Character c) {
if (c == null) return null;
CharacterDTO dto = new CharacterDTO();
dto.setId(c.getId());
dto.setName(c.getName());
dto.setMarkdownContent(c.getMarkdownContent());
dto.setCampaignId(c.getCampaignId());
dto.setOrder(c.getOrder());
return dto;
}
public Character toDomain(CharacterDTO dto) {
if (dto == null) return null;
return Character.builder()
.id(dto.getId())
.name(dto.getName())
.markdownContent(dto.getMarkdownContent())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.build();
}
}

View File

@@ -0,0 +1,33 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import org.springframework.stereotype.Component;
@Component
public class GameSystemMapper {
public GameSystemDTO toDTO(GameSystem g) {
if (g == null) return null;
GameSystemDTO dto = new GameSystemDTO();
dto.setId(g.getId());
dto.setName(g.getName());
dto.setDescription(g.getDescription());
dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic());
return dto;
}
public GameSystem toDomain(GameSystemDTO dto) {
if (dto == null) return null;
return GameSystem.builder()
.id(dto.getId())
.name(dto.getName())
.description(dto.getDescription())
.rulesMarkdown(dto.getRulesMarkdown())
.author(dto.getAuthor())
.isPublic(dto.isPublic())
.build();
}
}