6 Commits

Author SHA1 Message Date
7c74c12f3e Changement du readme
Some checks failed
E2E Tests / e2e (push) Failing after 58s
2026-05-17 18:04:24 +02:00
86836ad81c Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m36s
Passage V0.8.3
2026-04-30 15:53:38 +02:00
7c4a42327d Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
52e389db24 Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
efaf5a3794 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (web) (push) Successful in 1m38s
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
4fe93b5ff3 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
106 changed files with 4031 additions and 762 deletions

5
.gitignore vendored
View File

@@ -7,6 +7,11 @@
brain/data/settings.json brain/data/settings.json
*.key *.key
*.pem *.pem
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
# publique par construction). Sans cette exception, le module licensing
# est silencieusement desactive dans les builds CI.
!core/src/main/resources/licensing/jwt-public-key.pem
# ============================================================================ # ============================================================================
# Java / Spring Boot / Maven # Java / Spring Boot / Maven

View File

@@ -1,37 +1,66 @@
# LoreMind # LoreMind
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel.
[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/)
[![Démo](https://img.shields.io/badge/d%C3%A9mo-en%20ligne-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ)
## Découvrir LoreMind en vidéo
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png) ![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes. ## Ce que ça fait
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés. LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
### Lore
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
### Game System
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
### Campaign
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu.
### Assistant IA
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir.
## Documentation ## Documentation
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/) Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
Pour l'installation, consultez le guide dans cette dernière . ## Démo en ligne
## Fonctionnalités Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers Quelques limites à connaître :
- Suivi de campagnes : Sessions, actions des joueurs, chronologie - 10 utilisateurs maximum simultanés (instances isolées)
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates - Session limitée à 20 minutes avant réinitialisation
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
## Démo ## Soutenir le projet
Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/) LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur. - **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser. ## Licence
Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai.
## License
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**. LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique : En pratique :
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer. - Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence. - Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application. - Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -41,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.6.6", version="0.8.3",
) )

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.8.0</version> <version>0.8.3</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -111,6 +111,16 @@
</exclude> </exclude>
</excludes> </excludes>
</configuration> </configuration>
<executions>
<!-- Genere META-INF/build-info.properties (project.version)
consomme par Spring BuildProperties pour exposer la
version courante a l'application (UpdateCheckService). -->
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin> </plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires. <!-- JaCoCo : rapport de couverture des tests unitaires.

View File

@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -22,8 +24,18 @@ public class CharacterService {
/** /**
* Parameter Object pour la création / mise à jour d'un Character. * Parameter Object pour la création / mise à jour d'un Character.
* `order` est fourni par le controller ; si absent, le service le calcule. * `order` est fourni par le controller ; si absent, le service le calcule.
* Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
*/ */
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {} public record CharacterData(
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order
) {}
public Character createCharacter(CharacterData data) { public Character createCharacter(CharacterData data) {
int order = data.order() != null int order = data.order() != null
@@ -31,7 +43,11 @@ public class CharacterService {
: nextOrderFor(data.campaignId()); : nextOrderFor(data.campaignId());
Character character = Character.builder() Character character = Character.builder()
.name(data.name()) .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<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId()) .campaignId(data.campaignId())
.order(order) .order(order)
.build(); .build();
@@ -50,7 +66,11 @@ public class CharacterService {
Character existing = characterRepository.findById(id) Character existing = characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
existing.setName(data.name()); 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<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }

View File

@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -19,11 +21,16 @@ public class NpcService {
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
} }
/** public record NpcData(
* Parameter Object pour la création / mise à jour d'un Npc. String name,
* `order` est fourni par le controller ; si absent, le service le calcule. String portraitImageId,
*/ String headerImageId,
public record NpcData(String name, String markdownContent, String campaignId, Integer order) {} Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order
) {}
public Npc createNpc(NpcData data) { public Npc createNpc(NpcData data) {
int order = data.order() != null int order = data.order() != null
@@ -31,7 +38,11 @@ public class NpcService {
: nextOrderFor(data.campaignId()); : nextOrderFor(data.campaignId());
Npc npc = Npc.builder() Npc npc = Npc.builder()
.name(data.name()) .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<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId()) .campaignId(data.campaignId())
.order(order) .order(order)
.build(); .build();
@@ -50,7 +61,11 @@ public class NpcService {
Npc existing = npcRepository.findById(id) Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
existing.setName(data.name()); 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<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }
@@ -61,7 +76,6 @@ public class NpcService {
npcRepository.deleteById(id); npcRepository.deleteById(id);
} }
/** Renvoie la prochaine position libre — append en fin de liste. */
private int nextOrderFor(String campaignId) { private int nextOrderFor(String campaignId) {
return npcRepository.findByCampaignId(campaignId).stream() return npcRepository.findByCampaignId(campaignId).stream()
.mapToInt(Npc::getOrder) .mapToInt(Npc::getOrder)

View File

@@ -2,6 +2,7 @@ package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -18,11 +19,14 @@ public class GameSystemService {
/** /**
* Parameter Object pour la création / mise à jour d'un GameSystem. * Parameter Object pour la création / mise à jour d'un GameSystem.
* Les templates peuvent etre null (interpretes comme listes vides).
*/ */
public record GameSystemData( public record GameSystemData(
String name, String name,
String description, String description,
String rulesMarkdown, String rulesMarkdown,
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
String author, String author,
boolean isPublic boolean isPublic
) {} ) {}
@@ -35,6 +39,8 @@ public class GameSystemService {
.author(normalize(data.author())) .author(normalize(data.author()))
.isPublic(data.isPublic()) .isPublic(data.isPublic())
.build(); .build();
gameSystem.replaceCharacterTemplate(data.characterTemplate());
gameSystem.replaceNpcTemplate(data.npcTemplate());
return gameSystemRepository.save(gameSystem); return gameSystemRepository.save(gameSystem);
} }
@@ -52,6 +58,8 @@ public class GameSystemService {
existing.setName(data.name()); existing.setName(data.name());
existing.setDescription(data.description()); existing.setDescription(data.description());
existing.setRulesMarkdown(data.rulesMarkdown()); existing.setRulesMarkdown(data.rulesMarkdown());
existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate());
existing.setAuthor(normalize(data.author())); existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic()); existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing); return gameSystemRepository.save(existing);

View File

@@ -104,23 +104,32 @@ public class CampaignStructuralContextBuilder {
* sans injecter toute sa fiche. * sans injecter toute sa fiche.
*/ */
private CharacterSummary toCharacterSummary(Character c) { 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. */ /** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
private NpcSummary toNpcSummary(Npc n) { 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 ""; * Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
String firstLine = markdown.lines() * du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
.map(String::strip) */
.filter(l -> !l.isEmpty() && !l.startsWith("#")) private static String extractSnippet(java.util.Map<String, String> values) {
.findFirst() if (values == null || values.isEmpty()) return "";
.orElse(""); for (String value : values.values()) {
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine; if (value == null || value.isBlank()) continue;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + ""; 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) { private ArcSummary toArcSummary(Arc arc) {

View File

@@ -130,13 +130,19 @@ public class NarrativeEntityContextBuilder {
private NarrativeEntityContext fromCharacter(Character c) { private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent()); if (c.getValues() != null) {
// Champs templates exposes individuellement — meilleur pour le LLM que
// l'ancien blob markdown monolithique.
c.getValues().forEach((k, v) -> putField(fields, k, v));
}
return new NarrativeEntityContext("character", c.getName(), fields); return new NarrativeEntityContext("character", c.getName(), fields);
} }
private NarrativeEntityContext fromNpc(Npc n) { private NarrativeEntityContext fromNpc(Npc n) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", n.getMarkdownContent()); if (n.getValues() != null) {
n.getValues().forEach((k, v) -> putField(fields, k, v));
}
return new NarrativeEntityContext("npc", n.getName(), fields); return new NarrativeEntityContext("npc", n.getName(), fields);
} }

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template; 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 com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View File

@@ -4,18 +4,26 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** /**
* Fiche de personnage joueur (PJ) d'une campagne. * Fiche de personnage joueur (PJ) d'une campagne.
* <p> * <p>
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats, * Champs universels hard-codes : {@code name}, {@code portraitImageId},
* backstory, équipement). Évolution prévue vers un système templaté par * {@code headerImageId}. Tout le reste est piloté par le template PJ du
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D). * GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
* <p> * <p>
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée * Les valeurs des champs templates sont stockées dans deux maps :
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents). * - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
* Évolution prévue : système de templating partagé PJ/PNJ piloté par * parsé à l'usage cote presentation)
* GameSystem pour adapter les blocs aux différents systèmes de JDR. * - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
* <p>
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
* <p>
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
*/ */
@Data @Data
@Builder @Builder
@@ -24,11 +32,32 @@ public class Character {
private String id; private String id;
private String name; 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, * Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
* renseigné progressivement par le MJ. * (sensible a la casse cote stockage mais comparaison case-insensitive
* dans le domaine GameSystem). Jamais null apres construction.
*/ */
private String markdownContent; private Map<String, String> values;
/**
* Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
* liste ordonnee d'IDs d'images. Jamais null apres construction.
*/
private Map<String, List<String>> imageValues;
/**
* Valeurs des champs KEY_VALUE_LIST du template PJ. Cle externe = nom du
* champ template (ex: "Caracteristiques"), cle interne = label predefini
* dans le template (ex: "FOR"), valeur = valeur saisie (ex: "16").
* Les labels suivent l'ordre defini dans TemplateField.labels.
*/
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente. */ /** Référence vers la Campaign parente. */
private String campaignId; private String campaignId;
@@ -38,4 +67,20 @@ public class Character {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** Garantit que les maps ne sont jamais null cote consommateur. */
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
} }

View File

@@ -4,21 +4,22 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; 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. * Fiche de personnage non-joueur (PNJ) d'une campagne.
* <p> * <p>
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé — * Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
* un PNJ a vocation à porter à terme des invariants métier propres (faction, * à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
* 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).
* <p> * <p>
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé * Mêmes champs universels hard-codés et meme structure de templating que Character,
* PJ/PNJ piloté par GameSystem. * pilotée par le template PNJ du GameSystem
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
* <p> * <p>
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent * Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
* gérés via le système Page/Template du LoreContext. * via le système Page/Template du LoreContext.
*/ */
@Data @Data
@Builder @Builder
@@ -27,10 +28,22 @@ public class Npc {
private String id; private String id;
private String name; private String name;
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */ /** ID de l'image portrait (champ universel hard-code). Nullable. */
private String markdownContent; private String portraitImageId;
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */ /** ID de l'image header/banniere (champ universel hard-code). Nullable. */
private String headerImageId;
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
private Map<String, String> values;
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId; private String campaignId;
/** Ordre d'affichage dans la liste des PNJ de la campagne. */ /** Ordre d'affichage dans la liste des PNJ de la campagne. */
@@ -38,4 +51,19 @@ public class Npc {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
} }

View File

@@ -1,9 +1,13 @@
package com.loremind.domain.gamesystemcontext; package com.loremind.domain.gamesystemcontext;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; 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). * 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 * 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). * à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
* <p> * <p>
* Porte aussi deux templates piloтant la structure des fiches PJ et PNJ d'une
* campagne adossée à ce système. Les fiches markdown libres ont laissé place à
* un système de champs typés (TEXT/IMAGE/NUMBER) défini ici.
* <p>
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace * {@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 * de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
* éviter une migration ultérieure. * éviter une migration ultérieure.
@@ -27,6 +35,21 @@ public class GameSystem {
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */ /** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
private String rulesMarkdown; private String rulesMarkdown;
/**
* Template de fiche PJ : champs typés affichés pour chaque personnage joueur.
* Hors champs universels hard-codés (nom, portrait, header). Jamais null après
* persistance — un template vide est représenté par une liste vide.
*/
private List<TemplateField> characterTemplate;
/**
* Template de fiche PNJ. Mêmes règles que {@link #characterTemplate}.
* Distinct du template PJ car les invariants métier divergent (un PNJ peut
* n'avoir qu'un nom + une motivation, un PJ porte généralement une feuille
* de stats complète).
*/
private List<TemplateField> npcTemplate;
/** Auteur déclaré — futur marketplace. Nullable. */ /** Auteur déclaré — futur marketplace. Nullable. */
private String author; private String author;
@@ -35,4 +58,88 @@ public class GameSystem {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
// --- Méthodes métier : templates PJ/PNJ --------------------------------
/**
* Ajoute un champ au template PJ. Refuse les doublons de nom (insensible à la casse)
* pour éviter les collisions de clés dans {@code Character.values}.
*/
public void addCharacterField(TemplateField field) {
characterTemplate = appendField(characterTemplate, field);
}
/** Pendant PNJ de {@link #addCharacterField}. */
public void addNpcField(TemplateField field) {
npcTemplate = appendField(npcTemplate, field);
}
/**
* Retire un champ du template PJ par nom (insensible à la casse).
* No-op silencieux si le champ n'existe pas — appelant n'a pas à pré-vérifier.
*/
public void removeCharacterField(String fieldName) {
characterTemplate = removeFieldByName(characterTemplate, fieldName);
}
public void removeNpcField(String fieldName) {
npcTemplate = removeFieldByName(npcTemplate, fieldName);
}
/**
* Remplace intégralement le template PJ. Utilisé pour le réordonnancement
* et l'édition en bloc côté UI. Valide l'unicité des noms.
*/
public void replaceCharacterTemplate(List<TemplateField> fields) {
characterTemplate = validateAndCopy(fields);
}
public void replaceNpcTemplate(List<TemplateField> fields) {
npcTemplate = validateAndCopy(fields);
}
// --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
if (field == null || field.getName() == null || field.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
List<TemplateField> next = current == null ? new ArrayList<>() : new ArrayList<>(current);
if (containsName(next, field.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + field.getName());
}
next.add(field);
return next;
}
private static List<TemplateField> removeFieldByName(List<TemplateField> current, String fieldName) {
if (current == null || fieldName == null) return current;
List<TemplateField> next = new ArrayList<>(current);
next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName));
return next;
}
private static List<TemplateField> validateAndCopy(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateField> copy = new ArrayList<>(fields.size());
for (TemplateField f : fields) {
if (f == null || f.getName() == null || f.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
if (containsName(copy, f.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + f.getName());
}
copy.add(f);
}
return copy;
}
private static boolean containsName(List<TemplateField> fields, String name) {
return fields.stream().anyMatch(f -> equalsIgnoreCase(f.getName(), name));
}
private static boolean equalsIgnoreCase(String a, String b) {
if (a == null || b == null) return a == b;
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
}
} }

View File

@@ -1,15 +0,0 @@
package com.loremind.domain.lorecontext;
/**
* Type d'un champ dynamique d'un Template.
* <p>
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
* (stockee dans Page.imageValues : Map<String, List<String>>)
* <p>
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
*/
public enum FieldType {
TEXT,
IMAGE
}

View File

@@ -1,5 +1,7 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.lorecontext;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,50 +0,0 @@
package com.loremind.domain.lorecontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Value Object d'un champ de Template.
* <p>
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible).
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
* <p>
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* Ignore pour les champs TEXT.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TemplateField {
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
private String name;
/** Type du champ, pilote le rendu et la generation IA. */
private FieldType type;
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
/** Constructeur de retrocompat : type seul, layout=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT, null);
}
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY);
}
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
public static TemplateField image(String name, ImageLayout layout) {
return new TemplateField(name, FieldType.IMAGE, layout);
}
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.shared.template;
/**
* Type d'un champ dynamique de template (kernel partage).
* <p>
* - TEXT : valeur textuelle libre (Map<String, String>)
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
* (Map<String, Map<String, String>> : fieldName -> label -> value).
* Usage : stat blocks, listes de competences, traits.
* <p>
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
*/
public enum FieldType {
TEXT,
IMAGE,
NUMBER,
KEY_VALUE_LIST
}

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.shared.template;
/** /**
* Variante de rendu pour un champ de type IMAGE. * Variante de rendu pour un champ de type IMAGE.
@@ -8,7 +8,7 @@ package com.loremind.domain.lorecontext;
* - MASONRY : mosaique hauteurs variables facon Pinterest * - MASONRY : mosaique hauteurs variables facon Pinterest
* - CAROUSEL : defilement horizontal * - CAROUSEL : defilement horizontal
* <p> * <p>
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT. * Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon.
*/ */
public enum ImageLayout { public enum ImageLayout {
GALLERY, GALLERY,

View File

@@ -0,0 +1,73 @@
package com.loremind.domain.shared.template;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* Value Object d'un champ de Template (kernel partage).
* <p>
* Un champ a un nom (affiche dans l'UI) et un type. Le type pilote
* le rendu cote front et la logique metier (seuls les champs TEXT sont
* envoyes a l'IA pour generation).
* <p>
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* Ignore pour les autres types.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TemplateField {
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
private String name;
/** Type du champ, pilote le rendu et la generation IA. */
private FieldType type;
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
/**
* Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif).
* Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques".
* Null/vide pour les autres types.
*/
private List<String> labels;
/** Constructeur de retrocompat : type seul, layout/labels=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null, null);
}
/** Constructeur de retrocompat : type + layout, labels=null. */
public TemplateField(String name, FieldType type, ImageLayout layout) {
this(name, type, layout, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT, null, null);
}
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY, null);
}
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
public static TemplateField image(String name, ImageLayout layout) {
return new TemplateField(name, FieldType.IMAGE, layout, null);
}
/** Raccourci : construit un champ de type NUMBER. */
public static TemplateField number(String name) {
return new TemplateField(name, FieldType.NUMBER, null, null);
}
/** Raccourci : construit un champ KEY_VALUE_LIST avec labels predefinis. */
public static TemplateField keyValueList(String name, List<String> labels) {
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
}
}

View File

@@ -0,0 +1,93 @@
package com.loremind.infrastructure.persistence;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* Backfill one-shot des fiches Character / Npc post-refonte 2026-04-30.
* <p>
* Avant la refonte, les fiches stockaient leur contenu dans la colonne
* {@code markdown_content}. Apres la refonte, le contenu est dans
* {@code field_values} (JSON Map<String,String>). La colonne
* {@code markdown_content} subsiste car Hibernate ddl-auto=update ne drop pas.
* <p>
* Ce backfill copie {@code markdown_content} dans {@code field_values["Notes"]}
* pour toutes les fiches qui ont un markdown non vide ET un field_values vide.
* Idempotent : si field_values contient deja des donnees, on ne touche pas.
* <p>
* La colonne {@code markdown_content} n'est PAS supprimee apres backfill —
* permet un rollback applicatif au cas ou. Suppression definitive a faire dans
* une release ulterieure quand la confiance est etablie.
*/
@Component
public class CharacterNpcMarkdownBackfill {
private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class);
private final JdbcTemplate jdbc;
private final ObjectMapper mapper = new ObjectMapper();
public CharacterNpcMarkdownBackfill(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@EventListener(ApplicationReadyEvent.class)
public void backfillIfNeeded() {
if (!hasMarkdownContentColumn("characters")) {
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
return;
}
int chars = backfillTable("characters");
int npcs = backfillTable("npcs");
if (chars + npcs > 0) {
log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs);
}
}
private boolean hasMarkdownContentColumn(String table) {
try {
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_name = ? AND column_name = 'markdown_content'",
Integer.class, table);
return count != null && count > 0;
} catch (Exception e) {
log.warn("Backfill : impossible de verifier la colonne markdown_content sur {}: {}",
table, e.getMessage());
return false;
}
}
private int backfillTable(String table) {
// Selection : fiches avec markdown non vide ET field_values vide ou absent.
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
String selectSql = "SELECT id, markdown_content FROM " + table
+ " WHERE markdown_content IS NOT NULL "
+ " AND markdown_content <> '' "
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
var rows = jdbc.queryForList(selectSql);
int migrated = 0;
for (var row : rows) {
Long id = ((Number) row.get("id")).longValue();
String markdown = (String) row.get("markdown_content");
String json;
try {
json = mapper.writeValueAsString(Map.of("Notes", markdown));
} catch (Exception e) {
log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", table, id, e.getMessage());
continue;
}
jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id);
migrated++;
}
return migrated;
}
}

View File

@@ -2,6 +2,8 @@ package com.loremind.infrastructure.persistence;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
@@ -23,6 +25,10 @@ import java.util.List;
* <p> * <p>
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé, * Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur). * il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
* <p>
* Backfill 2026-04-30 : pour les GameSystems existants (avant la refonte
* template-based), on remplit aussi les templates PJ/PNJ par defaut s'ils
* sont vides — sinon les fiches restent inutilisables.
*/ */
@Component @Component
public class GameSystemSeeder { public class GameSystemSeeder {
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
public void seedIfEmpty() { public void seedIfEmpty() {
if (!gameSystemRepository.findAll().isEmpty()) { List<GameSystem> existing = gameSystemRepository.findAll();
log.debug("GameSystem seed skipped — table non vide."); if (existing.isEmpty()) {
log.info("Seed initial des GameSystems (table vide)...");
for (GameSystem gs : defaultSystems()) {
gameSystemRepository.save(gs);
}
log.info("GameSystems seedés : {}", defaultSystems().size());
return; return;
} }
log.info("Seed initial des GameSystems (table vide)..."); log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
for (GameSystem gs : defaultSystems()) { backfillEmptyTemplates(existing);
gameSystemRepository.save(gs); }
/**
* Backfill idempotent : pour chaque GameSystem existant ou les deux templates
* sont vides (PJ ET PNJ), injecte le template generique. Si l'utilisateur a
* deja personnalise au moins un des deux, on ne touche a rien.
*/
private void backfillEmptyTemplates(List<GameSystem> systems) {
int patched = 0;
for (GameSystem gs : systems) {
boolean charEmpty = gs.getCharacterTemplate() == null || gs.getCharacterTemplate().isEmpty();
boolean npcEmpty = gs.getNpcTemplate() == null || gs.getNpcTemplate().isEmpty();
if (charEmpty && npcEmpty) {
gs.replaceCharacterTemplate(genericCharacterTemplate());
gs.replaceNpcTemplate(genericNpcTemplate());
gameSystemRepository.save(gs);
patched++;
}
} }
log.info("GameSystems seedés : {}", defaultSystems().size()); if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
} }
private List<GameSystem> defaultSystems() { private List<GameSystem> defaultSystems() {
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
.author("LoreMind seed") .author("LoreMind seed")
.isPublic(false) .isPublic(false)
.rulesMarkdown(NIMBLE_RULES) .rulesMarkdown(NIMBLE_RULES)
.characterTemplate(nimbleCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(), .build(),
GameSystem.builder() GameSystem.builder()
.name("D&D 5e SRD (extrait)") .name("D&D 5e SRD (extrait)")
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
.author("LoreMind seed") .author("LoreMind seed")
.isPublic(false) .isPublic(false)
.rulesMarkdown(DND_SRD_RULES) .rulesMarkdown(DND_SRD_RULES)
.characterTemplate(dndCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(), .build(),
GameSystem.builder() GameSystem.builder()
.name("Homebrew Exemple") .name("Homebrew Exemple")
@@ -70,10 +102,66 @@ public class GameSystemSeeder {
.author("LoreMind seed") .author("LoreMind seed")
.isPublic(false) .isPublic(false)
.rulesMarkdown(HOMEBREW_EXAMPLE) .rulesMarkdown(HOMEBREW_EXAMPLE)
.characterTemplate(genericCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build() .build()
); );
} }
// --- Templates par defaut ---------------------------------------------
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
private static List<TemplateField> genericCharacterTemplate() {
return List.of(
TemplateField.text("Histoire"),
TemplateField.text("Personnalite"),
TemplateField.text("Apparence"),
TemplateField.image("Galerie", ImageLayout.GALLERY),
TemplateField.text("Notes")
);
}
/** Template generique PNJ — focus besoins MJ. */
private static List<TemplateField> genericNpcTemplate() {
return List.of(
TemplateField.text("Apparence"),
TemplateField.text("Motivation"),
TemplateField.text("Faction"),
TemplateField.text("Notes MJ")
);
}
private static List<TemplateField> nimbleCharacterTemplate() {
return List.of(
TemplateField.text("Classe"),
TemplateField.number("Blessures graves max"),
TemplateField.text("Capacites de classe"),
TemplateField.text("Equipement"),
TemplateField.text("Histoire"),
TemplateField.text("Objectifs personnels"),
TemplateField.image("Galerie", ImageLayout.GALLERY)
);
}
private static List<TemplateField> dndCharacterTemplate() {
return List.of(
TemplateField.text("Classe"),
TemplateField.text("Race"),
TemplateField.text("Historique"),
TemplateField.text("Alignement"),
TemplateField.number("Niveau"),
TemplateField.number("PV max"),
TemplateField.number("CA"),
TemplateField.keyValueList("Caracteristiques",
List.of("FOR", "DEX", "CON", "INT", "SAG", "CHA")),
TemplateField.text("Competences"),
TemplateField.text("Equipement"),
TemplateField.text("Sorts"),
TemplateField.text("Histoire"),
TemplateField.image("Galerie", ImageLayout.GALLERY)
);
}
private static final String NIMBLE_RULES = """ private static final String NIMBLE_RULES = """
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé). Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).

View File

@@ -0,0 +1,49 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.Map;
/**
* Convertit une Map<String, Map<String, String>> en JSON et inversement.
* <p>
* Utilise pour Character/Npc.keyValueValues : pour chaque champ KEY_VALUE_LIST
* du template, stocke une map label -> value. Exemple :
* {"Caracteristiques": {"FOR":"16","DEX":"12","CON":"14"}}
* <p>
* Adaptateur technique pur : le domaine ignore ce converter.
*/
@Converter
public class StringMapMapJsonConverter
implements AttributeConverter<Map<String, Map<String, String>>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, Map<String, String>>> TYPE_REF =
new TypeReference<>() {};
@Override
public String convertToDatabaseColumn(Map<String, Map<String, String>> attribute) {
if (attribute == null || attribute.isEmpty()) return "{}";
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur serialisation Map<String, Map<String,String>> -> JSON", e);
}
}
@Override
public Map<String, Map<String, String>> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
try {
return MAPPER.readValue(dbData, TYPE_REF);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur deserialisation JSON -> Map<String, Map<String,String>>", e);
}
}
}

