diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java index 27344be..0b43a61 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java @@ -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 values, + Map> 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()); } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java index ca1f6b2..9e001e5 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java @@ -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 values, + Map> 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) diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java index 6f4af23..0a5e769 100644 --- a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java +++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java @@ -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 characterTemplate, + List 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); diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java index 3511820..3569767 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java @@ -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 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) { diff --git a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java index 7767225..6d3e1bb 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java @@ -130,13 +130,19 @@ public class NarrativeEntityContextBuilder { private NarrativeEntityContext fromCharacter(Character c) { Map 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 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); } diff --git a/core/src/main/java/com/loremind/application/lorecontext/TemplateService.java b/core/src/main/java/com/loremind/application/lorecontext/TemplateService.java index b1f1517..9aa5a30 100644 --- a/core/src/main/java/com/loremind/application/lorecontext/TemplateService.java +++ b/core/src/main/java/com/loremind/application/lorecontext/TemplateService.java @@ -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; diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java index 459a6f0..b5d31cd 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java @@ -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. *

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

- * 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) + *

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

+ * 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 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> 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 getValues() { + if (values == null) values = new HashMap<>(); + return values; + } + + public Map> getImageValues() { + if (imageValues == null) imageValues = new HashMap<>(); + return imageValues; + } } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java b/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java index 9969ddc..609d526 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java @@ -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. *

- * 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.). *

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

- * 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 values; + + /** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */ + private Map> 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; -} \ No newline at end of file + + public Map getValues() { + if (values == null) values = new HashMap<>(); + return values; + } + + public Map> getImageValues() { + if (imageValues == null) imageValues = new HashMap<>(); + return imageValues; + } +} diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java index edee045..241e1ba 100644 --- a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java @@ -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). *

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

* {@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 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 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 fields) { + characterTemplate = validateAndCopy(fields); + } + + public void replaceNpcTemplate(List fields) { + npcTemplate = validateAndCopy(fields); + } + + // --- Helpers privés ---------------------------------------------------- + + private static List appendField(List current, TemplateField field) { + if (field == null || field.getName() == null || field.getName().isBlank()) { + throw new IllegalArgumentException("Field name is required"); + } + List 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 removeFieldByName(List current, String fieldName) { + if (current == null || fieldName == null) return current; + List next = new ArrayList<>(current); + next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName)); + return next; + } + + private static List validateAndCopy(List fields) { + if (fields == null) return new ArrayList<>(); + List 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 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)); + } } diff --git a/core/src/main/java/com/loremind/domain/lorecontext/FieldType.java b/core/src/main/java/com/loremind/domain/lorecontext/FieldType.java deleted file mode 100644 index 832c098..0000000 --- a/core/src/main/java/com/loremind/domain/lorecontext/FieldType.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loremind.domain.lorecontext; - -/** - * Type d'un champ dynamique d'un Template. - *

- * - TEXT : valeur textuelle libre (stockee dans Page.values : Map) - * - IMAGE : galerie d'images, represente comme une liste d'IDs d'images - * (stockee dans Page.imageValues : Map>) - *

- * Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK... - */ -public enum FieldType { - TEXT, - IMAGE -} diff --git a/core/src/main/java/com/loremind/domain/lorecontext/Template.java b/core/src/main/java/com/loremind/domain/lorecontext/Template.java index 9e6d100..dc5c09c 100644 --- a/core/src/main/java/com/loremind/domain/lorecontext/Template.java +++ b/core/src/main/java/com/loremind/domain/lorecontext/Template.java @@ -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; diff --git a/core/src/main/java/com/loremind/domain/shared/template/FieldType.java b/core/src/main/java/com/loremind/domain/shared/template/FieldType.java new file mode 100644 index 0000000..bed317e --- /dev/null +++ b/core/src/main/java/com/loremind/domain/shared/template/FieldType.java @@ -0,0 +1,16 @@ +package com.loremind.domain.shared.template; + +/** + * Type d'un champ dynamique de template (kernel partage). + *

+ * - TEXT : valeur textuelle libre (Map) + * - IMAGE : galerie d'images, liste d'IDs (Map>) + * - NUMBER : valeur numerique stockee en texte (parsee a l'usage) + *

+ * Extension future possible : RICH_TEXT, DATE, BOOLEAN, KEY_VALUE_LIST, REFERENCE... + */ +public enum FieldType { + TEXT, + IMAGE, + NUMBER +} diff --git a/core/src/main/java/com/loremind/domain/lorecontext/ImageLayout.java b/core/src/main/java/com/loremind/domain/shared/template/ImageLayout.java similarity index 89% rename from core/src/main/java/com/loremind/domain/lorecontext/ImageLayout.java rename to core/src/main/java/com/loremind/domain/shared/template/ImageLayout.java index 768954b..e6950b3 100644 --- a/core/src/main/java/com/loremind/domain/lorecontext/ImageLayout.java +++ b/core/src/main/java/com/loremind/domain/shared/template/ImageLayout.java @@ -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 *

- * Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT. + * Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon. */ public enum ImageLayout { GALLERY, diff --git a/core/src/main/java/com/loremind/domain/lorecontext/TemplateField.java b/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java similarity index 74% rename from core/src/main/java/com/loremind/domain/lorecontext/TemplateField.java rename to core/src/main/java/com/loremind/domain/shared/template/TemplateField.java index b03a494..7cdef3b 100644 --- a/core/src/main/java/com/loremind/domain/lorecontext/TemplateField.java +++ b/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java @@ -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). *

- * 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). *

* 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); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java index 99c7833..654ec9d 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java @@ -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; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java index 39aeab0..0533965 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java @@ -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). + *

+ * 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 values; + + /** Valeurs IMAGE serialisees JSON. */ + @Convert(converter = StringListMapJsonConverter.class) + @Column(name = "image_values", columnDefinition = "TEXT") + private Map> 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 diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java index 3185e9c..44dd1f8 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java @@ -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 characterTemplate; + + /** Template PNJ serialise en JSON. */ + @Convert(converter = TemplateFieldListJsonConverter.class) + @Column(name = "npc_template", columnDefinition = "TEXT") + private List 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 diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java index c6a8d10..109c3dd 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java @@ -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 values; + + @Convert(converter = StringListMapJsonConverter.class) + @Column(name = "image_values", columnDefinition = "TEXT") + private Map> 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 diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/TemplateJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/TemplateJpaEntity.java index 48bb7c4..64cb09b 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/TemplateJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/TemplateJpaEntity.java @@ -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; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java index 411b107..d707b5d 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java @@ -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()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java index c32b816..8ca4433 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java @@ -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()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java index f72eb28..419ec64 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java @@ -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()) diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java index 92e8dfb..d21c46e 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java @@ -24,9 +24,7 @@ public class CharacterController { @PostMapping public ResponseEntity 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 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 + ); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java index a243423..adb632b 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java @@ -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 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 toDomainFields(List dtos) { + if (dtos == null) return new ArrayList<>(); + List out = new ArrayList<>(dtos.size()); + for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d)); + return out; + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java index 4e7d6f3..72b7f7a 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java @@ -24,9 +24,7 @@ public class NpcController { @PostMapping public ResponseEntity 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 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 + ); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/TemplateController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/TemplateController.java index 835c472..1f90596 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/TemplateController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/TemplateController.java @@ -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; diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java index 201a936..206fd6a 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java @@ -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 values = new HashMap<>(); + private Map> imageValues = new HashMap<>(); private String campaignId; private int order; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java index fa5e425..dae8f23 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java @@ -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 values = new HashMap<>(); + private Map> imageValues = new HashMap<>(); private String campaignId; private int order; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java index e745b4e..dab9cde 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/gamesystemcontext/GameSystemDTO.java @@ -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 characterTemplate = new ArrayList<>(); + private List npcTemplate = new ArrayList<>(); private String author; private boolean isPublic; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/TemplateDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/TemplateDTO.java index bc64b5b..2ce1a00 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/TemplateDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/TemplateDTO.java @@ -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; diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/TemplateFieldDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java similarity index 85% rename from core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/TemplateFieldDTO.java rename to core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java index d8f7083..f35ba23 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/TemplateFieldDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java @@ -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. *

