Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple) - changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
This commit is contained in:
@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -22,8 +24,17 @@ public class CharacterService {
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un Character.
|
||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||
* Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
|
||||
*/
|
||||
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
|
||||
public record CharacterData(
|
||||
String name,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
String campaignId,
|
||||
Integer order
|
||||
) {}
|
||||
|
||||
public Character createCharacter(CharacterData data) {
|
||||
int order = data.order() != null
|
||||
@@ -31,7 +42,10 @@ public class CharacterService {
|
||||
: nextOrderFor(data.campaignId());
|
||||
Character character = Character.builder()
|
||||
.name(data.name())
|
||||
.markdownContent(data.markdownContent())
|
||||
.portraitImageId(data.portraitImageId())
|
||||
.headerImageId(data.headerImageId())
|
||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||
.campaignId(data.campaignId())
|
||||
.order(order)
|
||||
.build();
|
||||
@@ -50,7 +64,10 @@ public class CharacterService {
|
||||
Character existing = characterRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setMarkdownContent(data.markdownContent());
|
||||
existing.setPortraitImageId(data.portraitImageId());
|
||||
existing.setHeaderImageId(data.headerImageId());
|
||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -19,11 +21,15 @@ public class NpcService {
|
||||
this.npcRepository = npcRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un Npc.
|
||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||
*/
|
||||
public record NpcData(String name, String markdownContent, String campaignId, Integer order) {}
|
||||
public record NpcData(
|
||||
String name,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
String campaignId,
|
||||
Integer order
|
||||
) {}
|
||||
|
||||
public Npc createNpc(NpcData data) {
|
||||
int order = data.order() != null
|
||||
@@ -31,7 +37,10 @@ public class NpcService {
|
||||
: nextOrderFor(data.campaignId());
|
||||
Npc npc = Npc.builder()
|
||||
.name(data.name())
|
||||
.markdownContent(data.markdownContent())
|
||||
.portraitImageId(data.portraitImageId())
|
||||
.headerImageId(data.headerImageId())
|
||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||
.campaignId(data.campaignId())
|
||||
.order(order)
|
||||
.build();
|
||||
@@ -50,7 +59,10 @@ public class NpcService {
|
||||
Npc existing = npcRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setMarkdownContent(data.markdownContent());
|
||||
existing.setPortraitImageId(data.portraitImageId());
|
||||
existing.setHeaderImageId(data.headerImageId());
|
||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
@@ -61,7 +73,6 @@ public class NpcService {
|
||||
npcRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/** Renvoie la prochaine position libre — append en fin de liste. */
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return npcRepository.findByCampaignId(campaignId).stream()
|
||||
.mapToInt(Npc::getOrder)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.loremind.application.gamesystemcontext;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
@@ -18,11 +19,14 @@ public class GameSystemService {
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
||||
* Les templates peuvent etre null (interpretes comme listes vides).
|
||||
*/
|
||||
public record GameSystemData(
|
||||
String name,
|
||||
String description,
|
||||
String rulesMarkdown,
|
||||
List<TemplateField> characterTemplate,
|
||||
List<TemplateField> npcTemplate,
|
||||
String author,
|
||||
boolean isPublic
|
||||
) {}
|
||||
@@ -35,6 +39,8 @@ public class GameSystemService {
|
||||
.author(normalize(data.author()))
|
||||
.isPublic(data.isPublic())
|
||||
.build();
|
||||
gameSystem.replaceCharacterTemplate(data.characterTemplate());
|
||||
gameSystem.replaceNpcTemplate(data.npcTemplate());
|
||||
return gameSystemRepository.save(gameSystem);
|
||||
}
|
||||
|
||||
@@ -52,6 +58,8 @@ public class GameSystemService {
|
||||
existing.setName(data.name());
|
||||
existing.setDescription(data.description());
|
||||
existing.setRulesMarkdown(data.rulesMarkdown());
|
||||
existing.replaceCharacterTemplate(data.characterTemplate());
|
||||
existing.replaceNpcTemplate(data.npcTemplate());
|
||||
existing.setAuthor(normalize(data.author()));
|
||||
existing.setPublic(data.isPublic());
|
||||
return gameSystemRepository.save(existing);
|
||||
|
||||
@@ -104,23 +104,32 @@ public class CampaignStructuralContextBuilder {
|
||||
* sans injecter toute sa fiche.
|
||||
*/
|
||||
private CharacterSummary toCharacterSummary(Character c) {
|
||||
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
||||
return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
|
||||
}
|
||||
|
||||
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
|
||||
private NpcSummary toNpcSummary(Npc n) {
|
||||
return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent()));
|
||||
return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
|
||||
}
|
||||
|
||||
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() + "…";
|
||||
/**
|
||||
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
|
||||
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
|
||||
*/
|
||||
private static String extractSnippet(java.util.Map<String, String> values) {
|
||||
if (values == null || values.isEmpty()) return "";
|
||||
for (String value : values.values()) {
|
||||
if (value == null || value.isBlank()) continue;
|
||||
String firstLine = value.lines()
|
||||
.map(String::strip)
|
||||
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
if (firstLine.isEmpty()) continue;
|
||||
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
||||
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private ArcSummary toArcSummary(Arc arc) {
|
||||
|
||||
@@ -130,13 +130,19 @@ public class NarrativeEntityContextBuilder {
|
||||
|
||||
private NarrativeEntityContext fromCharacter(Character c) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
||||
if (c.getValues() != null) {
|
||||
// Champs templates exposes individuellement — meilleur pour le LLM que
|
||||
// l'ancien blob markdown monolithique.
|
||||
c.getValues().forEach((k, v) -> putField(fields, k, v));
|
||||
}
|
||||
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromNpc(Npc n) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "fiche complète (markdown)", n.getMarkdownContent());
|
||||
if (n.getValues() != null) {
|
||||
n.getValues().forEach((k, v) -> putField(fields, k, v));
|
||||
}
|
||||
return new NarrativeEntityContext("npc", n.getName(), fields);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
||||
@@ -4,18 +4,26 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Champs universels hard-codes : {@code name}, {@code portraitImageId},
|
||||
* {@code headerImageId}. Tout le reste est piloté par le template PJ du
|
||||
* GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
|
||||
* <p>
|
||||
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
|
||||
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
|
||||
* Évolution prévue : système de templating partagé PJ/PNJ piloté par
|
||||
* GameSystem pour adapter les blocs aux différents systèmes de JDR.
|
||||
* Les valeurs des champs templates sont stockées dans deux maps :
|
||||
* - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
|
||||
* parsé à l'usage cote presentation)
|
||||
* - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
|
||||
* <p>
|
||||
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
|
||||
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
|
||||
* <p>
|
||||
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -24,11 +32,24 @@ public class Character {
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
|
||||
private String portraitImageId;
|
||||
|
||||
/** ID de l'image header/banniere (champ universel hard-codé). Nullable. */
|
||||
private String headerImageId;
|
||||
|
||||
/**
|
||||
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
|
||||
* renseigné progressivement par le MJ.
|
||||
* Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
|
||||
* (sensible a la casse cote stockage mais comparaison case-insensitive
|
||||
* dans le domaine GameSystem). Jamais null apres construction.
|
||||
*/
|
||||
private String markdownContent;
|
||||
private Map<String, String> values;
|
||||
|
||||
/**
|
||||
* Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
|
||||
* liste ordonnee d'IDs d'images. Jamais null apres construction.
|
||||
*/
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/** Référence vers la Campaign parente. */
|
||||
private String campaignId;
|
||||
@@ -38,4 +59,15 @@ public class Character {
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** Garantit que les maps ne sont jamais null cote consommateur. */
|
||||
public Map<String, String> getValues() {
|
||||
if (values == null) values = new HashMap<>();
|
||||
return values;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> getImageValues() {
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
return imageValues;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,22 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||
* <p>
|
||||
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé —
|
||||
* un PNJ a vocation à porter à terme des invariants métier propres (faction,
|
||||
* statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ)
|
||||
* qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité
|
||||
* PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern).
|
||||
* Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
|
||||
* à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
|
||||
* <p>
|
||||
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé
|
||||
* PJ/PNJ piloté par GameSystem.
|
||||
* Mêmes champs universels hard-codés et meme structure de templating que Character,
|
||||
* pilotée par le template PNJ du GameSystem
|
||||
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
|
||||
* <p>
|
||||
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent
|
||||
* gérés via le système Page/Template du LoreContext.
|
||||
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
|
||||
* via le système Page/Template du LoreContext.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -27,10 +28,19 @@ public class Npc {
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */
|
||||
private String markdownContent;
|
||||
/** ID de l'image portrait (champ universel hard-code). Nullable. */
|
||||
private String portraitImageId;
|
||||
|
||||
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */
|
||||
/** ID de l'image header/banniere (champ universel hard-code). Nullable. */
|
||||
private String headerImageId;
|
||||
|
||||
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
|
||||
private Map<String, String> values;
|
||||
|
||||
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||
private String campaignId;
|
||||
|
||||
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
||||
@@ -38,4 +48,14 @@ public class Npc {
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
public Map<String, String> getValues() {
|
||||
if (values == null) values = new HashMap<>();
|
||||
return values;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> getImageValues() {
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
return imageValues;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.loremind.domain.gamesystemcontext;
|
||||
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant un GameSystem (système de JDR).
|
||||
@@ -12,6 +16,10 @@ import java.time.LocalDateTime;
|
||||
* 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>
|
||||
* Porte aussi deux templates piloтant la structure des fiches PJ et PNJ d'une
|
||||
* campagne adossée à ce système. Les fiches markdown libres ont laissé place à
|
||||
* un système de champs typés (TEXT/IMAGE/NUMBER) défini ici.
|
||||
* <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.
|
||||
@@ -27,6 +35,21 @@ public class GameSystem {
|
||||
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
|
||||
private String rulesMarkdown;
|
||||
|
||||
/**
|
||||
* Template de fiche PJ : champs typés affichés pour chaque personnage joueur.
|
||||
* Hors champs universels hard-codés (nom, portrait, header). Jamais null après
|
||||
* persistance — un template vide est représenté par une liste vide.
|
||||
*/
|
||||
private List<TemplateField> characterTemplate;
|
||||
|
||||
/**
|
||||
* Template de fiche PNJ. Mêmes règles que {@link #characterTemplate}.
|
||||
* Distinct du template PJ car les invariants métier divergent (un PNJ peut
|
||||
* n'avoir qu'un nom + une motivation, un PJ porte généralement une feuille
|
||||
* de stats complète).
|
||||
*/
|
||||
private List<TemplateField> npcTemplate;
|
||||
|
||||
/** Auteur déclaré — futur marketplace. Nullable. */
|
||||
private String author;
|
||||
|
||||
@@ -35,4 +58,88 @@ public class GameSystem {
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// --- Méthodes métier : templates PJ/PNJ --------------------------------
|
||||
|
||||
/**
|
||||
* Ajoute un champ au template PJ. Refuse les doublons de nom (insensible à la casse)
|
||||
* pour éviter les collisions de clés dans {@code Character.values}.
|
||||
*/
|
||||
public void addCharacterField(TemplateField field) {
|
||||
characterTemplate = appendField(characterTemplate, field);
|
||||
}
|
||||
|
||||
/** Pendant PNJ de {@link #addCharacterField}. */
|
||||
public void addNpcField(TemplateField field) {
|
||||
npcTemplate = appendField(npcTemplate, field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire un champ du template PJ par nom (insensible à la casse).
|
||||
* No-op silencieux si le champ n'existe pas — appelant n'a pas à pré-vérifier.
|
||||
*/
|
||||
public void removeCharacterField(String fieldName) {
|
||||
characterTemplate = removeFieldByName(characterTemplate, fieldName);
|
||||
}
|
||||
|
||||
public void removeNpcField(String fieldName) {
|
||||
npcTemplate = removeFieldByName(npcTemplate, fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplace intégralement le template PJ. Utilisé pour le réordonnancement
|
||||
* et l'édition en bloc côté UI. Valide l'unicité des noms.
|
||||
*/
|
||||
public void replaceCharacterTemplate(List<TemplateField> fields) {
|
||||
characterTemplate = validateAndCopy(fields);
|
||||
}
|
||||
|
||||
public void replaceNpcTemplate(List<TemplateField> fields) {
|
||||
npcTemplate = validateAndCopy(fields);
|
||||
}
|
||||
|
||||
// --- Helpers privés ----------------------------------------------------
|
||||
|
||||
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
|
||||
if (field == null || field.getName() == null || field.getName().isBlank()) {
|
||||
throw new IllegalArgumentException("Field name is required");
|
||||
}
|
||||
List<TemplateField> next = current == null ? new ArrayList<>() : new ArrayList<>(current);
|
||||
if (containsName(next, field.getName())) {
|
||||
throw new IllegalArgumentException("Duplicate field name: " + field.getName());
|
||||
}
|
||||
next.add(field);
|
||||
return next;
|
||||
}
|
||||
|
||||
private static List<TemplateField> removeFieldByName(List<TemplateField> current, String fieldName) {
|
||||
if (current == null || fieldName == null) return current;
|
||||
List<TemplateField> next = new ArrayList<>(current);
|
||||
next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName));
|
||||
return next;
|
||||
}
|
||||
|
||||
private static List<TemplateField> validateAndCopy(List<TemplateField> fields) {
|
||||
if (fields == null) return new ArrayList<>();
|
||||
List<TemplateField> copy = new ArrayList<>(fields.size());
|
||||
for (TemplateField f : fields) {
|
||||
if (f == null || f.getName() == null || f.getName().isBlank()) {
|
||||
throw new IllegalArgumentException("Field name is required");
|
||||
}
|
||||
if (containsName(copy, f.getName())) {
|
||||
throw new IllegalArgumentException("Duplicate field name: " + f.getName());
|
||||
}
|
||||
copy.add(f);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static boolean containsName(List<TemplateField> fields, String name) {
|
||||
return fields.stream().anyMatch(f -> equalsIgnoreCase(f.getName(), name));
|
||||
}
|
||||
|
||||
private static boolean equalsIgnoreCase(String a, String b) {
|
||||
if (a == null || b == null) return a == b;
|
||||
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
/**
|
||||
* Type d'un champ dynamique d'un Template.
|
||||
* <p>
|
||||
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
|
||||
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
|
||||
* (stockee dans Page.imageValues : Map<String, List<String>>)
|
||||
* <p>
|
||||
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
|
||||
*/
|
||||
public enum FieldType {
|
||||
TEXT,
|
||||
IMAGE
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.domain.shared.template;
|
||||
|
||||
/**
|
||||
* Type d'un champ dynamique de template (kernel partage).
|
||||
* <p>
|
||||
* - TEXT : valeur textuelle libre (Map<String, String>)
|
||||
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
|
||||
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
|
||||
* <p>
|
||||
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, KEY_VALUE_LIST, REFERENCE...
|
||||
*/
|
||||
public enum FieldType {
|
||||
TEXT,
|
||||
IMAGE,
|
||||
NUMBER
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
package com.loremind.domain.shared.template;
|
||||
|
||||
/**
|
||||
* Variante de rendu pour un champ de type IMAGE.
|
||||
@@ -8,7 +8,7 @@ package com.loremind.domain.lorecontext;
|
||||
* - MASONRY : mosaique hauteurs variables facon Pinterest
|
||||
* - CAROUSEL : defilement horizontal
|
||||
* <p>
|
||||
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
|
||||
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon.
|
||||
*/
|
||||
public enum ImageLayout {
|
||||
GALLERY,
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
package com.loremind.domain.shared.template;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
@@ -6,15 +6,15 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Value Object d'un champ de Template.
|
||||
* Value Object d'un champ de Template (kernel partage).
|
||||
* <p>
|
||||
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible).
|
||||
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
|
||||
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
|
||||
* Un champ a un nom (affiche dans l'UI) et un type. Le type pilote
|
||||
* le rendu cote front et la logique metier (seuls les champs TEXT sont
|
||||
* envoyes a l'IA pour generation).
|
||||
* <p>
|
||||
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
|
||||
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
|
||||
* Ignore pour les champs TEXT.
|
||||
* Ignore pour les autres types.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -47,4 +47,9 @@ public class TemplateField {
|
||||
public static TemplateField image(String name, ImageLayout layout) {
|
||||
return new TemplateField(name, FieldType.IMAGE, layout);
|
||||
}
|
||||
|
||||
/** Raccourci : construit un champ de type NUMBER. */
|
||||
public static TemplateField number(String name) {
|
||||
return new TemplateField(name, FieldType.NUMBER, null);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package com.loremind.infrastructure.persistence.converter;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.lorecontext.FieldType;
|
||||
import com.loremind.domain.lorecontext.ImageLayout;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
@@ -7,11 +9,18 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Entité JPA pour les fiches de personnages (PJ).
|
||||
* <p>
|
||||
* Refonte 2026-04-30 : ancien champ markdownContent migré vers {@code values["Notes"]}
|
||||
* via Hibernate au demarrage. Hibernate ddl-auto=update ajoute les nouvelles colonnes
|
||||
* sans dropper {@code markdown_content} — les donnees existantes sont conservees mais
|
||||
* plus mappees au domaine. Migration manuelle prevue (script SQL one-shot) si le
|
||||
* deploiement passe en bluegreen.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "characters")
|
||||
@@ -28,8 +37,21 @@ public class CharacterJpaEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "markdown_content", columnDefinition = "TEXT")
|
||||
private String markdownContent;
|
||||
@Column(name = "portrait_image_id")
|
||||
private String portraitImageId;
|
||||
|
||||
@Column(name = "header_image_id")
|
||||
private String headerImageId;
|
||||
|
||||
/** Valeurs TEXT/NUMBER serialisees JSON. */
|
||||
@Convert(converter = StringMapJsonConverter.class)
|
||||
@Column(name = "field_values", columnDefinition = "TEXT")
|
||||
private Map<String, String> values;
|
||||
|
||||
/** Valeurs IMAGE serialisees JSON. */
|
||||
@Convert(converter = StringListMapJsonConverter.class)
|
||||
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
@Column(name = "campaign_id", nullable = false)
|
||||
private Long campaignId;
|
||||
@@ -47,6 +69,8 @@ public class CharacterJpaEntity {
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (values == null) values = new HashMap<>();
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
@@ -7,6 +9,8 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
|
||||
@@ -32,6 +36,16 @@ public class GameSystemJpaEntity {
|
||||
@Column(name = "rules_markdown", columnDefinition = "TEXT")
|
||||
private String rulesMarkdown;
|
||||
|
||||
/** Template PJ serialise en JSON via {@link TemplateFieldListJsonConverter}. */
|
||||
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||
@Column(name = "character_template", columnDefinition = "TEXT")
|
||||
private List<TemplateField> characterTemplate;
|
||||
|
||||
/** Template PNJ serialise en JSON. */
|
||||
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||
@Column(name = "npc_template", columnDefinition = "TEXT")
|
||||
private List<TemplateField> npcTemplate;
|
||||
|
||||
@Column
|
||||
private String author;
|
||||
|
||||
@@ -48,6 +62,8 @@ public class GameSystemJpaEntity {
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
||||
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
@@ -7,10 +9,13 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Entité JPA pour les fiches de PNJ d'une campagne.
|
||||
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte).
|
||||
* Entité JPA pour les fiches de PNJ. Memes regles que CharacterJpaEntity
|
||||
* (cf. note de refonte 2026-04-30 sur la migration markdownContent).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "npcs")
|
||||
@@ -27,8 +32,19 @@ public class NpcJpaEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "markdown_content", columnDefinition = "TEXT")
|
||||
private String markdownContent;
|
||||
@Column(name = "portrait_image_id")
|
||||
private String portraitImageId;
|
||||
|
||||
@Column(name = "header_image_id")
|
||||
private String headerImageId;
|
||||
|
||||
@Convert(converter = StringMapJsonConverter.class)
|
||||
@Column(name = "field_values", columnDefinition = "TEXT")
|
||||
private Map<String, String> values;
|
||||
|
||||
@Convert(converter = StringListMapJsonConverter.class)
|
||||
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
@Column(name = "campaign_id", nullable = false)
|
||||
private Long campaignId;
|
||||
@@ -46,6 +62,8 @@ public class NpcJpaEntity {
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (values == null) values = new HashMap<>();
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -52,7 +53,10 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
||||
return Character.builder()
|
||||
.id(e.getId().toString())
|
||||
.name(e.getName())
|
||||
.markdownContent(e.getMarkdownContent())
|
||||
.portraitImageId(e.getPortraitImageId())
|
||||
.headerImageId(e.getHeaderImageId())
|
||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||
.campaignId(e.getCampaignId().toString())
|
||||
.order(e.getOrder())
|
||||
.createdAt(e.getCreatedAt())
|
||||
@@ -65,7 +69,10 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
||||
return CharacterJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(c.getName())
|
||||
.markdownContent(c.getMarkdownContent())
|
||||
.portraitImageId(c.getPortraitImageId())
|
||||
.headerImageId(c.getHeaderImageId())
|
||||
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
|
||||
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
|
||||
.campaignId(Long.parseLong(c.getCampaignId()))
|
||||
.order(c.getOrder())
|
||||
.createdAt(c.getCreatedAt())
|
||||
|
||||
@@ -61,6 +61,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||
.name(e.getName())
|
||||
.description(e.getDescription())
|
||||
.rulesMarkdown(e.getRulesMarkdown())
|
||||
.characterTemplate(e.getCharacterTemplate() != null
|
||||
? new java.util.ArrayList<>(e.getCharacterTemplate())
|
||||
: new java.util.ArrayList<>())
|
||||
.npcTemplate(e.getNpcTemplate() != null
|
||||
? new java.util.ArrayList<>(e.getNpcTemplate())
|
||||
: new java.util.ArrayList<>())
|
||||
.author(e.getAuthor())
|
||||
.isPublic(e.isPublic())
|
||||
.createdAt(e.getCreatedAt())
|
||||
@@ -75,6 +81,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||
.name(g.getName())
|
||||
.description(g.getDescription())
|
||||
.rulesMarkdown(g.getRulesMarkdown())
|
||||
.characterTemplate(g.getCharacterTemplate() != null
|
||||
? new java.util.ArrayList<>(g.getCharacterTemplate())
|
||||
: new java.util.ArrayList<>())
|
||||
.npcTemplate(g.getNpcTemplate() != null
|
||||
? new java.util.ArrayList<>(g.getNpcTemplate())
|
||||
: new java.util.ArrayList<>())
|
||||
.author(g.getAuthor())
|
||||
.isPublic(g.isPublic())
|
||||
.createdAt(g.getCreatedAt())
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -52,7 +53,10 @@ public class PostgresNpcRepository implements NpcRepository {
|
||||
return Npc.builder()
|
||||
.id(e.getId().toString())
|
||||
.name(e.getName())
|
||||
.markdownContent(e.getMarkdownContent())
|
||||
.portraitImageId(e.getPortraitImageId())
|
||||
.headerImageId(e.getHeaderImageId())
|
||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||
.campaignId(e.getCampaignId().toString())
|
||||
.order(e.getOrder())
|
||||
.createdAt(e.getCreatedAt())
|
||||
@@ -65,7 +69,10 @@ public class PostgresNpcRepository implements NpcRepository {
|
||||
return NpcJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(n.getName())
|
||||
.markdownContent(n.getMarkdownContent())
|
||||
.portraitImageId(n.getPortraitImageId())
|
||||
.headerImageId(n.getHeaderImageId())
|
||||
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
||||
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||
.order(n.getOrder())
|
||||
.createdAt(n.getCreatedAt())
|
||||
|
||||
@@ -24,9 +24,7 @@ public class CharacterController {
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
|
||||
Character created = characterService.createCharacter(
|
||||
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
||||
);
|
||||
Character created = characterService.createCharacter(toData(dto, null));
|
||||
return ResponseEntity.ok(characterMapper.toDTO(created));
|
||||
}
|
||||
|
||||
@@ -47,10 +45,7 @@ public class CharacterController {
|
||||
|
||||
@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())
|
||||
);
|
||||
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
|
||||
return ResponseEntity.ok(characterMapper.toDTO(updated));
|
||||
}
|
||||
|
||||
@@ -59,4 +54,16 @@ public class CharacterController {
|
||||
characterService.deleteCharacter(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private CharacterService.CharacterData toData(CharacterDTO dto, Integer order) {
|
||||
return new CharacterService.CharacterData(
|
||||
dto.getName(),
|
||||
dto.getPortraitImageId(),
|
||||
dto.getHeaderImageId(),
|
||||
dto.getValues(),
|
||||
dto.getImageValues(),
|
||||
dto.getCampaignId(),
|
||||
order
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@ package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.gamesystemcontext.GameSystemService;
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -16,10 +21,14 @@ public class GameSystemController {
|
||||
|
||||
private final GameSystemService gameSystemService;
|
||||
private final GameSystemMapper gameSystemMapper;
|
||||
private final TemplateFieldMapper templateFieldMapper;
|
||||
|
||||
public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) {
|
||||
public GameSystemController(GameSystemService gameSystemService,
|
||||
GameSystemMapper gameSystemMapper,
|
||||
TemplateFieldMapper templateFieldMapper) {
|
||||
this.gameSystemService = gameSystemService;
|
||||
this.gameSystemMapper = gameSystemMapper;
|
||||
this.templateFieldMapper = templateFieldMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -63,13 +72,28 @@ public class GameSystemController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
|
||||
}
|
||||
|
||||
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
||||
return new GameSystemService.GameSystemData(
|
||||
dto.getName(),
|
||||
dto.getDescription(),
|
||||
dto.getRulesMarkdown(),
|
||||
toDomainFields(dto.getCharacterTemplate()),
|
||||
toDomainFields(dto.getNpcTemplate()),
|
||||
dto.getAuthor(),
|
||||
dto.isPublic()
|
||||
);
|
||||
}
|
||||
|
||||
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
|
||||
if (dtos == null) return new ArrayList<>();
|
||||
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,7 @@ public class NpcController {
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
|
||||
Npc created = npcService.createNpc(
|
||||
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
||||
);
|
||||
Npc created = npcService.createNpc(toData(dto, null));
|
||||
return ResponseEntity.ok(npcMapper.toDTO(created));
|
||||
}
|
||||
|
||||
@@ -47,10 +45,7 @@ public class NpcController {
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
||||
Npc updated = npcService.updateNpc(
|
||||
id,
|
||||
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
|
||||
);
|
||||
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
|
||||
return ResponseEntity.ok(npcMapper.toDTO(updated));
|
||||
}
|
||||
|
||||
@@ -59,4 +54,16 @@ public class NpcController {
|
||||
npcService.deleteNpc(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private NpcService.NpcData toData(NpcDTO dto, Integer order) {
|
||||
return new NpcService.NpcData(
|
||||
dto.getName(),
|
||||
dto.getPortraitImageId(),
|
||||
dto.getHeaderImageId(),
|
||||
dto.getValues(),
|
||||
dto.getImageValues(),
|
||||
dto.getCampaignId(),
|
||||
order
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.lorecontext.TemplateService;
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||
import com.loremind.infrastructure.web.mapper.TemplateMapper;
|
||||
|
||||
@@ -2,15 +2,25 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* DTO pour les fiches de personnages (PJ) d'une campagne.
|
||||
* Reflete la refonte template-based : champs universels hard-codes (name,
|
||||
* portrait, header) + maps {@code values}/{@code imageValues} pour les
|
||||
* champs templates pilotes par le GameSystem.
|
||||
*/
|
||||
@Data
|
||||
public class CharacterDTO {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String markdownContent;
|
||||
private String portraitImageId;
|
||||
private String headerImageId;
|
||||
private Map<String, String> values = new HashMap<>();
|
||||
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||
private String campaignId;
|
||||
private int order;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,22 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* DTO pour les fiches de PNJ d'une campagne.
|
||||
* DTO pour les fiches de PNJ d'une campagne. Meme structure que CharacterDTO.
|
||||
*/
|
||||
@Data
|
||||
public class NpcDTO {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String markdownContent;
|
||||
private String portraitImageId;
|
||||
private String headerImageId;
|
||||
private Map<String, String> values = new HashMap<>();
|
||||
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||
private String campaignId;
|
||||
private int order;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.loremind.infrastructure.web.dto.gamesystemcontext;
|
||||
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DTO pour l'entité GameSystem (système de JDR).
|
||||
* Expose les templates PJ/PNJ comme listes de TemplateFieldDTO pour le wire.
|
||||
*/
|
||||
@Data
|
||||
public class GameSystemDTO {
|
||||
@@ -12,6 +17,8 @@ public class GameSystemDTO {
|
||||
private String name;
|
||||
private String description;
|
||||
private String rulesMarkdown;
|
||||
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
|
||||
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
|
||||
private String author;
|
||||
private boolean isPublic;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
||||
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
||||
package com.loremind.infrastructure.web.dto.shared;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
@@ -7,7 +7,7 @@ import lombok.NoArgsConstructor;
|
||||
/**
|
||||
* DTO pour un champ de Template.
|
||||
* <p>
|
||||
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
|
||||
* Miroir wire-friendly de {@link com.loremind.domain.shared.template.TemplateField}.
|
||||
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
||||
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
|
||||
* le rendu visuel des champs image cote front.
|
||||
@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
@Component
|
||||
public class CharacterMapper {
|
||||
|
||||
@@ -12,7 +14,10 @@ public class CharacterMapper {
|
||||
CharacterDTO dto = new CharacterDTO();
|
||||
dto.setId(c.getId());
|
||||
dto.setName(c.getName());
|
||||
dto.setMarkdownContent(c.getMarkdownContent());
|
||||
dto.setPortraitImageId(c.getPortraitImageId());
|
||||
dto.setHeaderImageId(c.getHeaderImageId());
|
||||
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
|
||||
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
|
||||
dto.setCampaignId(c.getCampaignId());
|
||||
dto.setOrder(c.getOrder());
|
||||
return dto;
|
||||
@@ -23,7 +28,10 @@ public class CharacterMapper {
|
||||
return Character.builder()
|
||||
.id(dto.getId())
|
||||
.name(dto.getName())
|
||||
.markdownContent(dto.getMarkdownContent())
|
||||
.portraitImageId(dto.getPortraitImageId())
|
||||
.headerImageId(dto.getHeaderImageId())
|
||||
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||
.campaignId(dto.getCampaignId())
|
||||
.order(dto.getOrder())
|
||||
.build();
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
package com.loremind.infrastructure.web.mapper;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class GameSystemMapper {
|
||||
|
||||
private final TemplateFieldMapper fieldMapper;
|
||||
|
||||
public GameSystemMapper(TemplateFieldMapper fieldMapper) {
|
||||
this.fieldMapper = fieldMapper;
|
||||
}
|
||||
|
||||
public GameSystemDTO toDTO(GameSystem g) {
|
||||
if (g == null) return null;
|
||||
GameSystemDTO dto = new GameSystemDTO();
|
||||
@@ -14,6 +25,8 @@ public class GameSystemMapper {
|
||||
dto.setName(g.getName());
|
||||
dto.setDescription(g.getDescription());
|
||||
dto.setRulesMarkdown(g.getRulesMarkdown());
|
||||
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
|
||||
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
|
||||
dto.setAuthor(g.getAuthor());
|
||||
dto.setPublic(g.isPublic());
|
||||
return dto;
|
||||
@@ -26,8 +39,24 @@ public class GameSystemMapper {
|
||||
.name(dto.getName())
|
||||
.description(dto.getDescription())
|
||||
.rulesMarkdown(dto.getRulesMarkdown())
|
||||
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
|
||||
.npcTemplate(toDomainList(dto.getNpcTemplate()))
|
||||
.author(dto.getAuthor())
|
||||
.isPublic(dto.isPublic())
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
|
||||
if (fields == null) return new ArrayList<>();
|
||||
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
|
||||
for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
|
||||
if (dtos == null) return new ArrayList<>();
|
||||
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||
for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
@Component
|
||||
public class NpcMapper {
|
||||
|
||||
@@ -12,7 +14,10 @@ public class NpcMapper {
|
||||
NpcDTO dto = new NpcDTO();
|
||||
dto.setId(n.getId());
|
||||
dto.setName(n.getName());
|
||||
dto.setMarkdownContent(n.getMarkdownContent());
|
||||
dto.setPortraitImageId(n.getPortraitImageId());
|
||||
dto.setHeaderImageId(n.getHeaderImageId());
|
||||
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
|
||||
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
||||
dto.setCampaignId(n.getCampaignId());
|
||||
dto.setOrder(n.getOrder());
|
||||
return dto;
|
||||
@@ -23,7 +28,10 @@ public class NpcMapper {
|
||||
return Npc.builder()
|
||||
.id(dto.getId())
|
||||
.name(dto.getName())
|
||||
.markdownContent(dto.getMarkdownContent())
|
||||
.portraitImageId(dto.getPortraitImageId())
|
||||
.headerImageId(dto.getHeaderImageId())
|
||||
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||
.campaignId(dto.getCampaignId())
|
||||
.order(dto.getOrder())
|
||||
.build();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.loremind.infrastructure.web.mapper;
|
||||
|
||||
import com.loremind.domain.lorecontext.FieldType;
|
||||
import com.loremind.domain.lorecontext.ImageLayout;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.loremind.infrastructure.web.mapper;
|
||||
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -38,7 +39,7 @@ public class NpcServiceTest {
|
||||
testNpc = Npc.builder()
|
||||
.id("npc-1")
|
||||
.name("Borin le forgeron")
|
||||
.markdownContent("# Borin\nForgeron nain")
|
||||
.values(new java.util.HashMap<>(Map.of("Notes", "Forgeron nain")))
|
||||
.campaignId("camp-1")
|
||||
.order(1)
|
||||
.build();
|
||||
@@ -49,7 +50,8 @@ public class NpcServiceTest {
|
||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||
|
||||
Npc result = npcService.createNpc(
|
||||
new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5));
|
||||
new NpcService.NpcData("Borin le forgeron", null, null,
|
||||
Map.of("Notes", "Borin"), null, "camp-1", 5));
|
||||
|
||||
assertNotNull(result);
|
||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||
@@ -65,7 +67,7 @@ public class NpcServiceTest {
|
||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||
|
||||
npcService.createNpc(new NpcService.NpcData("Nouveau", null, "camp-1", null));
|
||||
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, "camp-1", null));
|
||||
|
||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||
verify(npcRepository).save(captor.capture());
|
||||
@@ -77,7 +79,7 @@ public class NpcServiceTest {
|
||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||
|
||||
npcService.createNpc(new NpcService.NpcData("Premier", null, "camp-1", null));
|
||||
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, "camp-1", null));
|
||||
|
||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||
verify(npcRepository).save(captor.capture());
|
||||
@@ -121,10 +123,11 @@ public class NpcServiceTest {
|
||||
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Npc result = npcService.updateNpc("npc-1",
|
||||
new NpcService.NpcData("Borin renommé", "# v2", "camp-1", 7));
|
||||
new NpcService.NpcData("Borin renommé", null, null,
|
||||
Map.of("Notes", "v2"), null, "camp-1", 7));
|
||||
|
||||
assertEquals("Borin renommé", result.getName());
|
||||
assertEquals("# v2", result.getMarkdownContent());
|
||||
assertEquals("v2", result.getValues().get("Notes"));
|
||||
assertEquals(7, result.getOrder());
|
||||
}
|
||||
|
||||
@@ -134,7 +137,8 @@ public class NpcServiceTest {
|
||||
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Npc result = npcService.updateNpc("npc-1",
|
||||
new NpcService.NpcData("Borin", "# txt", "camp-1", null));
|
||||
new NpcService.NpcData("Borin", null, null,
|
||||
Map.of("Notes", "txt"), null, "camp-1", null));
|
||||
|
||||
// testNpc avait order=1 → préservé
|
||||
assertEquals(1, result.getOrder());
|
||||
@@ -146,7 +150,7 @@ public class NpcServiceTest {
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> npcService.updateNpc("missing",
|
||||
new NpcService.NpcData("x", null, "camp-1", null)));
|
||||
new NpcService.NpcData("x", null, null, null, null, "camp-1", null)));
|
||||
assertTrue(ex.getMessage().contains("missing"));
|
||||
verify(npcRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@@ -153,19 +153,19 @@ public class CampaignStructuralContextBuilderTest {
|
||||
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
|
||||
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
|
||||
.name("Aragorn")
|
||||
.markdownContent("# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")
|
||||
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")))
|
||||
.build();
|
||||
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
|
||||
.name("Legolas")
|
||||
.markdownContent(null) // pas de snippet → string vide
|
||||
.values(null) // pas de snippet → string vide
|
||||
.build();
|
||||
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
|
||||
.name("Borin le forgeron")
|
||||
.markdownContent("# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")
|
||||
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")))
|
||||
.build();
|
||||
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
|
||||
.name("Dame Elara")
|
||||
.markdownContent("")
|
||||
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "")))
|
||||
.build();
|
||||
|
||||
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||
@@ -196,7 +196,7 @@ public class CampaignStructuralContextBuilderTest {
|
||||
// Snippet > 160 chars : doit être tronqué à 159 + "…"
|
||||
String longLine = "x".repeat(200);
|
||||
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
|
||||
.name("Verbeux").markdownContent(longLine).build();
|
||||
.name("Verbeux").values(new java.util.HashMap<>(java.util.Map.of("Histoire", longLine))).build();
|
||||
|
||||
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||
|
||||
@@ -3,12 +3,12 @@ package com.loremind.application.generationcontext;
|
||||
import com.loremind.domain.generationcontext.GenerationContext;
|
||||
import com.loremind.domain.generationcontext.GenerationResult;
|
||||
import com.loremind.domain.generationcontext.ports.AiProvider;
|
||||
import com.loremind.domain.lorecontext.FieldType;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.lorecontext.Lore;
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
|
||||
@@ -115,8 +115,13 @@ public class NarrativeEntityContextBuilderTest {
|
||||
|
||||
@Test
|
||||
void testBuild_Character_MarkdownProjected() {
|
||||
// Refonte 2026-04-30 : les valeurs templates sont projetees individuellement
|
||||
// dans la map fields (cle = nom du champ template).
|
||||
Character c = Character.builder()
|
||||
.id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur")
|
||||
.id("c-1").name("Aragorn")
|
||||
.values(new java.util.HashMap<>(java.util.Map.of(
|
||||
"Histoire", "# Aragorn\nRôdeur",
|
||||
"Race", "Humain")))
|
||||
.build();
|
||||
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
|
||||
|
||||
@@ -124,14 +129,17 @@ public class NarrativeEntityContextBuilderTest {
|
||||
|
||||
assertEquals("character", ctx.entityType());
|
||||
assertEquals("Aragorn", ctx.title());
|
||||
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)"));
|
||||
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("Histoire"));
|
||||
assertEquals("Humain", ctx.fields().get("Race"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuild_Npc_MarkdownProjected() {
|
||||
Npc n = Npc.builder()
|
||||
.id("n-1").name("Borin le forgeron")
|
||||
.markdownContent("# Borin\n**Faction :** Clan Feuillefer")
|
||||
.values(new java.util.HashMap<>(java.util.Map.of(
|
||||
"Faction", "Clan Feuillefer",
|
||||
"Histoire", "# Borin")))
|
||||
.build();
|
||||
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||
|
||||
@@ -139,13 +147,14 @@ public class NarrativeEntityContextBuilderTest {
|
||||
|
||||
assertEquals("npc", ctx.entityType());
|
||||
assertEquals("Borin le forgeron", ctx.title());
|
||||
assertEquals("# Borin\n**Faction :** Clan Feuillefer",
|
||||
ctx.fields().get("fiche complète (markdown)"));
|
||||
assertEquals("Clan Feuillefer", ctx.fields().get("Faction"));
|
||||
assertEquals("# Borin", ctx.fields().get("Histoire"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuild_Npc_NormalizesCase() {
|
||||
Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build();
|
||||
Npc n = Npc.builder().id("n-1").name("Elara")
|
||||
.values(new java.util.HashMap<>(java.util.Map.of("Notes", "desc"))).build();
|
||||
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||
|
||||
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");
|
||||
|
||||
@@ -5,10 +5,10 @@ import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||
import com.loremind.domain.lorecontext.FieldType;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.loremind.domain.gamesystemcontext;
|
||||
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests unitaires du domaine GameSystem ciblant la gestion des templates PJ/PNJ.
|
||||
* Le ruleset markdown est testé ailleurs via GameSystemContextSelector.
|
||||
*/
|
||||
class GameSystemTest {
|
||||
|
||||
// --- addCharacterField --------------------------------------------------
|
||||
|
||||
@Test
|
||||
void addCharacterField_appendsField() {
|
||||
GameSystem gs = GameSystem.builder().build();
|
||||
|
||||
gs.addCharacterField(TemplateField.text("Histoire"));
|
||||
gs.addCharacterField(TemplateField.image("Portrait", ImageLayout.HERO));
|
||||
|
||||
assertEquals(2, gs.getCharacterTemplate().size());
|
||||
assertEquals("Histoire", gs.getCharacterTemplate().get(0).getName());
|
||||
assertEquals(FieldType.IMAGE, gs.getCharacterTemplate().get(1).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addCharacterField_rejectsDuplicateNameCaseInsensitive() {
|
||||
GameSystem gs = GameSystem.builder().build();
|
||||
gs.addCharacterField(TemplateField.text("Histoire"));
|
||||
|
||||
// Doublon de cle dans Character.values garanti casse-insensible :
|
||||
// "Histoire" et "histoire" produiraient la meme cle JSON.
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> gs.addCharacterField(TemplateField.number("HISTOIRE")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addCharacterField_rejectsBlankName() {
|
||||
GameSystem gs = GameSystem.builder().build();
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> gs.addCharacterField(new TemplateField(" ", FieldType.TEXT)));
|
||||
}
|
||||
|
||||
// --- removeCharacterField ----------------------------------------------
|
||||
|
||||
@Test
|
||||
void removeCharacterField_removesByNameCaseInsensitive() {
|
||||
GameSystem gs = GameSystem.builder()
|
||||
.characterTemplate(new ArrayList<>(List.of(
|
||||
TemplateField.text("Histoire"),
|
||||
TemplateField.text("Notes")
|
||||
)))
|
||||
.build();
|
||||
|
||||
gs.removeCharacterField("HISTOIRE");
|
||||
|
||||
assertEquals(1, gs.getCharacterTemplate().size());
|
||||
assertEquals("Notes", gs.getCharacterTemplate().get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeCharacterField_silentNoOpWhenMissing() {
|
||||
GameSystem gs = GameSystem.builder()
|
||||
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Histoire"))))
|
||||
.build();
|
||||
|
||||
gs.removeCharacterField("absent");
|
||||
|
||||
assertEquals(1, gs.getCharacterTemplate().size());
|
||||
}
|
||||
|
||||
// --- replaceCharacterTemplate ------------------------------------------
|
||||
|
||||
@Test
|
||||
void replaceCharacterTemplate_overwritesEntireList() {
|
||||
GameSystem gs = GameSystem.builder()
|
||||
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Old"))))
|
||||
.build();
|
||||
|
||||
gs.replaceCharacterTemplate(List.of(
|
||||
TemplateField.text("A"),
|
||||
TemplateField.number("B")));
|
||||
|
||||
assertEquals(2, gs.getCharacterTemplate().size());
|
||||
assertEquals("A", gs.getCharacterTemplate().get(0).getName());
|
||||
assertEquals("B", gs.getCharacterTemplate().get(1).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaceCharacterTemplate_rejectsDuplicates() {
|
||||
GameSystem gs = GameSystem.builder().build();
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> gs.replaceCharacterTemplate(List.of(
|
||||
TemplateField.text("a"),
|
||||
TemplateField.text("A"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaceCharacterTemplate_nullBecomesEmptyList() {
|
||||
GameSystem gs = GameSystem.builder().build();
|
||||
gs.replaceCharacterTemplate(null);
|
||||
assertTrue(gs.getCharacterTemplate().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaceCharacterTemplate_isolatesInternalListFromCallerMutations() {
|
||||
// Garantie d'encapsulation : muter la liste passee ne doit pas affecter le GameSystem.
|
||||
List<TemplateField> external = new ArrayList<>(List.of(TemplateField.text("A")));
|
||||
GameSystem gs = GameSystem.builder().build();
|
||||
|
||||
gs.replaceCharacterTemplate(external);
|
||||
external.add(TemplateField.text("B"));
|
||||
|
||||
assertEquals(1, gs.getCharacterTemplate().size());
|
||||
}
|
||||
|
||||
// --- Templates NPC : meme logique, sanity check minimal ----------------
|
||||
|
||||
@Test
|
||||
void npcTemplate_followsSameRulesAsCharacterTemplate() {
|
||||
GameSystem gs = GameSystem.builder().build();
|
||||
|
||||
gs.addNpcField(TemplateField.text("Motivation"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> gs.addNpcField(TemplateField.text("motivation")));
|
||||
|
||||
gs.removeNpcField("Motivation");
|
||||
assertTrue(gs.getNpcTemplate().isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
package com.loremind.domain.shared.template;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.loremind.domain.lorecontext.FieldType;
|
||||
import com.loremind.domain.lorecontext.ImageLayout;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.lorecontext.FieldType;
|
||||
import com.loremind.domain.lorecontext.ImageLayout;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.lorecontext.Lore;
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Tests d'integration du GameSystemController centres sur la persistance
|
||||
* des templates PJ/PNJ via l'API REST. Le CRUD de base est suppose stable.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class GameSystemControllerTest {
|
||||
|
||||
@Autowired private MockMvc mockMvc;
|
||||
@Autowired private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void create_persistsCharacterAndNpcTemplates() throws Exception {
|
||||
GameSystemDTO dto = new GameSystemDTO();
|
||||
dto.setName("Nimble Test");
|
||||
dto.setRulesMarkdown("## Combat\n- d20");
|
||||
dto.setCharacterTemplate(List.of(
|
||||
new TemplateFieldDTO("Histoire", "TEXT", null),
|
||||
new TemplateFieldDTO("PV", "NUMBER", null),
|
||||
new TemplateFieldDTO("Portrait", "IMAGE", "HERO")));
|
||||
dto.setNpcTemplate(List.of(
|
||||
new TemplateFieldDTO("Motivation", "TEXT", null)));
|
||||
|
||||
mockMvc.perform(post("/api/game-systems")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").exists())
|
||||
.andExpect(jsonPath("$.characterTemplate.length()").value(3))
|
||||
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"))
|
||||
.andExpect(jsonPath("$.characterTemplate[2].layout").value("HERO"))
|
||||
.andExpect(jsonPath("$.npcTemplate.length()").value(1))
|
||||
.andExpect(jsonPath("$.npcTemplate[0].name").value("Motivation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_replacesTemplates() throws Exception {
|
||||
// Creation initiale avec un seul champ.
|
||||
GameSystemDTO dto = new GameSystemDTO();
|
||||
dto.setName("RuleSet");
|
||||
dto.setCharacterTemplate(List.of(new TemplateFieldDTO("Old", "TEXT", null)));
|
||||
|
||||
MvcResult posted = mockMvc.perform(post("/api/game-systems")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
GameSystemDTO created = objectMapper.readValue(
|
||||
posted.getResponse().getContentAsString(), GameSystemDTO.class);
|
||||
|
||||
// Replace template integralement.
|
||||
created.setCharacterTemplate(List.of(
|
||||
new TemplateFieldDTO("Histoire", "TEXT", null),
|
||||
new TemplateFieldDTO("Niveau", "NUMBER", null)));
|
||||
|
||||
mockMvc.perform(put("/api/game-systems/{id}", created.getId())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(created)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.characterTemplate.length()").value(2))
|
||||
.andExpect(jsonPath("$.characterTemplate[0].name").value("Histoire"))
|
||||
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"));
|
||||
|
||||
// Verification que le GET independant retourne bien les nouveaux champs (pas de cache stale).
|
||||
mockMvc.perform(get("/api/game-systems/{id}", created.getId()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Old')]").doesNotExist())
|
||||
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Histoire')]").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_rejectsDuplicateFieldNames() throws Exception {
|
||||
GameSystemDTO dto = new GameSystemDTO();
|
||||
dto.setName("BadRules");
|
||||
dto.setCharacterTemplate(List.of(
|
||||
new TemplateFieldDTO("Nom", "TEXT", null),
|
||||
new TemplateFieldDTO("nom", "NUMBER", null)));
|
||||
|
||||
mockMvc.perform(post("/api/game-systems")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().is4xxClientError());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
Reference in New Issue
Block a user