View File

@@ -3,9 +3,9 @@ package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.ImageLayout; import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import jakarta.persistence.AttributeConverter; import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter; import jakarta.persistence.Converter;
@@ -85,8 +85,18 @@ public class TemplateFieldListJsonConverter
} }
} }
} }
List<String> labels = null;
if (type == FieldType.KEY_VALUE_LIST) {
JsonNode labelsNode = item.path("labels");
if (labelsNode.isArray()) {
labels = new ArrayList<>();
for (JsonNode label : labelsNode) {
if (label.isTextual()) labels.add(label.asText());
}
}
}
if (name != null && !name.isBlank()) { if (name != null && !name.isBlank()) {
result.add(new TemplateField(name, type, layout)); result.add(new TemplateField(name, type, layout, labels));
} }
} }
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement. // Autres types de noeuds (nombre, booleen...) : ignores silencieusement.

View File

@@ -1,5 +1,8 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -7,11 +10,18 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; 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. * Entité JPA pour les fiches de personnages (PJ).
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte : * <p>
* on reste dans le Campaign Context, mais l'agrégat Character est autonome). * 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 @Entity
@Table(name = "characters") @Table(name = "characters")
@@ -28,8 +38,26 @@ public class CharacterJpaEntity {
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT") @Column(name = "portrait_image_id")
private String markdownContent; private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
/** Valeurs TEXT/NUMBER serialisees JSON. */
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
/** Valeurs IMAGE serialisees JSON. */
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST serialisees JSON (fieldName -> label -> value). */
@Convert(converter = StringMapMapJsonConverter.class)
@Column(name = "key_value_values", columnDefinition = "TEXT")
private Map<String, Map<String, String>> keyValueValues;
@Column(name = "campaign_id", nullable = false) @Column(name = "campaign_id", nullable = false)
private Long campaignId; private Long campaignId;
@@ -47,6 +75,9 @@ public class CharacterJpaEntity {
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>();
} }
@PreUpdate @PreUpdate

View File

@@ -1,5 +1,7 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -7,6 +9,8 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/** /**
* Entité JPA pour la persistance des GameSystems (systèmes de JDR). * Entité JPA pour la persistance des GameSystems (systèmes de JDR).
@@ -32,6 +36,16 @@ public class GameSystemJpaEntity {
@Column(name = "rules_markdown", columnDefinition = "TEXT") @Column(name = "rules_markdown", columnDefinition = "TEXT")
private String rulesMarkdown; private String rulesMarkdown;
/** Template PJ serialise en JSON via {@link TemplateFieldListJsonConverter}. */
@Convert(converter = TemplateFieldListJsonConverter.class)
@Column(name = "character_template", columnDefinition = "TEXT")
private List<TemplateField> characterTemplate;
/** Template PNJ serialise en JSON. */
@Convert(converter = TemplateFieldListJsonConverter.class)
@Column(name = "npc_template", columnDefinition = "TEXT")
private List<TemplateField> npcTemplate;
@Column @Column
private String author; private String author;
@@ -48,6 +62,8 @@ public class GameSystemJpaEntity {
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (characterTemplate == null) characterTemplate = new ArrayList<>();
if (npcTemplate == null) npcTemplate = new ArrayList<>();
} }
@PreUpdate @PreUpdate

View File

@@ -1,5 +1,8 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -7,10 +10,13 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; 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. * Entité JPA pour les fiches de PNJ. Memes regles que CharacterJpaEntity
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte). * (cf. note de refonte 2026-04-30 sur la migration markdownContent).
*/ */
@Entity @Entity
@Table(name = "npcs") @Table(name = "npcs")
@@ -27,8 +33,23 @@ public class NpcJpaEntity {
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT") @Column(name = "portrait_image_id")
private String markdownContent; private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
@Convert(converter = StringMapMapJsonConverter.class)
@Column(name = "key_value_values", columnDefinition = "TEXT")
private Map<String, Map<String, String>> keyValueValues;
@Column(name = "campaign_id", nullable = false) @Column(name = "campaign_id", nullable = false)
private Long campaignId; private Long campaignId;
@@ -46,6 +67,9 @@ public class NpcJpaEntity {
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>();
} }
@PreUpdate @PreUpdate

View File

@@ -1,6 +1,6 @@
package com.loremind.infrastructure.persistence.entity; 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 com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository; import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -52,7 +53,11 @@ public class PostgresCharacterRepository implements CharacterRepository {
return Character.builder() return Character.builder()
.id(e.getId().toString()) .id(e.getId().toString())
.name(e.getName()) .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<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString()) .campaignId(e.getCampaignId().toString())
.order(e.getOrder()) .order(e.getOrder())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -65,7 +70,11 @@ public class PostgresCharacterRepository implements CharacterRepository {
return CharacterJpaEntity.builder() return CharacterJpaEntity.builder()
.id(id) .id(id)
.name(c.getName()) .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<>())
.keyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(c.getCampaignId())) .campaignId(Long.parseLong(c.getCampaignId()))
.order(c.getOrder()) .order(c.getOrder())
.createdAt(c.getCreatedAt()) .createdAt(c.getCreatedAt())

View File

@@ -61,6 +61,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.name(e.getName()) .name(e.getName())
.description(e.getDescription()) .description(e.getDescription())
.rulesMarkdown(e.getRulesMarkdown()) .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()) .author(e.getAuthor())
.isPublic(e.isPublic()) .isPublic(e.isPublic())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -75,6 +81,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.name(g.getName()) .name(g.getName())
.description(g.getDescription()) .description(g.getDescription())
.rulesMarkdown(g.getRulesMarkdown()) .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()) .author(g.getAuthor())
.isPublic(g.isPublic()) .isPublic(g.isPublic())
.createdAt(g.getCreatedAt()) .createdAt(g.getCreatedAt())

View File

@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository; import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -52,7 +53,11 @@ public class PostgresNpcRepository implements NpcRepository {
return Npc.builder() return Npc.builder()
.id(e.getId().toString()) .id(e.getId().toString())
.name(e.getName()) .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<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString()) .campaignId(e.getCampaignId().toString())
.order(e.getOrder()) .order(e.getOrder())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -65,7 +70,11 @@ public class PostgresNpcRepository implements NpcRepository {
return NpcJpaEntity.builder() return NpcJpaEntity.builder()
.id(id) .id(id)
.name(n.getName()) .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<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId())) .campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder()) .order(n.getOrder())
.createdAt(n.getCreatedAt()) .createdAt(n.getCreatedAt())

View File