- * 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. diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java index 86bc49d..fcb86f9 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java @@ -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(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java index 901baa6..c7f958c 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java @@ -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 toDTOList(List fields) { + if (fields == null) return new ArrayList<>(); + List out = new ArrayList<>(fields.size()); + for (TemplateField f : fields) out.add(fieldMapper.toDTO(f)); + return out; + } + + private List toDomainList(List dtos) { + if (dtos == null) return new ArrayList<>(); + List out = new ArrayList<>(dtos.size()); + for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d)); + return out; + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java index 685c221..2c9e65c 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java @@ -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(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java index 3f7017c..d6da088 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java @@ -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; /** diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateMapper.java index f4ddd53..5b30bc9 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateMapper.java @@ -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; diff --git a/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java index f95311f..447efbf 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java @@ -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 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 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 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()); } diff --git a/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java index 8e38cd0..cbcef68 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java @@ -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()); diff --git a/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java index 5b2924a..82f140e 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/GeneratePageValuesUseCaseTest.java @@ -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; diff --git a/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java index 4f71989..0bd2dbd 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilderTest.java @@ -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"); diff --git a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java index 9968281..0db356b 100644 --- a/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java +++ b/core/src/test/java/com/loremind/application/generationcontext/StreamChatForLoreUseCaseTest.java @@ -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; diff --git a/core/src/test/java/com/loremind/application/lorecontext/TemplateServiceTest.java b/core/src/test/java/com/loremind/application/lorecontext/TemplateServiceTest.java index a363004..b93bc90 100644 --- a/core/src/test/java/com/loremind/application/lorecontext/TemplateServiceTest.java +++ b/core/src/test/java/com/loremind/application/lorecontext/TemplateServiceTest.java @@ -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; diff --git a/core/src/test/java/com/loremind/domain/gamesystemcontext/GameSystemTest.java b/core/src/test/java/com/loremind/domain/gamesystemcontext/GameSystemTest.java new file mode 100644 index 0000000..d674267 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/gamesystemcontext/GameSystemTest.java @@ -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 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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/lorecontext/TemplateTest.java b/core/src/test/java/com/loremind/domain/lorecontext/TemplateTest.java index 8f98052..11aa225 100644 --- a/core/src/test/java/com/loremind/domain/lorecontext/TemplateTest.java +++ b/core/src/test/java/com/loremind/domain/lorecontext/TemplateTest.java @@ -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; diff --git a/core/src/test/java/com/loremind/domain/lorecontext/TemplateFieldTest.java b/core/src/test/java/com/loremind/domain/shared/template/TemplateFieldTest.java similarity index 98% rename from core/src/test/java/com/loremind/domain/lorecontext/TemplateFieldTest.java rename to core/src/test/java/com/loremind/domain/shared/template/TemplateFieldTest.java index 7e77e8a..457282f 100644 --- a/core/src/test/java/com/loremind/domain/lorecontext/TemplateFieldTest.java +++ b/core/src/test/java/com/loremind/domain/shared/template/TemplateFieldTest.java @@ -1,4 +1,4 @@ -package com.loremind.domain.lorecontext; +package com.loremind.domain.shared.template; import org.junit.jupiter.api.Test; diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java index 6d901a9..249e0ae 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java @@ -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; diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepositoryTest.java index 9d9d254..d6339e1 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepositoryTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepositoryTest.java @@ -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; diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerTest.java new file mode 100644 index 0000000..1e1584c --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/GameSystemControllerTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/TemplateControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/TemplateControllerTest.java index 08293b9..246c070 100644 --- a/core/src/test/java/com/loremind/infrastructure/web/controller/TemplateControllerTest.java +++ b/core/src/test/java/com/loremind/infrastructure/web/controller/TemplateControllerTest.java @@ -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; diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts index 955e728..f0c3b98 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts @@ -205,19 +205,23 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { } /** - * Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). - * Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte). + * Extrait une ligne de resume pour la fiche PJ/PNJ — 1re valeur de template + * non-vide (apres refonte 2026-04-30, remplace l'ancien parsing markdown). */ - personaSnippet(p: { markdownContent?: string | null }): string { - if (!p.markdownContent) return '(Fiche vide)'; - const firstMeaningful = p.markdownContent - .split('\n') - .map(l => l.trim()) - .find(l => l && !l.startsWith('#')); - if (!firstMeaningful) return '(Fiche vide)'; - return firstMeaningful.length > 80 - ? firstMeaningful.substring(0, 77) + '…' - : firstMeaningful; + personaSnippet(p: { values?: Record }): string { + const values = p.values ?? {}; + for (const v of Object.values(values)) { + if (!v) continue; + const firstMeaningful = v + .split('\n') + .map(l => l.trim()) + .find(l => l && !l.startsWith('#')); + if (!firstMeaningful) continue; + return firstMeaningful.length > 80 + ? firstMeaningful.substring(0, 77) + '…' + : firstMeaningful; + } + return '(Fiche vide)'; } /** Alias gardé pour compatibilité avec les anciens templates. */ diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.html b/web/src/app/campaigns/character/character-edit/character-edit.component.html index eb3c41b..3cc8136 100644 --- a/web/src/app/campaigns/character/character-edit/character-edit.component.html +++ b/web/src/app/campaigns/character/character-edit/character-edit.component.html @@ -35,18 +35,38 @@ /> -

- -

- Tout en markdown libre : stats, classe, backstory, équipement, objectifs personnels… - L'IA lira ces infos pour rester cohérente quand vous générez des scènes impliquant ce PJ. -

- +
+
+ + +
+
+ + +
+
+

+ Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir. +

+ +
+ +
diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.ts b/web/src/app/campaigns/character/character-edit/character-edit.component.ts index 6ce6978..973a581 100644 --- a/web/src/app/campaigns/character/character-edit/character-edit.component.ts +++ b/web/src/app/campaigns/character/character-edit/character-edit.component.ts @@ -4,22 +4,27 @@ import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular'; import { CharacterService } from '../../../services/character.service'; -import { Character } from '../../../services/character.model'; +import { CampaignService } from '../../../services/campaign.service'; +import { GameSystemService } from '../../../services/game-system.service'; +import { TemplateField } from '../../../services/template-field.model'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; +import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component'; /** - * Éditeur plein écran d'une fiche de personnage (PJ). - * Double rôle création/édition : - * - `/campaigns/:campaignId/characters/create` → POST - * - `/campaigns/:campaignId/characters/:characterId/edit` → PUT + * Editeur plein ecran d'une fiche de personnage (PJ). + * Refonte 2026-04-30 : remplace le markdown libre par un formulaire dynamique + * pilote par le characterTemplate du GameSystem associe a la campagne. * - * MVP : name + markdown libre. Évolution prévue vers un template dérivé - * du GameSystem de la campagne (stats structurées). + * Comportements : + * - Si la campagne n'a pas de GameSystem ou si son template est vide, affiche + * uniquement les champs universels (nom, portrait, header). + * - Le picker d'images dedie portrait/header est hors scope MVP — pour l'instant + * saisie manuelle d'IDs d'images. */ @Component({ selector: 'app-character-edit', standalone: true, - imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent], + imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent], templateUrl: './character-edit.component.html', styleUrls: ['./character-edit.component.scss'] }) @@ -30,12 +35,11 @@ export class CharacterEditComponent implements OnInit { readonly Trash2 = Trash2; readonly Sparkles = Sparkles; - /** État drawer chat IA focalisé sur ce PJ. */ chatOpen = false; readonly chatQuickSuggestions = [ - 'Propose une backstory cohérente avec l\'univers', - 'Suggère 3 objectifs personnels pour ce personnage', - 'Aide-moi à équilibrer les stats de combat' + 'Propose une backstory coherente avec l\'univers', + 'Suggere 3 objectifs personnels pour ce personnage', + 'Aide-moi a equilibrer les stats de combat' ]; toggleChat(): void { this.chatOpen = !this.chatOpen; } @@ -44,13 +48,19 @@ export class CharacterEditComponent implements OnInit { characterId: string | null = null; name = ''; - markdownContent = ''; + portraitImageId = ''; + headerImageId = ''; + values: Record = {}; + imageValues: Record = {}; + templateFields: TemplateField[] = []; private order = 0; constructor( private route: ActivatedRoute, private router: Router, - private service: CharacterService + private service: CharacterService, + private campaignService: CampaignService, + private gameSystemService: GameSystemService ) {} ngOnInit(): void { @@ -58,11 +68,18 @@ export class CharacterEditComponent implements OnInit { this.campaignId = params.get('campaignId'); this.characterId = params.get('characterId'); + if (this.campaignId) { + this.loadTemplateForCampaign(this.campaignId); + } + if (this.characterId) { this.service.getById(this.characterId).subscribe({ next: (c) => { this.name = c.name; - this.markdownContent = c.markdownContent ?? ''; + this.portraitImageId = c.portraitImageId ?? ''; + this.headerImageId = c.headerImageId ?? ''; + this.values = c.values ?? {}; + this.imageValues = c.imageValues ?? {}; this.order = c.order ?? 0; }, error: () => this.back() @@ -70,21 +87,35 @@ export class CharacterEditComponent implements OnInit { } } + private loadTemplateForCampaign(campaignId: string): void { + this.campaignService.getCampaignById(campaignId).subscribe({ + next: (campaign) => { + if (!campaign.gameSystemId) { + this.templateFields = []; + return; + } + this.gameSystemService.getById(campaign.gameSystemId).subscribe({ + next: (gs) => { this.templateFields = gs.characterTemplate ?? []; }, + error: () => { this.templateFields = []; } + }); + }, + error: () => { this.templateFields = []; } + }); + } + submit(): void { if (!this.name.trim() || !this.campaignId) return; + const payload = { + name: this.name.trim(), + portraitImageId: this.portraitImageId.trim() || null, + headerImageId: this.headerImageId.trim() || null, + values: this.values, + imageValues: this.imageValues, + campaignId: this.campaignId + }; const req = this.characterId - ? this.service.update(this.characterId, { - id: this.characterId, - name: this.name.trim(), - markdownContent: this.markdownContent || null, - campaignId: this.campaignId, - order: this.order - }) - : this.service.create({ - name: this.name.trim(), - markdownContent: this.markdownContent || null, - campaignId: this.campaignId - }); + ? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order }) + : this.service.create(payload); req.subscribe({ next: () => this.back(), error: () => console.error('Erreur sauvegarde Character') @@ -93,7 +124,7 @@ export class CharacterEditComponent implements OnInit { deleteCharacter(): void { if (!this.characterId) return; - if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return; + if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return; this.service.delete(this.characterId).subscribe({ next: () => this.back(), error: () => console.error('Erreur suppression Character') diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html index 53b2dc5..728ea4a 100644 --- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html @@ -35,18 +35,28 @@ />
-
- -

- Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ… - À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes. -

- +
+
+ + +
+
+ + +
+
+

+ Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir. +

+ +
+ +
diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts index 8bd8028..4a7d5e2 100644 --- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts @@ -4,21 +4,22 @@ import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular'; import { NpcService } from '../../../services/npc.service'; +import { CampaignService } from '../../../services/campaign.service'; +import { GameSystemService } from '../../../services/game-system.service'; +import { TemplateField } from '../../../services/template-field.model'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; +import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component'; /** - * Éditeur plein écran d'une fiche de PNJ. - * Double rôle création/édition : - * - `/campaigns/:campaignId/npcs/create` → POST - * - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT - * - * MVP : name + markdown libre. L'Assistant IA est branché en mode édition - * (focus entityType="npc") pour proposer apparence, motivations, secrets... + * Editeur plein ecran d'une fiche de PNJ. + * Refonte 2026-04-30 : meme principe que CharacterEditComponent — markdown + * libre remplace par un formulaire dynamique pilote par le npcTemplate du + * GameSystem associe a la campagne. */ @Component({ selector: 'app-npc-edit', standalone: true, - imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent], + imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent], templateUrl: './npc-edit.component.html', styleUrls: ['./npc-edit.component.scss'] }) @@ -29,12 +30,11 @@ export class NpcEditComponent implements OnInit { readonly Trash2 = Trash2; readonly Sparkles = Sparkles; - /** État drawer chat IA focalisé sur ce PNJ. */ chatOpen = false; readonly chatQuickSuggestions = [ 'Propose une apparence et une posture marquantes', - 'Suggère 2 motivations et un secret pour ce PNJ', - 'Imagine 3 répliques signatures qui le caractérisent' + 'Suggere 2 motivations et un secret pour ce PNJ', + 'Imagine 3 repliques signatures qui le caracterisent' ]; toggleChat(): void { this.chatOpen = !this.chatOpen; } @@ -43,13 +43,19 @@ export class NpcEditComponent implements OnInit { npcId: string | null = null; name = ''; - markdownContent = ''; + portraitImageId = ''; + headerImageId = ''; + values: Record = {}; + imageValues: Record = {}; + templateFields: TemplateField[] = []; private order = 0; constructor( private route: ActivatedRoute, private router: Router, - private service: NpcService + private service: NpcService, + private campaignService: CampaignService, + private gameSystemService: GameSystemService ) {} ngOnInit(): void { @@ -57,11 +63,18 @@ export class NpcEditComponent implements OnInit { this.campaignId = params.get('campaignId'); this.npcId = params.get('npcId'); + if (this.campaignId) { + this.loadTemplateForCampaign(this.campaignId); + } + if (this.npcId) { this.service.getById(this.npcId).subscribe({ next: (n) => { this.name = n.name; - this.markdownContent = n.markdownContent ?? ''; + this.portraitImageId = n.portraitImageId ?? ''; + this.headerImageId = n.headerImageId ?? ''; + this.values = n.values ?? {}; + this.imageValues = n.imageValues ?? {}; this.order = n.order ?? 0; }, error: () => this.back() @@ -69,21 +82,35 @@ export class NpcEditComponent implements OnInit { } } + private loadTemplateForCampaign(campaignId: string): void { + this.campaignService.getCampaignById(campaignId).subscribe({ + next: (campaign) => { + if (!campaign.gameSystemId) { + this.templateFields = []; + return; + } + this.gameSystemService.getById(campaign.gameSystemId).subscribe({ + next: (gs) => { this.templateFields = gs.npcTemplate ?? []; }, + error: () => { this.templateFields = []; } + }); + }, + error: () => { this.templateFields = []; } + }); + } + submit(): void { if (!this.name.trim() || !this.campaignId) return; + const payload = { + name: this.name.trim(), + portraitImageId: this.portraitImageId.trim() || null, + headerImageId: this.headerImageId.trim() || null, + values: this.values, + imageValues: this.imageValues, + campaignId: this.campaignId + }; const req = this.npcId - ? this.service.update(this.npcId, { - id: this.npcId, - name: this.name.trim(), - markdownContent: this.markdownContent || null, - campaignId: this.campaignId, - order: this.order - }) - : this.service.create({ - name: this.name.trim(), - markdownContent: this.markdownContent || null, - campaignId: this.campaignId - }); + ? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order }) + : this.service.create(payload); req.subscribe({ next: () => this.back(), error: () => console.error('Erreur sauvegarde Npc') @@ -92,7 +119,7 @@ export class NpcEditComponent implements OnInit { deleteNpc(): void { if (!this.npcId) return; - if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return; + if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return; this.service.delete(this.npcId).subscribe({ next: () => this.back(), error: () => console.error('Erreur suppression Npc') diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html index 8ee5e88..cd719d7 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html @@ -90,6 +90,32 @@
+ +
+

Fiches de personnages

+

+ Definissez la structure des fiches PJ et PNJ pour ce systeme. Les champs + universels (nom, portrait, header) sont automatiques — ne rajoutez ici + que les champs specifiques au systeme (Histoire, PV, Stats…). +

+ + + + + + +
+
+ +
+ + + + + + + + +
+ +
+ Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous. +
+ + +
+ Ajouter : + + + + +
+ diff --git a/web/src/app/shared/template-fields-editor/template-fields-editor.component.scss b/web/src/app/shared/template-fields-editor/template-fields-editor.component.scss new file mode 100644 index 0000000..e352ab1 --- /dev/null +++ b/web/src/app/shared/template-fields-editor/template-fields-editor.component.scss @@ -0,0 +1,139 @@ +.tfe { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; +} + +.tfe-header { + .tfe-label { + margin: 0 0 4px; + font-size: 1rem; + font-weight: 600; + color: var(--color-text, #fff); + } + .tfe-hint { + margin: 0; + font-size: 0.85rem; + color: var(--color-text-muted, #aaa); + } +} + +.tfe-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.tfe-row { + display: grid; + grid-template-columns: auto 1fr 130px auto auto; + gap: 8px; + align-items: center; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; + transition: border-color 120ms; + + &.invalid { + border-color: rgba(255, 100, 100, 0.5); + } +} + +.tfe-row-controls { + display: flex; + flex-direction: column; + gap: 2px; +} + +.btn-arrow, +.btn-remove { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--color-text-muted, #aaa); + cursor: pointer; + transition: all 120ms; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + color: var(--color-text, #fff); + } + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +} + +.btn-remove:hover { + border-color: rgba(255, 100, 100, 0.4); + color: rgba(255, 100, 100, 0.9); +} + +.tfe-name, +.tfe-type, +.tfe-layout { + padding: 6px 8px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--color-text, #fff); + font-size: 0.9rem; +} + +.tfe-empty { + padding: 12px; + text-align: center; + font-size: 0.85rem; + font-style: italic; + color: var(--color-text-muted, #888); +} + +.tfe-add { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + + .tfe-add-label { + font-size: 0.85rem; + color: var(--color-text-muted, #aaa); + margin-right: 4px; + } +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + color: var(--color-text-muted, #ccc); + font-size: 0.8rem; + cursor: pointer; + transition: all 120ms; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: var(--color-text, #fff); + } + &.disabled, + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.chip-custom { + border-style: dashed; +} diff --git a/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts b/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts new file mode 100644 index 0000000..b701deb --- /dev/null +++ b/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts @@ -0,0 +1,115 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash } from 'lucide-angular'; +import { TemplateField, FieldType, ImageLayout } from '../../services/template-field.model'; + +/** + * Editeur reutilisable d'une liste de TemplateField. + * Pilote l'ajout / suppression / reordonnancement / changement de type / renommage. + * + * Emet `fieldsChange` a chaque modification pour permettre un binding 2-way : + * + * + * Validation locale : duplicats de noms (case-insensitive) marques visuellement, + * mais c'est le parent qui decide du blocage du submit. + */ +@Component({ + selector: 'app-template-fields-editor', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './template-fields-editor.component.html', + styleUrls: ['./template-fields-editor.component.scss'] +}) +export class TemplateFieldsEditorComponent { + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly ArrowUp = ArrowUp; + readonly ArrowDown = ArrowDown; + readonly Type = Type; + readonly ImageIcon = ImageIcon; + readonly Hash = Hash; + + /** Liste des champs (binding parent). */ + @Input() fields: TemplateField[] = []; + + /** Suggestions de noms de champs (chips ajout rapide). */ + @Input() suggestions: string[] = []; + + /** Label de la section (ex: "Champs de la fiche PJ"). */ + @Input() label = 'Champs du template'; + + /** Hint affichee sous le label. */ + @Input() hint?: string; + + @Output() fieldsChange = new EventEmitter(); + + readonly typeOptions: { value: FieldType; label: string }[] = [ + { value: 'TEXT', label: 'Texte' }, + { value: 'NUMBER', label: 'Nombre' }, + { value: 'IMAGE', label: 'Image(s)' } + ]; + + readonly layoutOptions: { value: ImageLayout; label: string }[] = [ + { value: 'GALLERY', label: 'Galerie' }, + { value: 'HERO', label: 'Bandeau' }, + { value: 'MASONRY', label: 'Mosaique' }, + { value: 'CAROUSEL', label: 'Carrousel' } + ]; + + isDuplicate(field: TemplateField, index: number): boolean { + if (!field.name?.trim()) return false; + const lower = field.name.trim().toLowerCase(); + return this.fields.some((f, i) => i !== index && f.name?.trim().toLowerCase() === lower); + } + + isSuggestionUsed(name: string): boolean { + const lower = name.toLowerCase(); + return this.fields.some(f => f.name?.trim().toLowerCase() === lower); + } + + addSuggestion(name: string): void { + if (this.isSuggestionUsed(name)) return; + this.emit([...this.fields, { name, type: 'TEXT', layout: null }]); + } + + addBlank(type: FieldType): void { + const layout: ImageLayout | null = type === 'IMAGE' ? 'GALLERY' : null; + this.emit([...this.fields, { name: '', type, layout }]); + } + + remove(index: number): void { + const next = [...this.fields]; + next.splice(index, 1); + this.emit(next); + } + + moveUp(index: number): void { + if (index <= 0) return; + const next = [...this.fields]; + [next[index - 1], next[index]] = [next[index], next[index - 1]]; + this.emit(next); + } + + moveDown(index: number): void { + if (index >= this.fields.length - 1) return; + const next = [...this.fields]; + [next[index], next[index + 1]] = [next[index + 1], next[index]]; + this.emit(next); + } + + /** Notifie les changements internes (input/select sur un champ existant). */ + onFieldChanged(): void { + // Quand le type passe a IMAGE, layout = GALLERY ; sinon null. + for (const f of this.fields) { + if (f.type === 'IMAGE' && !f.layout) f.layout = 'GALLERY'; + if (f.type !== 'IMAGE') f.layout = null; + } + this.emit([...this.fields]); + } + + private emit(fields: TemplateField[]): void { + this.fields = fields; + this.fieldsChange.emit(fields); + } +}