12 Commits

Author SHA1 Message Date
4b9b7f0995 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
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 1m42s
Build & Push Images / build (web) (push) Successful in 1m38s
Build & Push Images / build-switcher (push) Successful in 1m48s
2026-05-19 18:36:00 +02:00
3d73b1e6a7 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build-switcher (push) Successful in 43s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-05-19 18:25:54 +02:00
759e47fc1f Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
2026-05-19 18:05:17 +02:00
f71bf3fcad Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 2m27s
Build & Push Images / build (core) (push) Successful in 3m15s
Build & Push Images / build (web) (push) Successful in 2m49s
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
0cd99dfb32 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m51s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build (web) (push) Successful in 1m39s
2026-05-19 14:06:57 +02:00
f24ef0891e Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 2m53s
Build & Push Images / build (web) (push) Successful in 2m36s
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
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
168 changed files with 6915 additions and 1276 deletions

View File

@@ -42,19 +42,24 @@ jobs:
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract version
# Detection du canal :
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
# loremind-beta-<component> ; backup Gitea avec :version)
- name: Extract version & channel
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
# Push vers les deux registries en un seul build (build-push-action
# accepte une liste de tags ; aucun build supplementaire necessaire).
# Naming :
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
# dans leur .env.
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
# la collision avec d'autres projets de l'org.
- name: Build & push ${{ matrix.component }}
# Build & push canal STABLE
- name: Build & push ${{ matrix.component }} (stable)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
@@ -64,3 +69,73 @@ jobs:
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Build & push canal BETA
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
# via le relais Patreon aux tiers Compagnon).
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
# pas faire upgrader les installs branchees sur Gitea).
- name: Build & push ${{ matrix.component }} (beta)
if: steps.meta.outputs.channel == 'beta'
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Job separe pour le sidecar `switcher`.
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
# donc uniquement sur les releases stables — pas la peine de re-publier
# une variante beta du switcher, c'est une infrastructure neutre.
build-switcher:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Detect channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
- name: Login to Gitea Registry
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Login to GHCR
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build & push switcher (stable only)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5
with:
context: ./switcher
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}

6
.gitignore vendored
View File

@@ -7,6 +7,11 @@
brain/data/settings.json
*.key
*.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
@@ -102,3 +107,4 @@ docker-compose.override.yml
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
# ============================================================================
relay/
scripts/bump-version.mjs

View File

@@ -1,37 +1,66 @@
# 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)
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.
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.
## Ce que ça fait
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
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
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
Quelques limites à connaître :
- 10 utilisateurs maximum simultanés (instances isolées)
- 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.
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
## Licence
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le 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.
- 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.
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
- 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 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(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.6.6",
version="0.8.6",
)

View File