@@ -4,10 +4,10 @@ import com.loremind.application.licensing.LicenseService;
import com.loremind.domain.licensing.LicenseSnapshot; import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus; import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials; import com.loremind.domain.licensing.RegistryCredentials;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -29,187 +29,121 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* Detection des mises a jour disponibles + declenchement via Watchtower. * Detection des mises a jour disponibles + declenchement via Watchtower.
* <p>
* <b>Strategie</b> : comparaison de versions semver, pas de digests.
* <ul>
* <li>La version courante de l'app est lue depuis {@link BuildProperties}
* (genere par spring-boot-maven-plugin dans META-INF/build-info.properties).</li>
* <li>Pour chaque image suivie, on interroge le registry sur
* {@code /v2/<image>/tags/list}, on extrait les tags semver, on prend le max.</li>
* <li>Si max > version courante => UPDATE_AVAILABLE.</li>
* <li>Si max == version courante => UP_TO_DATE.</li>
* <li>Si registry injoignable ou aucun tag valide => UNKNOWN.</li>
* </ul>
* *
* Strategie : * <b>Pourquoi pas les digests ?</b> Le bug historique etait : le baseline-digest
* - Au demarrage, on interroge le registry pour le digest courant de chaque * pose au @PostConstruct supposait que le pull venait d'avoir lieu (vrai apres
* image suivie ({@code update-check.images}). On stocke ces digests comme * `docker compose pull && up -d`, faux apres un simple restart de daemon ou un
* "baseline" (= ce que le conteneur en cours d'execution est cense faire * OOM). La version semver lue depuis le binaire est <b>fiable par construction</b> :
* tourner, puisque le `docker compose pull` precede toujours `up -d`). * c'est ce que le code source declare faire tourner.
* - Si l'init echoue (reseau Docker pas encore pret, registry transitoirement
* indisponible), un thread daemon de retry avec backoff complete les
* baselines manquantes en arriere-plan.
* - {@link #check()} re-interroge le registry et compare. Si un digest a
* change, une mise a jour est disponible. Si la baseline manque (echec
* de tous les retries), retourne {@link ImageStatusKind#UNKNOWN} pour
* cette image — JAMAIS d'alignement silencieux (eviterait des MAJ ratees).
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
*
* Apres un apply reussi, Watchtower redemarre core => ce service est
* re-instancie => baseline re-aligne sur le registry => check renvoie
* "pas de MAJ" (etat coherent).
*
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
* masque le badge / bouton.
*/ */
@Service @Service
public class UpdateCheckService { public class UpdateCheckService {
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class); private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
);
private final RestTemplate http; private final RestTemplate http;
private final String registry; private final String registry;
private final List<String> images; private final List<String> images;
private final String tag;
private final String watchtowerUrl; private final String watchtowerUrl;
private final String watchtowerToken; private final String watchtowerToken;
private final List<String> betaImages; private final List<String> betaImages;
private final String betaTag;
private final LicenseService licenseService; private final LicenseService licenseService;
/** Version semver courante du binaire (ex: "0.8.0"). Source de verite. */
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>(); private final String currentVersion;
public UpdateCheckService( public UpdateCheckService(
RestTemplateBuilder builder, RestTemplateBuilder builder,
@Value("${update-check.registry:}") String registry, @Value("${update-check.registry:}") String registry,
@Value("${update-check.images:}") String imagesCsv, @Value("${update-check.images:}") String imagesCsv,
@Value("${update-check.tag:latest}") String tag,
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl, @Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
@Value("${update-check.watchtower-token:}") String watchtowerToken, @Value("${update-check.watchtower-token:}") String watchtowerToken,
@Value("${licensing.beta.images:}") String betaImagesCsv, @Value("${licensing.beta.images:}") String betaImagesCsv,
@Value("${licensing.beta.tag:latest}") String betaTag, LicenseService licenseService,
LicenseService licenseService) { @Nullable BuildProperties buildProperties) {
this.http = builder this.http = builder
.setConnectTimeout(Duration.ofSeconds(5)) .setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15)) .setReadTimeout(Duration.ofSeconds(15))
.build(); .build();
this.registry = normalizeRegistry(registry); this.registry = normalizeRegistry(registry);
this.images = parseImages(imagesCsv); this.images = parseImages(imagesCsv);
this.tag = tag;
this.watchtowerUrl = watchtowerUrl; this.watchtowerUrl = watchtowerUrl;
this.watchtowerToken = watchtowerToken; this.watchtowerToken = watchtowerToken;
this.betaImages = parseImages(betaImagesCsv); this.betaImages = parseImages(betaImagesCsv);
this.betaTag = betaTag;
this.licenseService = licenseService; this.licenseService = licenseService;
} this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
log.info("Update check init - registry={} images={} currentVersion={}",
/** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */ this.registry, this.images, this.currentVersion);
private static final long[] BASELINE_RETRY_BACKOFFS_MS = {2_000, 5_000, 15_000, 30_000, 60_000};
@PostConstruct
void initBaseline() {
if (!isEnabled()) {
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
return;
}
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
boolean complete = tryBaselineMissing();
if (!complete) {
startBaselineRetryThread();
}
}
/**
* Tente de poser la baseline pour les images qui ne l'ont pas encore.
* @return true si TOUTES les images ont leur baseline apres cet essai.
*/
private boolean tryBaselineMissing() {
for (String image : images) {
if (baselineDigests.containsKey(image)) continue;
try {
String digest = fetchRemoteDigest(image);
if (digest != null) {
baselineDigests.put(image, digest);
log.debug("Baseline digest for {} = {}", image, digest);
}
} catch (Exception e) {
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
}
}
return baselineDigests.size() == images.size();
}
/**
* Lance un thread daemon qui retente de poser les baselines manquantes
* avec backoff. Le thread s'arrete des que toutes les baselines sont
* posees, ou apres epuisement des backoffs (et alors {@link #check()}
* retournera UNKNOWN pour ces images jusqu'au prochain redemarrage).
*/
private void startBaselineRetryThread() {
Thread t = new Thread(() -> {
for (long backoff : BASELINE_RETRY_BACKOFFS_MS) {
try {
Thread.sleep(backoff);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
if (tryBaselineMissing()) {
log.info("Baseline complete after retry");
return;
}
}
log.warn("Baseline incomplete after all retries; check() will return UNKNOWN for missing images");
}, "update-baseline-retry");
t.setDaemon(true);
t.start();
} }
public boolean isEnabled() { public boolean isEnabled() {
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty(); return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
} }
/**
* @return version courante exposee aux endpoints (ex: pour affichage UI).
* {@code null} si build-info.properties absent (dev en IDE sans build Maven).
*/
public String getCurrentVersion() {
return currentVersion;
}
public UpdateStatus check() { public UpdateStatus check() {
if (!isEnabled()) { if (!isEnabled()) {
return new UpdateStatus(false, false, false, List.of(), Instant.now()); return new UpdateStatus(false, false, false, null, List.of(), Instant.now());
} }
if (currentVersion == null) {
log.warn("Update check : currentVersion absente (build-info manquant). Tous UNKNOWN.");
List<ImageStatus> statuses = new ArrayList<>();
for (String image : images) {
statuses.add(new ImageStatus(image, null, null, ImageStatusKind.UNKNOWN));
}
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
}
List<ImageStatus> statuses = new ArrayList<>(); List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false; boolean anyUpdate = false;
boolean anyUnknown = false; boolean anyUnknown = false;
for (String image : images) { for (String image : images) {
String baseline = baselineDigests.get(image); String latest = null;
String remote = null;
try { try {
remote = fetchRemoteDigest(image); latest = fetchLatestSemverTag(registry, image, null);
} catch (Exception e) { } catch (Exception e) {
log.warn("Check failed for {}: {}", image, e.getMessage()); log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
} }
// PAS d'alignement lazy si baseline absente : ce serait un faux negatif
// silencieux. On reporte UNKNOWN pour que l'UI le signale.
ImageStatusKind kind; ImageStatusKind kind;
if (baseline == null || remote == null) { if (latest == null) {
kind = ImageStatusKind.UNKNOWN; kind = ImageStatusKind.UNKNOWN;
anyUnknown = true; anyUnknown = true;
} else if (baseline.equals(remote)) {
kind = ImageStatusKind.UP_TO_DATE;
} else { } else {
kind = ImageStatusKind.UPDATE_AVAILABLE; int cmp = compareSemver(currentVersion, latest);
anyUpdate = true; if (cmp >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
} }
statuses.add(new ImageStatus(image, baseline, remote, kind)); statuses.add(new ImageStatus(image, currentVersion, latest, kind));
} }
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now()); return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
} }
/** /**
* Verifie l'etat du canal beta (images privees GHCR). * Verifie l'etat du canal beta (images privees GHCR) avec auth basique.
* Necessite licence valide/grace + toggle beta ON.
* Authentification basic auth via le PAT distribue par le relais.
*
* @return statut beta (peut etre {@link BetaStatus#disabled()} si licence absente,
* beta off ou licence expiree)
*/ */
public BetaStatus checkBeta() { public BetaStatus checkBeta() {
if (!licenseService.isLicensingEnabled()) { if (!licenseService.isLicensingEnabled()) {
@@ -239,48 +173,156 @@ public class UpdateCheckService {
boolean anyUpdate = false; boolean anyUpdate = false;
boolean anyUnknown = false; boolean anyUnknown = false;
for (String image : betaImages) { for (String image : betaImages) {
String remote = null; String latest = null;
try { try {
remote = fetchRemoteDigestAuth(betaRegistry, image, betaTag, basicAuth); latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
} catch (Exception e) { } catch (Exception e) {
log.warn("Beta check failed for {}: {}", image, e.getMessage()); log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
} }
// Pas de baseline pour la beta : on ne peut pas dire "a jour" car on ImageStatusKind kind;
// ne sait pas quelle version le user fait tourner. On expose juste le if (latest == null) {
// digest remote ; l'UI affichera "version disponible : <tag>" sans kind = ImageStatusKind.UNKNOWN;
// comparaison locale tant qu'il n'y a pas un mecanisme de baseline. anyUnknown = true;
ImageStatusKind kind = (remote == null) ? ImageStatusKind.UNKNOWN : ImageStatusKind.UPDATE_AVAILABLE; } else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
if (kind == ImageStatusKind.UNKNOWN) anyUnknown = true; kind = ImageStatusKind.UP_TO_DATE;
else anyUpdate = true; } else {
statuses.add(new ImageStatus(image, null, remote, kind)); kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
} }
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null); return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
} }
private String fetchRemoteDigestAuth(String registryUrl, String image, String tagName, String authHeader) { public void apply() {
String url = registryUrl + "/v2/" + image + "/manifests/" + tagName; if (!isEnabled()) {
HttpHeaders headers = new HttpHeaders(); throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
headers.setAccept(MANIFEST_ACCEPT);
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
try {
return digestCall(url, headers);
} catch (HttpClientErrorException.Unauthorized e) {
// GHCR peut exiger d'echanger basic auth contre un bearer token via
// le challenge WWW-Authenticate. On reuse la logique existante en
// ajoutant l'auth header a la requete /token.
String www = e.getResponseHeaders() == null ? null
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
String token = obtainBearerTokenWithAuth(www, authHeader);
if (token == null) return null;
HttpHeaders bearerHeaders = new HttpHeaders();
bearerHeaders.setAccept(MANIFEST_ACCEPT);
bearerHeaders.setBearerAuth(token);
return digestCall(url, bearerHeaders);
} }
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(watchtowerToken);
http.exchange(
watchtowerUrl + "/v1/update",
HttpMethod.POST,
new HttpEntity<>(headers),
Void.class);
} }
// -----------------------------------------------------------------------
// Registry HTTP API v2 - tags listing + auth bearer
// -----------------------------------------------------------------------
/**
* Interroge le registry pour la liste des tags d'une image, parse les
* versions semver et retourne la plus elevee. {@code null} si echec
* ou aucun tag valide.
*
* @param registryUrl URL normalisee (ex: "https://ghcr.io")
* @param image nom de l'image (ex: "igmlcreation/loremind-core")
* @param authHeader optionnel - "Basic ..." pour les registries prives
*/
private String fetchLatestSemverTag(String registryUrl, String image, @Nullable String authHeader) {
String url = registryUrl + "/v2/" + image + "/tags/list";
HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
if (authHeader != null) {
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
}
TagsListResponse body;
try {
body = tagsCall(url, headers);
} catch (HttpClientErrorException.Unauthorized e) {
String www = e.getResponseHeaders() == null ? null
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
String token = obtainBearerToken(www, authHeader);
if (token == null) {
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
return null;
}
HttpHeaders bearerHeaders = new HttpHeaders();
bearerHeaders.setAccept(List.of(MediaType.APPLICATION_JSON));
bearerHeaders.setBearerAuth(token);
body = tagsCall(url, bearerHeaders);
}
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
return findMaxSemver(body.tags);
}
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
ResponseEntity<TagsListResponse> resp = http.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), TagsListResponse.class);
return resp.getBody();
}
/**
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
* (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
* Pre-release / build metadata sont strippes pour la comparaison.
*/
@Nullable
static String findMaxSemver(List<String> tags) {
String maxTag = null;
int[] maxParts = null;
for (String t : tags) {
if (t == null || t.isBlank()) continue;
int[] parts = parseSemver(t);
if (parts == null) continue;
if (maxParts == null || compareParts(parts, maxParts) > 0) {
maxParts = parts;
maxTag = t;
}
}
return maxTag;
}
/** @return [major, minor, patch] ou null si non parsable. */
@Nullable
static int[] parseSemver(String tag) {
if (tag == null) return null;
String s = tag.trim();
if (s.isEmpty()) return null;
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
int dashIdx = s.indexOf('-');
if (dashIdx > 0) s = s.substring(0, dashIdx);
int plusIdx = s.indexOf('+');
if (plusIdx > 0) s = s.substring(0, plusIdx);
String[] parts = s.split("\\.");
if (parts.length < 1 || parts.length > 3) return null;
int[] result = new int[]{0, 0, 0};
for (int i = 0; i < parts.length; i++) {
try {
int v = Integer.parseInt(parts[i]);
if (v < 0) return null;
result[i] = v;
} catch (NumberFormatException e) {
return null;
}
}
return result;
}
/** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
static int compareSemver(String a, String b) {
int[] aParts = parseSemver(a);
int[] bParts = parseSemver(b);
if (aParts == null || bParts == null) return 0;
return compareParts(aParts, bParts);
}
private static int compareParts(int[] a, int[] b) {
for (int i = 0; i < 3; i++) {
int diff = Integer.compare(a[i], b[i]);
if (diff != 0) return diff;
}
return 0;
}
/**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
* registry prive). Sinon anonyme (cas registry public).
*/
@SuppressWarnings("rawtypes") @SuppressWarnings("rawtypes")
private String obtainBearerTokenWithAuth(@Nullable String wwwAuth, String authHeader) { private String obtainBearerToken(@Nullable String wwwAuth, @Nullable String basicAuth) {
if (wwwAuth == null) return null; if (wwwAuth == null) return null;
String prefix = "Bearer "; String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null; if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
@@ -301,7 +343,9 @@ public class UpdateCheckService {
} }
try { try {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, authHeader); if (basicAuth != null) {
headers.set(HttpHeaders.AUTHORIZATION, basicAuth);
}
ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET, ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET,
new HttpEntity<>(headers), Map.class); new HttpEntity<>(headers), Map.class);
Map<?, ?> body = resp.getBody(); Map<?, ?> body = resp.getBody();
@@ -309,97 +353,6 @@ public class UpdateCheckService {
Object t = body.get("token"); Object t = body.get("token");
if (t == null) t = body.get("access_token"); if (t == null) t = body.get("access_token");
return t == null ? null : t.toString(); return t == null ? null : t.toString();
} catch (Exception e) {
log.warn("Beta bearer token request failed: {}", e.getMessage());
return null;
}
}
public void apply() {
if (!isEnabled()) {
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
}
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(watchtowerToken);
// Watchtower /v1/update declenche un scan+update immediat de tous les
// conteneurs labellises. La reponse est synchrone et peut prendre
// plusieurs secondes; en cas de redemarrage de core, le client
// recevra une connexion coupee — c'est attendu, l'UI le gere.
http.exchange(
watchtowerUrl + "/v1/update",
HttpMethod.POST,
new HttpEntity<>(headers),
Void.class);
}
// -----------------------------------------------------------------------
// Registry HTTP API v2
// -----------------------------------------------------------------------
private String fetchRemoteDigest(String image) {
String url = registry + "/v2/" + image + "/manifests/" + tag;
HttpHeaders headers = new HttpHeaders();
headers.setAccept(MANIFEST_ACCEPT);
try {
return digestCall(url, headers);
} catch (HttpClientErrorException.Unauthorized e) {
String www = e.getResponseHeaders() == null ? null
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
String token = obtainBearerToken(www);
if (token == null) {
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
return null;
}
headers.setBearerAuth(token);
return digestCall(url, headers);
}
}
private String digestCall(String url, HttpHeaders headers) {
ResponseEntity<Void> resp = http.exchange(
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
return resp.getHeaders().getFirst("Docker-Content-Digest");
}
/**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
*/
@SuppressWarnings("rawtypes")
private String obtainBearerToken(String wwwAuth) {
if (wwwAuth == null) return null;
String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
String realm = params.get("realm");
if (realm == null) return null;
StringBuilder url = new StringBuilder(realm);
boolean hasQuery = realm.contains("?");
for (String key : new String[]{"service", "scope"}) {
String v = params.get(key);
if (v != null) {
// URLEncoder fait du "form encoding" qui transforme `:` et `/`
// en %3A et %2F. La plupart des registries (Docker Hub, Gitea)
// acceptent les deux, mais GHCR est strict et rejette le scope
// encode (403 DENIED). On preserve donc `:` et `/` dans la
// valeur, conformement a ce que GHCR attend
// (et que docker pull lui-meme envoie).
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
.replace("%3A", ":")
.replace("%2F", "/");
url.append(hasQuery ? '&' : '?')
.append(key).append('=')
.append(encoded);
hasQuery = true;
}
}
try {
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
Map<?, ?> body = resp.getBody();
if (body == null) return null;
Object t = body.get("token");
if (t == null) t = body.get("access_token");
return t == null ? null : t.toString();
} catch (Exception e) { } catch (Exception e) {
log.warn("Bearer token request failed: {}", e.getMessage()); log.warn("Bearer token request failed: {}", e.getMessage());
return null; return null;
@@ -455,37 +408,36 @@ public class UpdateCheckService {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Records de retour (sortis sous forme JSON par Jackson) // Records / DTO
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/**
* Etat tri-state d'une image vis-a-vis du registry.
* <ul>
* <li>{@link #UP_TO_DATE} : digest local == digest remote.</li>
* <li>{@link #UPDATE_AVAILABLE} : digests differents, MAJ disponible.</li>
* <li>{@link #UNKNOWN} : impossible de comparer (baseline ou remote manquant).
* L'UI doit afficher un avertissement plutot que de declarer "a jour".</li>
* </ul>
*/
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN } public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
public record UpdateStatus( public record UpdateStatus(
boolean enabled, boolean enabled,
boolean updateAvailable, boolean updateAvailable,
boolean anyUnknown, boolean anyUnknown,
String currentVersion,
List<ImageStatus> images, List<ImageStatus> images,
Instant checkedAt) {} Instant checkedAt) {}
/** /**
* Etat du canal beta. * Statut par image. {@code localVersion} = version embarquee dans le binaire ;
* <ul> * {@code remoteVersion} = plus haute version semver trouvee dans le registry.
* <li>{@code enabled} : true si le canal beta est actif et la licence valide.</li> * {@code updateAvailable} est derive de {@code status} (back-compat front).
* <li>{@code disabledReason} : si {@code enabled=false}, raison technique
* (licensing-not-configured, license-none, license-expired, beta-toggle-off,
* no-beta-images-configured, relay-unavailable). Permet a l'UI d'afficher
* un message contextuel.</li>
* </ul>
*/ */
public record ImageStatus(
String image,
String localVersion,
String remoteVersion,
ImageStatusKind status,
boolean updateAvailable) {
public ImageStatus(String image, String localVersion, String remoteVersion, ImageStatusKind status) {
this(image, localVersion, remoteVersion, status, status == ImageStatusKind.UPDATE_AVAILABLE);
}
}
public record BetaStatus( public record BetaStatus(
boolean enabled, boolean enabled,
boolean updateAvailable, boolean updateAvailable,
@@ -499,20 +451,9 @@ public class UpdateCheckService {
} }
} }
/** /** DTO pour deserialisation Jackson de /v2/.../tags/list. */
* Le champ {@code updateAvailable} est conserve pour la compatibilite static class TagsListResponse {
* avec les anciens clients ; il est strictement derive de {@code status} public String name;
* dans le constructeur compact. public List<String> tags;
*/
public record ImageStatus(
String image,
String localDigest,
String remoteDigest,
ImageStatusKind status,
boolean updateAvailable) {
public ImageStatus(String image, String localDigest, String remoteDigest, ImageStatusKind status) {
this(image, localDigest, remoteDigest, status, status == ImageStatusKind.UPDATE_AVAILABLE);
}
} }
} }

View File

@@ -24,9 +24,7 @@ public class CharacterController {
@PostMapping @PostMapping
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) { public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
Character created = characterService.createCharacter( Character created = characterService.createCharacter(toData(dto, null));
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
);
return ResponseEntity.ok(characterMapper.toDTO(created)); return ResponseEntity.ok(characterMapper.toDTO(created));
} }
@@ -47,10 +45,7 @@ public class CharacterController {
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) { public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
Character updated = characterService.updateCharacter( Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
id,
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
);
return ResponseEntity.ok(characterMapper.toDTO(updated)); return ResponseEntity.ok(characterMapper.toDTO(updated));
} }
@@ -59,4 +54,17 @@ public class CharacterController {
characterService.deleteCharacter(id); characterService.deleteCharacter(id);
return ResponseEntity.noContent().build(); 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.getKeyValueValues(),
dto.getCampaignId(),
order
);
}
} }

View File

@@ -2,11 +2,16 @@ package com.loremind.infrastructure.web.controller;
import com.loremind.application.gamesystemcontext.GameSystemService; import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem; 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.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper; 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.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -16,10 +21,14 @@ public class GameSystemController {
private final GameSystemService gameSystemService; private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper; 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.gameSystemService = gameSystemService;
this.gameSystemMapper = gameSystemMapper; this.gameSystemMapper = gameSystemMapper;
this.templateFieldMapper = templateFieldMapper;
} }
@PostMapping @PostMapping
@@ -63,13 +72,28 @@ public class GameSystemController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
}
private GameSystemService.GameSystemData toData(GameSystemDTO dto) { private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
return new GameSystemService.GameSystemData( return new GameSystemService.GameSystemData(
dto.getName(), dto.getName(),
dto.getDescription(), dto.getDescription(),
dto.getRulesMarkdown(), dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()),
dto.getAuthor(), dto.getAuthor(),
dto.isPublic() dto.isPublic()
); );
} }
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
return out;
}
} }

View File

@@ -24,9 +24,7 @@ public class NpcController {
@PostMapping @PostMapping
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) { public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
Npc created = npcService.createNpc( Npc created = npcService.createNpc(toData(dto, null));
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
);
return ResponseEntity.ok(npcMapper.toDTO(created)); return ResponseEntity.ok(npcMapper.toDTO(created));
} }
@@ -47,10 +45,7 @@ public class NpcController {
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) { public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc( Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
id,
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
);
return ResponseEntity.ok(npcMapper.toDTO(updated)); return ResponseEntity.ok(npcMapper.toDTO(updated));
} }
@@ -59,4 +54,17 @@ public class NpcController {
npcService.deleteNpc(id); npcService.deleteNpc(id);
return ResponseEntity.noContent().build(); 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.getKeyValueValues(),
dto.getCampaignId(),
order
);
}
} }

View File

@@ -2,7 +2,7 @@ package com.loremind.infrastructure.web.controller;
import com.loremind.application.lorecontext.TemplateService; import com.loremind.application.lorecontext.TemplateService;
import com.loremind.domain.lorecontext.Template; 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.TemplateDTO;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper; import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import com.loremind.infrastructure.web.mapper.TemplateMapper; import com.loremind.infrastructure.web.mapper.TemplateMapper;

View File

@@ -0,0 +1,35 @@
package com.loremind.infrastructure.web.controller;
import org.springframework.boot.info.BuildProperties;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Endpoint public exposant la version courante du binaire.
* <p>
* Consomme par le frontend pour detecter qu'une mise a jour a ete deployee
* pendant qu'un onglet utilisateur etait deja ouvert : si la version polled
* differe de celle observee au boot, l'UI affiche un bandeau "rechargez".
* <p>
* Volontairement public (pas d'auth) : la version est deja exposee dans le
* JAR / l'image Docker, aucun risque de leak.
*/
@RestController
@RequestMapping("/api/version")
public class VersionController {
private final String version;
public VersionController(@Nullable BuildProperties buildProperties) {
this.version = buildProperties != null ? buildProperties.getVersion() : "dev";
}
@GetMapping
public Map<String, String> getVersion() {
return Map.of("version", version);
}
}

View File

@@ -2,15 +2,26 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data; 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. * 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 @Data
public class CharacterDTO { public class CharacterDTO {
private String id; private String id;
private String name; private String name;
private String markdownContent; private String portraitImageId;
private String headerImageId;
private Map<String, String> values = new HashMap<>();
private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId; private String campaignId;
private int order; private int order;
} }

View File

@@ -2,15 +2,23 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data; 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 @Data
public class NpcDTO { public class NpcDTO {
private String id; private String id;
private String name; private String name;
private String markdownContent; private String portraitImageId;
private String headerImageId;
private Map<String, String> values = new HashMap<>();
private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId; private String campaignId;
private int order; private int order;
} }

View File

@@ -1,9 +1,14 @@
package com.loremind.infrastructure.web.dto.gamesystemcontext; package com.loremind.infrastructure.web.dto.gamesystemcontext;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/** /**
* DTO pour l'entité GameSystem (système de JDR). * DTO pour l'entité GameSystem (système de JDR).
* Expose les templates PJ/PNJ comme listes de TemplateFieldDTO pour le wire.
*/ */
@Data @Data
public class GameSystemDTO { public class GameSystemDTO {
@@ -12,6 +17,8 @@ public class GameSystemDTO {
private String name; private String name;
private String description; private String description;
private String rulesMarkdown; private String rulesMarkdown;
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
private String author; private String author;
private boolean isPublic; private boolean isPublic;
} }

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.web.dto.lorecontext; package com.loremind.infrastructure.web.dto.lorecontext;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import lombok.Data; import lombok.Data;
import java.util.List; import java.util.List;

View File

@@ -1,29 +0,0 @@
package com.loremind.infrastructure.web.dto.lorecontext;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO pour un champ de Template.
* <p>
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.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.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TemplateFieldDTO {
private String name;
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
private String type;
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null si type=TEXT. */
private String layout;
/** Retrocompat : constructeur sans layout. */
public TemplateFieldDTO(String name, String type) {
this(name, type, null);
}
}

View File

@@ -0,0 +1,38 @@
package com.loremind.infrastructure.web.dto.shared;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* DTO pour un champ de Template.
* <p>
* 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.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TemplateFieldDTO {
private String name;
/** "TEXT" | "IMAGE" | "NUMBER" | "KEY_VALUE_LIST". */
private String type;
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", uniquement pour IMAGE. */
private String layout;
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
private List<String> labels;
/** Retrocompat : constructeur sans labels. */
public TemplateFieldDTO(String name, String type, String layout) {
this(name, type, layout, null);
}
/** Retrocompat : constructeur sans layout ni labels. */
public TemplateFieldDTO(String name, String type) {
this(name, type, null, null);
}
}

View File

@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Character;
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO; import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.HashMap;
@Component @Component
public class CharacterMapper { public class CharacterMapper {
@@ -12,7 +14,11 @@ public class CharacterMapper {
CharacterDTO dto = new CharacterDTO(); CharacterDTO dto = new CharacterDTO();
dto.setId(c.getId()); dto.setId(c.getId());
dto.setName(c.getName()); 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.setKeyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(c.getCampaignId()); dto.setCampaignId(c.getCampaignId());
dto.setOrder(c.getOrder()); dto.setOrder(c.getOrder());
return dto; return dto;
@@ -23,7 +29,11 @@ public class CharacterMapper {
return Character.builder() return Character.builder()
.id(dto.getId()) .id(dto.getId())
.name(dto.getName()) .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<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.order(dto.getOrder()) .order(dto.getOrder())
.build(); .build();

View File

@@ -1,12 +1,23 @@
package com.loremind.infrastructure.web.mapper; package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.gamesystemcontext.GameSystem; 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.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component @Component
public class GameSystemMapper { public class GameSystemMapper {
private final TemplateFieldMapper fieldMapper;
public GameSystemMapper(TemplateFieldMapper fieldMapper) {
this.fieldMapper = fieldMapper;
}
public GameSystemDTO toDTO(GameSystem g) { public GameSystemDTO toDTO(GameSystem g) {
if (g == null) return null; if (g == null) return null;
GameSystemDTO dto = new GameSystemDTO(); GameSystemDTO dto = new GameSystemDTO();
@@ -14,6 +25,8 @@ public class GameSystemMapper {
dto.setName(g.getName()); dto.setName(g.getName());
dto.setDescription(g.getDescription()); dto.setDescription(g.getDescription());
dto.setRulesMarkdown(g.getRulesMarkdown()); dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
dto.setAuthor(g.getAuthor()); dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic()); dto.setPublic(g.isPublic());
return dto; return dto;
@@ -26,8 +39,24 @@ public class GameSystemMapper {
.name(dto.getName()) .name(dto.getName())
.description(dto.getDescription()) .description(dto.getDescription())
.rulesMarkdown(dto.getRulesMarkdown()) .rulesMarkdown(dto.getRulesMarkdown())
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
.npcTemplate(toDomainList(dto.getNpcTemplate()))
.author(dto.getAuthor()) .author(dto.getAuthor())
.isPublic(dto.isPublic()) .isPublic(dto.isPublic())
.build(); .build();
} }
private List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
return out;
}
private List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
return out;
}
} }

View File

@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Npc;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO; import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.HashMap;
@Component @Component
public class NpcMapper { public class NpcMapper {
@@ -12,7 +14,11 @@ public class NpcMapper {
NpcDTO dto = new NpcDTO(); NpcDTO dto = new NpcDTO();
dto.setId(n.getId()); dto.setId(n.getId());
dto.setName(n.getName()); 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.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(n.getCampaignId()); dto.setCampaignId(n.getCampaignId());
dto.setOrder(n.getOrder()); dto.setOrder(n.getOrder());
return dto; return dto;
@@ -23,7 +29,11 @@ public class NpcMapper {
return Npc.builder() return Npc.builder()
.id(dto.getId()) .id(dto.getId())
.name(dto.getName()) .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<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.order(dto.getOrder()) .order(dto.getOrder())
.build(); .build();

View File

@@ -1,19 +1,21 @@
package com.loremind.infrastructure.web.mapper; package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.ImageLayout; import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO; import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/** /**
* Mapper pour convertir entre {@link TemplateField} (domaine) et * Mapper pour convertir entre {@link TemplateField} (domaine) et
* {@link TemplateFieldDTO} (wire). * {@link TemplateFieldDTO} (wire).
* <p> * <p>
* Tolerance : un type inconnu recu du client est interprete comme TEXT * Tolerance : un type inconnu recu du client est interprete comme TEXT.
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY. * Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
* Le layout est force a null pour les champs TEXT. * Layout/labels forces a null pour les types qui ne les utilisent pas.
*/ */
@Component @Component
public class TemplateFieldMapper { public class TemplateFieldMapper {
@@ -26,7 +28,11 @@ public class TemplateFieldMapper {
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY; ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
layoutStr = layout.name(); layoutStr = layout.name();
} }
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr); List<String> labels = null;
if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) {
labels = new ArrayList<>(field.getLabels());
}
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
} }
public TemplateField toDomain(TemplateFieldDTO dto) { public TemplateField toDomain(TemplateFieldDTO dto) {
@@ -47,6 +53,10 @@ public class TemplateFieldMapper {
layout = ImageLayout.GALLERY; layout = ImageLayout.GALLERY;
} }
} }
return new TemplateField(dto.getName(), type, layout); List<String> labels = null;
if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) {
labels = new ArrayList<>(dto.getLabels());
}
return new TemplateField(dto.getName(), type, layout, labels);
} }
} }

View File

@@ -1,9 +1,9 @@
package com.loremind.infrastructure.web.mapper; package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.Template; 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.TemplateDTO;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO; import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList; import java.util.ArrayList;

View File

@@ -65,7 +65,6 @@ app.demo-mode=${DEMO_MODE:false}
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide. # Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
update-check.registry=${UPDATE_CHECK_REGISTRY:} update-check.registry=${UPDATE_CHECK_REGISTRY:}
update-check.images=${UPDATE_CHECK_IMAGES:} update-check.images=${UPDATE_CHECK_IMAGES:}
update-check.tag=${UPDATE_CHECK_TAG:latest}
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080} update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
update-check.watchtower-token=${WATCHTOWER_TOKEN:} update-check.watchtower-token=${WATCHTOWER_TOKEN:}
@@ -97,7 +96,6 @@ licensing.instance-id-file=${LICENSING_INSTANCE_ID_FILE:}
# Image beta : si la licence est valide ET le toggle canal beta active, # Image beta : si la licence est valide ET le toggle canal beta active,
# UpdateCheckService check ces images en plus du canal stable. # UpdateCheckService check ces images en plus du canal stable.
licensing.beta.images=${LICENSING_BETA_IMAGES:igmlcreation/loremind-beta-core,igmlcreation/loremind-beta-brain,igmlcreation/loremind-beta-web} licensing.beta.images=${LICENSING_BETA_IMAGES:igmlcreation/loremind-beta-core,igmlcreation/loremind-beta-brain,igmlcreation/loremind-beta-web}
licensing.beta.tag=${LICENSING_BETA_TAG:latest}
# Chemin de sortie pour le docker config.json partage avec Watchtower. # Chemin de sortie pour le docker config.json partage avec Watchtower.
# Volume Docker `docker-config` monte sur ce chemin dans Core, et sur # Volume Docker `docker-config` monte sur ce chemin dans Core, et sur

View File

@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEArbfaqBq54HJR1pKqliTShKrNIab32gpBwSTDw90I4wg=
-----END PUBLIC KEY-----

View File

@@ -11,6 +11,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -38,7 +39,7 @@ public class NpcServiceTest {
testNpc = Npc.builder() testNpc = Npc.builder()
.id("npc-1") .id("npc-1")
.name("Borin le forgeron") .name("Borin le forgeron")
.markdownContent("# Borin\nForgeron nain") .values(new java.util.HashMap<>(Map.of("Notes", "Forgeron nain")))
.campaignId("camp-1") .campaignId("camp-1")
.order(1) .order(1)
.build(); .build();
@@ -49,7 +50,8 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
Npc result = npcService.createNpc( 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, null, "camp-1", 5));
assertNotNull(result); assertNotNull(result);
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
@@ -65,7 +67,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b)); when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); 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, null, "camp-1", null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture()); verify(npcRepository).save(captor.capture());
@@ -77,7 +79,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); 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, null, "camp-1", null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture()); verify(npcRepository).save(captor.capture());
@@ -121,10 +123,11 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0)); when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
Npc result = npcService.updateNpc("npc-1", 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, null, "camp-1", 7));
assertEquals("Borin renommé", result.getName()); assertEquals("Borin renommé", result.getName());
assertEquals("# v2", result.getMarkdownContent()); assertEquals("v2", result.getValues().get("Notes"));
assertEquals(7, result.getOrder()); assertEquals(7, result.getOrder());
} }
@@ -134,7 +137,8 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0)); when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
Npc result = npcService.updateNpc("npc-1", 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, null, "camp-1", null));
// testNpc avait order=1 → préservé // testNpc avait order=1 → préservé
assertEquals(1, result.getOrder()); assertEquals(1, result.getOrder());
@@ -146,7 +150,7 @@ public class NpcServiceTest {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> npcService.updateNpc("missing", () -> npcService.updateNpc("missing",
new NpcService.NpcData("x", null, "camp-1", null))); new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null)));
assertTrue(ex.getMessage().contains("missing")); assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any()); verify(npcRepository, never()).save(any());
} }