@@ -8,13 +8,13 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<version>3.2.12</version>
<relativePath/>
</parent>
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.8.0</version>
<version>0.8.6</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -96,6 +96,15 @@
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.14.1</version>
</dependency>
</dependencies>
<build>
@@ -111,6 +120,16 @@
</exclude>
</excludes>
</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>
<!-- 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 org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@@ -22,8 +24,18 @@ public class CharacterService {
/**
* Parameter Object pour la création / mise à jour d'un Character.
* `order` est fourni par le controller ; si absent, le service le calcule.
* Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
*/
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
public record CharacterData(
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order
) {}
public Character createCharacter(CharacterData data) {
int order = data.order() != null
@@ -31,7 +43,11 @@ public class CharacterService {
: nextOrderFor(data.campaignId());
Character character = Character.builder()
.name(data.name())
.markdownContent(data.markdownContent())
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
@@ -50,7 +66,11 @@ public class CharacterService {
Character existing = characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setMarkdownContent(data.markdownContent());
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) {
existing.setOrder(data.order());
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,135 @@
package com.loremind.application.licensing;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
*
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
* dans un volume partage. Quand on depose une commande, il :
* <ol>
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
* <li>Lance docker compose pull + up -d</li>
* <li>Ecrit son resultat dans {@code result.json}</li>
* </ol>
*
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
* sur l'hote. Le sidecar valide strictement le contenu de la commande
* (channel ∈ {stable, beta} uniquement).
*
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
* presence de "loremind-beta-" => canal beta, sinon stable.
*/
@Service
public class ChannelSwitcherService {
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
public enum Channel { STABLE, BETA }
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SwitchResult(
String id,
SwitchStatus status,
Channel channel,
String message,
Instant completedAt) {}
private final Path switcherDataPath;
private final String imageNamespace;
private final ObjectMapper json = new ObjectMapper();
public ChannelSwitcherService(
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
// qui peut etre absente dans les .env legacy).
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
this.switcherDataPath = Path.of(switcherDataPath);
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
switcherDataPath, this.imageNamespace);
}
/**
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
*/
public Channel getCurrentChannel() {
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
}
/**
* Indique si le sidecar est disponible (volume partage accessible).
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
* avec instructions manuelles).
*/
public boolean isSwitcherAvailable() {
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
}
/**
* Depose une commande de switch dans le volume partage. Renvoie l'ID
* de la commande, que le client peut utiliser pour poller le status.
*
* @throws IllegalStateException si le sidecar n'est pas disponible
* @throws IOException si l'ecriture du fichier echoue
*/
public String requestSwitch(Channel target) throws IOException {
if (!isSwitcherAvailable()) {
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
}
String id = UUID.randomUUID().toString();
Map<String, Object> command = new LinkedHashMap<>();
command.put("id", id);
command.put("channel", target.name().toLowerCase());
command.put("requestedAt", Instant.now().toString());
Path commandFile = switcherDataPath.resolve("command.json");
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
try {
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} finally {
// Cleanup au cas ou move aurait echoue avant le rename.
Files.deleteIfExists(tmp);
}
log.info("Switch command written: id={} channel={}", id, target);
return id;
}
/**
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
*/
public SwitchResult getLastResult() {
Path resultFile = switcherDataPath.resolve("result.json");
if (!Files.exists(resultFile)) return null;
try {
return json.readValue(resultFile.toFile(), SwitchResult.class);
} catch (IOException e) {
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
return null;
}
}
}

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;

View File

@@ -4,18 +4,26 @@ import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche de personnage joueur (PJ) d'une campagne.
* <p>
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
* backstory, équipement). Évolution prévue vers un système templaté par
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
* Champs universels hard-codes : {@code name}, {@code portraitImageId},
* {@code headerImageId}. Tout le reste est piloté par le template PJ du
* GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
* <p>
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
* Évolution prévue : système de templating partagé PJ/PNJ piloté par
* GameSystem pour adapter les blocs aux différents systèmes de JDR.
* Les valeurs des champs templates sont stockées dans deux maps :
* - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
* parsé à l'usage cote presentation)
* - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
* <p>
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
* <p>
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
*/
@Data
@Builder
@@ -24,11 +32,32 @@ public class Character {
private String id;
private String name;
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
private String portraitImageId;
/** ID de l'image header/banniere (champ universel hard-codé). Nullable. */
private String headerImageId;
/**
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
* renseigné progressivement par le MJ.
* Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
* (sensible a la casse cote stockage mais comparaison case-insensitive
* dans le domaine GameSystem). Jamais null apres construction.
*/
private String markdownContent;
private Map<String, String> values;
/**
* Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
* liste ordonnee d'IDs d'images. Jamais null apres construction.
*/
private Map<String, List<String>> imageValues;
/**
* 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. */
private String campaignId;
@@ -38,4 +67,20 @@ public class Character {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** Garantit que les maps ne sont jamais null cote consommateur. */
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
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 java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche de personnage non-joueur (PNJ) d'une campagne.
* <p>
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé —
* un PNJ a vocation à porter à terme des invariants métier propres (faction,
* statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ)
* qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité
* PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern).
* Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
* à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
* <p>
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé
* PJ/PNJ piloté par GameSystem.
* Mêmes champs universels hard-codés et meme structure de templating que Character,
* pilotée par le template PNJ du GameSystem
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
* <p>
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent
* gérés via le système Page/Template du LoreContext.
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
* via le système Page/Template du LoreContext.
*/
@Data
@Builder
@@ -27,10 +28,22 @@ public class Npc {
private String id;
private String name;
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */
private String markdownContent;
/** ID de l'image portrait (champ universel hard-code). Nullable. */
private String portraitImageId;
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */
/** ID de l'image header/banniere (champ universel hard-code). Nullable. */
private String headerImageId;
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
private Map<String, String> values;
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
private Map<String, List<String>> imageValues;
/** 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;
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
@@ -38,4 +51,19 @@ public class Npc {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
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;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Entité de domaine représentant un GameSystem (système de JDR).
@@ -12,6 +16,10 @@ import java.time.LocalDateTime;
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
* <p>
* Porte aussi deux templates piloтant la structure des fiches PJ et PNJ d'une
* campagne adossée à ce système. Les fiches markdown libres ont laissé place à
* un système de champs typés (TEXT/IMAGE/NUMBER) défini ici.
* <p>
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
* éviter une migration ultérieure.
@@ -27,6 +35,21 @@ public class GameSystem {
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
private String rulesMarkdown;
/**
* Template de fiche PJ : champs typés affichés pour chaque personnage joueur.
* Hors champs universels hard-codés (nom, portrait, header). Jamais null après
* persistance — un template vide est représenté par une liste vide.
*/
private List<TemplateField> characterTemplate;
/**
* Template de fiche PNJ. Mêmes règles que {@link #characterTemplate}.
* Distinct du template PJ car les invariants métier divergent (un PNJ peut
* n'avoir qu'un nom + une motivation, un PJ porte généralement une feuille
* de stats complète).
*/
private List<TemplateField> npcTemplate;
/** Auteur déclaré — futur marketplace. Nullable. */
private String author;
@@ -35,4 +58,88 @@ public class GameSystem {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// --- Méthodes métier : templates PJ/PNJ --------------------------------
/**
* Ajoute un champ au template PJ. Refuse les doublons de nom (insensible à la casse)
* pour éviter les collisions de clés dans {@code Character.values}.
*/
public void addCharacterField(TemplateField field) {
characterTemplate = appendField(characterTemplate, field);
}
/** Pendant PNJ de {@link #addCharacterField}. */
public void addNpcField(TemplateField field) {
npcTemplate = appendField(npcTemplate, field);
}
/**
* Retire un champ du template PJ par nom (insensible à la casse).
* No-op silencieux si le champ n'existe pas — appelant n'a pas à pré-vérifier.
*/
public void removeCharacterField(String fieldName) {
characterTemplate = removeFieldByName(characterTemplate, fieldName);
}
public void removeNpcField(String fieldName) {
npcTemplate = removeFieldByName(npcTemplate, fieldName);
}
/**
* Remplace intégralement le template PJ. Utilisé pour le réordonnancement
* et l'édition en bloc côté UI. Valide l'unicité des noms.
*/
public void replaceCharacterTemplate(List<TemplateField> fields) {
characterTemplate = validateAndCopy(fields);
}
public void replaceNpcTemplate(List<TemplateField> fields) {
npcTemplate = validateAndCopy(fields);
}
// --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
if (field == null || field.getName() == null || field.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
List<TemplateField> next = current == null ? new ArrayList<>() : new ArrayList<>(current);
if (containsName(next, field.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + field.getName());
}
next.add(field);
return next;
}
private static List<TemplateField> removeFieldByName(List<TemplateField> current, String fieldName) {
if (current == null || fieldName == null) return current;
List<TemplateField> next = new ArrayList<>(current);
next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName));
return next;
}
private static List<TemplateField> validateAndCopy(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateField> copy = new ArrayList<>(fields.size());
for (TemplateField f : fields) {
if (f == null || f.getName() == null || f.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
if (containsName(copy, f.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + f.getName());
}
copy.add(f);
}
return copy;
}
private static boolean containsName(List<TemplateField> fields, String name) {
return fields.stream().anyMatch(f -> equalsIgnoreCase(f.getName(), name));
}
private static boolean equalsIgnoreCase(String a, String b) {
if (a == null || b == null) return a == b;
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
}
}

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;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder;
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.
@@ -8,7 +8,7 @@ package com.loremind.domain.lorecontext;
* - MASONRY : mosaique hauteurs variables facon Pinterest
* - CAROUSEL : defilement horizontal
* <p>
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon.
*/
public enum ImageLayout {
GALLERY,

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.ports.GameSystemRepository;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
@@ -23,6 +25,10 @@ import java.util.List;
* <p>
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
* <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
public class GameSystemSeeder {
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
@EventListener(ApplicationReadyEvent.class)
public void seedIfEmpty() {
if (!gameSystemRepository.findAll().isEmpty()) {
log.debug("GameSystem seed skipped — table non vide.");
return;
}
List<GameSystem> existing = gameSystemRepository.findAll();
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;
}
log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
backfillEmptyTemplates(existing);
}
/**
* 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++;
}
}
if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
}
private List<GameSystem> defaultSystems() {
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(NIMBLE_RULES)
.characterTemplate(nimbleCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(),
GameSystem.builder()
.name("D&D 5e SRD (extrait)")
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(DND_SRD_RULES)
.characterTemplate(dndCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(),
GameSystem.builder()
.name("Homebrew Exemple")
@@ -70,10 +102,66 @@ public class GameSystemSeeder {
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(HOMEBREW_EXAMPLE)
.characterTemplate(genericCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.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 = """
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.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@@ -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()) {
result.add(new TemplateField(name, type, layout));
result.add(new TemplateField(name, type, layout, labels));
}
}
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.

View File

@@ -1,5 +1,8 @@
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 lombok.AllArgsConstructor;
import lombok.Builder;
@@ -7,11 +10,18 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Entité JPA pour les fiches de personnages (PJ) d'une campagne.
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte :
* on reste dans le Campaign Context, mais l'agrégat Character est autonome).
* Entité JPA pour les fiches de personnages (PJ).
* <p>
* Refonte 2026-04-30 : ancien champ markdownContent migré vers {@code values["Notes"]}
* via Hibernate au demarrage. Hibernate ddl-auto=update ajoute les nouvelles colonnes
* sans dropper {@code markdown_content} — les donnees existantes sont conservees mais
* plus mappees au domaine. Migration manuelle prevue (script SQL one-shot) si le
* deploiement passe en bluegreen.
*/
@Entity
@Table(name = "characters")
@@ -28,8 +38,26 @@ public class CharacterJpaEntity {
@Column(nullable = false)
private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT")
private String markdownContent;
@Column(name = "portrait_image_id")
private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
/** Valeurs TEXT/NUMBER serialisees JSON. */
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
/** Valeurs IMAGE serialisees JSON. */
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
/** 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)
private Long campaignId;
@@ -47,6 +75,9 @@ public class CharacterJpaEntity {
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>();
}
@PreUpdate

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;

View File

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

View File

@@ -61,6 +61,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.name(e.getName())
.description(e.getDescription())
.rulesMarkdown(e.getRulesMarkdown())
.characterTemplate(e.getCharacterTemplate() != null
? new java.util.ArrayList<>(e.getCharacterTemplate())
: new java.util.ArrayList<>())
.npcTemplate(e.getNpcTemplate() != null
? new java.util.ArrayList<>(e.getNpcTemplate())
: new java.util.ArrayList<>())
.author(e.getAuthor())
.isPublic(e.isPublic())
.createdAt(e.getCreatedAt())
@@ -75,6 +81,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.name(g.getName())
.description(g.getDescription())
.rulesMarkdown(g.getRulesMarkdown())
.characterTemplate(g.getCharacterTemplate() != null
? new java.util.ArrayList<>(g.getCharacterTemplate())
: new java.util.ArrayList<>())
.npcTemplate(g.getNpcTemplate() != null
? new java.util.ArrayList<>(g.getNpcTemplate())
: new java.util.ArrayList<>())
.author(g.getAuthor())
.isPublic(g.isPublic())
.createdAt(g.getCreatedAt())

View File

@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -52,7 +53,11 @@ public class PostgresNpcRepository implements NpcRepository {
return Npc.builder()
.id(e.getId().toString())
.name(e.getName())
.markdownContent(e.getMarkdownContent())
.portraitImageId(e.getPortraitImageId())
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
@@ -65,7 +70,11 @@ public class PostgresNpcRepository implements NpcRepository {
return NpcJpaEntity.builder()
.id(id)
.name(n.getName())
.markdownContent(n.getMarkdownContent())
.portraitImageId(n.getPortraitImageId())
.headerImageId(n.getHeaderImageId())
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder())
.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.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
@@ -29,187 +29,121 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* 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 :
* - Au demarrage, on interroge le registry pour le digest courant de chaque
* image suivie ({@code update-check.images}). On stocke ces digests comme
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
* - 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.
* <b>Pourquoi pas les digests ?</b> Le bug historique etait : le baseline-digest
* pose au @PostConstruct supposait que le pull venait d'avoir lieu (vrai apres
* `docker compose pull && up -d`, faux apres un simple restart de daemon ou un
* OOM). La version semver lue depuis le binaire est <b>fiable par construction</b> :
* c'est ce que le code source declare faire tourner.
*/
@Service
public class UpdateCheckService {
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 String registry;
private final List<String> images;
private final String tag;
private final String watchtowerUrl;
private final String watchtowerToken;
private final List<String> betaImages;
private final String betaTag;
private final LicenseService licenseService;
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
/** Version semver courante du binaire (ex: "0.8.0"). Source de verite. */
private final String currentVersion;
public UpdateCheckService(
RestTemplateBuilder builder,
@Value("${update-check.registry:}") String registry,
@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-token:}") String watchtowerToken,
@Value("${licensing.beta.images:}") String betaImagesCsv,
@Value("${licensing.beta.tag:latest}") String betaTag,
LicenseService licenseService) {
LicenseService licenseService,
@Nullable BuildProperties buildProperties) {
this.http = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15))
.build();
this.registry = normalizeRegistry(registry);
this.images = parseImages(imagesCsv);
this.tag = tag;
this.watchtowerUrl = watchtowerUrl;
this.watchtowerToken = watchtowerToken;
this.betaImages = parseImages(betaImagesCsv);
this.betaTag = betaTag;
this.licenseService = licenseService;
}
/** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */
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();
this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
log.info("Update check init - registry={} images={} currentVersion={}",
this.registry, this.images, this.currentVersion);
}
public boolean isEnabled() {
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() {
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<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : images) {
String baseline = baselineDigests.get(image);
String remote = null;
String latest = null;
try {
remote = fetchRemoteDigest(image);
latest = fetchLatestSemverTag(registry, image, null);
} 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;
if (baseline == null || remote == null) {
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else if (baseline.equals(remote)) {
} else {
int cmp = compareSemver(currentVersion, latest);
if (cmp >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
statuses.add(new ImageStatus(image, baseline, remote, kind));
}
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now());
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
}
/**
* Verifie l'etat du canal beta (images privees GHCR).
* 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)
* Verifie l'etat du canal beta (images privees GHCR) avec auth basique.
*/
public BetaStatus checkBeta() {
if (!licenseService.isLicensingEnabled()) {
@@ -239,48 +173,156 @@ public class UpdateCheckService {
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : betaImages) {
String remote = null;
String latest = null;
try {
remote = fetchRemoteDigestAuth(betaRegistry, image, betaTag, basicAuth);
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
} 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
// ne sait pas quelle version le user fait tourner. On expose juste le
// digest remote ; l'UI affichera "version disponible : <tag>" sans
// comparaison locale tant qu'il n'y a pas un mecanisme de baseline.
ImageStatusKind kind = (remote == null) ? ImageStatusKind.UNKNOWN : ImageStatusKind.UPDATE_AVAILABLE;
if (kind == ImageStatusKind.UNKNOWN) anyUnknown = true;
else anyUpdate = true;
statuses.add(new ImageStatus(image, null, remote, kind));
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
}
private String fetchRemoteDigestAuth(String registryUrl, String image, String tagName, String authHeader) {
String url = registryUrl + "/v2/" + image + "/manifests/" + tagName;
HttpHeaders headers = new HttpHeaders();
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);
public void apply() {
if (!isEnabled()) {
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
}
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")
private String obtainBearerTokenWithAuth(@Nullable String wwwAuth, String authHeader) {
private String obtainBearerToken(@Nullable String wwwAuth, @Nullable String basicAuth) {
if (wwwAuth == null) return null;
String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
@@ -301,7 +343,9 @@ public class UpdateCheckService {
}
try {
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,
new HttpEntity<>(headers), Map.class);
Map<?, ?> body = resp.getBody();
@@ -309,97 +353,6 @@ public class UpdateCheckService {
Object t = body.get("token");
if (t == null) t = body.get("access_token");
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) {
log.warn("Bearer token request failed: {}", e.getMessage());
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 record UpdateStatus(
boolean enabled,
boolean updateAvailable,
boolean anyUnknown,
String currentVersion,
List<ImageStatus> images,
Instant checkedAt) {}
/**
* Etat du canal beta.
* <ul>
* <li>{@code enabled} : true si le canal beta est actif et la licence valide.</li>
* <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>
* Statut par image. {@code localVersion} = version embarquee dans le binaire ;
* {@code remoteVersion} = plus haute version semver trouvee dans le registry.
* {@code updateAvailable} est derive de {@code status} (back-compat front).
*/
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(
boolean enabled,
boolean updateAvailable,
@@ -499,20 +451,9 @@ public class UpdateCheckService {
}
}
/**
* Le champ {@code updateAvailable} est conserve pour la compatibilite
* avec les anciens clients ; il est strictement derive de {@code status}
* dans le constructeur compact.
*/
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);
}
/** DTO pour deserialisation Jackson de /v2/.../tags/list. */
static class TagsListResponse {
public String name;
public List<String> tags;
}
}

View File

@@ -0,0 +1,97 @@
package com.loremind.infrastructure.web;
import jakarta.persistence.EntityNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Intercepteur global d'exceptions pour TOUS les @RestController.
*
* <p>Role :
* <ul>
* <li>Logger systematiquement les exceptions non gerees (avec stack trace + path)
* — evite d'avoir a creuser dans les logs Docker apres coup.</li>
* <li>Renvoyer un JSON propre au client (`{error, type, ...}`) au lieu du 500 nu
* par defaut de Spring — utile pour debug cote frontend (visible directement
* dans la DevTools reseau).</li>
* <li>Mapper les exceptions courantes vers des status HTTP appropries
* (IllegalArgumentException -> 400, EntityNotFoundException -> 404).</li>
* </ul>
*
* <p>Important : ne court-circuite PAS les try/catch locaux des controllers
* (ex: LicenseController.install catche InstallException -> 400 lui-meme).
* Ce handler n'attrape QUE ce qui a echappe au catch local.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/**
* Violation d'invariant domaine (doublons, valeurs invalides, etc.) -> 400.
* Concentre ici la logique qui etait dupliquee dans GameSystemController.
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(ex)));
}
/** Entite JPA introuvable -> 404. */
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", safeMessage(ex)));
}
/** JSON malforme dans le body de la requete -> 400. */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, String>> handleUnreadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest().body(Map.of("error", "Malformed request body"));
}
/** Validation @Valid echouee -> 400 avec liste des erreurs par champ. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fields = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(e ->
fields.put(e.getField(), e.getDefaultMessage() != null ? e.getDefaultMessage() : "invalid"));
return ResponseEntity.badRequest().body(Map.of(
"error", "Validation failed",
"fields", fields
));
}
/**
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
* cote client. C'est LE filet de securite.
*
* Note : on attrape Throwable (pas Exception) pour aussi capturer les
* Error (NoClassDefFoundError, OutOfMemoryError... — cf. incident Tink).
* On NE swallow PAS — on log AVANT de renvoyer une reponse.
*/
@ExceptionHandler(Throwable.class)
public ResponseEntity<Map<String, String>> handleUnexpected(HttpServletRequest request, Throwable ex) {
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
Map<String, String> body = new LinkedHashMap<>();
body.put("error", "Internal server error");
body.put("type", ex.getClass().getSimpleName());
String msg = safeMessage(ex);
if (!msg.isEmpty()) body.put("message", msg);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
/** Evite les NPE quand getMessage() est null sur certaines exceptions. */
private static String safeMessage(Throwable ex) {
return ex.getMessage() != null ? ex.getMessage() : "";
}
}

View File

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

View File

@@ -2,11 +2,15 @@ package com.loremind.infrastructure.web.controller;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@@ -16,10 +20,14 @@ public class GameSystemController {
private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper;
private final TemplateFieldMapper templateFieldMapper;
public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) {
public GameSystemController(GameSystemService gameSystemService,
GameSystemMapper gameSystemMapper,
TemplateFieldMapper templateFieldMapper) {
this.gameSystemService = gameSystemService;
this.gameSystemMapper = gameSystemMapper;
this.templateFieldMapper = templateFieldMapper;
}
@PostMapping
@@ -68,8 +76,17 @@ public class GameSystemController {
dto.getName(),
dto.getDescription(),
dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()),
dto.getAuthor(),
dto.isPublic()
);
}
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
return out;
}
}