View File

@@ -153,19 +153,19 @@ public class CampaignStructuralContextBuilderTest {
void testBuild_ProjectsCharactersAndNpcsWithSnippets() { void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1) Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
.name("Aragorn") .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(); .build();
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2) Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
.name("Legolas") .name("Legolas")
.markdownContent(null) // pas de snippet → string vide .values(null) // pas de snippet → string vide
.build(); .build();
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2) Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
.name("Borin le forgeron") .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(); .build();
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1) Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
.name("Dame Elara") .name("Dame Elara")
.markdownContent("") .values(new java.util.HashMap<>(java.util.Map.of("Histoire", "")))
.build(); .build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
@@ -196,7 +196,7 @@ public class CampaignStructuralContextBuilderTest {
// Snippet > 160 chars : doit être tronqué à 159 + "…" // Snippet > 160 chars : doit être tronqué à 159 + "…"
String longLine = "x".repeat(200); String longLine = "x".repeat(200);
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1) 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(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());

View File

@@ -3,12 +3,12 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.GenerationContext; import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult; import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider; 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.Lore;
import com.loremind.domain.lorecontext.LoreNode; import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page; import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template; 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.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;

View File

@@ -115,8 +115,13 @@ public class NarrativeEntityContextBuilderTest {
@Test @Test
void testBuild_Character_MarkdownProjected() { 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() 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(); .build();
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c)); when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
@@ -124,14 +129,17 @@ public class NarrativeEntityContextBuilderTest {
assertEquals("character", ctx.entityType()); assertEquals("character", ctx.entityType());
assertEquals("Aragorn", ctx.title()); 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 @Test
void testBuild_Npc_MarkdownProjected() { void testBuild_Npc_MarkdownProjected() {
Npc n = Npc.builder() Npc n = Npc.builder()
.id("n-1").name("Borin le forgeron") .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(); .build();
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n)); when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
@@ -139,13 +147,14 @@ public class NarrativeEntityContextBuilderTest {
assertEquals("npc", ctx.entityType()); assertEquals("npc", ctx.entityType());
assertEquals("Borin le forgeron", ctx.title()); assertEquals("Borin le forgeron", ctx.title());
assertEquals("# Borin\n**Faction :** Clan Feuillefer", assertEquals("Clan Feuillefer", ctx.fields().get("Faction"));
ctx.fields().get("fiche complète (markdown)")); assertEquals("# Borin", ctx.fields().get("Histoire"));
} }
@Test @Test
void testBuild_Npc_NormalizesCase() { 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)); when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1"); NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");

View File

@@ -5,10 +5,10 @@ import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; 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.Page;
import com.loremind.domain.lorecontext.Template; 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.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template; 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 com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -0,0 +1,139 @@
package com.loremind.domain.gamesystemcontext;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine GameSystem ciblant la gestion des templates PJ/PNJ.
* Le ruleset markdown est testé ailleurs via GameSystemContextSelector.
*/
class GameSystemTest {
// --- addCharacterField --------------------------------------------------
@Test
void addCharacterField_appendsField() {
GameSystem gs = GameSystem.builder().build();
gs.addCharacterField(TemplateField.text("Histoire"));
gs.addCharacterField(TemplateField.image("Portrait", ImageLayout.HERO));
assertEquals(2, gs.getCharacterTemplate().size());
assertEquals("Histoire", gs.getCharacterTemplate().get(0).getName());
assertEquals(FieldType.IMAGE, gs.getCharacterTemplate().get(1).getType());
}
@Test
void addCharacterField_rejectsDuplicateNameCaseInsensitive() {
GameSystem gs = GameSystem.builder().build();
gs.addCharacterField(TemplateField.text("Histoire"));
// Doublon de cle dans Character.values garanti casse-insensible :
// "Histoire" et "histoire" produiraient la meme cle JSON.
assertThrows(IllegalArgumentException.class,
() -> gs.addCharacterField(TemplateField.number("HISTOIRE")));
}
@Test
void addCharacterField_rejectsBlankName() {
GameSystem gs = GameSystem.builder().build();
assertThrows(IllegalArgumentException.class,
() -> gs.addCharacterField(new TemplateField(" ", FieldType.TEXT)));
}
// --- removeCharacterField ----------------------------------------------
@Test
void removeCharacterField_removesByNameCaseInsensitive() {
GameSystem gs = GameSystem.builder()
.characterTemplate(new ArrayList<>(List.of(
TemplateField.text("Histoire"),
TemplateField.text("Notes")
)))
.build();
gs.removeCharacterField("HISTOIRE");
assertEquals(1, gs.getCharacterTemplate().size());
assertEquals("Notes", gs.getCharacterTemplate().get(0).getName());
}
@Test
void removeCharacterField_silentNoOpWhenMissing() {
GameSystem gs = GameSystem.builder()
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Histoire"))))
.build();
gs.removeCharacterField("absent");
assertEquals(1, gs.getCharacterTemplate().size());
}
// --- replaceCharacterTemplate ------------------------------------------
@Test
void replaceCharacterTemplate_overwritesEntireList() {
GameSystem gs = GameSystem.builder()
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Old"))))
.build();
gs.replaceCharacterTemplate(List.of(
TemplateField.text("A"),
TemplateField.number("B")));
assertEquals(2, gs.getCharacterTemplate().size());
assertEquals("A", gs.getCharacterTemplate().get(0).getName());
assertEquals("B", gs.getCharacterTemplate().get(1).getName());
}
@Test
void replaceCharacterTemplate_rejectsDuplicates() {
GameSystem gs = GameSystem.builder().build();
assertThrows(IllegalArgumentException.class,
() -> gs.replaceCharacterTemplate(List.of(
TemplateField.text("a"),
TemplateField.text("A"))));
}
@Test
void replaceCharacterTemplate_nullBecomesEmptyList() {
GameSystem gs = GameSystem.builder().build();
gs.replaceCharacterTemplate(null);
assertTrue(gs.getCharacterTemplate().isEmpty());
}
@Test
void replaceCharacterTemplate_isolatesInternalListFromCallerMutations() {
// Garantie d'encapsulation : muter la liste passee ne doit pas affecter le GameSystem.
List<TemplateField> external = new ArrayList<>(List.of(TemplateField.text("A")));
GameSystem gs = GameSystem.builder().build();
gs.replaceCharacterTemplate(external);
external.add(TemplateField.text("B"));
assertEquals(1, gs.getCharacterTemplate().size());
}
// --- Templates NPC : meme logique, sanity check minimal ----------------
@Test
void npcTemplate_followsSameRulesAsCharacterTemplate() {
GameSystem gs = GameSystem.builder().build();
gs.addNpcField(TemplateField.text("Motivation"));
assertThrows(IllegalArgumentException.class,
() -> gs.addNpcField(TemplateField.text("motivation")));
gs.removeNpcField("Motivation");
assertTrue(gs.getNpcTemplate().isEmpty());
}
}

View File

@@ -1,5 +1,7 @@
package com.loremind.domain.lorecontext; 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 org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.shared.template;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -1,8 +1,8 @@
package com.loremind.infrastructure.persistence.converter; package com.loremind.infrastructure.persistence.converter;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.ImageLayout; import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;

View File

@@ -1,10 +1,10 @@
package com.loremind.infrastructure.persistence.postgres; package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.ImageLayout; import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.Lore; import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.Template; 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.LoreRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;

View File

@@ -1,17 +1,21 @@
package com.loremind.infrastructure.updates; package com.loremind.infrastructure.updates;
import com.loremind.application.licensing.LicenseService;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus; import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.util.Map; import java.util.List;
import java.util.concurrent.ConcurrentHashMap; import java.util.Properties;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -19,69 +23,68 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
* Test unitaire pour UpdateCheckService. * Tests UpdateCheckService - approche semver (post-refactor v0.8.x).
* *
* Couvre les invariants critiques de la detection de MAJ : * Couvre :
* - feature desactivee si token absent * - feature desactivee si WATCHTOWER_TOKEN absent
* - status UP_TO_DATE quand baseline == remote * - UP_TO_DATE quand version locale == max(tags remote)
* - status UPDATE_AVAILABLE quand baseline != remote * - UPDATE_AVAILABLE quand un tag plus eleve existe
* - status UNKNOWN quand baseline manque (PAS d'alignement lazy — invariant * - UNKNOWN quand le registry echoue
* central, regression historique) * - UNKNOWN quand BuildProperties est absent (currentVersion = null)
* - status UNKNOWN quand remote impossible a fetcher * - parseSemver / findMaxSemver / compareSemver utilitaires
* - drapeaux top-level updateAvailable / anyUnknown coherents
* - back-compat : champ updateAvailable sur ImageStatus = (status == UPDATE_AVAILABLE)
*/ */
public class UpdateCheckServiceTest { public class UpdateCheckServiceTest {
private static UpdateCheckService newService(String token) { private static UpdateCheckService newService(String token, String currentVersion) {
// licensing.* params left empty + LicenseService null : la feature beta est BuildProperties bp = null;
// desactivee dans ces tests, qui couvrent uniquement le canal stable. if (currentVersion != null) {
Properties p = new Properties();
p.setProperty("version", currentVersion);
bp = new BuildProperties(p);
}
// licenseService null : la beta est testee separement, ces tests
// couvrent uniquement le canal stable.
return new UpdateCheckService( return new UpdateCheckService(
new RestTemplateBuilder(), new RestTemplateBuilder(),
"ghcr.io", "ghcr.io",
"igmlcreation/loremind-core,igmlcreation/loremind-brain", "igmlcreation/loremind-core,igmlcreation/loremind-brain",
"latest",
"http://watchtower:8080", "http://watchtower:8080",
token, token,
"", "",
"latest", null,
null bp
); );
} }
/**
* Injecte un RestTemplate moque dans le service deja construit, et pose
* directement les baselines pour eviter les vrais appels HTTP.
*/
@SuppressWarnings("unchecked")
private static void setBaselines(UpdateCheckService svc, Map<String, String> baselines) {
((Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests")).putAll(baselines);
}
private static RestTemplate stubHttp(UpdateCheckService svc) { private static RestTemplate stubHttp(UpdateCheckService svc) {
RestTemplate http = mock(RestTemplate.class); RestTemplate http = mock(RestTemplate.class);
ReflectionTestUtils.setField(svc, "http", http); ReflectionTestUtils.setField(svc, "http", http);
return http; return http;
} }
private static void stubRemoteDigest(RestTemplate http, String image, String digest) { private static void stubTags(RestTemplate http, String image, List<String> tags) {
HttpHeaders headers = new HttpHeaders(); TagsListResponse body = new TagsListResponse();
if (digest != null) headers.add("Docker-Content-Digest", digest); body.name = image;
ResponseEntity<Void> resp = new ResponseEntity<>(headers, org.springframework.http.HttpStatus.OK); body.tags = tags;
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"), ResponseEntity<TagsListResponse> resp = new ResponseEntity<>(body, HttpStatus.OK);
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class))) when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
.thenReturn(resp); .thenReturn(resp);
} }
private static void stubRemoteFailure(RestTemplate http, String image) { private static void stubTagsFailure(RestTemplate http, String image) {
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"), when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class))) eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
.thenThrow(new RuntimeException("network down")); .thenThrow(new RuntimeException("network down"));
} }
// -----------------------------------------------------------------
// Comportement du service
// -----------------------------------------------------------------
@Test @Test
void disabledWhenTokenMissing() { void disabledWhenTokenMissing() {
UpdateCheckService svc = newService(""); UpdateCheckService svc = newService("", "0.8.0");
UpdateStatus status = svc.check(); UpdateStatus status = svc.check();
assertFalse(status.enabled()); assertFalse(status.enabled());
assertFalse(status.updateAvailable()); assertFalse(status.updateAvailable());
@@ -90,118 +93,153 @@ public class UpdateCheckServiceTest {
} }
@Test @Test
void upToDate_whenBaselineEqualsRemote() { void upToDate_whenCurrentEqualsMaxRemote() {
UpdateCheckService svc = newService("token"); UpdateCheckService svc = newService("token", "0.8.0");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of(
"igmlcreation/loremind-core", "sha256:aaa",
"igmlcreation/loremind-brain", "sha256:bbb"
));
RestTemplate http = stubHttp(svc); RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:aaa"); stubTags(http, "igmlcreation/loremind-core",
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb"); List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
stubTags(http, "igmlcreation/loremind-brain",
List.of("0.7.0", "0.8.0", "latest"));
UpdateStatus status = svc.check(); UpdateStatus status = svc.check();
assertTrue(status.enabled()); assertTrue(status.enabled());
assertFalse(status.updateAvailable()); assertFalse(status.updateAvailable());
assertFalse(status.anyUnknown()); assertFalse(status.anyUnknown());
assertEquals("0.8.0", status.currentVersion());
for (ImageStatus img : status.images()) { for (ImageStatus img : status.images()) {
assertEquals(ImageStatusKind.UP_TO_DATE, img.status()); assertEquals(ImageStatusKind.UP_TO_DATE, img.status());
assertEquals("0.8.0", img.localVersion());
assertEquals("0.8.0", img.remoteVersion());
assertFalse(img.updateAvailable(), "back-compat bool"); assertFalse(img.updateAvailable(), "back-compat bool");
} }
} }
@Test @Test
void updateAvailable_whenRemoteDiffers() { void updateAvailable_whenRemoteHigher() {
UpdateCheckService svc = newService("token"); UpdateCheckService svc = newService("token", "0.7.2");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of(
"igmlcreation/loremind-core", "sha256:OLD",
"igmlcreation/loremind-brain", "sha256:bbb"
));
RestTemplate http = stubHttp(svc); RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW"); stubTags(http, "igmlcreation/loremind-core",
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb"); List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
stubTags(http, "igmlcreation/loremind-brain",
List.of("0.7.2", "latest"));
UpdateStatus status = svc.check(); UpdateStatus status = svc.check();
assertTrue(status.updateAvailable()); assertTrue(status.updateAvailable());
assertFalse(status.anyUnknown()); assertFalse(status.anyUnknown());
ImageStatus core = status.images().stream() ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow(); .filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status()); assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
assertEquals("0.7.2", core.localVersion());
assertEquals("0.8.0", core.remoteVersion());
assertTrue(core.updateAvailable(), "back-compat bool"); assertTrue(core.updateAvailable(), "back-compat bool");
ImageStatus brain = status.images().stream() ImageStatus brain = status.images().stream()
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow(); .filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status()); assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
} }
@Test @Test
void unknown_whenBaselineMissing_DOES_NOT_lazyAlign() { void unknown_whenRegistryFails() {
// INVARIANT CENTRAL : si la baseline est absente (echec init au boot), UpdateCheckService svc = newService("token", "0.8.0");
// on NE DOIT PAS aligner lazy sur le remote courant — sinon une MAJ
// pousse APRES le boot serait declaree "a jour" silencieusement.
UpdateCheckService svc = newService("token");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
// baseline DELIBEREMENT vide
RestTemplate http = stubHttp(svc); RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:remote-now"); stubTagsFailure(http, "igmlcreation/loremind-core");
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:remote-now-2"); stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
UpdateStatus status = svc.check();
assertTrue(status.anyUnknown());
ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UNKNOWN, core.status());
assertNull(core.remoteVersion());
assertEquals("0.8.0", core.localVersion());
}
@Test
void unknown_whenNoValidSemverTags() {
UpdateCheckService svc = newService("token", "0.8.0");
RestTemplate http = stubHttp(svc);
stubTags(http, "igmlcreation/loremind-core", List.of("latest", "stable", "main"));
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
UpdateStatus status = svc.check();
assertTrue(status.anyUnknown());
ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UNKNOWN, core.status());
assertNull(core.remoteVersion());
}
@Test
void unknown_whenBuildPropertiesAbsent() {
// INVARIANT : pas de version courante => tout UNKNOWN, jamais "a jour"
// par defaut. Evite de declarer "a jour" un build dev sans build-info.
UpdateCheckService svc = newService("token", null);
RestTemplate http = stubHttp(svc);
// Meme si on stub des tags, le service doit bypass et renvoyer UNKNOWN
stubTags(http, "igmlcreation/loremind-core", List.of("0.8.0"));
UpdateStatus status = svc.check(); UpdateStatus status = svc.check();
assertTrue(status.enabled()); assertTrue(status.enabled());
assertFalse(status.updateAvailable()); assertFalse(status.updateAvailable());
assertTrue(status.anyUnknown()); assertTrue(status.anyUnknown());
assertNull(status.currentVersion());
for (ImageStatus img : status.images()) { for (ImageStatus img : status.images()) {
assertEquals(ImageStatusKind.UNKNOWN, img.status()); assertEquals(ImageStatusKind.UNKNOWN, img.status());
assertNull(img.localDigest());
assertNotNull(img.remoteDigest()); // remote OK, baseline manquante
} }
}
// VERIFICATION CRITIQUE : la baseline ne doit PAS avoir ete posee. // -----------------------------------------------------------------
@SuppressWarnings("unchecked") // Utilitaires semver
Map<String, String> baselines = (Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests"); // -----------------------------------------------------------------
assertTrue(baselines.isEmpty(),
"check() ne doit JAMAIS aligner lazy la baseline sur le remote — " @Test
+ "regression de bug historique (faux negatif silencieux)."); void parseSemver_acceptsCommonFormats() {
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0"));
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0"));
assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0"));
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1"));
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42"));
} }
@Test @Test
void unknown_whenRemoteFetchFails() { void parseSemver_rejectsInvalid() {
UpdateCheckService svc = newService("token"); assertNull(UpdateCheckService.parseSemver(null));
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>()); assertNull(UpdateCheckService.parseSemver(""));
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:aaa", assertNull(UpdateCheckService.parseSemver("latest"));
"igmlcreation/loremind-brain", "sha256:bbb")); assertNull(UpdateCheckService.parseSemver("stable"));
RestTemplate http = stubHttp(svc); assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
stubRemoteFailure(http, "igmlcreation/loremind-core"); assertNull(UpdateCheckService.parseSemver("0.x.0"));
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
UpdateStatus status = svc.check();
assertFalse(status.updateAvailable());
assertTrue(status.anyUnknown());
ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UNKNOWN, core.status());
assertNull(core.remoteDigest());
assertEquals("sha256:aaa", core.localDigest()); // baseline preservee
} }
@Test @Test
void mixedStatuses_anyUnknownAndAnyUpdateBothTrue() { void compareSemver_basic() {
UpdateCheckService svc = newService("token"); assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>()); assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:OLD")); assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
// brain n'a pas de baseline -> UNKNOWN assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
RestTemplate http = stubHttp(svc); assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW"); assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
stubRemoteFailure(http, "igmlcreation/loremind-brain"); }
UpdateStatus status = svc.check(); @Test
void findMaxSemver_picksHighest() {
assertEquals("0.8.0", UpdateCheckService.findMaxSemver(
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
assertEquals("0.10.0", UpdateCheckService.findMaxSemver(
List.of("0.8.0", "0.10.0", "0.9.5")));
assertEquals("v1.0.0", UpdateCheckService.findMaxSemver(
List.of("v0.8.0", "v1.0.0", "latest")));
}
assertTrue(status.updateAvailable(), "core a une MAJ disponible"); @Test
assertTrue(status.anyUnknown(), "brain est UNKNOWN"); void findMaxSemver_returnsNullWhenNoValidTag() {
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
assertNull(UpdateCheckService.findMaxSemver(List.of()));
} }
} }