View File

@@ -1,12 +1,16 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.licensing.ChannelSwitcherService;
import com.loremind.application.licensing.LicenseService;
import com.loremind.application.licensing.LicenseService.InstallException;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.infrastructure.web.dto.licensing.ChannelStatusDTO;
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
/**
@@ -26,9 +30,11 @@ import java.util.Map;
public class LicenseController {
private final LicenseService licenseService;
private final ChannelSwitcherService channelSwitcher;
public LicenseController(LicenseService licenseService) {
public LicenseController(LicenseService licenseService, ChannelSwitcherService channelSwitcher) {
this.licenseService = licenseService;
this.channelSwitcher = channelSwitcher;
}
@GetMapping
@@ -82,6 +88,68 @@ public class LicenseController {
}
}
// ─── Bascule de canal (stable <-> beta) via sidecar switcher ────────────
//
// Le flux :
// 1. UI POST /api/license/channel/switch { channel: "beta" }
// 2. Core valide la licence (refus si target=beta sans Patreon actif)
// 3. Core depose une commande dans le volume partage
// 4. Sidecar `switcher` la traite (sed .env, docker compose up -d)
// 5. UI poll GET /api/license/channel pour suivre le status
/** Etat courant : canal actuel + dispo du sidecar + dernier resultat. */
@GetMapping("/channel")
public ChannelStatusDTO getChannel() {
return ChannelStatusDTO.from(channelSwitcher);
}
/** Declenche un switch de canal. Renvoie l'ID de la commande pour le polling. */
@PostMapping("/channel/switch")
public ResponseEntity<?> switchChannel(@RequestBody ChannelSwitchRequest request) {
if (request == null || request.channel() == null) {
return ResponseEntity.badRequest().body(Map.of("error", "missing channel"));
}
ChannelSwitcherService.Channel target;
try {
target = ChannelSwitcherService.Channel.valueOf(request.channel().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"error", "invalid channel (allowed: stable, beta)"));
}
// Garde : pas de switch vers beta sans licence Patreon valide.
// Le switcher ferait le boulot quoi qu'il arrive (il valide juste le
// format), donc c'est ici qu'on doit refuser cote metier.
// VALID + GRACE autorisent l'acces beta (cf. javadoc de LicenseStatus).
if (target == ChannelSwitcherService.Channel.BETA) {
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
com.loremind.domain.licensing.LicenseStatus s = (snap != null) ? snap.status() : null;
boolean allowed = s == com.loremind.domain.licensing.LicenseStatus.VALID
|| s == com.loremind.domain.licensing.LicenseStatus.GRACE;
if (!allowed) {
return ResponseEntity.status(403).body(Map.of(
"error", "Aucune licence Patreon active — impossible de basculer sur le canal beta."));
}
}
if (!channelSwitcher.isSwitcherAvailable()) {
return ResponseEntity.status(503).body(Map.of(
"error", "Sidecar switcher non disponible (mise a jour requise du docker-compose.yml)."));
}
try {
String id = channelSwitcher.requestSwitch(target);
return ResponseEntity.accepted().body(Map.of(
"id", id,
"channel", target.name().toLowerCase(Locale.ROOT)));
} catch (IOException e) {
return ResponseEntity.status(500).body(Map.of(
"error", "Impossible d'ecrire la commande de switch: " + e.getMessage()));
}
}
public record InstallRequest(String jwt) {}
public record BetaChannelRequest(boolean enabled) {}
public record ChannelSwitchRequest(String channel) {}
}

View File

@@ -24,9 +24,7 @@ public class NpcController {
@PostMapping
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
Npc created = npcService.createNpc(
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
);
Npc created = npcService.createNpc(toData(dto, null));
return ResponseEntity.ok(npcMapper.toDTO(created));
}
@@ -47,10 +45,7 @@ public class NpcController {
@PutMapping("/{id}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc(
id,
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
);
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
return ResponseEntity.ok(npcMapper.toDTO(updated));
}
@@ -59,4 +54,17 @@ public class NpcController {
npcService.deleteNpc(id);
return ResponseEntity.noContent().build();
}
private NpcService.NpcData toData(NpcDTO dto, Integer order) {
return new NpcService.NpcData(
dto.getName(),
dto.getPortraitImageId(),
dto.getHeaderImageId(),
dto.getValues(),
dto.getImageValues(),
dto.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.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import com.loremind.infrastructure.web.mapper.TemplateMapper;

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 java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* DTO pour les fiches de personnages (PJ) d'une campagne.
* Reflete la refonte template-based : champs universels hard-codes (name,
* portrait, header) + maps {@code values}/{@code imageValues} pour les
* champs templates pilotes par le GameSystem.
*/
@Data
public class CharacterDTO {
private String id;
private String name;
private String markdownContent;
private String portraitImageId;
private String headerImageId;
private Map<String, String> values = new HashMap<>();
private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId;
private int order;
}

View File

@@ -2,15 +2,23 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* DTO pour les fiches de PNJ d'une campagne.
* DTO pour les fiches de PNJ d'une campagne. Meme structure que CharacterDTO.
*/
@Data
public class NpcDTO {
private String id;
private String name;
private String markdownContent;
private String portraitImageId;
private String headerImageId;
private Map<String, String> values = new HashMap<>();
private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId;
private int order;
}

View File

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

View File

@@ -0,0 +1,30 @@
package com.loremind.infrastructure.web.dto.licensing;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.loremind.application.licensing.ChannelSwitcherService;
/**
* Etat du canal courant + dernier resultat de switch.
*
* <p>{@code currentChannel} : detecte au demarrage de Core a partir du prefixe
* d'image. {@code switcherAvailable} : indique si le sidecar de switch est
* monte (V0.9+) ou si on est sur une vieille install qui doit encore passer
* par les instructions manuelles.
*
* <p>{@code lastSwitch} : null tant qu'aucun switch n'a ete tente sur cette
* instance. Sinon, contient le resultat du dernier appel (en cours / succes /
* erreur), utilise par l'UI pour suivre la progression apres clic.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ChannelStatusDTO(
String currentChannel,
boolean switcherAvailable,
ChannelSwitcherService.SwitchResult lastSwitch) {
public static ChannelStatusDTO from(ChannelSwitcherService service) {
return new ChannelStatusDTO(
service.getCurrentChannel().name().toLowerCase(),
service.isSwitcherAvailable(),
service.getLastResult());
}
}

View File

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

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 org.springframework.stereotype.Component;
import java.util.HashMap;
@Component
public class CharacterMapper {
@@ -12,7 +14,11 @@ public class CharacterMapper {
CharacterDTO dto = new CharacterDTO();
dto.setId(c.getId());
dto.setName(c.getName());
dto.setMarkdownContent(c.getMarkdownContent());
dto.setPortraitImageId(c.getPortraitImageId());
dto.setHeaderImageId(c.getHeaderImageId());
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
dto.setKeyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(c.getCampaignId());
dto.setOrder(c.getOrder());
return dto;
@@ -23,7 +29,11 @@ public class CharacterMapper {
return Character.builder()
.id(dto.getId())
.name(dto.getName())
.markdownContent(dto.getMarkdownContent())
.portraitImageId(dto.getPortraitImageId())
.headerImageId(dto.getHeaderImageId())
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.build();

View File

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

View File

@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Npc;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import org.springframework.stereotype.Component;
import java.util.HashMap;
@Component
public class NpcMapper {
@@ -12,7 +14,11 @@ public class NpcMapper {
NpcDTO dto = new NpcDTO();
dto.setId(n.getId());
dto.setName(n.getName());
dto.setMarkdownContent(n.getMarkdownContent());
dto.setPortraitImageId(n.getPortraitImageId());
dto.setHeaderImageId(n.getHeaderImageId());
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(n.getCampaignId());
dto.setOrder(n.getOrder());
return dto;
@@ -23,7 +29,11 @@ public class NpcMapper {
return Npc.builder()
.id(dto.getId())
.name(dto.getName())
.markdownContent(dto.getMarkdownContent())
.portraitImageId(dto.getPortraitImageId())
.headerImageId(dto.getHeaderImageId())
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.build();

View File

@@ -1,19 +1,21 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Mapper pour convertir entre {@link TemplateField} (domaine) et
* {@link TemplateFieldDTO} (wire).
* <p>
* Tolerance : un type inconnu recu du client est interprete comme TEXT
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
* Tolerance : un type inconnu recu du client est interprete comme TEXT.
* 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
public class TemplateFieldMapper {
@@ -26,7 +28,11 @@ public class TemplateFieldMapper {
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
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) {
@@ -47,6 +53,10 @@ public class TemplateFieldMapper {
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;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component;
import java.util.ArrayList;

View File

@@ -65,7 +65,6 @@ app.demo-mode=${DEMO_MODE:false}
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
update-check.registry=${UPDATE_CHECK_REGISTRY:}
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-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,
# 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.tag=${LICENSING_BETA_TAG:latest}
# Chemin de sortie pour le docker config.json partage avec Watchtower.
# 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 java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@@ -38,7 +39,7 @@ public class NpcServiceTest {
testNpc = Npc.builder()
.id("npc-1")
.name("Borin le forgeron")
.markdownContent("# Borin\nForgeron nain")
.values(new java.util.HashMap<>(Map.of("Notes", "Forgeron nain")))
.campaignId("camp-1")
.order(1)
.build();
@@ -49,7 +50,8 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
Npc result = npcService.createNpc(
new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5));
new NpcService.NpcData("Borin le forgeron", null, null,
Map.of("Notes", "Borin"), null, null, "camp-1", 5));
assertNotNull(result);
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
@@ -65,7 +67,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Nouveau", null, "camp-1", null));
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -77,7 +79,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Premier", null, "camp-1", null));
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -121,10 +123,11 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin renommé", "# v2", "camp-1", 7));
new NpcService.NpcData("Borin renommé", null, null,
Map.of("Notes", "v2"), null, null, "camp-1", 7));
assertEquals("Borin renommé", result.getName());
assertEquals("# v2", result.getMarkdownContent());
assertEquals("v2", result.getValues().get("Notes"));
assertEquals(7, result.getOrder());
}
@@ -134,7 +137,8 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin", "# txt", "camp-1", null));
new NpcService.NpcData("Borin", null, null,
Map.of("Notes", "txt"), null, null, "camp-1", null));
// testNpc avait order=1 → préservé
assertEquals(1, result.getOrder());
@@ -146,7 +150,7 @@ public class NpcServiceTest {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> npcService.updateNpc("missing",
new NpcService.NpcData("x", null, "camp-1", null)));
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null)));
assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any());
}

View File

@@ -153,19 +153,19 @@ public class CampaignStructuralContextBuilderTest {
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
.name("Aragorn")
.markdownContent("# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")))
.build();
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
.name("Legolas")
.markdownContent(null) // pas de snippet → string vide
.values(null) // pas de snippet → string vide
.build();
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
.name("Borin le forgeron")
.markdownContent("# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")))
.build();
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
.name("Dame Elara")
.markdownContent("")
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "")))
.build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
@@ -196,7 +196,7 @@ public class CampaignStructuralContextBuilderTest {
// Snippet > 160 chars : doit être tronqué à 159 + "…"
String longLine = "x".repeat(200);
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
.name("Verbeux").markdownContent(longLine).build();
.name("Verbeux").values(new java.util.HashMap<>(java.util.Map.of("Histoire", longLine))).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());

View File

@@ -3,12 +3,12 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;

View File

@@ -115,8 +115,13 @@ public class NarrativeEntityContextBuilderTest {
@Test
void testBuild_Character_MarkdownProjected() {
// Refonte 2026-04-30 : les valeurs templates sont projetees individuellement
// dans la map fields (cle = nom du champ template).
Character c = Character.builder()
.id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur")
.id("c-1").name("Aragorn")
.values(new java.util.HashMap<>(java.util.Map.of(
"Histoire", "# Aragorn\nRôdeur",
"Race", "Humain")))
.build();
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
@@ -124,14 +129,17 @@ public class NarrativeEntityContextBuilderTest {
assertEquals("character", ctx.entityType());
assertEquals("Aragorn", ctx.title());
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)"));
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("Histoire"));
assertEquals("Humain", ctx.fields().get("Race"));
}
@Test
void testBuild_Npc_MarkdownProjected() {
Npc n = Npc.builder()
.id("n-1").name("Borin le forgeron")
.markdownContent("# Borin\n**Faction :** Clan Feuillefer")
.values(new java.util.HashMap<>(java.util.Map.of(
"Faction", "Clan Feuillefer",
"Histoire", "# Borin")))
.build();
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
@@ -139,13 +147,14 @@ public class NarrativeEntityContextBuilderTest {
assertEquals("npc", ctx.entityType());
assertEquals("Borin le forgeron", ctx.title());
assertEquals("# Borin\n**Faction :** Clan Feuillefer",
ctx.fields().get("fiche complète (markdown)"));
assertEquals("Clan Feuillefer", ctx.fields().get("Faction"));
assertEquals("# Borin", ctx.fields().get("Histoire"));
}
@Test
void testBuild_Npc_NormalizesCase() {
Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build();
Npc n = Npc.builder().id("n-1").name("Elara")
.values(new java.util.HashMap<>(java.util.Map.of("Notes", "desc"))).build();
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");

View File

@@ -5,10 +5,10 @@ import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

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;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.junit.jupiter.api.Test;
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;

View File

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

View File

@@ -1,10 +1,10 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;

View File

@@ -1,17 +1,21 @@
package com.loremind.infrastructure.updates;
import com.loremind.application.licensing.LicenseService;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
import org.junit.jupiter.api.Test;
import org.springframework.boot.info.BuildProperties;
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.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.List;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
@@ -19,69 +23,68 @@ import static org.mockito.ArgumentMatchers.eq;
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 :
* - feature desactivee si token absent
* - status UP_TO_DATE quand baseline == remote
* - status UPDATE_AVAILABLE quand baseline != remote
* - status UNKNOWN quand baseline manque (PAS d'alignement lazy — invariant
* central, regression historique)
* - status UNKNOWN quand remote impossible a fetcher
* - drapeaux top-level updateAvailable / anyUnknown coherents
* - back-compat : champ updateAvailable sur ImageStatus = (status == UPDATE_AVAILABLE)
* Couvre :
* - feature desactivee si WATCHTOWER_TOKEN absent
* - UP_TO_DATE quand version locale == max(tags remote)
* - UPDATE_AVAILABLE quand un tag plus eleve existe
* - UNKNOWN quand le registry echoue
* - UNKNOWN quand BuildProperties est absent (currentVersion = null)
* - parseSemver / findMaxSemver / compareSemver utilitaires
*/
public class UpdateCheckServiceTest {
private static UpdateCheckService newService(String token) {
// licensing.* params left empty + LicenseService null : la feature beta est
// desactivee dans ces tests, qui couvrent uniquement le canal stable.
private static UpdateCheckService newService(String token, String currentVersion) {
BuildProperties bp = null;
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(
new RestTemplateBuilder(),
"ghcr.io",
"igmlcreation/loremind-core,igmlcreation/loremind-brain",
"latest",
"http://watchtower:8080",
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) {
RestTemplate http = mock(RestTemplate.class);
ReflectionTestUtils.setField(svc, "http", http);
return http;
}
private static void stubRemoteDigest(RestTemplate http, String image, String digest) {
HttpHeaders headers = new HttpHeaders();
if (digest != null) headers.add("Docker-Content-Digest", digest);
ResponseEntity<Void> resp = new ResponseEntity<>(headers, org.springframework.http.HttpStatus.OK);
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
private static void stubTags(RestTemplate http, String image, List<String> tags) {
TagsListResponse body = new TagsListResponse();
body.name = image;
body.tags = tags;
ResponseEntity<TagsListResponse> resp = new ResponseEntity<>(body, HttpStatus.OK);
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
.thenReturn(resp);
}
private static void stubRemoteFailure(RestTemplate http, String image) {
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
private static void stubTagsFailure(RestTemplate http, String image) {
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
.thenThrow(new RuntimeException("network down"));
}
// -----------------------------------------------------------------
// Comportement du service
// -----------------------------------------------------------------
@Test
void disabledWhenTokenMissing() {
UpdateCheckService svc = newService("");
UpdateCheckService svc = newService("", "0.8.0");
UpdateStatus status = svc.check();
assertFalse(status.enabled());
assertFalse(status.updateAvailable());
@@ -90,118 +93,153 @@ public class UpdateCheckServiceTest {
}
@Test
void upToDate_whenBaselineEqualsRemote() {
UpdateCheckService svc = newService("token");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of(
"igmlcreation/loremind-core", "sha256:aaa",
"igmlcreation/loremind-brain", "sha256:bbb"
));
void upToDate_whenCurrentEqualsMaxRemote() {
UpdateCheckService svc = newService("token", "0.8.0");
RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:aaa");
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
stubTags(http, "igmlcreation/loremind-core",
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();
assertTrue(status.enabled());
assertFalse(status.updateAvailable());
assertFalse(status.anyUnknown());
assertEquals("0.8.0", status.currentVersion());
for (ImageStatus img : status.images()) {
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");
}
}
@Test
void updateAvailable_whenRemoteDiffers() {
UpdateCheckService svc = newService("token");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of(
"igmlcreation/loremind-core", "sha256:OLD",
"igmlcreation/loremind-brain", "sha256:bbb"
));
void updateAvailable_whenRemoteHigher() {
UpdateCheckService svc = newService("token", "0.7.2");
RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
stubTags(http, "igmlcreation/loremind-core",
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();
assertTrue(status.updateAvailable());
assertFalse(status.anyUnknown());
ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
assertEquals("0.7.2", core.localVersion());
assertEquals("0.8.0", core.remoteVersion());
assertTrue(core.updateAvailable(), "back-compat bool");
ImageStatus brain = status.images().stream()
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
}
@Test
void unknown_whenBaselineMissing_DOES_NOT_lazyAlign() {
// INVARIANT CENTRAL : si la baseline est absente (echec init au boot),
// 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
void unknown_whenRegistryFails() {
UpdateCheckService svc = newService("token", "0.8.0");
RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:remote-now");
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:remote-now-2");
stubTagsFailure(http, "igmlcreation/loremind-core");
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();
assertTrue(status.enabled());
assertFalse(status.updateAvailable());
assertTrue(status.anyUnknown());
assertNull(status.currentVersion());
for (ImageStatus img : status.images()) {
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")
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 — "
+ "regression de bug historique (faux negatif silencieux).");
// -----------------------------------------------------------------
// Utilitaires semver
// -----------------------------------------------------------------
@Test
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
void unknown_whenRemoteFetchFails() {
UpdateCheckService svc = newService("token");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:aaa",
"igmlcreation/loremind-brain", "sha256:bbb"));
RestTemplate http = stubHttp(svc);
stubRemoteFailure(http, "igmlcreation/loremind-core");
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
void parseSemver_rejectsInvalid() {
assertNull(UpdateCheckService.parseSemver(null));
assertNull(UpdateCheckService.parseSemver(""));
assertNull(UpdateCheckService.parseSemver("latest"));
assertNull(UpdateCheckService.parseSemver("stable"));
assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
assertNull(UpdateCheckService.parseSemver("0.x.0"));
}
@Test
void mixedStatuses_anyUnknownAndAnyUpdateBothTrue() {
UpdateCheckService svc = newService("token");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:OLD"));
// brain n'a pas de baseline -> UNKNOWN
RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
stubRemoteFailure(http, "igmlcreation/loremind-brain");
void compareSemver_basic() {
assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
}
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");
assertTrue(status.anyUnknown(), "brain est UNKNOWN");
@Test
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.TemplateRepository;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

View File

@@ -102,11 +102,18 @@ services:
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
# Chemin du docker config.json partage avec Watchtower
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
# Chemin du repertoire partage avec le switcher (commande + resultat).
# Doit matcher le volume `switcher-data` monte ci-dessous.
SWITCHER_DATA_PATH: /shared/switcher
volumes:
# Volume partage avec Watchtower : Core ecrit les credentials registry
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
# privees du canal beta. Pas de creds = no-op.
- docker-config:/shared/docker
# Volume partage avec le switcher : Core ecrit une commande de switch
# de canal ici (command.json), le switcher la traite et y depose son
# resultat (result.json). Cf. service `switcher` ci-dessous.
- switcher-data:/shared/switcher
restart: unless-stopped
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
@@ -167,6 +174,51 @@ services:
- "${WEB_PORT:-8081}:80"
restart: unless-stopped
# Sidecar de bascule de canal (stable <-> beta).
#
# Pourquoi : la bascule entre canaux change le PREFIXE d'image (loremind- vs
# loremind-beta-), donc Watchtower seul ne peut pas la faire — il met a jour
# des images, pas leur reference. Ce sidecar fait le `sed .env` + le
# `docker compose pull/up -d` quand le Core depose une commande JSON.
#
# Securite : pas de port expose. La commande arrive via volume partage
# (`switcher-data`) que SEUL le Core ecrit. Le switcher valide strictement
# le contenu (channel ∈ {stable, beta}, rien d'autre) — pas de RCE via
# compromission du Core.
#
# L'image switcher est volontairement HORS de IMAGE_NAMESPACE : elle reste
# `igmlcreation/loremind-switcher` sur les deux canaux. Sinon le switcher
# se tuerait lui-meme pendant le `docker compose up -d` (race condition).
switcher:
image: ghcr.io/igmlcreation/loremind-switcher:${SWITCHER_TAG:-latest}
container_name: loremind-switcher
# PAS de label watchtower : la maj du switcher se fait via le canal
# stable uniquement, et hors du flow d'auto-update.
volumes:
# Socket Docker du host : permet de lancer docker compose pull/up.
- /var/run/docker.sock:/var/run/docker.sock
# Repertoire compose du host (docker-compose.yml + .env) — RW pour
# pouvoir sed la ligne IMAGE_NAMESPACE.
- ${COMPOSE_PROJECT_DIR:-./}:/compose
# Volume partage avec le Core pour la commande + le resultat.
- switcher-data:/data
# Volume partage avec le Core + Watchtower : contient config.json avec
# les creds GHCR (ecrits par le Core a partir du token Patreon).
# Indispensable pour pull les images privees du canal beta.
- docker-config:/shared/docker
environment:
# Repertoire interne ou trouver docker-compose.yml et .env. Bind au
# volume ci-dessus (COMPOSE_PROJECT_DIR = repertoire d'install du host).
COMPOSE_DIR: /compose
# Nom de projet docker compose : fixe ici pour que le switcher cible
# le MEME stack que celui qui tourne (sinon il creerait un duplicate).
# Doit matcher le `name:` (en V2.x) ou le nom du dossier du host.
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-loremind}
# Indique au CLI Docker du switcher ou trouver config.json (auth GHCR
# pour les images privees beta). Meme mecanisme que sur Watchtower.
DOCKER_CONFIG: /shared/docker
restart: unless-stopped
# Mises a jour automatiques des images core/brain/web.
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
@@ -214,3 +266,5 @@ volumes:
# Volume partage Core <-> Watchtower : config.json Docker pour
# l'authentification au registry prive GHCR (canal beta Patreon).
docker-config:
# Volume partage Core <-> Switcher : commande de bascule de canal + resultat.
switcher-data:

View File

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

26
switcher/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# LoreMind channel switcher — sidecar minimal qui orchestre les bascules
# stable <-> beta. Tourne en permanence en attente d'une commande deposee
# dans le volume partage par le Core.
#
# Image volontairement legere (Alpine + docker-cli + bash). Pas de port
# expose, pas de processus reseau : tout passe par fichiers + socket Docker.
FROM alpine:3.20
# docker-cli : pour parler au socket Docker du host
# docker-cli-compose : pour `docker compose pull/up`
# bash : pour les scripts (sh ne suffit pas, on utilise des features bash)
# jq : parsing JSON de la commande
# coreutils : pour `date -u --iso-8601=seconds`
RUN apk add --no-cache \
docker-cli \
docker-cli-compose \
bash \
jq \
coreutils
WORKDIR /switcher
COPY watch.sh switch.sh ./
RUN chmod +x watch.sh switch.sh
# Tourne en permanence en mode polling.
ENTRYPOINT ["/switcher/watch.sh"]

66
switcher/README.md Normal file
View File

@@ -0,0 +1,66 @@
# LoreMind channel switcher
Sidecar qui bascule LoreMind entre les canaux **stable** et **beta** depuis l'UI,
sans manipulation manuelle du `.env` ni de docker-compose.
## Principe
Le switcher est un container minimal (Alpine + docker-cli + bash) qui :
1. Watch un fichier `command.json` dans un volume partagé avec le Core
2. Quand une commande arrive :
- Valide le canal cible (`stable` | `beta`)
- Sed la ligne `IMAGE_NAMESPACE` du `.env` du host
- Lance `docker compose pull` puis `docker compose up -d` sur core/brain/web
3. Écrit son résultat dans `result.json` (le Core remonte ça à l'UI via polling)
## Sécurité
Le switcher a accès au socket Docker et au répertoire compose du host (RW),
donc beaucoup de pouvoir. Pour éviter qu'une compromission du Core devienne
un RCE sur l'hôte :
- Le Core n'a **pas** accès au socket Docker — il dépose une commande dans un
fichier, point.
- Le switcher **valide strictement** le contenu : `channel` doit valoir exactement
`stable` ou `beta` (case statement, pas de regex laxiste).
- Aucun port n'est exposé. La communication se fait uniquement via volume
partagé.
## Architecture
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌────────────┐
│ User clique │ │ Core │ │ switcher │ │ Docker │
│ "Passer beta"│─▶│ écrit command.json│─▶│ sed .env │─▶│ daemon │
│ dans UI │ │ dans volume │ │ docker compose│ │ (recreate) │
└──────────────┘ └──────────────────┘ └──────────────┘ └────────────┘
┌─────────────┐
│ result.json │ ◄── Core poll
└─────────────┘
```
## Upgrade pour les installs existantes
Le sidecar est arrivé dans LoreMind 0.9.0. Pour les installs antérieures qui
ne l'ont pas dans leur `docker-compose.yml`, l'utilisateur doit faire une
**dernière** manipulation :
1. Récupérer le nouveau `docker-compose.yml` du repo
2. Lancer `docker compose pull && docker compose up -d`
Après ça, tous les switchs futurs se font depuis l'UI sans intervention CLI.
## Pourquoi le switcher n'est PAS dans `IMAGE_NAMESPACE`
L'image du switcher est codée en dur (`ghcr.io/igmlcreation/loremind-switcher`)
plutôt que d'utiliser `${IMAGE_NAMESPACE}`. Raison : pendant un switch, le
switcher exécute `docker compose up -d`. Si son propre image faisait partie
de `IMAGE_NAMESPACE`, le compose voudrait le recréer en même temps que
core/brain/web — et il se tuerait au milieu de sa propre commande. Race
condition fatale.
Pour la même raison, le `docker compose up -d` dans `switch.sh` cible
explicitement `core brain web --no-deps` — jamais le switcher lui-même.

123
switcher/switch.sh Normal file
View File

@@ -0,0 +1,123 @@
#!/bin/bash
# switch.sh — execute le switch de canal pour LoreMind.
#
# Usage interne (appele par watch.sh) :
# ./switch.sh stable
# ./switch.sh beta
#
# Ce que ca fait, dans l'ordre :
# 1. Valide l'argument (stable|beta uniquement, rien d'autre — defense
# contre command injection si le Core etait compromis)
# 2. Sed la ligne IMAGE_NAMESPACE= du .env du host pour basculer le prefixe
# 3. docker compose pull (recupere les nouvelles images du canal cible)
# 4. docker compose up -d (recree core/brain/web avec les nouvelles images)
#
# Le switcher LUI-MEME n'est PAS dans IMAGE_NAMESPACE — il survit au switch
# sans interruption (cf. docker-compose.yml).
set -euo pipefail
CHANNEL="${1:-}"
# --- Validation stricte -----------------------------------------------------
# Aucune autre valeur acceptee. Pas d'echappement, pas de slash, rien.
# C'est le filet de securite si le JSON depose dans /data/command.json
# contenait un payload exotique (Core compromis = on ne laisse PAS
# executer du code arbitraire sur l'hote).
case "${CHANNEL}" in
stable|beta) ;;
*)
echo "Channel invalide: '${CHANNEL}' (attendu: stable|beta)" >&2
exit 2
;;
esac
# --- Configuration ---------------------------------------------------------
# Repertoire monte depuis l'hote contenant docker-compose.yml + .env
COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
ENV_FILE="${COMPOSE_DIR}/.env"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "Fichier .env introuvable dans ${COMPOSE_DIR}" >&2
exit 3
fi
if [[ ! -f "${COMPOSE_DIR}/docker-compose.yml" ]]; then
echo "docker-compose.yml introuvable dans ${COMPOSE_DIR}" >&2
exit 3
fi
# --- Detection du nom de projet compose ------------------------------------
# Pour eviter que le switcher cree un projet PARALLELE (cas ou COMPOSE_PROJECT_NAME
# ne correspond pas au nom du projet sous lequel les containers tournent),
# on lit le label compose du container core en cours d'execution.
# Ce label est ecrit par docker compose au moment du `up -d` initial — c'est
# la source de verite.
PROJECT_NAME=$(docker inspect loremind-core \
--format '{{ index .Config.Labels "com.docker.compose.project" }}' \
2>/dev/null || echo "")
if [[ -z "${PROJECT_NAME}" ]]; then
# Fallback : env var ou defaut. Ne devrait pas arriver en prod
# (loremind-core tourne forcement quand l'UI declenche un switch).
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-loremind}"
echo "Warning: nom de projet auto-detecte impossible, fallback sur '${PROJECT_NAME}'" >&2
fi
export COMPOSE_PROJECT_NAME="${PROJECT_NAME}"
echo "→ Projet compose cible: ${PROJECT_NAME}"
# --- Mapping canal -> (namespace, tag) -------------------------------------
# Le slash final du namespace est important : concatene avec le suffixe image
# (core/brain/web) dans le docker-compose.yml.
# Cote tag : le workflow CI pousse :latest pour le canal stable, :beta pour
# le canal beta. Le switcher doit donc forcer ces deux variables ensemble.
case "${CHANNEL}" in
stable)
NAMESPACE="igmlcreation/loremind-"
TAG="latest"
;;
beta)
NAMESPACE="igmlcreation/loremind-beta-"
TAG="beta"
;;
esac
# Helper : met a jour (ou ajoute) une variable key=value dans le .env.
update_env_var() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "${ENV_FILE}"; then
# Sur Alpine, sed -i sans backup. Le pattern '/' dans la valeur impose
# un delimiter alternatif (|).
sed -i "s|^${key}=.*|${key}=${value}|" "${ENV_FILE}"
else
# Ligne absente → on l'ajoute en fin de fichier la premiere fois.
{
echo ""
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
echo "${key}=${value}"
} >> "${ENV_FILE}"
fi
}
# --- Etape 1 : sed le .env -------------------------------------------------
echo "→ Mise a jour de IMAGE_NAMESPACE + TAG dans .env (canal: ${CHANNEL})"
update_env_var "IMAGE_NAMESPACE" "${NAMESPACE}"
update_env_var "TAG" "${TAG}"
# --- Etape 2 : docker compose pull -----------------------------------------
echo "→ Pull des nouvelles images (${NAMESPACE}*)"
# --no-deps inutile ici : pull n'a pas de notion de deps.
# --policy missing eviterait de re-puller si deja la, mais on VEUT puller
# pour avoir la derniere version disponible — c'est le but du switch.
cd "${COMPOSE_DIR}"
docker compose pull core brain web
# --- Etape 3 : recreate les containers avec les nouvelles images -----------
# On cible explicitement core/brain/web — pas le switcher (qui s'auto-tuerait
# au milieu de la commande), pas postgres/minio (pas de changement d'image).
# --no-deps : ne pas re-recreer postgres/minio comme effet de bord.
echo "→ Recreation des containers avec les nouvelles images"
docker compose up -d --no-deps core brain web
echo ""
echo "Switch vers le canal ${CHANNEL} termine avec succes."
echo "Containers core/brain/web recrees avec ${NAMESPACE}*:${TAG}."

88
switcher/watch.sh Normal file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
# watch.sh — boucle principale du switcher.
#
# Surveille /data/command.json (depose par le Core via l'API HTTP) et lance
# switch.sh quand une nouvelle commande arrive. L'ID de la commande sert
# d'idempotence : on ne traite pas deux fois la meme requete.
#
# Le resultat est ecrit dans /data/result.json pour que le Core puisse le
# remonter a l'UI via son endpoint de status.
set -euo pipefail
DATA_DIR="/data"
COMMAND_FILE="${DATA_DIR}/command.json"
RESULT_FILE="${DATA_DIR}/result.json"
LAST_PROCESSED_FILE="${DATA_DIR}/.last-processed-id"
mkdir -p "${DATA_DIR}"
log() {
echo "[$(date -u --iso-8601=seconds)] $*"
}
# Ecrit un resultat JSON dans result.json — atomique via tmp + mv.
write_result() {
local status="$1" # "in-progress" | "success" | "error"
local channel="$2" # "stable" | "beta" | ""
local message="$3"
local id="$4"
local tmp
tmp="$(mktemp -p "${DATA_DIR}" result.XXXXXX)"
cat > "${tmp}" <<EOF
{
"id": "${id}",
"status": "${status}",
"channel": "${channel}",
"message": $(printf '%s' "${message}" | jq -Rs .),
"completedAt": "$(date -u --iso-8601=seconds)"
}
EOF
mv "${tmp}" "${RESULT_FILE}"
}
log "LoreMind channel switcher started — watching ${COMMAND_FILE}"
# Boucle de polling. Intervalle court (1s) — la charge est negligeable
# (un test de fichier) et l'utilisateur attend une reaction rapide.
while true; do
if [[ -f "${COMMAND_FILE}" ]]; then
# Parse la commande. Tolere les JSON malformes : on ignore et on attend.
if ! id=$(jq -er '.id' "${COMMAND_FILE}" 2>/dev/null); then
sleep 1
continue
fi
# Idempotence : skip si on a deja traite cet ID.
last_id=""
[[ -f "${LAST_PROCESSED_FILE}" ]] && last_id=$(cat "${LAST_PROCESSED_FILE}")
if [[ "${id}" == "${last_id}" ]]; then
sleep 1
continue
fi
channel=$(jq -er '.channel' "${COMMAND_FILE}" 2>/dev/null || echo "")
log "New command received: id=${id} channel=${channel}"
write_result "in-progress" "${channel}" "Switch en cours..." "${id}"
# Lance le switch. On capture stdout+stderr et le code de sortie.
if output=$(/switcher/switch.sh "${channel}" 2>&1); then
log "Switch SUCCESS for id=${id} channel=${channel}"
# Log la sortie sur plusieurs lignes pour faciliter le debug
# (ce qu'on voit en docker logs).
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
write_result "success" "${channel}" "${output}" "${id}"
else
rc=$?
log "Switch FAILED for id=${id} channel=${channel} rc=${rc}"
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
write_result "error" "${channel}" "${output}" "${id}"
fi
# Marque l'ID comme traite — empeche les replays.
echo "${id}" > "${LAST_PROCESSED_FILE}"
fi
sleep 1
done

View File

@@ -355,3 +355,55 @@ export async function getTemplateById(
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
// ─────────────── GameSystem ───────────────
export interface SeededGameSystem {
id: string;
name: string;
}
export async function seedGameSystem(
request: APIRequestContext,
opts: { name?: string; description?: string; author?: string; rulesMarkdown?: string } = {},
): Promise<SeededGameSystem> {
const name = opts.name ?? `E2E GameSystem ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/game-systems', {
data: {
name,
description: opts.description ?? null,
author: opts.author ?? null,
rulesMarkdown: opts.rulesMarkdown ?? null,
characterTemplate: [],
npcTemplate: [],
isPublic: false,
},
});
expect(res.ok(), `POST /api/game-systems -> ${res.status()}`).toBeTruthy();
const gs = await res.json();
return { id: gs.id, name };
}
export async function deleteGameSystem(
request: APIRequestContext,
id: string,
): Promise<void> {
// Best-effort : ignore 404 si déjà supprimé par le test (ex: delete spec).
await request.delete(`/api/game-systems/${id}`);
}
export async function getGameSystemById(
request: APIRequestContext,
id: string,
): Promise<{
id: string;
name: string;
description: string | null;
author: string | null;
rulesMarkdown: string | null;
isPublic: boolean;
}> {
const res = await request.get(`/api/game-systems/${id}`);
expect(res.ok(), `GET /api/game-systems/${id} -> ${res.status()}`).toBeTruthy();
return res.json();
}

View File

@@ -24,10 +24,12 @@ test.describe('Arc delete', () => {
page,
request,
}) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
@@ -36,10 +38,12 @@ test.describe('Arc delete', () => {
});
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));

View File

@@ -38,6 +38,10 @@ test.describe('Arc edit', () => {
};
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
// Attend que le formulaire soit prerempli par le ngOnInit (HTTP async) avant
// de fill — sinon le patchValue du load arrive APRES nos fills et ecrase
// les valeurs, le test echoue alors a la verif persisted.name.
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
await page.getByLabel(/Titre de l'arc/i).fill(newName);
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);

View File

@@ -16,10 +16,12 @@ test.describe('Campaign delete', () => {
page,
request,
}) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(/\/campaigns$/);
@@ -28,10 +30,12 @@ test.describe('Campaign delete', () => {
});
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));

View File

@@ -17,20 +17,22 @@ test.describe('NPC creation', () => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates an NPC and redirects back to the campaign', async ({ page, request }) => {
test('creates an NPC and redirects to the NPC detail page', async ({ page, request }) => {
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
// templates dynamiques pilotes par le GameSystem (plus de markdownContent
// libre). La campagne seedee n'a pas de GameSystem donc on ne fill que le
// nom — c'est suffisant pour valider la creation + la redirection.
const npcName = `Borin le forgeron ${Date.now()}`;
const markdown = '# Borin\n\n**Faction :** Clan Feuillefer\n\nNain barbu au regard perçant.';
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
await page.getByLabel(/Fiche \(markdown\)/i).fill(markdown);
await page.getByRole('button', { name: /^Créer$/i }).click();
// Retour à la page campagne après création
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
// Redirection vers la fiche du PNJ après création
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
// Persistance vérifiée via API
const npcs = await getNpcsByCampaign(request, campaign.id);
@@ -58,7 +60,7 @@ test.describe('NPC creation', () => {
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
await page.getByRole('button', { name: /^Créer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,

View File

@@ -14,19 +14,19 @@ test.describe('NPC edit', () => {
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
npc = await seedNpc(request, {
campaignId: campaign.id,
markdownContent: '# Initial\n\nFiche de départ.',
});
npc = await seedNpc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('edits name + markdown content and persists via API', async ({ page, request }) => {
test('edits name and persists via API', async ({ page, request }) => {
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
// templates dynamiques pilotes par le GameSystem, plus le markdownContent
// libre. La campagne seedee n'a pas de GameSystem donc pas de champs
// dynamiques a tester ici — on se contente du nom (champ universel).
const newName = `${npc.name} (renommé)`;
const newMarkdown = '# Borin réécrit\n\n**Statut :** Disparu\n\nDes traces dans la neige...';
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
@@ -34,7 +34,6 @@ test.describe('NPC edit', () => {
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
await page.getByLabel(/Nom du PNJ/i).fill(newName);
await page.getByLabel(/Fiche \(markdown\)/i).fill(newMarkdown);
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
@@ -43,7 +42,6 @@ test.describe('NPC edit', () => {
const persisted = await getNpcById(request, npc.id);
expect(persisted.name).toBe(newName);
expect(persisted.markdownContent).toBe(newMarkdown);
});
test('save button is disabled when name is cleared', async ({ page }) => {

View File

@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
import { deleteGameSystem } from '../../fixtures/api';
test.describe('GameSystem creation', () => {
// Les game systems crees par les tests sont nettoyes via cet array — chaque
// test pousse les IDs qu'il a crees pour qu'on les supprime en afterEach.
const createdIds: string[] = [];
test.afterEach(async ({ request }) => {
while (createdIds.length) {
const id = createdIds.pop()!;
await deleteGameSystem(request, id);
}
});
test('creates a game system and redirects to the list', async ({ page, request }) => {
const gsName = `Système E2E ${Date.now()}`;
const description = 'Système créé par les tests automatisés.';
const author = 'Playwright';
await page.goto('/game-systems');
await expect(page.getByRole('heading', { name: /Systèmes de JDR/i })).toBeVisible();
// Carte "Nouveau système" → ouvre l'editeur en mode creation.
await page.locator('.gs-card.card-new').click();
await expect(page).toHaveURL(/\/game-systems\/create$/);
await expect(page.getByRole('heading', { name: /Nouveau système de JDR/i })).toBeVisible();
await page.getByLabel(/^Nom/i).fill(gsName);
await page.getByLabel(/Description courte/i).fill(description);
await page.getByLabel(/Auteur/i).fill(author);
await page.getByRole('button', { name: /^Créer$/i }).click();
// Redirection vers la liste apres creation.
await expect(page).toHaveURL(/\/game-systems$/);
// Et la carte du nouveau systeme est visible dans la grille.
await expect(page.locator('.gs-card', { hasText: gsName })).toBeVisible();
// Verification API : le systeme est bien persistant.
const all = await request.get('/api/game-systems').then((r) => r.json());
const created = all.find((gs: { id: string; name: string }) => gs.name === gsName);
expect(created).toBeDefined();
expect(created.author).toBe(author);
createdIds.push(created.id);
});
test('submit button is disabled when name is empty', async ({ page }) => {
await page.goto('/game-systems/create');
const submit = page.getByRole('button', { name: /^Créer$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/^Nom/i).fill('Quelque chose');
await expect(submit).toBeEnabled();
await page.getByLabel(/^Nom/i).fill(' ');
await expect(submit).toBeDisabled();
});
test('cancel returns to the list without creating', async ({ page, request }) => {
const abandoned = `Système abandonné ${Date.now()}`;
await page.goto('/game-systems/create');
await page.getByLabel(/^Nom/i).fill(abandoned);
await page.getByRole('button', { name: /^Annuler$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/);
// Rien n'a ete cree cote API.
const all = await request.get('/api/game-systems').then((r) => r.json());
expect(all.find((gs: { name: string }) => gs.name === abandoned)).toBeUndefined();
});
});

View File

@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test';
import {
seedGameSystem,
deleteGameSystem,
type SeededGameSystem,
} from '../../fixtures/api';
test.describe('GameSystem delete', () => {
let gs: SeededGameSystem;
test.beforeEach(async ({ request }) => {
gs = await seedGameSystem(request);
});
test.afterEach(async ({ request }) => {
// Best-effort cleanup — ne fait rien si deja supprime par le test.
if (gs?.id) await deleteGameSystem(request, gs.id);
});
test('deletes a game system after confirming and removes it from the list', async ({
page,
request,
}) => {
await page.goto('/game-systems');
const card = page.locator('.gs-card', { hasText: gs.name });
await expect(card).toBeVisible();
// Bouton corbeille dans le coin de la carte du systeme seede.
await card.locator('.icon-btn').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(gs.name);
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
// La carte disparait apres reload de la liste.
await expect(page.locator('.gs-card', { hasText: gs.name })).toHaveCount(0);
const res = await request.get(`/api/game-systems/${gs.id}`);
expect(res.status()).toBe(404);
});
test('keeps the game system when cancel is clicked', async ({ page, request }) => {
await page.goto('/game-systems');
const card = page.locator('.gs-card', { hasText: gs.name });
await expect(card).toBeVisible();
await card.locator('.icon-btn').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
// La carte est toujours la, le systeme est toujours en base.
await expect(page.locator('.gs-card', { hasText: gs.name })).toBeVisible();
const res = await request.get(`/api/game-systems/${gs.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,68 @@
import { test, expect } from '@playwright/test';
import {
seedGameSystem,
deleteGameSystem,
getGameSystemById,
type SeededGameSystem,
} from '../../fixtures/api';
test.describe('GameSystem edit', () => {
let gs: SeededGameSystem;
test.beforeEach(async ({ request }) => {
gs = await seedGameSystem(request, {
description: 'Description initiale.',
author: 'Auteur initial',
});
});
test.afterEach(async ({ request }) => {
if (gs?.id) await deleteGameSystem(request, gs.id);
});
test('form is prefilled with the game system data', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByRole('heading', { name: /Éditer le système/i })).toBeVisible();
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
await expect(page.getByLabel(/Description courte/i)).toHaveValue('Description initiale.');
await expect(page.getByLabel(/Auteur/i)).toHaveValue('Auteur initial');
});
test('edits name and description and persists them to API', async ({ page, request }) => {
const newName = `${gs.name} renamed`;
const newDescription = 'Description mise à jour par le test.';
await page.goto(`/game-systems/${gs.id}/edit`);
// Attente que le formulaire soit prerempli avant de fill — sinon le load
// async ecrase les valeurs filled (cf. bug arc-edit corrige).
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
await page.getByLabel(/^Nom/i).fill(newName);
await page.getByLabel(/Description courte/i).fill(newDescription);
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
// Retour a la liste apres save.
await expect(page).toHaveURL(/\/game-systems$/);
const persisted = await getGameSystemById(request, gs.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(newDescription);
});
test('save button is disabled when name is cleared', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
const nameField = page.getByLabel(/^Nom/i);
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i });
await expect(saveBtn).toBeEnabled();
await nameField.fill('');
await expect(saveBtn).toBeDisabled();
await nameField.fill('OK');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,111 @@
import { test, expect } from '@playwright/test';
import {
seedGameSystem,
deleteGameSystem,
type SeededGameSystem,
} from '../../fixtures/api';
test.describe('GameSystem rule sections editor', () => {
let gs: SeededGameSystem;
test.beforeEach(async ({ request }) => {
// On part d'un GameSystem vide (pas de regles seedees) — chaque test gere
// ses propres ajouts pour eviter les couplages.
gs = await seedGameSystem(request);
});
test.afterEach(async ({ request }) => {
if (gs?.id) await deleteGameSystem(request, gs.id);
});
test('adds a suggested section, fills it, and persists it', async ({ page, request }) => {
const sectionContent = 'Initiative à d20, action+bonus+mouvement, dégâts par dés.';
await page.goto(`/game-systems/${gs.id}/edit`);
// Attendre le chargement du form (nom prerempli).
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
// Empty state visible tant qu'aucune section n'est ajoutee.
await expect(page.locator('.section-list .empty-hint')).toBeVisible();
// Ajout via la chip suggeree "Combat".
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
// Une section-card est apparue avec titre "Combat" prerempli + textarea visible.
const card = page.locator('.section-card').first();
await expect(card).toBeVisible();
await expect(card.locator('.section-title-input')).toHaveValue('Combat');
await card.locator('.section-content').fill(sectionContent);
// Save + retour a la liste.
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/);
// Verification cote API : le markdown contient bien la section + son contenu.
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
expect(persisted.rulesMarkdown).toContain('## Combat');
expect(persisted.rulesMarkdown).toContain(sectionContent);
});
test('disables a suggested chip after it has been used', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
const combatChip = page.locator('.add-row .chip', { hasText: 'Combat' });
await expect(combatChip).toBeEnabled();
await combatChip.click();
// Apres ajout, la chip "Combat" est desactivee (suggestion deja utilisee).
await expect(combatChip).toBeDisabled();
});
test('adds a custom blank section via "Autre…" and lets the user name it', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
await page.locator('.add-row .chip-custom', { hasText: /Autre/i }).click();
// Section vierge ajoutee : titre vide, prete a remplir.
const card = page.locator('.section-card').first();
await expect(card).toBeVisible();
const titleInput = card.locator('.section-title-input');
await expect(titleInput).toHaveValue('');
await titleInput.fill('Sorts');
await expect(titleInput).toHaveValue('Sorts');
});
test('removes a section', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
await page.locator('.add-row .chip', { hasText: 'Classes' }).click();
await expect(page.locator('.section-card')).toHaveCount(2);
// Supprime la premiere section (Combat).
await page.locator('.section-card').first().locator('.btn-remove').click();
await expect(page.locator('.section-card')).toHaveCount(1);
await expect(page.locator('.section-card').first().locator('.section-title-input')).toHaveValue('Classes');
});
test('collapses and expands a section', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
const card = page.locator('.section-card').first();
// Par defaut deployee : textarea visible.
await expect(card.locator('.section-content')).toBeVisible();
// Clic sur le bouton collapse → textarea masquee.
await card.locator('.btn-collapse').click();
await expect(card.locator('.section-content')).toHaveCount(0);
// Re-clic → re-deployee.
await card.locator('.btn-collapse').click();
await expect(card.locator('.section-content')).toBeVisible();
});
});

View File

@@ -0,0 +1,151 @@
import { test, expect, Page } from '@playwright/test';
import {
seedGameSystem,
deleteGameSystem,
type SeededGameSystem,
} from '../../fixtures/api';
/**
* Tests du composant <app-template-fields-editor> dans le contexte GameSystem.
*
* Le composant est instancie DEUX fois sur la page d'edition d'un GameSystem
* (une fois pour PJ "characterTemplate", une fois pour PNJ "npcTemplate"), donc
* les selecteurs doivent etre scopes a l'instance ciblee. On utilise un helper
* `tfe(label)` qui renvoie le locator de l'editeur correspondant au titre.
*/
test.describe('GameSystem template fields editor (PJ / PNJ)', () => {
let gs: SeededGameSystem;
test.beforeEach(async ({ request }) => {
gs = await seedGameSystem(request);
});
test.afterEach(async ({ request }) => {
if (gs?.id) await deleteGameSystem(request, gs.id);
});
/** Helper : retourne le locator de l'editeur de templates par son label. */
const tfe = (page: Page, label: 'PJ' | 'PNJ') =>
page.locator('.tfe').filter({ hasText: `Champs de la fiche ${label}` });
test('adds a suggested field to the PJ template and persists it', async ({ page, request }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
const pjEditor = tfe(page, 'PJ');
await expect(pjEditor).toBeVisible();
// Ajout de "Histoire" via la chip suggeree.
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
// Une row apparait avec le nom prerempli.
const row = pjEditor.locator('.tfe-item').first();
await expect(row).toBeVisible();
await expect(row.locator('.tfe-name')).toHaveValue('Histoire');
// Save → retour a la liste.
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/);
// Verification API : le champ est bien dans characterTemplate.
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
expect(persisted.characterTemplate).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
);
// npcTemplate non touche (toujours vide).
expect(persisted.npcTemplate ?? []).toHaveLength(0);
});
test('adds a custom NUMBER field via "Nombre" chip', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
const pjEditor = tfe(page, 'PJ');
await pjEditor.locator('.tfe-add .chip-custom', { hasText: 'Nombre' }).click();
const row = pjEditor.locator('.tfe-item').first();
await expect(row).toBeVisible();
// Champ vide, nom a remplir, type "NUMBER" pre-selectionne dans le select.
await expect(row.locator('.tfe-name')).toHaveValue('');
await expect(row.locator('.tfe-type')).toHaveValue('NUMBER');
await row.locator('.tfe-name').fill('Points de vie');
await expect(row.locator('.tfe-name')).toHaveValue('Points de vie');
});
test('PJ and PNJ editors are independent (adding to one does not affect the other)', async ({
page,
request,
}) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
await tfe(page, 'PJ').locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
await tfe(page, 'PNJ').locator('.tfe-add .chip', { hasText: 'Motivation' }).click();
await expect(tfe(page, 'PJ').locator('.tfe-item')).toHaveCount(1);
await expect(tfe(page, 'PNJ').locator('.tfe-item')).toHaveCount(1);
await expect(tfe(page, 'PJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Histoire');
await expect(tfe(page, 'PNJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Motivation');
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/);
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
expect(persisted.characterTemplate).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
);
expect(persisted.npcTemplate).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'Motivation' })]),
);
});
test('removes a field from the template', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
const pjEditor = tfe(page, 'PJ');
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
await expect(pjEditor.locator('.tfe-item')).toHaveCount(2);
// Supprime le premier champ (Histoire) via son btn-remove.
await pjEditor.locator('.tfe-item').first().locator('.btn-remove').click();
await expect(pjEditor.locator('.tfe-item')).toHaveCount(1);
await expect(pjEditor.locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Apparence');
});
test('reorders fields with the up arrow button', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
const pjEditor = tfe(page, 'PJ');
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
// Ordre initial : Histoire, Apparence.
let rows = pjEditor.locator('.tfe-item');
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Histoire');
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Apparence');
// Monte Apparence d'un cran.
await rows.nth(1).locator('.btn-arrow').first().click();
rows = pjEditor.locator('.tfe-item');
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Apparence');
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Histoire');
});
test('disables a suggested chip after the field has been added', async ({ page }) => {
await page.goto(`/game-systems/${gs.id}/edit`);
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
const pjEditor = tfe(page, 'PJ');
const histoireChip = pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' });
await expect(histoireChip).toBeEnabled();
await histoireChip.click();
await expect(histoireChip).toBeDisabled();
});
});

View File

@@ -17,32 +17,31 @@ test.describe('Lore delete', () => {
page,
request,
}) => {
let confirmMessage = '';
page.on('dialog', async (dialog) => {
confirmMessage = dialog.message();
await dialog.accept();
});
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
// Attente du dialog et du retour sur la liste des lores.
await expect(page).toHaveURL(/\/lore$/);
expect(confirmMessage).toContain(seeded.name);
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(seeded.name);
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
expect(confirmMessage).toMatch(/1 dossier/i);
await expect(dialog).toContainText(/1 dossier/i);
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
// Attente du retour sur la liste des lores.
await expect(page).toHaveURL(/\/lore$/);
const res = await request.get(`/api/lores/${seeded.id}`);
expect(res.status()).toBe(404);
});
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
});
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
// On reste sur le détail, le titre du lore est toujours visible.
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);

View File

@@ -32,10 +32,12 @@ test.describe('Page delete', () => {
});
test('deletes the page after accepting confirm', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
// Le composant redirige vers la racine du Lore après suppression.
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
@@ -45,10 +47,12 @@ test.describe('Page delete', () => {
});
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));

View File

@@ -25,11 +25,13 @@ test.describe('Template delete', () => {
});
test('deletes the template after accepting confirm', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
await page.locator('.page-header .btn-danger').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const templates = await getTemplatesForLore(request, seeded.id);
@@ -37,11 +39,13 @@ test.describe('Template delete', () => {
});
test('keeps the template when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
await page.locator('.page-header .btn-danger').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
// On reste sur l'écran d'édition (l'URL ne change pas).
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));

4
web/package-lock.json generated
View File

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

View File

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

View File

@@ -1,6 +1,12 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081';
// Par defaut on cible le serveur de dev Angular (ng serve) sur :4200 pour les
// runs locaux — c'est ce qu'on veut quand on bosse en TDD/dev sur le front.
// La CI (.gitea/workflows/e2e.yml) override avec `E2E_BASE_URL=http://web`
// pour cibler l'instance Docker dans le reseau du runner. Pour tester
// localement contre le container docker-compose, lancer :
// E2E_BASE_URL=http://localhost:8081 npm run e2e
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:4200';
export default defineConfig({
testDir: './e2e/tests',

View File

@@ -1,3 +1,5 @@
<app-update-banner></app-update-banner>
<div class="app-container">
<app-sidebar></app-sidebar>
@@ -16,3 +18,4 @@
</div>
<app-global-search></app-global-search>
<app-confirm-dialog-host></app-confirm-dialog-host>

View File

@@ -4,13 +4,25 @@ import { RouterOutlet } from '@angular/router';
import { SidebarComponent } from './sidebar/sidebar.component';
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
import { ConfirmDialogHostComponent } from './shared/confirm-dialog/confirm-dialog-host.component';
import { LayoutService } from './services/layout.service';
import { GlobalSearchService } from './services/global-search.service';
import { VersionCheckerService } from './services/version-checker.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf],
imports: [
RouterOutlet,
SidebarComponent,
SecondarySidebarComponent,
GlobalSearchComponent,
UpdateBannerComponent,
ConfirmDialogHostComponent,
AsyncPipe,
NgIf,
],
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
@@ -19,8 +31,14 @@ export class AppComponent {
constructor(
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'])
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/: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', 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/: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/: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) },

View File

@@ -7,9 +7,8 @@ import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { Campaign } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
@@ -62,21 +61,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length;
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -89,7 +74,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
order: this.existingArcCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id, 'edit']),
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
error: () => console.error('Erreur lors de la création de l\'arc')
});
}
@@ -99,6 +84,9 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -9,16 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de détail/modification d'un Arc.
@@ -78,7 +79,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -142,21 +144,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
resolution: arc.resolution ?? ''
});
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -183,11 +171,19 @@ export class ArcEditComponent implements OnInit, OnDestroy {
}
delete(): void {
if (!confirm(`Supprimer l'arc "${this.arc?.name}" ? Cette action est irréversible.`)) return;
this.confirmDialog.confirm({
title: 'Supprimer l\'arc',
message: `Supprimer l'arc "${this.arc?.name}" ?`,
details: ['Cette action est irréversible.'],
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.campaignService.deleteArc(this.arcId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
});
}
cancel(): void {
@@ -195,6 +191,9 @@ export class ArcEditComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de consultation d'un Arc narratif (lecture seule).
@@ -50,7 +51,8 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
@@ -83,20 +85,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(arc.name);
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -122,25 +111,34 @@ export class ArcViewComponent implements OnInit, OnDestroy {
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
const lines = [`Supprimer l'arc "${arc.name}" ?`];
const details: string[] = [];
if (parts.length) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
}
lines.push('');
lines.push('Cette action est irréversible.');
details.push('Cette action est irréversible.');
if (!confirm(lines.join('\n'))) return;
this.confirmDialog.confirm({
title: 'Supprimer l\'arc',
message: `Supprimer l'arc "${arc.name}" ?`,
details,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.campaignService.deleteArc(arc.id!).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression de l\'arc')
});
});
},
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -3,8 +3,8 @@ import { switchMap, map } from 'rxjs/operators';
import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service';
import { TreeItem } from '../services/layout.service';
import { Arc, Chapter, Scene } from '../services/campaign.model';
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
import { Character } from '../services/character.model';
import { Npc } from '../services/npc.model';
@@ -83,7 +83,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
id: `character-${ch.id}`,
label: ch.name,
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
route: `/campaigns/${campaignId}/characters/${ch.id}`
}));
const charactersNode: TreeItem = {
@@ -107,7 +107,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
id: `npc-${n.id}`,
label: n.name,
route: `/campaigns/${campaignId}/npcs/${n.id}/edit`
route: `/campaigns/${campaignId}/npcs/${n.id}`
}));
const npcsNode: TreeItem = {
@@ -172,3 +172,35 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
return [...arcNodes, charactersNode, npcsNode];
}
/**
* Construit la SecondarySidebarConfig complete d'une campagne a partir des
* donnees deja chargees. A utiliser quand le composant fait deja un forkJoin
* pour ses propres donnees (arc-view, scene-edit, etc.) et a deja `campaign`,
* `allCampaigns` et `treeData` en main — evite de refaire les memes HTTP.
*
* Pour les composants qui n'ont pas d'autre fetch a faire (character-view,
* npc-view...), preferer CampaignSidebarService.show(campaignId) qui orchestre
* le forkJoin et appelle layoutService.show() en une seule ligne.
*/
export function buildCampaignSidebarConfig(
campaign: Campaign,
allCampaigns: Campaign[],
treeData: CampaignTreeData,
campaignId: string
): SecondarySidebarConfig {
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
return {
title: campaign.name,
items: buildCampaignTree(campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
};
}

View File

@@ -50,14 +50,50 @@
<div class="field">
<label for="campaign-game-system">Système de JDR</label>
<select id="campaign-game-system" formControlName="gameSystemId">
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
<!-- Mode creation inline : remplace temporairement le select. -->
<div *ngIf="creatingGameSystem" class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
<p class="hint">
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
et le reste depuis la section "Systèmes" plus tard.
</p>
</div>
<p *ngIf="!creatingGameSystem" class="hint">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
dans ses suggestions pour respecter les mécaniques du JDR.
</p>
<p *ngIf="!creatingGameSystem" class="hint hint-warning">
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
Le changer plus tard rendra les champs des fiches existantes invisibles
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
Choisissez bien dès le départ si possible.
</p>
</div>
<div class="info-box">

View File

@@ -87,6 +87,81 @@ form {
input[type="number"] { width: 120px; }
}
.inline-create {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem;
background: #1a2233;
border: 1px solid #2d3748;
border-left: 3px solid #6c63ff;
border-radius: 8px;
input {
width: 100%;
background: #1f2937;
border: 1px solid #374151;
border-radius: 6px;
padding: 0.6rem 0.875rem;
color: white;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
&::placeholder { color: #4b5563; }
&:focus { border-color: #6c63ff; }
}
}
.inline-create-actions {
display: flex;
gap: 0.5rem;
}
.btn-inline-primary {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
.btn-inline-secondary {
padding: 0.5rem 0.875rem;
background: transparent;
color: #9ca3af;
border: 1px solid #374151;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, color 0.2s;
&:hover { background: #1f2937; color: white; }
}
.hint-warning {
margin-top: 0.5rem;
background: rgba(234, 179, 8, 0.08);
border-left: 3px solid #eab308;
border-radius: 4px;
padding: 0.625rem 0.875rem;
color: #fbbf24;
font-size: 0.8rem;
line-height: 1.5;
strong { color: #fde68a; }
}
.info-box {
background: #1f2937;
border-radius: 8px;

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