View File

@@ -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());
}
}

View File

@@ -6,7 +6,7 @@ import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO; 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;

View File

@@ -40,7 +40,7 @@
Auteur : ietm64 Auteur : ietm64
Licence : AGPL-3.0 Licence : AGPL-3.0
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
Version : 0.8.0 Version : 0.8.3
.LINK .LINK
https://github.com/IGMLcreation/LoreMind https://github.com/IGMLcreation/LoreMind

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.8.0", "version": "0.8.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.8.0", "version": "0.8.3",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.8.0", "version": "0.8.3",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -1,3 +1,5 @@
<app-update-banner></app-update-banner>
<div class="app-container"> <div class="app-container">
<app-sidebar></app-sidebar> <app-sidebar></app-sidebar>

View File

@@ -4,13 +4,23 @@ import { RouterOutlet } from '@angular/router';
import { SidebarComponent } from './sidebar/sidebar.component'; import { SidebarComponent } from './sidebar/sidebar.component';
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component'; import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
import { GlobalSearchComponent } from './shared/global-search/global-search.component'; import { GlobalSearchComponent } from './shared/global-search/global-search.component';
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
import { LayoutService } from './services/layout.service'; import { LayoutService } from './services/layout.service';
import { GlobalSearchService } from './services/global-search.service'; import { GlobalSearchService } from './services/global-search.service';
import { VersionCheckerService } from './services/version-checker.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf], imports: [
RouterOutlet,
SidebarComponent,
SecondarySidebarComponent,
GlobalSearchComponent,
UpdateBannerComponent,
AsyncPipe,
NgIf,
],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss']
}) })
@@ -19,8 +29,14 @@ export class AppComponent {
constructor( constructor(
private layoutService: LayoutService, private layoutService: LayoutService,
private globalSearch: GlobalSearchService private globalSearch: GlobalSearchService,
) {} versionChecker: VersionCheckerService,
) {
// Demarre la detection de mise a jour en arriere-plan.
// Si une nouvelle version est deployee pendant la session, l'UpdateBanner
// s'affichera automatiquement.
versionChecker.start();
}
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {

View File

@@ -18,8 +18,10 @@ export const routes: Routes = [
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, { path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },

View File

@@ -90,7 +90,7 @@
</div> </div>
<div class="characters-grid" *ngIf="characters.length > 0"> <div class="characters-grid" *ngIf="characters.length > 0">
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)"> <div class="character-card" *ngFor="let character of characters" (click)="viewCharacter(character)">
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon> <lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
<div class="character-info"> <div class="character-info">
<span class="character-name">{{ character.name }}</span> <span class="character-name">{{ character.name }}</span>
@@ -123,7 +123,7 @@
</div> </div>
<div class="characters-grid" *ngIf="npcs.length > 0"> <div class="characters-grid" *ngIf="npcs.length > 0">
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(npc)"> <div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon> <lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info"> <div class="character-info">
<span class="character-name">{{ npc.name }}</span> <span class="character-name">{{ npc.name }}</span>

View File

@@ -194,6 +194,17 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']); this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
} }
/** Ouvre la vue lecture seule (style WorldAnvil) — clic sur la carte. */
viewCharacter(character: Character): void {
if (!this.campaign || !character.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id]);
}
viewNpc(npc: Npc): void {
if (!this.campaign || !npc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id]);
}
createArc(): void { createArc(): void {
if (!this.campaign) return; if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']); this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
@@ -205,19 +216,23 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
} }
/** /**
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). * Extrait une ligne de resume pour la fiche PJ/PNJ — 1re valeur de template
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte). * non-vide (apres refonte 2026-04-30, remplace l'ancien parsing markdown).
*/ */
personaSnippet(p: { markdownContent?: string | null }): string { personaSnippet(p: { values?: Record<string, string> }): string {
if (!p.markdownContent) return '(Fiche vide)'; const values = p.values ?? {};
const firstMeaningful = p.markdownContent for (const v of Object.values(values)) {
.split('\n') if (!v) continue;
.map(l => l.trim()) const firstMeaningful = v
.find(l => l && !l.startsWith('#')); .split('\n')
if (!firstMeaningful) return '(Fiche vide)'; .map(l => l.trim())
return firstMeaningful.length > 80 .find(l => l && !l.startsWith('#'));
? firstMeaningful.substring(0, 77) + '…' if (!firstMeaningful) continue;
: firstMeaningful; return firstMeaningful.length > 80
? firstMeaningful.substring(0, 77) + '…'
: firstMeaningful;
}
return '(Fiche vide)';
} }
/** Alias gardé pour compatibilité avec les anciens templates. */ /** Alias gardé pour compatibilité avec les anciens templates. */

View File

@@ -35,18 +35,37 @@
/> />
</div> </div>
<div class="field content-field"> <div class="field-row image-row">
<label>Fiche (markdown)</label> <div class="field portrait-field">
<p class="hint"> <label>Portrait</label>
Tout en markdown libre : stats, classe, backstory, équipement, objectifs personnels… <app-single-image-picker
L'IA lira ces infos pour rester cohérente quand vous générez des scènes impliquant ce PJ. [imageId]="portraitImageId"
</p> aspectRatio="1 / 1"
<textarea hint="Carre conseille (400×400)."
[(ngModel)]="markdownContent" (imageIdChange)="portraitImageId = $event">
name="markdownContent" </app-single-image-picker>
rows="22" </div>
placeholder="# Thorin Grand-Hache&#10;&#10;**Race :** Nain&#10;**Classe :** Guerrier niveau 4&#10;**PV :** 35 / 35&#10;&#10;## Stats&#10;- Force : 16&#10;- Dextérité : 12&#10;...&#10;&#10;## Backstory&#10;Originaire des montagnes du Nord, Thorin a fui son clan après..." <div class="field header-field">
></textarea> <label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div> </div>
<div class="actions"> <div class="actions">

View File

@@ -4,22 +4,28 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service'; 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.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
/** /**
* Éditeur plein écran d'une fiche de personnage (PJ). * Editeur plein ecran d'une fiche de personnage (PJ).
* Double rôle création/édition : * Refonte 2026-04-30 : remplace le markdown libre par un formulaire dynamique
* - `/campaigns/:campaignId/characters/create` → POST * pilote par le characterTemplate du GameSystem associe a la campagne.
* - `/campaigns/:campaignId/characters/:characterId/edit` → PUT
* *
* MVP : name + markdown libre. Évolution prévue vers un template dérivé * Comportements :
* du GameSystem de la campagne (stats structurées). * - 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({ @Component({
selector: 'app-character-edit', selector: 'app-character-edit',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent], imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './character-edit.component.html', templateUrl: './character-edit.component.html',
styleUrls: ['./character-edit.component.scss'] styleUrls: ['./character-edit.component.scss']
}) })
@@ -30,12 +36,11 @@ export class CharacterEditComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
/** État drawer chat IA focalisé sur ce PJ. */
chatOpen = false; chatOpen = false;
readonly chatQuickSuggestions = [ readonly chatQuickSuggestions = [
'Propose une backstory cohérente avec l\'univers', 'Propose une backstory coherente avec l\'univers',
'Suggère 3 objectifs personnels pour ce personnage', 'Suggere 3 objectifs personnels pour ce personnage',
'Aide-moi à équilibrer les stats de combat' 'Aide-moi a equilibrer les stats de combat'
]; ];
toggleChat(): void { this.chatOpen = !this.chatOpen; } toggleChat(): void { this.chatOpen = !this.chatOpen; }
@@ -44,13 +49,20 @@ export class CharacterEditComponent implements OnInit {
characterId: string | null = null; characterId: string | null = null;
name = ''; name = '';
markdownContent = ''; portraitImageId: string | null = null;
headerImageId: string | null = null;
values: Record<string, string> = {};
imageValues: Record<string, string[]> = {};
keyValueValues: Record<string, Record<string, string>> = {};
templateFields: TemplateField[] = [];
private order = 0; private order = 0;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private service: CharacterService private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -58,11 +70,19 @@ export class CharacterEditComponent implements OnInit {
this.campaignId = params.get('campaignId'); this.campaignId = params.get('campaignId');
this.characterId = params.get('characterId'); this.characterId = params.get('characterId');
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
}
if (this.characterId) { if (this.characterId) {
this.service.getById(this.characterId).subscribe({ this.service.getById(this.characterId).subscribe({
next: (c) => { next: (c) => {
this.name = c.name; this.name = c.name;
this.markdownContent = c.markdownContent ?? ''; this.portraitImageId = c.portraitImageId ?? null;
this.headerImageId = c.headerImageId ?? null;
this.values = c.values ?? {};
this.imageValues = c.imageValues ?? {};
this.keyValueValues = c.keyValueValues ?? {};
this.order = c.order ?? 0; this.order = c.order ?? 0;
}, },
error: () => this.back() error: () => this.back()
@@ -70,21 +90,36 @@ 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 { submit(): void {
if (!this.name.trim() || !this.campaignId) return; if (!this.name.trim() || !this.campaignId) return;
const payload = {
name: this.name.trim(),
portraitImageId: this.portraitImageId,
headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const req = this.characterId const req = this.characterId
? this.service.update(this.characterId, { ? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
id: this.characterId, : this.service.create(payload);
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId,
order: this.order
})
: this.service.create({
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId
});
req.subscribe({ req.subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur sauvegarde Character') error: () => console.error('Erreur sauvegarde Character')
@@ -93,7 +128,7 @@ export class CharacterEditComponent implements OnInit {
deleteCharacter(): void { deleteCharacter(): void {
if (!this.characterId) return; 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({ this.service.delete(this.characterId).subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur suppression Character') error: () => console.error('Erreur suppression Character')

View File

@@ -0,0 +1,28 @@
<div class="cv-page">
<div class="cv-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="characterId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
</button>
</div>
<app-persona-view [persona]="character" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="characterId && campaignId"
[campaignId]="campaignId"
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,53 @@
.cv-page {
padding: 16px 0 48px;
min-height: 100vh;
}
.cv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 16px;
max-width: 1100px;
margin: 0 auto;
.spacer { flex: 1; }
}
.btn-back,
.btn-edit,
.btn-ai {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
transition: all 120ms;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.btn-edit {
border-color: rgba(209, 168, 120, 0.4);
color: #d1a878;
&:hover {
background: rgba(209, 168, 120, 0.15);
}
}
.btn-ai {
&.active {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
}
}

View File

@@ -0,0 +1,80 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { Character } from '../../../services/character.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Vue lecture seule "WorldAnvil" d'une fiche PJ.
* Route : /campaigns/:campaignId/characters/:characterId
*/
@Component({
selector: 'app-character-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './character-view.component.html',
styleUrls: ['./character-view.component.scss']
})
export class CharacterViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
characterId: string | null = null;
character: Character | null = null;
templateFields: TemplateField[] = [];
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.characterId = params.get('characterId');
if (this.characterId) {
this.service.getById(this.characterId).subscribe({
next: c => { this.character = c; },
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.characterTemplate ?? [];
});
}
});
}
}
edit(): void {
if (this.campaignId && this.characterId) {
this.router.navigate(['/campaigns', this.campaignId, 'characters', this.characterId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -35,18 +35,37 @@
/> />
</div> </div>
<div class="field content-field"> <div class="field-row image-row">
<label>Fiche (markdown)</label> <div class="field portrait-field">
<p class="hint"> <label>Portrait</label>
Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ… <app-single-image-picker
À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes. [imageId]="portraitImageId"
</p> aspectRatio="1 / 1"
<textarea hint="Carre conseille (400×400)."
[(ngModel)]="markdownContent" (imageIdChange)="portraitImageId = $event">
name="markdownContent" </app-single-image-picker>
rows="22" </div>
placeholder="# Borin le forgeron&#10;&#10;**Race :** Nain&#10;**Faction :** Clan Feuillefer&#10;**Statut :** Vivant&#10;&#10;## Apparence&#10;Barbe rousse tressée, tablier de cuir brûlé...&#10;&#10;## Motivations&#10;Venger son clan décimé par les orcs il y a 10 hivers.&#10;&#10;## Notes MJ (secret)&#10;Connaît l'emplacement du marteau de Durin..." <div class="field header-field">
></textarea> <label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div> </div>
<div class="actions"> <div class="actions">

View File

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

View File

@@ -0,0 +1,28 @@
<div class="nv-page">
<div class="nv-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="npcId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
</button>
</div>
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="npcId && campaignId"
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,51 @@
.nv-page {
padding: 16px 0 48px;
min-height: 100vh;
}
.nv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 16px;
max-width: 1100px;
margin: 0 auto;
.spacer { flex: 1; }
}
.btn-back,
.btn-edit,
.btn-ai {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
transition: all 120ms;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.btn-edit {
border-color: rgba(209, 168, 120, 0.4);
color: #d1a878;
&:hover {
background: rgba(209, 168, 120, 0.15);
}
}
.btn-ai.active {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
}

View File

@@ -0,0 +1,80 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, 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.model';
import { Npc } from '../../../services/npc.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Vue lecture seule "WorldAnvil" d'une fiche PNJ.
* Route : /campaigns/:campaignId/npcs/:npcId
*/
@Component({
selector: 'app-npc-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './npc-view.component.html',
styleUrls: ['./npc-view.component.scss']
})
export class NpcViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
npcId: string | null = null;
npc: Npc | null = null;
templateFields: TemplateField[] = [];
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.npcId = params.get('npcId');
if (this.npcId) {
this.service.getById(this.npcId).subscribe({
next: n => { this.npc = n; },
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.npcTemplate ?? [];
});
}
});
}
}
edit(): void {
if (this.campaignId && this.npcId) {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -90,6 +90,32 @@
</div> </div>
</div> </div>
<!-- Templates de fiches PJ/PNJ -->
<div class="templates-area">
<h2 class="sections-title">Fiches de personnages</h2>
<p class="sections-hint">
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…).
</p>
<app-template-fields-editor
label="Champs de la fiche PJ"
hint="Affiches lors de la creation/edition d'un personnage joueur."
[fields]="characterTemplate"
[suggestions]="characterFieldSuggestions"
(fieldsChange)="characterTemplate = $event">
</app-template-fields-editor>
<app-template-fields-editor
label="Champs de la fiche PNJ"
hint="Affiches lors de la creation/edition d'un personnage non-joueur."
[fields]="npcTemplate"
[suggestions]="npcFieldSuggestions"
(fieldsChange)="npcTemplate = $event">
</app-template-fields-editor>
</div>
<div class="actions"> <div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()"> <button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon> <lucide-icon [img]="Save" [size]="16"></lucide-icon>

View File

@@ -5,6 +5,13 @@
margin: 0 auto; margin: 0 auto;
} }
.templates-area {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.gse-header { .gse-header {
margin-bottom: 2rem; margin-bottom: 2rem;

View File

@@ -4,6 +4,8 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
import { GameSystemService } from '../../services/game-system.service'; import { GameSystemService } from '../../services/game-system.service';
import { TemplateField } from '../../services/template.model';
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
/** /**
* Éditeur plein écran d'un GameSystem. Rôle double création/édition : * Éditeur plein écran d'un GameSystem. Rôle double création/édition :
@@ -31,10 +33,16 @@ const SUGGESTED_SECTIONS = [
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression' 'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
]; ];
/** Suggestions de champs pour la fiche PJ — generiques (extension par template). */
const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'Notes'];
/** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */
const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ'];
@Component({ @Component({
selector: 'app-game-system-edit', selector: 'app-game-system-edit',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [CommonModule, FormsModule, LucideAngularModule, TemplateFieldsEditorComponent],
templateUrl: './game-system-edit.component.html', templateUrl: './game-system-edit.component.html',
styleUrls: ['./game-system-edit.component.scss'] styleUrls: ['./game-system-edit.component.scss']
}) })
@@ -53,8 +61,12 @@ export class GameSystemEditComponent implements OnInit {
description = ''; description = '';
author = ''; author = '';
sections: RuleSection[] = []; sections: RuleSection[] = [];
characterTemplate: TemplateField[] = [];
npcTemplate: TemplateField[] = [];
readonly suggestedSections = SUGGESTED_SECTIONS; readonly suggestedSections = SUGGESTED_SECTIONS;
readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -71,6 +83,8 @@ export class GameSystemEditComponent implements OnInit {
this.description = gs.description ?? ''; this.description = gs.description ?? '';
this.author = gs.author ?? ''; this.author = gs.author ?? '';
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? ''); this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
}, },
error: () => this.back() error: () => this.back()
}); });
@@ -104,11 +118,17 @@ export class GameSystemEditComponent implements OnInit {
submit(): void { submit(): void {
if (!this.name.trim()) return; if (!this.name.trim()) return;
if (this.hasInvalidTemplateFields()) {
console.warn('Champs templates invalides (noms vides ou doublons) — sauvegarde bloquee.');
return;
}
const payload = { const payload = {
name: this.name.trim(), name: this.name.trim(),
description: this.description.trim() || null, description: this.description.trim() || null,
author: this.author.trim() || null, author: this.author.trim() || null,
rulesMarkdown: this.serializeMarkdown(), rulesMarkdown: this.serializeMarkdown(),
characterTemplate: this.characterTemplate,
npcTemplate: this.npcTemplate,
isPublic: false isPublic: false
}; };
const req = this.id const req = this.id
@@ -124,6 +144,22 @@ export class GameSystemEditComponent implements OnInit {
this.router.navigate(['/game-systems']); this.router.navigate(['/game-systems']);
} }
/** Validation cote front : nom vide ou doublons (case-insensitive). */
private hasInvalidTemplateFields(): boolean {
return this.hasInvalidList(this.characterTemplate) || this.hasInvalidList(this.npcTemplate);
}
private hasInvalidList(fields: TemplateField[]): boolean {
const seen = new Set<string>();
for (const f of fields) {
const name = f.name?.trim().toLowerCase();
if (!name) return true;
if (seen.has(name)) return true;
seen.add(name);
}
return false;
}
// --- Parse / Serialize markdown ------------------------------------------ // --- Parse / Serialize markdown ------------------------------------------
/** /**

View File

@@ -1,18 +1,30 @@
/** /**
* Fiche de personnage joueur (PJ) d'une campagne. * Fiche de personnage joueur (PJ) d'une campagne.
* MVP : markdownContent libre. Évolution prévue vers des fiches templatées * Refonte 2026-04-30 : abandon du markdownContent au profit d'un systeme
* par GameSystem (stats structurées selon le JDR joué). * template-based pilote par le GameSystem de la campagne.
* - portraitImageId / headerImageId : champs universels hard-codes
* - values : Map<champ template TEXT/NUMBER, valeur>
* - imageValues : Map<champ template IMAGE, liste d'IDs d'images>
*/ */
export interface Character { export interface Character {
id?: string; id?: string;
name: string; name: string;
markdownContent?: string | null; portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
/** Champs KEY_VALUE_LIST : fieldName -> label -> value. */
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string; campaignId: string;
order?: number; order?: number;
} }
export interface CharacterCreate { export interface CharacterCreate {
name: string; name: string;
markdownContent?: string | null; portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string; campaignId: string;
} }

View File

@@ -1,24 +1,30 @@
import { TemplateField } from './template.model';
/** /**
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java). * Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
* *
* rulesMarkdown : markdown monolithique, sections découpées par titres H2 * rulesMarkdown : markdown monolithique, sections decoupees par titres H2.
* (## Combat, ## Classes, etc.). Le découpage et la sélection des sections * characterTemplate / npcTemplate : champs templates pilotant le rendu des
* à injecter dans le prompt IA sont faits côté backend Java. * fiches PJ / PNJ d'une campagne adossee a ce systeme (cf. refonte 2026-04-30).
*/ */
export interface GameSystem { export interface GameSystem {
id?: string; id?: string;
name: string; name: string;
description?: string | null; description?: string | null;
rulesMarkdown?: string | null; rulesMarkdown?: string | null;
characterTemplate?: TemplateField[];
npcTemplate?: TemplateField[];
author?: string | null; author?: string | null;
isPublic?: boolean; isPublic?: boolean;
} }
/** Payload de création/mise à jour (sans id). */ /** Payload de creation/mise a jour (sans id). */
export interface GameSystemCreate { export interface GameSystemCreate {
name: string; name: string;
description?: string | null; description?: string | null;
rulesMarkdown?: string | null; rulesMarkdown?: string | null;
characterTemplate?: TemplateField[];
npcTemplate?: TemplateField[];
author?: string | null; author?: string | null;
isPublic: boolean; isPublic: boolean;
} }

View File

@@ -28,8 +28,8 @@ export interface BetaStatusDTO {
anyUnknown: boolean; anyUnknown: boolean;
images: Array<{ images: Array<{
image: string; image: string;
localDigest: string | null; localVersion: string | null;
remoteDigest: string | null; remoteVersion: string | null;
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN'; status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
updateAvailable: boolean; updateAvailable: boolean;
}>; }>;

View File

@@ -1,18 +1,25 @@
/** /**
* Fiche de personnage non-joueur (PNJ) d'une campagne. * Fiche de personnage non-joueur (PNJ) d'une campagne.
* MVP : markdownContent libre (description, motivation, stats, notes MJ). * Refonte 2026-04-30 : meme structure que Character (template-based).
* Évolution prévue : templating partagé PJ/PNJ piloté par GameSystem.
*/ */
export interface Npc { export interface Npc {
id?: string; id?: string;
name: string; name: string;
markdownContent?: string | null; portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string; campaignId: string;
order?: number; order?: number;
} }
export interface NpcCreate { export interface NpcCreate {
name: string; name: string;
markdownContent?: string | null; portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string; campaignId: string;
} }

View File

@@ -1,11 +1,13 @@
// Interfaces TypeScript pour TemplateDTO (Backend Java). // Interfaces TypeScript pour TemplateDTO (Backend Java).
/** /**
* Type d'un champ de Template. Miroir de com.loremind.domain.lorecontext.FieldType. * Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
* - 'TEXT' : champ textuel libre (rendu en textarea) * - 'TEXT' : champ textuel libre (rendu en textarea)
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery) * - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
* - 'NUMBER' : valeur numerique (rendu en input number)
* - 'KEY_VALUE_LIST' : liste de paires {label, value} avec labels figes au template
*/ */
export type FieldType = 'TEXT' | 'IMAGE'; export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST';
/** /**
* Variante de rendu pour un champ IMAGE. Miroir de * Variante de rendu pour un champ IMAGE. Miroir de
@@ -26,6 +28,8 @@ export interface TemplateField {
type: FieldType; type: FieldType;
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */ /** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
layout?: ImageLayout | null; layout?: ImageLayout | null;
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
labels?: string[] | null;
} }
export interface Template { export interface Template {

View File

@@ -3,19 +3,18 @@ import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs'; import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs';
/** /**
* Reflet de UpdateCheckService.UpdateStatus cote backend. * Reflet de UpdateCheckService.UpdateStatus cote backend (post-refactor v0.8.x).
* *
* Etat tri-state par image : UP_TO_DATE / UPDATE_AVAILABLE / UNKNOWN. * Etat tri-state par image : UP_TO_DATE / UPDATE_AVAILABLE / UNKNOWN.
* UNKNOWN signale que la comparaison est impossible (baseline absente ou * Comparaison faite par version semver (BuildProperties cote backend), plus
* remote injoignable) — l'UI doit afficher un avertissement plutot que * fiable que les digests qui ne survivaient pas a un restart sans pull.
* d'annoncer "a jour" silencieusement.
*/ */
export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN'; export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
export interface ImageStatus { export interface ImageStatus {
image: string; image: string;
localDigest: string | null; localVersion: string | null;
remoteDigest: string | null; remoteVersion: string | null;
status: ImageStatusKind; status: ImageStatusKind;
/** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */ /** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */
updateAvailable: boolean; updateAvailable: boolean;
@@ -27,6 +26,8 @@ export interface UpdateStatus {
updateAvailable: boolean; updateAvailable: boolean;
/** True si au moins une image a status === 'UNKNOWN'. */ /** True si au moins une image a status === 'UNKNOWN'. */
anyUnknown: boolean; anyUnknown: boolean;
/** Version courante du binaire (BuildProperties). null si build-info absent. */
currentVersion: string | null;
images: ImageStatus[]; images: ImageStatus[];
checkedAt: string; checkedAt: string;
} }

View File

@@ -0,0 +1,94 @@
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
/**
* Detecte qu'une nouvelle version de LoreMind a ete deployee pendant qu'un
* onglet utilisateur est deja ouvert.
*
* Strategie : polling toutes les {@link POLL_INTERVAL_MS} sur /api/version.
* La version observee au boot est figee comme reference. Si la version polled
* differe, {@link hasUpdate} passe a true et l'UI peut afficher un bandeau
* proposant un reload.
*
* Pourquoi pas un Service Worker ? Trop lourd pour le besoin (offline pas
* pertinent, lifecycle complexe). Polling simple = ~15 lignes, fait le job.
*/
@Injectable({ providedIn: 'root' })
export class VersionCheckerService {
private static readonly POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
private static readonly INITIAL_DELAY_MS = 30 * 1000; // 30s avant premier poll
private readonly _bootVersion = signal<string | null>(null);
private readonly _remoteVersion = signal<string | null>(null);
private readonly _hasUpdate = computed(
() => {
const boot = this._bootVersion();
const remote = this._remoteVersion();
return boot !== null && remote !== null && boot !== remote;
}
);
/** True quand la version remote differe de celle observee au boot. */
readonly hasUpdate = this._hasUpdate;
/** Version observee a l'ouverture du tab (figee). */
readonly bootVersion = this._bootVersion.asReadonly();
/** Derniere version observee sur le backend. */
readonly remoteVersion = this._remoteVersion.asReadonly();
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(private http: HttpClient) {}
/**
* Demarre le polling. A appeler une fois au boot de l'app.
* Ignore si deja demarre (idempotent).
*/
start(): void {
if (this.timer !== null) return;
void this.fetchInitial();
this.timer = setInterval(
() => void this.poll(),
VersionCheckerService.POLL_INTERVAL_MS,
);
}
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
}
/** Recharge l'app (force re-fetch index.html + assets). */
reload(): void {
window.location.reload();
}
private async fetchInitial(): Promise<void> {
// Petit delai pour laisser le bootstrap se finir avant le premier appel.
await new Promise(r => setTimeout(r, VersionCheckerService.INITIAL_DELAY_MS));
const v = await this.fetchVersion();
if (v && this._bootVersion() === null) {
this._bootVersion.set(v);
this._remoteVersion.set(v);
}
}
private async poll(): Promise<void> {
const v = await this.fetchVersion();
if (v) this._remoteVersion.set(v);
}
private async fetchVersion(): Promise<string | null> {
try {
const resp = await firstValueFrom(
this.http.get<{ version: string }>('/api/version'),
);
return resp?.version ?? null;
} catch {
// Backend down / restart en cours — on ignore, on retentera au prochain tick
return null;
}
}
}

View File

@@ -0,0 +1,56 @@
<div class="dff" *ngIf="fields?.length; else emptyTpl">
<div class="dff-field" *ngFor="let f of fields; trackBy: trackByName">
<label>{{ f.name }}</label>
<ng-container [ngSwitch]="f.type">
<textarea
*ngSwitchCase="'TEXT'"
rows="4"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="Renseignez {{ f.name }}…">
</textarea>
<input
*ngSwitchCase="'NUMBER'"
type="number"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="0"
/>
<app-image-gallery
*ngSwitchCase="'IMAGE'"
[editable]="true"
[layout]="f.layout || 'GALLERY'"
[imageIds]="imagesFor(f)"
(imageIdsChange)="onImageIdsChange(f, $event)">
</app-image-gallery>
<!-- KEY_VALUE_LIST : grille d'inputs avec labels figes du template -->
<div *ngSwitchCase="'KEY_VALUE_LIST'" class="dff-kv-grid">
<div class="dff-kv-cell" *ngFor="let lbl of f.labels; trackBy: trackByLabel">
<span class="dff-kv-label">{{ lbl }}</span>
<input
type="text"
[ngModel]="kvValue(f, lbl)"
(ngModelChange)="onKvChange(f, lbl, $event)"
[name]="'kv-' + f.name + '-' + lbl"
placeholder="—"
/>
</div>
<div *ngIf="!f.labels?.length" class="dff-kv-empty">
Aucun label defini dans le template pour ce champ.
</div>
</div>
</ng-container>
</div>
</div>
<ng-template #emptyTpl>
<div class="dff-empty">
Aucun champ defini dans le template de ce systeme. Editez le GameSystem pour ajouter des champs.
</div>
</ng-template>

View File

@@ -0,0 +1,94 @@
.dff {
display: flex;
flex-direction: column;
gap: 14px;
}
.dff-field {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text, #fff);
}
input,
textarea {
padding: 8px 10px;
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.95rem;
font-family: inherit;
resize: vertical;
}
}
.image-mvp {
display: flex;
flex-direction: column;
gap: 4px;
.hint {
font-size: 0.75rem;
color: var(--color-text-muted, #888);
font-style: italic;
}
}
.dff-kv-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
}
.dff-kv-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 8px 6px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
.dff-kv-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted, #9ca3af);
}
input {
width: 100%;
text-align: center;
padding: 4px 6px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 3px;
color: var(--color-text, #fff);
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-weight: 700;
font-size: 1.05rem;
}
}
.dff-kv-empty {
padding: 8px;
font-size: 0.8rem;
color: var(--color-text-muted, #888);
font-style: italic;
}
.dff-empty {
padding: 24px;
text-align: center;
color: var(--color-text-muted, #888);
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 6px;
}

View File

@@ -0,0 +1,63 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TemplateField } from '../../services/template.model';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
/**
* Formulaire dynamique pilote par une liste de TemplateField.
*
* Inputs :
* - fields : structure (provient du GameSystem.characterTemplate / npcTemplate)
* - values : Record<champName, string> pour les types TEXT et NUMBER
* - imageValues : Record<champName, string[]> pour le type IMAGE
*
* Pour les champs IMAGE, delegue au composant <app-image-gallery editable>
* qui gere l'upload, la suppression et le respect du layout.
*/
@Component({
selector: 'app-dynamic-fields-form',
standalone: true,
imports: [CommonModule, FormsModule, ImageGalleryComponent],
templateUrl: './dynamic-fields-form.component.html',
styleUrls: ['./dynamic-fields-form.component.scss']
})
export class DynamicFieldsFormComponent {
@Input() fields: TemplateField[] = [];
@Input() values: Record<string, string> = {};
@Input() imageValues: Record<string, string[]> = {};
@Input() keyValueValues: Record<string, Record<string, string>> = {};
@Output() valuesChange = new EventEmitter<Record<string, string>>();
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
@Output() keyValueValuesChange = new EventEmitter<Record<string, Record<string, string>>>();
onTextChange(field: TemplateField, value: string): void {
this.values = { ...this.values, [field.name]: value };
this.valuesChange.emit(this.values);
}
onImageIdsChange(field: TemplateField, ids: string[]): void {
this.imageValues = { ...this.imageValues, [field.name]: ids };
this.imageValuesChange.emit(this.imageValues);
}
imagesFor(field: TemplateField): string[] {
return this.imageValues[field.name] ?? [];
}
/** Valeur d'un label particulier dans un champ KEY_VALUE_LIST. */
kvValue(field: TemplateField, label: string): string {
return this.keyValueValues?.[field.name]?.[label] ?? '';
}
/** Met a jour la valeur d'un label dans un champ KEY_VALUE_LIST. */
onKvChange(field: TemplateField, label: string, value: string): void {
const inner = { ...(this.keyValueValues[field.name] ?? {}), [label]: value };
this.keyValueValues = { ...this.keyValueValues, [field.name]: inner };
this.keyValueValuesChange.emit(this.keyValueValues);
}
trackByName = (_: number, f: TemplateField) => f.name;
trackByLabel = (_: number, l: string) => l;
}

View File

@@ -0,0 +1,108 @@
<div class="pv" *ngIf="persona">
<!-- Bandeau / Header -->
<div class="pv-banner" *ngIf="persona.headerImageId">
<img [src]="contentUrl(persona.headerImageId)" alt="" />
<div class="pv-banner-fade"></div>
</div>
<!-- En-tete : portrait + titre -->
<div class="pv-hero" [class.no-banner]="!persona.headerImageId">
<div class="pv-portrait" *ngIf="persona.portraitImageId">
<img [src]="contentUrl(persona.portraitImageId)" alt="" />
</div>
<div class="pv-title-block">
<h1 class="pv-name">{{ persona.name }}</h1>
<p *ngIf="subtitle" class="pv-subtitle">{{ subtitle }}</p>
<!-- Badges des NUMBER isoles (rendu compact, evite la grosse card pour 1 valeur) -->
<div *ngIf="heroBadges.length" class="pv-hero-badges">
<span *ngFor="let b of heroBadges" class="pv-hero-badge">
<span class="pv-hero-badge-label">{{ b.label }}</span>
<span class="pv-hero-badge-value">{{ b.value }}</span>
</span>
</div>
</div>
</div>
<!-- Sections rendues dans l'ordre du template -->
<div class="pv-sections">
<ng-container *ngFor="let s of orderedSections">
<!-- TEXT -->
<section *ngIf="s.kind === 'TEXT'" class="pv-section">
<h2 class="pv-section-title">{{ s.name }}</h2>
<div class="pv-section-body">
<p [class.with-dropcap]="s.name === firstTextSectionName" class="pv-paragraph">
{{ firstParagraph(s.value) }}
</p>
<p *ngIf="restAfterFirstParagraph(s.value)" class="pv-paragraph">
{{ restAfterFirstParagraph(s.value) }}
</p>
</div>
</section>
<!-- NUMBER_GROUP : NUMBER consecutifs - table si <=6, sinon liste 2 cols -->
<section *ngIf="s.kind === 'NUMBER_GROUP'" class="pv-section pv-section-number">
<ng-container *ngIf="s.entries.length <= 6; else numGroupList">
<div class="pv-kv-table" [style.--cols]="s.entries.length">
<div class="pv-kv-row pv-kv-row-labels">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.label }}</div>
</div>
<div class="pv-kv-row pv-kv-row-values">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.value }}</div>
</div>
</div>
</ng-container>
<ng-template #numGroupList>
<div class="pv-kv-list">
<div class="pv-kv-list-row" *ngFor="let e of s.entries">
<span class="pv-kv-list-label">{{ e.label }}</span>
<span class="pv-kv-list-dots"></span>
<span class="pv-kv-list-value">{{ e.value }}</span>
</div>
</div>
</ng-template>
</section>
<!-- KEY_VALUE_LIST : table style Foundry si <=6, sinon liste 2 cols (skills) -->
<section *ngIf="s.kind === 'KEY_VALUE_LIST'" class="pv-section pv-section-kv">
<h2 class="pv-section-title">{{ s.name }}</h2>
<ng-container *ngIf="s.entries.length <= 6; else kvList">
<div class="pv-kv-table" [style.--cols]="s.entries.length">
<div class="pv-kv-row pv-kv-row-labels">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.label }}</div>
</div>
<div class="pv-kv-row pv-kv-row-values">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.value || '—' }}</div>
</div>
</div>
</ng-container>
<ng-template #kvList>
<div class="pv-kv-list">
<div class="pv-kv-list-row" *ngFor="let e of s.entries">
<span class="pv-kv-list-label">{{ e.label }}</span>
<span class="pv-kv-list-dots"></span>
<span class="pv-kv-list-value">{{ e.value || '—' }}</span>
</div>
</div>
</ng-template>
</section>
<!-- IMAGE : galerie -->
<section *ngIf="s.kind === 'IMAGE'" class="pv-section pv-section-images">
<h2 class="pv-section-title">{{ s.name }}</h2>
<app-image-gallery [imageIds]="s.ids" [layout]="s.layout" [editable]="false">
</app-image-gallery>
</section>
</ng-container>
<!-- Etat vide -->
<div *ngIf="orderedSections.length === 0" class="pv-empty">
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
<p>Cette fiche est encore vide.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,339 @@
// Vue WorldAnvil-style : bandeau, portrait latteral, sections elegantes, drop cap.
.pv {
max-width: 1100px;
margin: 0 auto;
color: #e5e7eb;
}
// --- Bandeau ----------------------------------------------------------------
.pv-banner {
position: relative;
height: 280px;
overflow: hidden;
border-radius: 8px 8px 0 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.pv-banner-fade {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to bottom,
transparent 60%,
rgba(15, 17, 23, 0.85) 100%
);
}
// --- Hero (portrait + titre) ------------------------------------------------
.pv-hero {
display: grid;
grid-template-columns: 220px 1fr;
gap: 28px;
padding: 20px 32px;
margin-top: -90px;
position: relative;
z-index: 1;
&.no-banner {
margin-top: 0;
padding-top: 32px;
}
}
.pv-portrait {
width: 220px;
height: 220px;
border-radius: 6px;
overflow: hidden;
border: 3px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
background: #1a1d24;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.pv-title-block {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom: 8px;
}
.pv-name {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 3rem;
font-weight: 600;
letter-spacing: 0.04em;
margin: 0;
color: #f3f4f6;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
}
.pv-subtitle {
margin: 8px 0 0;
font-size: 1.05rem;
font-style: italic;
color: #b5b9c4;
letter-spacing: 0.05em;
}
// Badges compacts pour les NUMBER isoles (Niveau, etc.) — evite la grosse card.
.pv-hero-badges {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pv-hero-badge {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 4px 12px;
background: rgba(209, 168, 120, 0.08);
border: 1px solid rgba(209, 168, 120, 0.3);
border-radius: 100px;
.pv-hero-badge-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #9ca3af;
}
.pv-hero-badge-value {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-weight: 700;
font-size: 1.05rem;
color: #d1a878;
}
}
// --- Table compacte (NUMBER_GROUP / KEY_VALUE_LIST) ------------------------
// Style Foundry : 2 rangees (labels uppercase / valeurs serif), separateurs
// fins, sans gros ornements. --cols est la variable CSS pour le nombre
// d'entrees, posee inline dans le HTML.
.pv-kv-table {
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: hidden;
background: rgba(0, 0, 0, 0.18);
}
.pv-kv-row {
display: grid;
grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr));
}
.pv-kv-row-labels {
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
.pv-kv-cell {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #9ca3af;
padding: 6px 4px;
text-align: center;
}
}
.pv-kv-row-values {
.pv-kv-cell {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 1.15rem;
font-weight: 700;
color: #d1a878;
padding: 8px 4px;
text-align: center;
}
}
.pv-kv-cell {
border-right: 1px solid rgba(255, 255, 255, 0.04);
&:last-child {
border-right: none;
}
}
@media (max-width: 600px) {
// Sur mobile : si plus de 4 colonnes, on retombe en wrap (sacrifice de la
// structure tableau pour eviter le scroll horizontal sur les stats blocks).
.pv-kv-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
// --- Liste 2 colonnes (KV avec >6 entrees, type "skills" Foundry) ---------
.pv-kv-list {
column-count: 2;
column-gap: 32px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
}
.pv-kv-list-row {
display: flex;
align-items: baseline;
gap: 6px;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
break-inside: avoid; // empeche un row de se couper entre 2 colonnes
&:last-child {
border-bottom: none;
}
.pv-kv-list-label {
font-size: 0.85rem;
color: #d6d8de;
flex-shrink: 0;
}
// Pointilles entre label et valeur (style fiche papier)
.pv-kv-list-dots {
flex: 1;
border-bottom: 1px dotted rgba(255, 255, 255, 0.12);
transform: translateY(-3px);
}
.pv-kv-list-value {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-weight: 700;
font-size: 1rem;
color: #d1a878;
flex-shrink: 0;
}
}
@media (max-width: 600px) {
.pv-kv-list {
column-count: 1;
}
}
// --- Sections ---------------------------------------------------------------
.pv-sections {
padding: 32px 32px 48px;
}
.pv-section {
margin-bottom: 36px;
&:last-child {
margin-bottom: 0;
}
}
.pv-section-kv,
.pv-section-number {
.pv-kv-table {
margin-top: 8px;
}
}
.pv-section-title {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 1.4rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #d1a878;
margin: 0 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(209, 168, 120, 0.25);
position: relative;
// Petit ornement central
&::after {
content: '';
position: absolute;
left: 50%;
bottom: -3px;
width: 6px;
height: 6px;
background: #d1a878;
border-radius: 50%;
transform: translateX(-50%);
}
}
.pv-section-body {
font-size: 1rem;
line-height: 1.65;
color: #d6d8de;
}
.pv-paragraph {
margin: 0 0 14px;
white-space: pre-wrap;
&.with-dropcap::first-letter {
float: left;
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 3.5rem;
line-height: 0.9;
font-weight: 700;
color: #d1a878;
padding: 4px 8px 0 0;
margin-top: 4px;
}
}
// --- Etat vide --------------------------------------------------------------
.pv-empty {
text-align: center;
padding: 64px 24px;
color: #6b7280;
font-style: italic;
p {
margin: 12px 0 0;
}
}
// --- Responsive -------------------------------------------------------------
@media (max-width: 720px) {
.pv-hero {
grid-template-columns: 1fr;
margin-top: -60px;
}
.pv-portrait {
width: 160px;
height: 160px;
}
.pv-name {
font-size: 2.2rem;
}
.pv-banner {
height: 180px;
}
}

View File

@@ -0,0 +1,135 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { TemplateField, ImageLayout } from '../../services/template.model';
/** Section rendue dans la vue, dans l'ordre du template. Discriminee par `kind`. */
export type RenderedSection =
| { kind: 'TEXT'; name: string; value: string }
| { kind: 'NUMBER_GROUP'; entries: { label: string; value: string }[] }
| { kind: 'KEY_VALUE_LIST'; name: string; entries: { label: string; value: string }[] }
| { kind: 'IMAGE'; name: string; ids: string[]; layout: ImageLayout };
import { ImageService } from '../../services/image.service';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
/**
* Affichage type "WorldAnvil" d'une fiche PJ ou PNJ.
*
* Layout :
* - Bandeau (headerImageId) en haut, pleine largeur
* - Bloc 2 colonnes : portrait a gauche, infos textuelles a droite
* - Sections suivantes pour chaque champ template TEXT/NUMBER/IMAGE
* - Drop cap sur la 1re lettre du 1er paragraphe TEXT
*
* Composant pur de presentation : ne fetche rien, recoit (persona, templateFields).
*/
export interface PersonaLike {
name: string;
portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
}
@Component({
selector: 'app-persona-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './persona-view.component.html',
styleUrls: ['./persona-view.component.scss']
})
export class PersonaViewComponent {
readonly BookOpen = BookOpen;
@Input() persona: PersonaLike | null = null;
@Input() templateFields: TemplateField[] = [];
/** Sous-titre optionnel sous le nom (ex: "Champion d'Aerimor"). */
@Input() subtitle?: string;
constructor(private imageService: ImageService) {}
contentUrl(id: string): string {
return this.imageService.contentUrl(id);
}
/**
* Decompose la fiche en (heroBadges, sections) en un seul passage :
* - NUMBER consecutifs groupes : 2+ → NUMBER_GROUP en section ; 1 isole → badge hero.
* - TEXT / KEY_VALUE_LIST / IMAGE : sections dans l'ordre du template.
* Le calcul est fait par rendered() et cache via le get pour eviter les
* recalculs multiples par cycle de change detection.
*/
private rendered(): { heroBadges: { label: string; value: string }[]; sections: RenderedSection[] } {
const sections: RenderedSection[] = [];
const heroBadges: { label: string; value: string }[] = [];
let numberBuffer: { label: string; value: string }[] = [];
const flushNumberBuffer = () => {
if (numberBuffer.length === 1) {
heroBadges.push(numberBuffer[0]);
} else if (numberBuffer.length > 1) {
sections.push({ kind: 'NUMBER_GROUP', entries: numberBuffer });
}
numberBuffer = [];
};
for (const f of this.templateFields) {
if (f.type === 'NUMBER') {
const value = this.persona?.values?.[f.name] ?? '';
if (value.trim()) numberBuffer.push({ label: f.name, value });
continue;
}
flushNumberBuffer();
if (f.type === 'TEXT') {
const value = this.persona?.values?.[f.name] ?? '';
if (value.trim()) sections.push({ kind: 'TEXT', name: f.name, value });
} else if (f.type === 'KEY_VALUE_LIST') {
const inner = this.persona?.keyValueValues?.[f.name] ?? {};
const labels = f.labels ?? [];
const entries = labels.map(label => ({ label, value: inner[label] ?? '' }));
if (entries.some(e => e.value.trim())) {
sections.push({ kind: 'KEY_VALUE_LIST', name: f.name, entries });
}
} else if (f.type === 'IMAGE') {
const ids = this.persona?.imageValues?.[f.name] ?? [];
if (ids.length > 0) {
sections.push({ kind: 'IMAGE', name: f.name, ids, layout: f.layout ?? 'GALLERY' });
}
}
}
flushNumberBuffer();
return { heroBadges, sections };
}
get heroBadges(): { label: string; value: string }[] {
return this.rendered().heroBadges;
}
get orderedSections(): RenderedSection[] {
return this.rendered().sections;
}
/** Pour la drop cap : seul le 1er TEXT la recoit. */
get firstTextSectionName(): string | null {
for (const s of this.orderedSections) {
if (s.kind === 'TEXT') return s.name;
}
return null;
}
/** Premier paragraphe d'un texte (utilise pour la drop cap). */
firstParagraph(text: string): string {
if (!text) return '';
const paragraphs = text.split(/\n\s*\n/);
return paragraphs[0]?.trim() ?? '';
}
/** Reste du texte apres le 1er paragraphe. */
restAfterFirstParagraph(text: string): string {
if (!text) return '';
const paragraphs = text.split(/\n\s*\n/);
return paragraphs.slice(1).join('\n\n').trim();
}
}

View File

@@ -0,0 +1,14 @@
<div class="sip">
<div class="sip-frame" [style.aspectRatio]="aspectRatio">
<ng-container *ngIf="imageId; else uploadTpl">
<img [src]="contentUrl(imageId)" alt="" />
<button type="button" class="sip-remove" (click)="remove()" title="Retirer l'image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</ng-container>
<ng-template #uploadTpl>
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</ng-template>
</div>
<small *ngIf="hint" class="sip-hint">{{ hint }}</small>
</div>

View File

@@ -0,0 +1,48 @@
.sip {
display: flex;
flex-direction: column;
gap: 4px;
}
.sip-frame {
position: relative;
width: 100%;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.sip-remove {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
transition: background 120ms;
&:hover {
background: rgba(220, 38, 38, 0.9);
}
}
.sip-hint {
font-size: 0.75rem;
font-style: italic;
color: var(--color-text-muted, #888);
}

View File

@@ -0,0 +1,60 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
/**
* Picker d'image unique : preview + upload + suppression.
*
* Usage :
* <app-single-image-picker [imageId]="portraitId" (imageIdChange)="portraitId = $event">
* </app-single-image-picker>
*
* Comportements :
* - Si imageId est defini : affiche la miniature avec un bouton X pour retirer
* - Sinon : affiche le bouton d'upload (compact mode)
*
* Le composant ne supprime pas l'image cote backend — il decouple juste le
* lien (passe imageId a null). L'image reste accessible via d'autres entites.
*/
@Component({
selector: 'app-single-image-picker',
standalone: true,
imports: [CommonModule, LucideAngularModule, ImageUploaderComponent],
templateUrl: './single-image-picker.component.html',
styleUrls: ['./single-image-picker.component.scss']
})
export class SingleImagePickerComponent {
readonly X = X;
readonly ImageIcon = ImageIcon;
@Input() imageId: string | null = null;
/** Texte d'aide affiche sous le picker (ex: "Format conseille : 400×400"). */
@Input() hint?: string;
/** Aspect ratio de la preview (CSS aspect-ratio property). */
@Input() aspectRatio = '1 / 1';
@Output() imageIdChange = new EventEmitter<string | null>();
constructor(private imageService: ImageService) {}
contentUrl(id: string): string {
return this.imageService.contentUrl(id);
}
onUploaded(img: Image): void {
if (img?.id) {
this.imageId = img.id;
this.imageIdChange.emit(this.imageId);
}
}
remove(): void {
this.imageId = null;
this.imageIdChange.emit(null);
}
}

Some files were not shown because too many files have changed in this diff Show More