32 Commits

Author SHA1 Message Date
addf78f01d Mise en place v0.6.8
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
5e04e84ee4 Mise à jour de la conf pour être sur que le cache angular est bien refresh
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
8d5c2e2b7f Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-25 18:37:40 +02:00
788d2c12f2 Ajout d'un .bat pour l'exécution du .ps1
Some checks failed
E2E Tests / e2e (push) Failing after 16s
2026-04-25 18:34:52 +02:00
b25a9746cf Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
Some checks failed
E2E Tests / e2e (push) Failing after 17s
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
41fda9aeee Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

Passage version 0.6.6
2026-04-25 13:24:32 +02:00
550078268c Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 55s
Build & Push Images / build (core) (push) Successful in 1m35s
E2E Tests / e2e (push) Failing after 4m10s
Build & Push Images / build (web) (push) Successful in 2m0s
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
f1989c1d77 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
All checks were successful
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m24s
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
8efdf5d0e0 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
96bc5de942 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
84ccdd53ad Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
29978058ee Correction d'un test unitaire
All checks were successful
Build & Push Images / build (brain) (push) Successful in 49s
Build & Push Images / build (core) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-22 13:38:48 +02:00
e510f64336 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Failing after 1m19s
Build & Push Images / build (web) (push) Successful in 1m23s
2026-04-22 13:33:47 +02:00
f189f67aaf Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
8efa148739 Corrections visuel ; optimisation du chargement des pages (préchargement anticité, sinon temps de latence chaque fois qu'on visite un type de page une première fois) 2026-04-22 13:17:05 +02:00
8f4dd3e9d6 Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
2026-04-22 11:58:50 +02:00
bf38b6695f Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
302 changed files with 17480 additions and 1224 deletions

View File

@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
# 1min.ai (si LLM_PROVIDER=onemin) # 1min.ai (si LLM_PROVIDER=onemin)
ONEMIN_API_KEY= ONEMIN_API_KEY=
ONEMIN_MODEL=gpt-4o-mini ONEMIN_MODEL=gpt-4o-mini
# --- Mises a jour automatiques (Watchtower) ------------------------------
# Watchtower verifie les nouvelles versions de core/brain/web et permet
# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
# MinIO sont exclus volontairement.
#
# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
# COMPOSE_PROFILES=autoupdate
# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
# WATCHTOWER_SCHEDULE=0 0 4 * * *
# TZ=Europe/Paris

95
.gitea/workflows/e2e.yml Normal file
View File

@@ -0,0 +1,95 @@
name: E2E Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Create .env for stack
run: |
cat > .env <<'EOF'
POSTGRES_PASSWORD=ci-postgres-pass
BRAIN_INTERNAL_SECRET=ci-brain-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD=ci-admin-pass
WEB_PORT=8081
LLM_PROVIDER=ollama
EOF
- name: Build & start stack
run: |
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
- name: Attach runner to compose network
run: |
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
if [ -z "$NET" ]; then
echo "Compose network not found" >&2
docker network ls
exit 1
fi
echo "Connecting $(hostname) to network $NET"
docker network connect "$NET" "$(hostname)"
- name: Wait for web to be ready
run: |
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
- name: Install web deps
working-directory: web
run: npm ci
- name: Work around runner clock skew for apt
run: |
sudo tee /etc/apt/apt.conf.d/99no-check-valid-until >/dev/null <<'EOF'
Acquire::Check-Valid-Until "false";
Acquire::Check-Date "false";
EOF
- name: Install Playwright browsers
working-directory: web
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: web
env:
E2E_BASE_URL: http://web
CI: 'true'
run: npm run e2e
- name: Dump container logs on failure
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: web/playwright-report/
retention-days: 14
- name: Stop stack
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v

6
.gitignore vendored
View File

@@ -53,6 +53,12 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
coverage/ coverage/
# Playwright (E2E)
web/test-results/
web/playwright-report/
web/blob-report/
web/playwright/.cache/
# ============================================================================ # ============================================================================
# IDE / Editeurs # IDE / Editeurs
# ============================================================================ # ============================================================================

View File

@@ -20,6 +20,8 @@ from app.domain.models import (
CampaignStructuralContext, CampaignStructuralContext,
ChatMessage, ChatMessage,
ChapterSummary, ChapterSummary,
CharacterSummary,
GameSystemContext,
LoreStructuralContext, LoreStructuralContext,
NarrativeEntityContext, NarrativeEntityContext,
PageContext, PageContext,
@@ -63,16 +65,17 @@ class ChatUseCase:
page_context: PageContext | None = None, page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None, campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user. """Streame les tokens de la réponse assistant pour le dernier message user.
Les 4 contextes sont tous optionnels, mais au moins l'un des deux Les contextes sont tous optionnels, mais au moins l'un des deux
"niveaux haut" (lore_context ou campaign_context) doit être fourni "niveaux haut" (lore_context ou campaign_context) doit être fourni
pour que le prompt ait du sens. Le controller (main.py) applique pour que le prompt ait du sens. Le controller (main.py) applique
cette règle à la frontière HTTP. cette règle à la frontière HTTP.
""" """
system_prompt = self._build_system_prompt( system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity lore_context, page_context, campaign_context, narrative_entity, game_system_context
) )
async for token in self._llm.stream_chat( async for token in self._llm.stream_chat(
messages, messages,
@@ -87,12 +90,13 @@ class ChatUseCase:
page_context: PageContext | None = None, page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None, campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
) -> str: ) -> str:
"""Version publique — utilisée par le controller HTTP pour compter """Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte). les tokens du system prompt avant de streamer (jauge de contexte).
""" """
return self._build_system_prompt( return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity lore_context, page_context, campaign_context, narrative_entity, game_system_context
) )
# --- Construction du system prompt -------------------------------------- # --- Construction du system prompt --------------------------------------
@@ -103,12 +107,15 @@ class ChatUseCase:
page: PageContext | None, page: PageContext | None,
campaign: CampaignStructuralContext | None, campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None, narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None,
) -> str: ) -> str:
sections = [_BASE_SYSTEM] sections = [_BASE_SYSTEM]
if lore is not None: if lore is not None:
sections.append(self._format_lore(lore)) sections.append(self._format_lore(lore))
if campaign is not None: if campaign is not None:
sections.append(self._format_campaign(campaign, lore_present=lore is not None)) sections.append(self._format_campaign(campaign, lore_present=lore is not None))
if game_system is not None:
sections.append(self._format_game_system(game_system))
if page is not None: if page is not None:
sections.append(self._format_page(page)) sections.append(self._format_page(page))
if narrative is not None: if narrative is not None:
@@ -190,14 +197,40 @@ class ChatUseCase:
if lore_present if lore_present
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)" else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
) )
characters_block = ChatUseCase._format_characters(ctx.characters)
return ( return (
"--- CAMPAGNE COURANTE ---\n" "--- CAMPAGNE COURANTE ---\n"
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n" f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
f"{characters_block}\n"
"Structure narrative (les flèches → indiquent des transitions de scène " "Structure narrative (les flèches → indiquent des transitions de scène "
"déclenchées par un choix des joueurs) :\n" "déclenchées par un choix des joueurs) :\n"
f"{arcs_block}" f"{arcs_block}"
) )
@staticmethod
def _format_characters(characters: list[CharacterSummary]) -> str:
"""Bloc PJ — liste nom + snippet. Rappel anti-hallucination IA.
Si la campagne n'a aucun PJ, on le signale explicitement : l'IA ne
doit pas inventer "les héros" ou leurs noms dans ses suggestions.
"""
if not characters:
return (
"\nPersonnages joueurs : aucune fiche pour l'instant. Ne suppose "
"ni noms ni classes pour les PJ tant que le MJ ne les a pas créés.\n"
)
lines = ["\nPersonnages joueurs (PJ) :"]
for c in characters:
if c.snippet:
lines.append(f"- **{c.name}** — {c.snippet}")
else:
lines.append(f"- **{c.name}** (fiche vide)")
lines.append(
"Pour une fiche complète (stats, backstory), n'invente rien : "
"demande au MJ d'ouvrir l'éditeur du PJ pour te donner les détails."
)
return "\n".join(lines) + "\n"
@staticmethod @staticmethod
def _format_arcs(arcs: list[ArcSummary]) -> str: def _format_arcs(arcs: list[ArcSummary]) -> str:
if not arcs: if not arcs:
@@ -248,12 +281,46 @@ class ChatUseCase:
noun = "illustration" if count == 1 else "illustrations" noun = "illustration" if count == 1 else "illustrations"
return f" [{count} {noun}]" return f" [{count} {noun}]"
# --- Bloc Système de JDR ------------------------------------------------
@staticmethod
def _format_game_system(gs: GameSystemContext) -> str:
"""Bloc des règles du système de JDR de la campagne.
Les sections ont été filtrées côté Core selon l'intent (combat,
classes, lore...). Si aucune section n'a matché, on affiche juste
le nom du système comme rappel de cadre.
"""
desc = f"\nDescription : {gs.system_description}" if gs.system_description else ""
if not gs.sections:
return (
"--- SYSTÈME DE JDR ---\n"
f"Nom : {gs.system_name}{desc}\n"
"(Aucune section de règles pertinente pour ce type de génération — "
"reste cohérent avec l'univers et les conventions du système.)"
)
sections_block = "\n\n".join(
f"### {title}\n{content}" for title, content in gs.sections.items()
)
return (
"--- SYSTÈME DE JDR ---\n"
f"Nom : {gs.system_name}{desc}\n\n"
"Respecte scrupuleusement les règles et conventions ci-dessous quand "
"tu proposes des stats, classes, rencontres, mécaniques ou éléments "
"d'ambiance. Les noms propres (classes, sorts, monstres) doivent "
"venir de ces règles — n'en invente pas d'autres.\n\n"
f"{sections_block}"
)
@staticmethod @staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str: def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene.""" """Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get( type_label = {
ne.entity_type.lower(), ne.entity_type.upper() "arc": "ARC",
) "chapter": "CHAPITRE",
"scene": "SCÈNE",
"character": "FICHE DE PERSONNAGE",
}.get(ne.entity_type.lower(), ne.entity_type.upper())
if ne.fields: if ne.fields:
fields_block = "\n".join( fields_block = "\n".join(
f'- "{key}" : {value or "(vide)"}' f'- "{key}" : {value or "(vide)"}'

View File

@@ -169,6 +169,20 @@ class CampaignStructuralContext:
campaign_name: str campaign_name: str
campaign_description: str | None campaign_description: str | None
arcs: list[ArcSummary] arcs: list[ArcSummary]
characters: list["CharacterSummary"] = field(default_factory=list)
@dataclass(frozen=True)
class CharacterSummary:
"""Résumé d'un PJ : nom + snippet court extrait du markdown de la fiche.
La fiche complète n'est JAMAIS dans ce résumé — elle n'arrive que si le PJ
est l'entité focus (via NarrativeEntityContext entity_type="character").
Ça plafonne le coût token à ~40 tokens/PJ quel que soit le détail des fiches.
"""
name: str
snippet: str
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -184,3 +198,20 @@ class NarrativeEntityContext:
entity_type: str entity_type: str
title: str title: str
fields: dict[str, str] fields: dict[str, str]
@dataclass(frozen=True)
class GameSystemContext:
"""Règles d'un système de JDR (D&D, Nimble, homebrew...) injectées
dans le system prompt pour que l'IA respecte les mécaniques du jeu.
Les sections ont été présélectionnées côté Core selon l'intent
(SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions,
GENERIC → toutes). Indexées par titre H2 original.
Campagne uniquement au MVP : jamais présent sur un chat Lore.
"""
system_name: str
system_description: str | None
sections: dict[str, str]

View File

@@ -22,7 +22,9 @@ from app.domain.models import (
ArcSummary, ArcSummary,
CampaignStructuralContext, CampaignStructuralContext,
ChapterSummary, ChapterSummary,
CharacterSummary,
ChatMessage, ChatMessage,
GameSystemContext,
LoreStructuralContext, LoreStructuralContext,
NarrativeEntityContext, NarrativeEntityContext,
PageContext, PageContext,
@@ -38,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.4.0", version="0.6.6",
) )
@@ -196,22 +198,42 @@ class ArcSummaryDTO(BaseModel):
illustration_count: int = 0 illustration_count: int = 0
class CharacterSummaryDTO(BaseModel):
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
name: str
snippet: str = ""
class CampaignContextDTO(BaseModel): class CampaignContextDTO(BaseModel):
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis.""" """Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
campaign_name: str campaign_name: str
campaign_description: str | None = None campaign_description: str | None = None
arcs: list[ArcSummaryDTO] = Field(default_factory=list) arcs: list[ArcSummaryDTO] = Field(default_factory=list)
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
class NarrativeEntityDTO(BaseModel): class NarrativeEntityDTO(BaseModel):
"""Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel.""" """Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
entity_type: str = Field(pattern="^(arc|chapter|scene)$") entity_type: str = Field(pattern="^(arc|chapter|scene|character)$")
title: str title: str
fields: dict[str, str] = Field(default_factory=dict) fields: dict[str, str] = Field(default_factory=dict)
class GameSystemContextDTO(BaseModel):
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
vides si aucune section ne matchait l'intent de génération courant.
"""
system_name: str
system_description: str | None = None
sections: dict[str, str] = Field(default_factory=dict)
class ChatStreamRequestDTO(BaseModel): class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels. """Requête de chat streamé : historique + contextes structurels.
@@ -226,6 +248,7 @@ class ChatStreamRequestDTO(BaseModel):
page_context: PageContextDTO | None = None page_context: PageContextDTO | None = None
campaign_context: CampaignContextDTO | None = None campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None narrative_entity: NarrativeEntityDTO | None = None
game_system_context: GameSystemContextDTO | None = None
def has_scope(self) -> bool: def has_scope(self) -> bool:
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni.""" """Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
@@ -352,6 +375,7 @@ async def chat_stream(
page_context = _to_page_context(body.page_context) page_context = _to_page_context(body.page_context)
campaign_context = _to_campaign_context(body.campaign_context) campaign_context = _to_campaign_context(body.campaign_context)
narrative_entity = _to_narrative_entity(body.narrative_entity) narrative_entity = _to_narrative_entity(body.narrative_entity)
game_system_context = _to_game_system_context(body.game_system_context)
# --- Comptage tokens pour la jauge de contexte frontend --- # --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case # On construit le system prompt une fois ici pour le compter — le use case
@@ -363,6 +387,7 @@ async def chat_stream(
page_context=page_context, page_context=page_context,
campaign_context=campaign_context, campaign_context=campaign_context,
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context,
) )
# Dernier message = "current" (souvent user), le reste = historique accumulé. # Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None current_msg = messages[-1] if messages else None
@@ -386,6 +411,7 @@ async def chat_stream(
page_context=page_context, page_context=page_context,
campaign_context=campaign_context, campaign_context=campaign_context,
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context,
): ):
# json.dumps avec ensure_ascii=False pour préserver les accents # json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
@@ -523,10 +549,15 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
) )
for arc in dto.arcs for arc in dto.arcs
] ]
characters = [
CharacterSummary(name=c.name, snippet=c.snippet)
for c in dto.characters
]
return CampaignStructuralContext( return CampaignStructuralContext(
campaign_name=dto.campaign_name, campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description, campaign_description=dto.campaign_description,
arcs=arcs, arcs=arcs,
characters=characters,
) )
@@ -658,6 +689,76 @@ async def get_ollama_model_info(
return OllamaModelInfoDTO(context_length=0) return OllamaModelInfoDTO(context_length=0)
@app.post("/models/ollama/pull")
async def pull_ollama_model(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
"""Telecharge un modele depuis Ollama et streame la progression.
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
digest, success. On reemet ce flux tel quel au client (le front
parsera les lignes et affichera une barre de progression).
Le timeout est intentionnellement tres long (60 min) car certains
modeles font 30+ Go.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/pull"
async def stream() -> AsyncIterator[bytes]:
# On utilise un timeout long pour la lecture (60 min) mais court pour
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
if r.status_code != 200:
# Ollama renvoie un message JSON d'erreur. On le passe
# tel quel au client en preservant le code HTTP.
body_text = await r.aread()
yield body_text
return
async for chunk in r.aiter_bytes():
yield chunk
except httpx.HTTPError as e:
# Erreur reseau : on emet une ligne JSON d'erreur compatible
# avec le format NDJSON d'Ollama.
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
yield err.encode("utf-8")
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
return StreamingResponse(stream(), media_type="application/x-ndjson")
@app.delete("/models/ollama/{name:path}")
async def delete_ollama_model(
name: str,
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, str]:
"""Supprime un modele du serveur Ollama.
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
sans avoir besoin de URL-encoder cote client.
"""
if not name.strip():
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/delete"
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.request("DELETE", url, json={"model": name})
if response.status_code == 404:
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
response.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
return {"status": "deleted", "name": name}
@app.get("/models/onemin") @app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]: def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur. """Catalogue statique des modeles 1min.ai, groupes par fournisseur.
@@ -742,3 +843,13 @@ def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityConte
title=dto.title, title=dto.title,
fields=dict(dto.fields), fields=dict(dto.fields),
) )
def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
if dto is None:
return None
return GameSystemContext(
system_name=dto.system_name,
system_description=dto.system_description,
sections=dict(dto.sections),
)

11
core/lombok.config Normal file
View File

@@ -0,0 +1,11 @@
## LoreMind Core - Configuration Lombok
#
# addLombokGeneratedAnnotation : ajoute @lombok.Generated sur toutes les
# methodes generees par Lombok (equals, hashCode, toString, builders,
# getters/setters, etc.). JaCoCo 0.8.2+ reconnait cette annotation et
# exclut automatiquement ces methodes du rapport de couverture.
#
# Objectif : mesurer la couverture UNIQUEMENT sur le code que nous ecrivons,
# pas sur le bytecode auto-genere (qui fausse les metriques : branches et
# instructions gonflees par les equals/hashCode).
lombok.addLombokGeneratedAnnotation = true

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.4.0</version> <version>0.6.8</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -1,9 +1,13 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -17,17 +21,31 @@ import java.util.Optional;
public class ArcService { public class ArcService {
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ArcService(ArcRepository arcRepository) { public ArcService(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
} }
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) { public Arc createArc(String name, String description, String campaignId, int order) {
return createArc(name, description, campaignId, order, null);
}
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
Arc arc = Arc.builder() Arc arc = Arc.builder()
.name(name) .name(name)
.description(description) .description(description)
.campaignId(campaignId) .campaignId(campaignId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return arcRepository.save(arc); return arcRepository.save(arc);
} }
@@ -59,7 +77,31 @@ public class ArcService {
return arcRepository.save(arc); return arcRepository.save(arc);
} }
/**
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
* qui disparaîtront avec l'arc.
*/
public DeletionImpact getDeletionImpact(String id) {
List<Chapter> chapters = chapterRepository.findByArcId(id);
int sceneTotal = 0;
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
return new DeletionImpact(chapters.size(), sceneTotal);
}
/**
* Supprime l'arc et toutes ses entités dépendantes (chapitres → scènes).
* Transactionnel : atomique.
*/
@Transactional
public void deleteArc(String id) { public void deleteArc(String id) {
for (Chapter chapter : chapterRepository.findByArcId(id)) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(id); arcRepository.deleteById(id);
} }

View File

@@ -1,8 +1,15 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -16,9 +23,22 @@ import java.util.Optional;
public class CampaignService { public class CampaignService {
private final CampaignRepository campaignRepository; private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
public CampaignService(CampaignRepository campaignRepository) { public CampaignService(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
} }
/** /**
@@ -28,13 +48,20 @@ public class CampaignService {
* *
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p> * <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
*/ */
public record CampaignData(String name, String description, String loreId) {} public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
/**
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
*/
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
public Campaign createCampaign(CampaignData data) { public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder() Campaign campaign = Campaign.builder()
.name(data.name()) .name(data.name())
.description(data.description()) .description(data.description())
.loreId(normalizeLoreId(data.loreId())) .loreId(normalizeId(data.loreId()))
.gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0) .arcsCount(0)
.build(); .build();
return campaignRepository.save(campaign); return campaignRepository.save(campaign);
@@ -57,19 +84,61 @@ public class CampaignService {
Campaign campaign = existingCampaign.get(); Campaign campaign = existingCampaign.get();
campaign.setName(data.name()); campaign.setName(data.name());
campaign.setDescription(data.description()); campaign.setDescription(data.description());
campaign.setLoreId(normalizeLoreId(data.loreId())); campaign.setLoreId(normalizeId(data.loreId()));
campaign.setGameSystemId(normalizeId(data.gameSystemId()));
return campaignRepository.save(campaign); return campaignRepository.save(campaign);
} }
/** /**
* Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien". * Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
* Utile car les payloads JSON peuvent envoyer "" au lieu de null. * Utile car les payloads JSON peuvent envoyer "" au lieu de null.
*/ */
private String normalizeLoreId(String loreId) { private String normalizeId(String id) {
return (loreId == null || loreId.isBlank()) ? null : loreId; return (id == null || id.isBlank()) ? null : id;
} }
/**
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
*/
public DeletionImpact getDeletionImpact(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
int chapterTotal = 0;
int sceneTotal = 0;
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
chapterTotal += chapters.size();
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
}
int characterTotal = characterRepository.findByCampaignId(id).size();
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
}
/**
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
* scènes, plus les personnages). L'opération est transactionnelle : soit
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
*/
@Transactional
public void deleteCampaign(String id) { public void deleteCampaign(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
for (Chapter chapter : chapters) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(arc.getId());
}
for (var character : characterRepository.findByCampaignId(id)) {
characterRepository.deleteById(character.getId());
}
campaignRepository.deleteById(id); campaignRepository.deleteById(id);
} }

View File

@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -17,17 +19,27 @@ import java.util.Optional;
public class ChapterService { public class ChapterService {
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ChapterService(ChapterRepository chapterRepository) { public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
} }
/** Compte des scènes qui seront supprimées en cascade avec le chapitre. */
public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) { public Chapter createChapter(String name, String description, String arcId, int order) {
return createChapter(name, description, arcId, order, null);
}
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
Chapter chapter = Chapter.builder() Chapter chapter = Chapter.builder()
.name(name) .name(name)
.description(description) .description(description)
.arcId(arcId) .arcId(arcId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return chapterRepository.save(chapter); return chapterRepository.save(chapter);
} }
@@ -58,7 +70,17 @@ public class ChapterService {
return chapterRepository.save(chapter); return chapterRepository.save(chapter);
} }
/** Compte des scènes qui tomberont avec le chapitre. */
public DeletionImpact getDeletionImpact(String id) {
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
}
/** Supprime le chapitre et toutes ses scènes. Transactionnel : atomique. */
@Transactional
public void deleteChapter(String id) { public void deleteChapter(String id) {
for (var scene : sceneRepository.findByChapterId(id)) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(id); chapterRepository.deleteById(id);
} }

View File

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

View File

@@ -26,11 +26,16 @@ public class SceneService {
} }
public Scene createScene(String name, String description, String chapterId, int order) { public Scene createScene(String name, String description, String chapterId, int order) {
return createScene(name, description, chapterId, order, null);
}
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
Scene scene = Scene.builder() Scene scene = Scene.builder()
.name(name) .name(name)
.description(description) .description(description)
.chapterId(chapterId) .chapterId(chapterId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return sceneRepository.save(scene); return sceneRepository.save(scene);
} }
@@ -93,7 +98,7 @@ public class SceneService {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
for (SceneBranch b : branches) { for (SceneBranch b : branches) {
String target = b.getTargetSceneId(); String target = b.targetSceneId();
if (target == null || target.isBlank()) { if (target == null || target.isBlank()) {
throw new IllegalArgumentException("Une branche doit avoir une scène de destination"); throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
} }

View File

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

View File

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

View File

@@ -3,15 +3,18 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint; import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -38,18 +41,24 @@ public class CampaignStructuralContextBuilder {
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
public CampaignStructuralContextBuilder( public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository) { SceneRepository sceneRepository,
CharacterRepository characterRepository) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
} }
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
/** /**
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes, * Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
* nom + description courte à chaque niveau). * nom + description courte à chaque niveau).
@@ -65,11 +74,36 @@ public class CampaignStructuralContextBuilder {
.map(this::toArcSummary) .map(this::toArcSummary)
.collect(Collectors.toList()); .collect(Collectors.toList());
return CampaignStructuralContext.builder() List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream()
.campaignName(campaign.getName()) .sorted(Comparator.comparingInt(Character::getOrder))
.campaignDescription(campaign.getDescription()) .map(this::toCharacterSummary)
.arcs(arcs) .collect(Collectors.toList());
.build();
return new CampaignStructuralContext(
campaign.getName(),
campaign.getDescription(),
arcs,
characters);
}
/**
* Projette un PJ vers un résumé court : nom + 1re ligne "signifiante" du
* markdown (ni vide, ni un titre). Permet à l'IA de savoir "qui est Thorin"
* sans injecter toute sa fiche.
*/
private CharacterSummary toCharacterSummary(Character c) {
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
}
private static String extractSnippet(String markdown) {
if (markdown == null || markdown.isBlank()) return "";
String firstLine = markdown.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
} }
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc) {
@@ -77,12 +111,11 @@ public class CampaignStructuralContextBuilder {
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary) .map(this::toChapterSummary)
.collect(Collectors.toList()); .collect(Collectors.toList());
return ArcSummary.builder() return new ArcSummary(
.name(arc.getName()) arc.getName(),
.description(arc.getDescription()) arc.getDescription(),
.illustrationCount(countImages(arc.getIllustrationImageIds())) countImages(arc.getIllustrationImageIds()),
.chapters(chapters) chapters);
.build();
} }
private ChapterSummary toChapterSummary(Chapter chapter) { private ChapterSummary toChapterSummary(Chapter chapter) {
@@ -99,32 +132,28 @@ public class CampaignStructuralContextBuilder {
.map(s -> toSceneSummary(s, nameById)) .map(s -> toSceneSummary(s, nameById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return ChapterSummary.builder() return new ChapterSummary(
.name(chapter.getName()) chapter.getName(),
.description(chapter.getDescription()) chapter.getDescription(),
.illustrationCount(countImages(chapter.getIllustrationImageIds())) countImages(chapter.getIllustrationImageIds()),
.scenes(summaries) summaries);
.build();
} }
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) { private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
List<BranchHint> hints = scene.getBranches() == null List<BranchHint> hints = scene.getBranches() == null
? List.of() ? List.of()
: scene.getBranches().stream() : scene.getBranches().stream()
.map(b -> BranchHint.builder() .map(b -> new BranchHint(
.label(b.getLabel()) b.label(),
.targetSceneName(nameById.getOrDefault( nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.getTargetSceneId(), "(scène inconnue)")) b.condition()))
.condition(b.getCondition())
.build())
.collect(Collectors.toList()); .collect(Collectors.toList());
return SceneSummary.builder() return new SceneSummary(
.name(scene.getName()) scene.getName(),
.description(scene.getDescription()) scene.getDescription(),
.illustrationCount(countImages(scene.getIllustrationImageIds())) countImages(scene.getIllustrationImageIds()),
.branches(hints) hints);
.build();
} }
/** Helper defensif : compte les illustrations attachees (null-safe). */ /** Helper defensif : compte les illustrations attachees (null-safe). */

View File

@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
requireNonEmptyFields(template); requireNonEmptyFields(template);
GenerationContext context = GenerationContext.builder() // Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
.loreName(lore.getName()) // necessitent un workflow different (pas de generation LLM texte).
.loreDescription(lore.getDescription()) GenerationContext context = new GenerationContext(
.folderName(folder.getName()) lore.getName(),
.templateName(template.getName()) lore.getDescription(),
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE folder.getName(),
// necessitent un workflow different (pas de generation LLM texte). template.getName(),
.templateFields(template.textFieldNames()) template.textFieldNames(),
.pageTitle(page.getTitle()) page.getTitle());
.build();
GenerationResult result = aiProvider.generatePage(context); GenerationResult result = aiProvider.generatePage(context);
return result.values(); return result.values();

View File

@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
Map<String, String> pageTitleById = pages.stream() Map<String, String> pageTitleById = pages.stream()
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a)); .collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
return LoreStructuralContext.builder() return new LoreStructuralContext(
.loreName(lore.getName()) lore.getName(),
.loreDescription(lore.getDescription()) lore.getDescription(),
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById)) buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
.tags(extractUniqueTags(pages)) extractUniqueTags(pages));
.build();
} }
private Map<String, List<PageSummary>> buildFoldersMap( private Map<String, List<PageSummary>> buildFoldersMap(
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
Page page, Page page,
Map<String, String> templateNameById, Map<String, String> templateNameById,
Map<String, String> pageTitleById) { Map<String, String> pageTitleById) {
return PageSummary.builder() return new PageSummary(
.title(page.getTitle()) page.getTitle(),
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?")) templateNameById.getOrDefault(page.getTemplateId(), "?"),
.values(truncatedValues(page.getValues())) truncatedValues(page.getValues()),
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList()) page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById)) resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
.build();
} }
/** /**

View File

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

View File

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

View File

@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
? page.getValues() ? page.getValues()
: Collections.emptyMap(); : Collections.emptyMap();
return PageContext.builder() return new PageContext(page.getTitle(), templateName, templateFields, values);
.title(page.getTitle())
.templateName(templateName)
.templateFields(templateFields)
.values(values)
.build();
} }
} }

View File

@@ -1,9 +1,13 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode; import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -16,11 +20,20 @@ import java.util.Optional;
public class LoreNodeService { public class LoreNodeService {
private final LoreNodeRepository loreNodeRepository; private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
public LoreNodeService(LoreNodeRepository loreNodeRepository) { public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
this.loreNodeRepository = loreNodeRepository; this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
} }
/**
* Compte des entités qui seront supprimées en cascade si le dossier est effacé :
* le dossier lui-même n'est pas compté, seuls les descendants (sous-dossiers
* récursifs + pages de l'ensemble du sous-arbre).
*/
public record DeletionImpact(int folders, int pages) {}
/** /**
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs * Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent * souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
@@ -68,7 +81,64 @@ public class LoreNodeService {
return loreNodeRepository.save(existing); return loreNodeRepository.save(existing);
} }
/**
* Calcule l'impact d'une suppression en cascade : nombre de sous-dossiers
* (récursif, sans compter la racine) et de pages dans l'ensemble du sous-arbre.
*/
public DeletionImpact getDeletionImpact(String id) {
List<LoreNode> descendants = collectDescendants(id);
int pageTotal = pageRepository.findByNodeId(id).size();
for (LoreNode descendant : descendants) {
pageTotal += pageRepository.findByNodeId(descendant.getId()).size();
}
return new DeletionImpact(descendants.size(), pageTotal);
}
/**
* Supprime le dossier et tout son sous-arbre (sous-dossiers récursifs + pages).
* Suppression en profondeur d'abord (feuilles → racine) pour limiter les
* références orphelines en cours de transaction. Les FKs applicatives n'ayant
* pas de CASCADE en DB, on orchestre la descente ici.
*/
@Transactional
public void deleteLoreNode(String id) { public void deleteLoreNode(String id) {
List<LoreNode> descendants = collectDescendants(id);
// Descendants retournés en ordre BFS (haut → bas) : on inverse pour
// supprimer les feuilles en premier, puis on finit par la racine.
for (int i = descendants.size() - 1; i >= 0; i--) {
String descendantId = descendants.get(i).getId();
deletePagesOfNode(descendantId);
loreNodeRepository.deleteById(descendantId);
}
deletePagesOfNode(id);
loreNodeRepository.deleteById(id); loreNodeRepository.deleteById(id);
} }
private void deletePagesOfNode(String nodeId) {
for (Page page : pageRepository.findByNodeId(nodeId)) {
pageRepository.deleteById(page.getId());
}
}
/**
* Retourne tous les descendants (hors racine) d'un dossier, en ordre BFS.
* Parcours itératif pour éviter tout risque de débordement de pile sur
* une arborescence profonde malicieuse.
*/
private List<LoreNode> collectDescendants(String rootId) {
List<LoreNode> result = new ArrayList<>();
List<String> frontier = new ArrayList<>();
frontier.add(rootId);
while (!frontier.isEmpty()) {
List<String> nextFrontier = new ArrayList<>();
for (String parentId : frontier) {
for (LoreNode child : loreNodeRepository.findByParentId(parentId)) {
result.add(child);
nextFrontier.add(child.getId());
}
}
frontier = nextFrontier;
}
return result;
}
} }

View File

@@ -1,10 +1,17 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.lorecontext.Lore; 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.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -26,15 +33,28 @@ public class LoreService {
private final LoreRepository loreRepository; private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository; private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository; private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final CampaignRepository campaignRepository;
public LoreService(LoreRepository loreRepository, public LoreService(LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository, LoreNodeRepository loreNodeRepository,
PageRepository pageRepository) { PageRepository pageRepository,
TemplateRepository templateRepository,
CampaignRepository campaignRepository) {
this.loreRepository = loreRepository; this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository; this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository; this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.campaignRepository = campaignRepository;
} }
/**
* Compte des entités qui seront supprimées / détachées en cascade si le Lore
* est effacé. `detachedCampaigns` : campagnes qui perdront leur référence à
* ce Lore (leur loreId sera nullé) mais resteront présentes.
*/
public record DeletionImpact(int folders, int pages, int templates, int detachedCampaigns) {}
public Lore createLore(String name, String description) { public Lore createLore(String name, String description) {
Lore lore = Lore.builder() Lore lore = Lore.builder()
.name(name) .name(name)
@@ -83,7 +103,54 @@ public class LoreService {
return loreRepository.save(lore); return loreRepository.save(lore);
} }
/**
* Calcule l'impact d'une suppression de Lore en cascade : dossiers + pages
* + templates supprimés, et campagnes qui seront détachées (loreId → null
* sans être supprimées, car une campagne peut vivre sans univers).
*/
public DeletionImpact getDeletionImpact(String id) {
int folders = (int) loreNodeRepository.countByLoreId(id);
int pages = (int) pageRepository.countByLoreId(id);
int templates = templateRepository.findByLoreId(id).size();
int detached = countCampaignsReferencingLore(id);
return new DeletionImpact(folders, pages, templates, detached);
}
/**
* Supprime le Lore et toutes ses entités dépendantes (dossiers, pages, templates).
* Les campagnes qui référençaient ce Lore sont conservées — leur loreId est
* mis à null (une campagne peut légitimement exister sans univers associé).
* Opération transactionnelle : atomique.
*/
@Transactional
public void deleteLore(String id) { public void deleteLore(String id) {
// Pages d'abord : elles référencent nodeId ET loreId, on les supprime
// globalement via loreId pour éviter d'en rater une rattachée à un
// node orphelin (ne devrait pas arriver, mais ceinture+bretelles).
for (Page page : pageRepository.findByLoreId(id)) {
pageRepository.deleteById(page.getId());
}
for (LoreNode node : loreNodeRepository.findByLoreId(id)) {
loreNodeRepository.deleteById(node.getId());
}
for (Template template : templateRepository.findByLoreId(id)) {
templateRepository.deleteById(template.getId());
}
// Détache les campagnes : on garde la campagne, on nulle juste la référence.
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) {
campaign.setLoreId(null);
campaignRepository.save(campaign);
}
}
loreRepository.deleteById(id); loreRepository.deleteById(id);
} }
private int countCampaignsReferencingLore(String id) {
int count = 0;
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) count++;
}
return count;
}
} }

View File

@@ -21,6 +21,9 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne private int order; // Ordre de l'arc dans la campagne
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String themes; // Thèmes principaux explorés dans cet arc private String themes; // Thèmes principaux explorés dans cet arc
private String stakes; // Enjeux globaux pour les personnages private String stakes; // Enjeux globaux pour les personnages

View File

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

View File

@@ -21,6 +21,9 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc private int order; // Ordre du chapitre dans l'arc
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT) private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs dans ce chapitre private String playerObjectives; // Objectifs des joueurs dans ce chapitre

View File

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

View File

@@ -21,6 +21,9 @@ public class Scene {
private String chapterId; // Référence vers le Chapter parent private String chapterId; // Référence vers le Chapter parent
private int order; // Ordre de la scène dans le chapitre private int order; // Ordre de la scène dans le chapitre
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// === Contexte et ambiance === // === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or) private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
private String timing; // Moment (ex: Soir, à la tombée de la nuit) private String timing; // Moment (ex: Soir, à la tombée de la nuit)

View File

@@ -1,31 +1,25 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
/** /**
* Value Object représentant une "sortie" narrative depuis une Scene. * Value Object représentant une "sortie" narrative depuis une Scene.
* Décrit un choix offert aux joueurs et la scène de destination associée. * Décrit un choix offert aux joueurs et la scène de destination associée.
* <p> * <p>
* Immuable (@Value) : pour "modifier" une branche on la remplace. * Record Java : immuable par construction, sans aucune dépendance technique
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA) * (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
* de reconstruire l'objet en passant par le builder malgré l'absence de setters. * les records nativement via le constructeur canonique — c'est ce dont
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
* <p> * <p>
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter * Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
* (validation portée par SceneService). * (validation portée par SceneService).
*
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
*/ */
@Value public record SceneBranch(String label, String targetSceneId, String condition) {
@Builder
@Jacksonized
public class SceneBranch {
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */ /** Raccourci pour construire une branche sans condition (cas le plus courant). */
String label; public static SceneBranch of(String label, String targetSceneId) {
return new SceneBranch(label, targetSceneId, null);
/** Id de la Scene de destination, intra-chapitre uniquement. */ }
String targetSceneId;
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
String condition;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List; import java.util.List;
/** /**
@@ -22,55 +18,62 @@ import java.util.List;
* <p> * <p>
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant * La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
* fait par le use case côté application layer). * fait par le use case côté application layer).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
*/ */
@Value public record CampaignStructuralContext(
@Builder String campaignName,
public class CampaignStructuralContext { String campaignDescription,
List<ArcSummary> arcs,
List<CharacterSummary> characters) {
String campaignName; /**
String campaignDescription; * Résumé d'un PJ : nom + snippet court du markdown.
@Singular List<ArcSummary> arcs; * Pas le markdown complet pour maîtriser le coût token (chaque campagne
* peut avoir 4-6 PJ × potentiellement 1-2k tokens/fiche = trop lourd).
* La fiche complète n'est injectée que si le PJ est l'entité focus
* (via NarrativeEntityContext, entity_type="character").
*/
public record CharacterSummary(String name, String snippet) {
}
/** Résumé d'un arc : nom + description courte + ses chapitres. */ /**
@Value * Résumé d'un arc : nom + description courte + ses chapitres.
@Builder *
public static class ArcSummary { * @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
String name; */
String description; public record ArcSummary(
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */ String name,
int illustrationCount; String description,
@Singular List<ChapterSummary> chapters; int illustrationCount,
List<ChapterSummary> chapters) {
} }
/** Résumé d'un chapitre : nom + description courte + ses scènes. */ /** Résumé d'un chapitre : nom + description courte + ses scènes. */
@Value public record ChapterSummary(
@Builder String name,
public static class ChapterSummary { String description,
String name; int illustrationCount,
String description; List<SceneSummary> scenes) {
int illustrationCount;
@Singular List<SceneSummary> scenes;
} }
/** Résumé d'une scène : nom + description courte + branches narratives. */ /** Résumé d'une scène : nom + description courte + branches narratives. */
@Value public record SceneSummary(
@Builder String name,
public static class SceneSummary { String description,
String name; int illustrationCount,
String description; List<BranchHint> branches) {
int illustrationCount;
@Singular List<BranchHint> branches;
} }
/** Indice d'une branche narrative vers une autre scène du même chapitre. */ /**
@Value * Indice d'une branche narrative vers une autre scène du même chapitre.
@Builder *
public static class BranchHint { * @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */ * @param targetSceneName Nom de la scène cible (résolu depuis targetSceneId côté builder).
String label; * @param condition Condition MJ privée (optionnel).
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */ */
String targetSceneName; public record BranchHint(String label, String targetSceneName, String condition) {
/** Condition MJ privée (optionnel). */
String condition;
} }
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List; import java.util.List;
/** /**
@@ -21,22 +18,74 @@ import java.util.List;
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas * Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore, * ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
* pas l'inverse). * pas l'inverse).
* <p>
* Record Java : pur domaine. Builder manuel fourni en raison des 6 champs
* dont 5 sont nullables — l'API fluide reste plus lisible aux call sites
* qu'un constructeur à 6 paramètres souvent à null.
*
* @param loreContext Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore.
* @param pageContext Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement).
* @param campaignContext Optionnel : carte narrative d'une Campagne (chat Campagne uniquement).
* @param narrativeEntity Optionnel : entité narrative en cours d'édition (arc/chapter/scene).
* @param gameSystemContext Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
*/ */
@Value public record ChatRequest(
@Builder List<ChatMessage> messages,
public class ChatRequest { LoreStructuralContext loreContext,
PageContext pageContext,
CampaignStructuralContext campaignContext,
NarrativeEntityContext narrativeEntity,
GameSystemContext gameSystemContext) {
List<ChatMessage> messages; public static Builder builder() {
return new Builder();
}
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */ /** Builder fluide : permet d'omettre les contextes non pertinents. */
LoreStructuralContext loreContext; public static final class Builder {
private List<ChatMessage> messages;
private LoreStructuralContext loreContext;
private PageContext pageContext;
private CampaignStructuralContext campaignContext;
private NarrativeEntityContext narrativeEntity;
private GameSystemContext gameSystemContext;
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */ private Builder() {}
PageContext pageContext;
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */ public Builder messages(List<ChatMessage> messages) {
CampaignStructuralContext campaignContext; this.messages = messages;
return this;
}
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */ public Builder loreContext(LoreStructuralContext loreContext) {
NarrativeEntityContext narrativeEntity; this.loreContext = loreContext;
return this;
}
public Builder pageContext(PageContext pageContext) {
this.pageContext = pageContext;
return this;
}
public Builder campaignContext(CampaignStructuralContext campaignContext) {
this.campaignContext = campaignContext;
return this;
}
public Builder narrativeEntity(NarrativeEntityContext narrativeEntity) {
this.narrativeEntity = narrativeEntity;
return this;
}
public Builder gameSystemContext(GameSystemContext gameSystemContext) {
this.gameSystemContext = gameSystemContext;
return this;
}
public ChatRequest build() {
return new ChatRequest(messages, loreContext, pageContext,
campaignContext, narrativeEntity, gameSystemContext);
}
}
} }

View File

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

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List; import java.util.List;
/** /**
@@ -10,19 +7,16 @@ import java.util.List;
* pour remplir une Page à partir d'un Template. * pour remplir une Page à partir d'un Template.
* <p> * <p>
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py). * Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
* Entité pure du domaine : aucune dépendance technique. * Record Java : pur domaine, aucune dépendance technique.
* <p> *
* Immuable via @Value (Lombok) : pas de setters, tous les champs final. * @param templateFields Champs à générer (clés attendues dans la réponse).
* C'est un DTO de domaine entrant dans le port AiProvider. * @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
*/ */
@Value public record GenerationContext(
@Builder String loreName,
public class GenerationContext { String loreDescription,
String folderName,
String loreName; String templateName,
String loreDescription; List<String> templateFields,
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux") String pageTitle) {
String templateName;
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
String pageTitle;
} }

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -16,15 +12,14 @@ import java.util.Map;
* <p> * <p>
* La map `folders` est indexée par nom de dossier et mappe vers la liste * La map `folders` est indexée par nom de dossier et mappe vers la liste
* des pages qu'il contient (liste vide autorisée pour les dossiers vides). * des pages qu'il contient (liste vide autorisée pour les dossiers vides).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*/ */
@Value public record LoreStructuralContext(
@Builder String loreName,
public class LoreStructuralContext { String loreDescription,
Map<String, List<PageSummary>> folders,
String loreName; List<String> tags) {
String loreDescription;
Map<String, List<PageSummary>> folders;
@Singular List<String> tags;
/** /**
* Résumé projeté d'une page pour l'IA. * Résumé projeté d'une page pour l'IA.
@@ -40,13 +35,11 @@ public class LoreStructuralContext {
* uniquement ce qui est partageable en narration — les secrets MJ * uniquement ce qui est partageable en narration — les secrets MJ
* restent confinés à leur page d'édition). * restent confinés à leur page d'édition).
*/ */
@Value public record PageSummary(
@Builder String title,
public static class PageSummary { String templateName,
String title; Map<String, String> values,
String templateName; List<String> tags,
Map<String, String> values; List<String> relatedPageTitles) {
List<String> tags;
List<String> relatedPageTitles;
} }
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map; import java.util.Map;
/** /**
@@ -17,13 +14,11 @@ import java.util.Map;
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration") * `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une * à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé). * LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
*
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
*/ */
@Value public record NarrativeEntityContext(
@Builder String entityType,
public class NarrativeEntityContext { String title,
Map<String, String> fields) {
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
String entityType;
String title;
Map<String, String> fields;
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -14,14 +11,11 @@ import java.util.Map;
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder * à l'IA de focaliser ses suggestions sur les bons champs sans déborder
* sur d'autres pages/templates. * sur d'autres pages/templates.
* <p> * <p>
* Object de valeur immuable, pur domaine aucune dépendance technique. * Record Java : immuable, pur domaine, aucune dépendance technique.
*/ */
@Value public record PageContext(
@Builder String title,
public class PageContext { String templateName,
List<String> templateFields,
String title; Map<String, String> values) {
String templateName;
List<String> templateFields;
Map<String, String> values;
} }

View File

@@ -1,17 +1,7 @@
package com.loremind.infrastructure.ai; package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException; import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -23,25 +13,21 @@ import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import java.time.Duration; import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors;
/** /**
* Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider * Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider
* en appelant le Brain Python via WebClient + SSE (Server-Sent Events). * en appelant le Brain Python via WebClient + SSE (Server-Sent Events).
* <p> * <p>
* Responsabilités : * Responsabilités (après extraction) :
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream. * 1. Transport HTTP + consommation du flux SSE.
* Sérialise lore_context, page_context, campaign_context et * 2. Dispatch des évènements SSE (data / done / error / usage).
* narrative_entity de façon conditionnelle selon le scénario d'appel * 3. Traduction des erreurs techniques en AiProviderException.
* (chat Lore / chat Lore focalisé page / chat Campagne / chat Campagne * <p>
* focalisé arc-chapter-scene). * Les responsabilités auxiliaires sont déléguées :
* 2. Consommer le flux SSE token par token. * - Construction du payload JSON : {@link BrainChatPayloadBuilder}.
* 3. Invoquer onToken / onComplete / onError au bon moment. * - Parsing des payloads SSE : {@link BrainSseParser}.
* 4. Traduire toute erreur technique en AiProviderException.
* <p> * <p>
* Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL. * Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL.
*/ */
@@ -53,11 +39,17 @@ public class BrainAiChatClient implements AiChatProvider {
new ParameterizedTypeReference<>() {}; new ParameterizedTypeReference<>() {};
private final WebClient webClient; private final WebClient webClient;
private final BrainChatPayloadBuilder payloadBuilder;
private final BrainSseParser sseParser;
public BrainAiChatClient( public BrainAiChatClient(
WebClient.Builder builder, WebClient.Builder builder,
@Value("${brain.base-url}") String baseUrl) { @Value("${brain.base-url}") String baseUrl,
BrainChatPayloadBuilder payloadBuilder,
BrainSseParser sseParser) {
this.webClient = builder.baseUrl(baseUrl).build(); this.webClient = builder.baseUrl(baseUrl).build();
this.payloadBuilder = payloadBuilder;
this.sseParser = sseParser;
} }
@Override @Override
@@ -68,7 +60,7 @@ public class BrainAiChatClient implements AiChatProvider {
Runnable onComplete, Runnable onComplete,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
Map<String, Object> payload = toPayload(request); Map<String, Object> payload = payloadBuilder.build(request);
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(CHAT_STREAM_PATH) .uri(CHAT_STREAM_PATH)
@@ -92,13 +84,13 @@ public class BrainAiChatClient implements AiChatProvider {
} }
} }
/** Dispatch selon le type d'événement SSE (data par défaut, done, error, usage). */ /** Dispatch selon le type d'évènement SSE (data par défaut, done, error, usage). */
private void handleEvent( private void handleEvent(
ServerSentEvent<String> sse, ServerSentEvent<String> sse,
Consumer<ChatUsage> onUsage, Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut String event = sse.event(); // null si pas d'event: xxx -> data par défaut
String data = sse.data(); String data = sse.data();
if ("error".equals(event)) { if ("error".equals(event)) {
@@ -107,235 +99,17 @@ public class BrainAiChatClient implements AiChatProvider {
return; return;
} }
if ("done".equals(event)) { if ("done".equals(event)) {
return; // la fin est gérée par blockLast + onComplete return; // fin gérée par blockLast + onComplete
} }
if ("usage".equals(event)) { if ("usage".equals(event)) {
ChatUsage usage = extractUsage(data); ChatUsage usage = sseParser.parseUsage(data);
if (usage != null) onUsage.accept(usage); if (usage != null) onUsage.accept(usage);
return; return;
} }
// Défaut : événement data avec JSON {"token":"..."}. // Défaut : évènement data avec JSON {"token":"..."}.
String token = extractToken(data); String token = sseParser.parseToken(data);
if (token != null && !token.isEmpty()) { if (token != null && !token.isEmpty()) {
onToken.accept(token); onToken.accept(token);
} }
} }
/**
* Parse un JSON {"system":N,"history":N,"current":N,"max":N} en ChatUsage.
* Renvoie null si le payload est illisible — dans ce cas on ne propage
* simplement pas d'usage, le stream token continue normalement.
*/
private ChatUsage extractUsage(String json) {
if (json == null) return null;
try {
int system = extractIntField(json, "system");
int history = extractIntField(json, "history");
int current = extractIntField(json, "current");
int max = extractIntField(json, "max");
return new ChatUsage(system, history, current, max);
} catch (Exception e) {
return null;
}
}
/** Parse minimaliste d'un champ entier JSON sans dépendre de Jackson. */
private int extractIntField(String json, String field) {
String needle = "\"" + field + "\"";
int idx = json.indexOf(needle);
if (idx < 0) return 0;
int colon = json.indexOf(':', idx);
if (colon < 0) return 0;
int start = colon + 1;
while (start < json.length() && Character.isWhitespace(json.charAt(start))) start++;
int end = start;
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) end++;
if (end == start) return 0;
return Integer.parseInt(json.substring(start, end));
}
/**
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
* Si le format se complexifie, on remplacera par un DTO Jackson.
*/
private String extractToken(String json) {
if (json == null) return null;
int idx = json.indexOf("\"token\"");
if (idx < 0) return null;
int colon = json.indexOf(':', idx);
int firstQuote = json.indexOf('"', colon + 1);
int lastQuote = json.lastIndexOf('"');
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
return json.substring(firstQuote + 1, lastQuote)
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\");
}
// --- Construction du payload JSON vers le Brain -------------------------
/**
* Construit le payload JSON. Chaque contexte optionnel est omis s'il est
* null, pour s'aligner sur le schéma Pydantic côté Brain (champs
* Optional qui restent absents du dict transmis au LLM).
*/
private Map<String, Object> toPayload(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
if (request.getLoreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext()));
}
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
}
if (request.getCampaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
}
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
}
return root;
}
private Map<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("role", m.role());
map.put("content", m.content());
return map;
}
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName());
map.put("lore_description", ctx.getLoreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.getTags());
return map;
}
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.getTitle());
map.put("template_name", ps.getTemplateName());
// values/tags/related_page_titles ne sont sérialisés que s'ils contiennent
// de l'info — payload réseau plus léger quand la page est vierge.
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
map.put("values", ps.getValues());
}
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
map.put("tags", ps.getTags());
}
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles());
}
return map;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
return map;
}
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.getCampaignName());
map.put("campaign_description", ctx.getCampaignDescription());
map.put("arcs", ctx.getArcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
return map;
}
/**
* Helper generic pour serialiser les entites structurelles (Arc/Chapter/Scene)
* avec name, description et illustration_count conditionnel.
*/
private <T> Map<String, Object> structuralSummaryToMap(
T entity,
java.util.function.Function<T, String> nameExtractor,
java.util.function.Function<T, String> descriptionExtractor,
java.util.function.Function<T, Integer> illustrationCountExtractor,
java.util.function.BiConsumer<Map<String, Object>, T> childSerializer) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", nameExtractor.apply(entity));
map.put("description", descriptionExtractor.apply(entity));
// Envoye au Python pour enrichir le prompt ("N illustrations attachees").
// Serialise uniquement si > 0 pour economiser le payload sur les entites sans images.
if (illustrationCountExtractor.apply(entity) > 0) {
map.put("illustration_count", illustrationCountExtractor.apply(entity));
}
childSerializer.accept(map, entity);
return map;
}
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap(
a,
ArcSummary::getName,
ArcSummary::getDescription,
ArcSummary::getIllustrationCount,
(map, arc) -> map.put("chapters", arc.getChapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap(
c,
ChapterSummary::getName,
ChapterSummary::getDescription,
ChapterSummary::getIllustrationCount,
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap(
s,
SceneSummary::getName,
SceneSummary::getDescription,
SceneSummary::getIllustrationCount,
(map, scene) -> {
// Branches narratives : serialise uniquement si presentes, pour garder
// un payload leger sur les scenes lineaires classiques.
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
map.put("branches", s.getBranches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
});
}
private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.getLabel());
map.put("target_scene_name", b.getTargetSceneName());
if (b.getCondition() != null && !b.getCondition().isBlank()) {
map.put("condition", b.getCondition());
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.getEntityType());
map.put("title", ne.getTitle());
map.put("fields", ne.getFields());
return map;
}
} }

View File

@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) { private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
return new BrainGeneratePageRequest( return new BrainGeneratePageRequest(
context.getLoreName(), context.loreName(),
context.getLoreDescription(), context.loreDescription(),
context.getFolderName(), context.folderName(),
context.getTemplateName(), context.templateName(),
context.getTemplateFields(), context.templateFields(),
context.getPageTitle() context.pageTitle()
); );
} }

View File

@@ -0,0 +1,222 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON
* attendu par le Brain Python (/chat/stream).
* <p>
* Extrait de BrainAiChatClient pour isoler la responsabilité "sérialisation
* de payload" (SRP) — le client HTTP se concentre désormais uniquement sur le
* transport et le streaming SSE.
* <p>
* Chaque contexte optionnel (lore, page, campaign, entité narrative) est omis
* si null, pour s'aligner sur le schéma Pydantic (champs Optional absents).
*/
@Component
public class BrainChatPayloadBuilder {
public Map<String, Object> build(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.messages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
if (request.loreContext() != null) {
root.put("lore_context", loreContextToMap(request.loreContext()));
}
if (request.pageContext() != null) {
root.put("page_context", pageContextToMap(request.pageContext()));
}
if (request.campaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.campaignContext()));
}
if (request.narrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
}
if (request.gameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
}
return root;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.systemName());
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
map.put("system_description", gs.systemDescription());
}
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
return map;
}
private Map<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("role", m.role());
map.put("content", m.content());
return map;
}
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.loreName());
map.put("lore_description", ctx.loreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.tags());
return map;
}
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.title());
map.put("template_name", ps.templateName());
// values/tags/related_page_titles : omis si vides pour alléger le payload.
if (ps.values() != null && !ps.values().isEmpty()) {
map.put("values", ps.values());
}
if (ps.tags() != null && !ps.tags().isEmpty()) {
map.put("tags", ps.tags());
}
if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.relatedPageTitles());
}
return map;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.title());
map.put("template_name", pc.templateName());
map.put("template_fields", pc.templateFields());
map.put("values", pc.values());
return map;
}
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.campaignName());
map.put("campaign_description", ctx.campaignDescription());
map.put("arcs", ctx.arcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
map.put("characters", ctx.characters().stream()
.map(this::characterSummaryToMap)
.collect(Collectors.toList()));
}
return map;
}
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.name());
if (c.snippet() != null && !c.snippet().isBlank()) {
map.put("snippet", c.snippet());
}
return map;
}
/**
* Helper générique pour sérialiser les entités structurelles (Arc/Chapter/Scene)
* avec name, description et illustration_count conditionnel.
*/
private <T> Map<String, Object> structuralSummaryToMap(
T entity,
Function<T, String> nameExtractor,
Function<T, String> descriptionExtractor,
Function<T, Integer> illustrationCountExtractor,
BiConsumer<Map<String, Object>, T> childSerializer) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", nameExtractor.apply(entity));
map.put("description", descriptionExtractor.apply(entity));
if (illustrationCountExtractor.apply(entity) > 0) {
map.put("illustration_count", illustrationCountExtractor.apply(entity));
}
childSerializer.accept(map, entity);
return map;
}
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap(
a,
ArcSummary::name,
ArcSummary::description,
ArcSummary::illustrationCount,
(map, arc) -> map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap(
c,
ChapterSummary::name,
ChapterSummary::description,
ChapterSummary::illustrationCount,
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap(
s,
SceneSummary::name,
SceneSummary::description,
SceneSummary::illustrationCount,
(map, scene) -> {
// Branches narratives : omises si absentes (scènes linéaires classiques).
if (s.branches() != null && !s.branches().isEmpty()) {
map.put("branches", s.branches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
});
}
private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.label());
map.put("target_scene_name", b.targetSceneName());
if (b.condition() != null && !b.condition().isBlank()) {
map.put("condition", b.condition());
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.entityType());
map.put("title", ne.title());
map.put("fields", ne.fields());
return map;
}
}

View File

@@ -0,0 +1,67 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.ChatUsage;
import org.springframework.stereotype.Component;
/**
* Helper d'infrastructure : parse les payloads JSON véhiculés dans les
* évènements SSE reçus du Brain Python.
* <p>
* Implémentation volontairement minimaliste (pas de Jackson ici) car les
* schémas attendus sont figés et simples : {"token":"..."} et
* {"system":N,"history":N,"current":N,"max":N}. Si la complexité augmente,
* remplacer par un ObjectMapper + DTOs.
*/
@Component
public class BrainSseParser {
/**
* Parse un JSON {"system":N,"history":N,"current":N,"max":N} en ChatUsage.
* Renvoie null si le payload est illisible — l'appelant décidera de ne
* simplement pas propager l'usage (le stream token continue).
*/
public ChatUsage parseUsage(String json) {
if (json == null) return null;
try {
int system = extractIntField(json, "system");
int history = extractIntField(json, "history");
int current = extractIntField(json, "current");
int max = extractIntField(json, "max");
return new ChatUsage(system, history, current, max);
} catch (Exception e) {
return null;
}
}
/**
* Parse {"token":"..."} et renvoie la valeur du champ token (chaîne vide
* ou null si introuvable).
*/
public String parseToken(String json) {
if (json == null) return null;
int idx = json.indexOf("\"token\"");
if (idx < 0) return null;
int colon = json.indexOf(':', idx);
int firstQuote = json.indexOf('"', colon + 1);
int lastQuote = json.lastIndexOf('"');
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
return json.substring(firstQuote + 1, lastQuote)
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\");
}
private int extractIntField(String json, String field) {
String needle = "\"" + field + "\"";
int idx = json.indexOf(needle);
if (idx < 0) return 0;
int colon = json.indexOf(':', idx);
if (colon < 0) return 0;
int start = colon + 1;
while (start < json.length() && Character.isWhitespace(json.charAt(start))) start++;
int end = start;
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) end++;
if (end == start) return 0;
return Integer.parseInt(json.substring(start, end));
}
}

View File

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

View File

@@ -37,6 +37,9 @@ public class ArcJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update) // Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String themes; private String themes;

View File

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

View File

@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update) // Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(name = "gm_notes", columnDefinition = "TEXT") @Column(name = "gm_notes", columnDefinition = "TEXT")
private String gmNotes; private String gmNotes;

View File

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

View File

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

View File

@@ -39,6 +39,9 @@ public class SceneJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update) // Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
// Contexte et ambiance // Contexte et ambiance

View File

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

View File

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

View File

@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(jpaEntity.getDescription()) .description(jpaEntity.getDescription())
.campaignId(jpaEntity.getCampaignId().toString()) .campaignId(jpaEntity.getCampaignId().toString())
.order(jpaEntity.getOrder()) .order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.themes(jpaEntity.getThemes()) .themes(jpaEntity.getThemes())
.stakes(jpaEntity.getStakes()) .stakes(jpaEntity.getStakes())
.gmNotes(jpaEntity.getGmNotes()) .gmNotes(jpaEntity.getGmNotes())
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(arc.getDescription()) .description(arc.getDescription())
.campaignId(Long.parseLong(arc.getCampaignId())) .campaignId(Long.parseLong(arc.getCampaignId()))
.order(arc.getOrder()) .order(arc.getOrder())
.icon(arc.getIcon())
.themes(arc.getThemes()) .themes(arc.getThemes())
.stakes(arc.getStakes()) .stakes(arc.getStakes())
.gmNotes(arc.getGmNotes()) .gmNotes(arc.getGmNotes())

View File

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

View File

@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(jpaEntity.getDescription()) .description(jpaEntity.getDescription())
.arcId(jpaEntity.getArcId().toString()) .arcId(jpaEntity.getArcId().toString())
.order(jpaEntity.getOrder()) .order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.gmNotes(jpaEntity.getGmNotes()) .gmNotes(jpaEntity.getGmNotes())
.playerObjectives(jpaEntity.getPlayerObjectives()) .playerObjectives(jpaEntity.getPlayerObjectives())
.narrativeStakes(jpaEntity.getNarrativeStakes()) .narrativeStakes(jpaEntity.getNarrativeStakes())
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(chapter.getDescription()) .description(chapter.getDescription())
.arcId(Long.parseLong(chapter.getArcId())) .arcId(Long.parseLong(chapter.getArcId()))
.order(chapter.getOrder()) .order(chapter.getOrder())
.icon(chapter.getIcon())
.gmNotes(chapter.getGmNotes()) .gmNotes(chapter.getGmNotes())
.playerObjectives(chapter.getPlayerObjectives()) .playerObjectives(chapter.getPlayerObjectives())
.narrativeStakes(chapter.getNarrativeStakes()) .narrativeStakes(chapter.getNarrativeStakes())

View File

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

View File

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

View File

@@ -71,6 +71,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(jpaEntity.getDescription()) .description(jpaEntity.getDescription())
.chapterId(jpaEntity.getChapterId().toString()) .chapterId(jpaEntity.getChapterId().toString())
.order(jpaEntity.getOrder()) .order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.location(jpaEntity.getLocation()) .location(jpaEntity.getLocation())
.timing(jpaEntity.getTiming()) .timing(jpaEntity.getTiming())
.atmosphere(jpaEntity.getAtmosphere()) .atmosphere(jpaEntity.getAtmosphere())
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(scene.getDescription()) .description(scene.getDescription())
.chapterId(Long.parseLong(scene.getChapterId())) .chapterId(Long.parseLong(scene.getChapterId()))
.order(scene.getOrder()) .order(scene.getOrder())
.icon(scene.getIcon())
.location(scene.getLocation()) .location(scene.getLocation())
.timing(scene.getTiming()) .timing(scene.getTiming())
.atmosphere(scene.getAtmosphere()) .atmosphere(scene.getAtmosphere())

View File

@@ -0,0 +1,283 @@
package com.loremind.infrastructure.updates;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Detection des mises a jour disponibles + declenchement via Watchtower.
*
* 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`).
* - {@link #check()} re-interroge le registry et compare. Si un digest a
* change, une mise a jour est disponible.
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
*
* Apres un apply reussi, Watchtower redemarre core => ce service est
* re-instancie => baseline re-aligne sur le registry => check renvoie
* "pas de MAJ" (etat coherent).
*
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
* masque le badge / bouton.
*/
@Service
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 Map<String, String> baselineDigests = new ConcurrentHashMap<>();
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) {
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;
}
@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);
for (String image : images) {
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());
}
}
}
public boolean isEnabled() {
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
}
public UpdateStatus check() {
if (!isEnabled()) {
return new UpdateStatus(false, false, List.of(), Instant.now());
}
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
for (String image : images) {
String baseline = baselineDigests.get(image);
String remote = null;
try {
remote = fetchRemoteDigest(image);
} catch (Exception e) {
log.warn("Check failed for {}: {}", image, e.getMessage());
}
// Si on n'a pas de baseline (echec au boot), on l'aligne maintenant
// pour eviter un faux positif "MAJ dispo".
if (baseline == null && remote != null) {
baselineDigests.put(image, remote);
baseline = remote;
}
boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote);
if (updateAvailable) anyUpdate = true;
statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
}
return new UpdateStatus(true, anyUpdate, statuses, Instant.now());
}
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) {
url.append(hasQuery ? '&' : '?')
.append(key).append('=')
.append(URLEncoder.encode(v, StandardCharsets.UTF_8));
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;
}
}
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
private static Map<String, String> parseAuthParams(String s) {
Map<String, String> out = new HashMap<>();
int i = 0;
int n = s.length();
while (i < n) {
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
int eq = s.indexOf('=', i);
if (eq < 0) break;
String key = s.substring(i, eq).trim();
int valStart = eq + 1;
String val;
if (valStart < n && s.charAt(valStart) == '"') {
int valEnd = s.indexOf('"', valStart + 1);
if (valEnd < 0) break;
val = s.substring(valStart + 1, valEnd);
i = valEnd + 1;
} else {
int valEnd = s.indexOf(',', valStart);
if (valEnd < 0) valEnd = n;
val = s.substring(valStart, valEnd).trim();
i = valEnd;
}
out.put(key, val);
}
return out;
}
private static String normalizeRegistry(String value) {
if (value == null || value.isBlank()) return "";
String v = value.trim();
if (!v.startsWith("http://") && !v.startsWith("https://")) {
v = "https://" + v;
}
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
return v;
}
private static List<String> parseImages(String csv) {
if (csv == null || csv.isBlank()) return List.of();
List<String> out = new ArrayList<>();
for (String part : csv.split(",")) {
String p = part.trim();
if (!p.isEmpty()) out.add(p);
}
return out;
}
// -----------------------------------------------------------------------
// Records de retour (sortis sous forme JSON par Jackson)
// -----------------------------------------------------------------------
public record UpdateStatus(
boolean enabled,
boolean updateAvailable,
List<ImageStatus> images,
Instant checkedAt) {}
public record ImageStatus(
String image,
String localDigest,
String remoteDigest,
boolean updateAvailable) {}
}

View File

@@ -66,6 +66,7 @@ public class SecurityConfig {
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS) // Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/settings/**").hasRole("ADMIN") .requestMatchers("/api/settings/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().permitAll() .anyRequest().permitAll()
) )
.httpBasic(basic -> {}); .httpBasic(basic -> {});

View File

@@ -28,7 +28,7 @@ public class ArcController {
@PostMapping @PostMapping
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) { public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
Arc arc = arcMapper.toDomain(arcDTO); Arc arc = arcMapper.toDomain(arcDTO);
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder()); Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon());
return ResponseEntity.ok(arcMapper.toDTO(createdArc)); return ResponseEntity.ok(arcMapper.toDTO(createdArc));
} }
@@ -40,17 +40,11 @@ public class ArcController {
} }
@GetMapping @GetMapping
public ResponseEntity<List<ArcDTO>> getAllArcs() { public ResponseEntity<List<ArcDTO>> getAllArcs(
List<Arc> arcs = arcService.getAllArcs(); @RequestParam(value = "campaignId", required = false) String campaignId) {
List<ArcDTO> arcDTOs = arcs.stream() List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
.map(arcMapper::toDTO) ? arcService.getArcsByCampaignId(campaignId)
.collect(Collectors.toList()); : arcService.getAllArcs();
return ResponseEntity.ok(arcDTOs);
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
List<ArcDTO> arcDTOs = arcs.stream() List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO) .map(arcMapper::toDTO)
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -68,4 +62,12 @@ public class ArcController {
arcService.deleteArc(id); arcService.deleteArc(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<ArcService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!arcService.arcExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(arcService.getDeletionImpact(id));
}
} }

View File

@@ -31,7 +31,7 @@ public class CampaignController {
public ResponseEntity<CampaignDTO> createCampaign(@RequestBody CampaignDTO campaignDTO) { public ResponseEntity<CampaignDTO> createCampaign(@RequestBody CampaignDTO campaignDTO) {
Campaign campaign = campaignMapper.toDomain(campaignDTO); Campaign campaign = campaignMapper.toDomain(campaignDTO);
Campaign createdCampaign = campaignService.createCampaign( Campaign createdCampaign = campaignService.createCampaign(
new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId()) new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId())
); );
return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign)); return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign));
} }
@@ -64,7 +64,7 @@ public class CampaignController {
public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) { public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
Campaign updatedCampaign = campaignService.updateCampaign( Campaign updatedCampaign = campaignService.updateCampaign(
id, id,
new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId()) new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId())
); );
return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign)); return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign));
} }
@@ -74,4 +74,16 @@ public class CampaignController {
campaignService.deleteCampaign(id); campaignService.deleteCampaign(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/**
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
* l'UI pour afficher "X arcs, Y chapitres, Z scènes..." dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<CampaignService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!campaignService.campaignExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(campaignService.getDeletionImpact(id));
}
} }

View File

@@ -28,7 +28,7 @@ public class ChapterController {
@PostMapping @PostMapping
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) { public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
Chapter chapter = chapterMapper.toDomain(chapterDTO); Chapter chapter = chapterMapper.toDomain(chapterDTO);
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder()); Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon());
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter)); return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
} }
@@ -40,17 +40,11 @@ public class ChapterController {
} }
@GetMapping @GetMapping
public ResponseEntity<List<ChapterDTO>> getAllChapters() { public ResponseEntity<List<ChapterDTO>> getAllChapters(
List<Chapter> chapters = chapterService.getAllChapters(); @RequestParam(value = "arcId", required = false) String arcId) {
List<ChapterDTO> chapterDTOs = chapters.stream() List<Chapter> chapters = (arcId != null && !arcId.isBlank())
.map(chapterMapper::toDTO) ? chapterService.getChaptersByArcId(arcId)
.collect(Collectors.toList()); : chapterService.getAllChapters();
return ResponseEntity.ok(chapterDTOs);
}
@GetMapping("/arc/{arcId}")
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
List<ChapterDTO> chapterDTOs = chapters.stream() List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO) .map(chapterMapper::toDTO)
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -68,4 +62,12 @@ public class ChapterController {
chapterService.deleteChapter(id); chapterService.deleteChapter(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<ChapterService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!chapterService.chapterExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(chapterService.getDeletionImpact(id));
}
} }

View File

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

View File

@@ -0,0 +1,36 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import org.springframework.beans.factory.annotation.Value;
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;
/**
* Expose la configuration publique consommee par le frontend au demarrage.
* Activer le mode demo via la variable d'env DEMO_MODE=true : le front
* masque alors Settings / Export VTT, et les endpoints sensibles sont
* verrouilles cote serveur (cf. SettingsController).
*/
@RestController
@RequestMapping("/api/config")
public class ConfigController {
private final boolean demoMode;
private final UpdateCheckService updates;
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode,
UpdateCheckService updates) {
this.demoMode = demoMode;
this.updates = updates;
}
@GetMapping
public Map<String, Object> getPublicConfig() {
return Map.of(
"demoMode", demoMode,
"updateCheckEnabled", updates.isEnabled());
}
}

View File

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

View File

@@ -69,4 +69,17 @@ public class LoreController {
loreService.deleteLore(id); loreService.deleteLore(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/**
* Récapitulatif des entités qui seront supprimées / détachées en cascade.
* Utilisé par l'UI pour afficher "X dossiers, Y pages, Z templates,
* N campagne(s) détachée(s)" dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<LoreService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (loreService.getLoreById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(loreService.getDeletionImpact(id));
}
} }

View File

@@ -97,4 +97,16 @@ public class LoreNodeController {
loreNodeService.deleteLoreNode(id); loreNodeService.deleteLoreNode(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/**
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
* l'UI pour afficher "X sous-dossiers, Y pages..." dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<LoreNodeService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (loreNodeService.getLoreNodeById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(loreNodeService.getDeletionImpact(id));
}
} }

View File

@@ -28,7 +28,7 @@ public class SceneController {
@PostMapping @PostMapping
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) { public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
Scene scene = sceneMapper.toDomain(sceneDTO); Scene scene = sceneMapper.toDomain(sceneDTO);
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder()); Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder(), scene.getIcon());
return ResponseEntity.ok(sceneMapper.toDTO(createdScene)); return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
} }
@@ -40,17 +40,11 @@ public class SceneController {
} }
@GetMapping @GetMapping
public ResponseEntity<List<SceneDTO>> getAllScenes() { public ResponseEntity<List<SceneDTO>> getAllScenes(
List<Scene> scenes = sceneService.getAllScenes(); @RequestParam(value = "chapterId", required = false) String chapterId) {
List<SceneDTO> sceneDTOs = scenes.stream() List<Scene> scenes = (chapterId != null && !chapterId.isBlank())
.map(sceneMapper::toDTO) ? sceneService.getScenesByChapterId(chapterId)
.collect(Collectors.toList()); : sceneService.getAllScenes();
return ResponseEntity.ok(sceneDTOs);
}
@GetMapping("/chapter/{chapterId}")
public ResponseEntity<List<SceneDTO>> getScenesByChapterId(@PathVariable String chapterId) {
List<Scene> scenes = sceneService.getScenesByChapterId(chapterId);
List<SceneDTO> sceneDTOs = scenes.stream() List<SceneDTO> sceneDTOs = scenes.stream()
.map(sceneMapper::toDTO) .map(sceneMapper::toDTO)
.collect(Collectors.toList()); .collect(Collectors.toList());

View File

@@ -4,16 +4,30 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map; import java.util.Map;
/** /**
@@ -32,20 +46,25 @@ public class SettingsController {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final String brainBaseUrl; private final String brainBaseUrl;
private final boolean demoMode;
public SettingsController(RestTemplate restTemplate, public SettingsController(RestTemplate restTemplate,
@Value("${brain.base-url}") String brainBaseUrl) { @Value("${brain.base-url}") String brainBaseUrl,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.restTemplate = restTemplate; this.restTemplate = restTemplate;
this.brainBaseUrl = brainBaseUrl; this.brainBaseUrl = brainBaseUrl;
this.demoMode = demoMode;
} }
@GetMapping @GetMapping
public ResponseEntity<Map<String, Object>> getSettings() { public ResponseEntity<Map<String, Object>> getSettings() {
guardDemoMode();
return forward(HttpMethod.GET, "/settings", null); return forward(HttpMethod.GET, "/settings", null);
} }
@PutMapping @PutMapping
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) { public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
guardDemoMode();
return forward(HttpMethod.PUT, "/settings", patch); return forward(HttpMethod.PUT, "/settings", patch);
} }
@@ -59,11 +78,87 @@ public class SettingsController {
return forward(HttpMethod.POST, "/models/ollama/info", body); return forward(HttpMethod.POST, "/models/ollama/info", body);
} }
/**
* Telecharge un modele Ollama et streame la progression au client.
* <p>
* On bypass RestTemplate (qui bufferise toute la reponse) au profit du
* client HTTP standard de Java en mode streaming. Le Brain renvoie du
* NDJSON ligne par ligne ; on relaie chaque chunk tel quel pour que le
* frontend voie la progression en temps reel.
*/
@PostMapping(value = "/models/ollama/pull", produces = "application/x-ndjson")
public ResponseEntity<StreamingResponseBody> pullOllamaModel(@RequestBody Map<String, Object> body) {
guardDemoMode();
StreamingResponseBody stream = output -> {
HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(brainBaseUrl + "/models/ollama/pull"))
.timeout(Duration.ofMinutes(60))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(toJson(body)))
.build();
try {
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream in = resp.body()) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1) {
output.write(buf, 0, n);
output.flush();
}
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("Pull interrompu", ie);
}
};
return ResponseEntity.ok().contentType(MediaType.parseMediaType("application/x-ndjson")).body(stream);
}
@DeleteMapping("/models/ollama/{name}")
public ResponseEntity<Map<String, Object>> deleteOllamaModel(@PathVariable("name") String name) {
guardDemoMode();
return forward(HttpMethod.DELETE, "/models/ollama/" + name, null);
}
@GetMapping("/models/onemin") @GetMapping("/models/onemin")
public ResponseEntity<Map<String, Object>> listOneMinModels() { public ResponseEntity<Map<String, Object>> listOneMinModels() {
return forward(HttpMethod.GET, "/models/onemin", null); return forward(HttpMethod.GET, "/models/onemin", null);
} }
/**
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des
* String/Number/Boolean en valeur.
*/
private static String toJson(Map<String, Object> m) {
StringBuilder sb = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> e : m.entrySet()) {
if (!first) sb.append(",");
sb.append("\"").append(escape(e.getKey())).append("\":");
Object v = e.getValue();
if (v == null) sb.append("null");
else if (v instanceof Number || v instanceof Boolean) sb.append(v);
else sb.append("\"").append(escape(v.toString())).append("\"");
first = false;
}
return sb.append("}").toString();
}
private static String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
}
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
}
}
@SuppressWarnings({"rawtypes", "unchecked"}) @SuppressWarnings({"rawtypes", "unchecked"})
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) { private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();

View File

@@ -0,0 +1,76 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
/**
* Endpoints admin pour la verification et le declenchement des mises a jour
* des conteneurs LoreMind (core/brain/web).
*
* Protege par HTTP Basic via SecurityConfig (path /api/admin/**).
* Si la feature n'est pas configuree (WATCHTOWER_TOKEN vide), check renvoie
* {enabled:false} et apply repond 503.
*/
@RestController
@RequestMapping("/api/admin/updates")
public class UpdatesController {
private static final Logger log = LoggerFactory.getLogger(UpdatesController.class);
private final UpdateCheckService updates;
private final boolean demoMode;
public UpdatesController(UpdateCheckService updates,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.updates = updates;
this.demoMode = demoMode;
}
@GetMapping("/check")
public UpdateStatus check() {
guardDemoMode();
return updates.check();
}
@PostMapping("/apply")
public ResponseEntity<Map<String, Object>> apply() {
guardDemoMode();
if (!updates.isEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", "Update apply not configured"));
}
try {
updates.apply();
return ResponseEntity.accepted()
.body(Map.of("status", "triggered",
"message", "Watchtower va telecharger et redemarrer les conteneurs."));
} catch (Exception e) {
log.error("Apply update failed", e);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("error", "Watchtower unreachable: " + e.getMessage()));
}
}
/**
* En mode demo, les instances ne doivent pas se mettre a jour ni meme
* exposer leur statut (pas de surface d'attaque, et pas de redemarrage
* intempestif d'une demo en cours). Cohérent avec SettingsController.
*/
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Updates disabled in demo mode");
}
}
}

View File

@@ -17,6 +17,9 @@ public class ArcDTO {
private String campaignId; private String campaignId;
private int order; private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis // Champs narratifs enrichis
private String themes; private String themes;
private String stakes; private String stakes;

View File

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

View File

@@ -17,6 +17,9 @@ public class ChapterDTO {
private String arcId; private String arcId;
private int order; private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis // Champs narratifs enrichis
private String gmNotes; private String gmNotes;
private String playerObjectives; private String playerObjectives;

View File

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

View File

@@ -17,6 +17,9 @@ public class SceneDTO {
private String chapterId; private String chapterId;
private int order; private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis // Champs narratifs enrichis
private String location; private String location;
private String timing; private String timing;

View File

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

View File

@@ -24,6 +24,7 @@ public class ArcMapper {
dto.setDescription(arc.getDescription()); dto.setDescription(arc.getDescription());
dto.setCampaignId(arc.getCampaignId()); dto.setCampaignId(arc.getCampaignId());
dto.setOrder(arc.getOrder()); dto.setOrder(arc.getOrder());
dto.setIcon(arc.getIcon());
dto.setThemes(arc.getThemes()); dto.setThemes(arc.getThemes());
dto.setStakes(arc.getStakes()); dto.setStakes(arc.getStakes());
dto.setGmNotes(arc.getGmNotes()); dto.setGmNotes(arc.getGmNotes());
@@ -46,6 +47,7 @@ public class ArcMapper {
.description(dto.getDescription()) .description(dto.getDescription())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.order(dto.getOrder()) .order(dto.getOrder())
.icon(dto.getIcon())
.themes(dto.getThemes()) .themes(dto.getThemes())
.stakes(dto.getStakes()) .stakes(dto.getStakes())
.gmNotes(dto.getGmNotes()) .gmNotes(dto.getGmNotes())

View File

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

View File

@@ -24,6 +24,7 @@ public class ChapterMapper {
dto.setDescription(chapter.getDescription()); dto.setDescription(chapter.getDescription());
dto.setArcId(chapter.getArcId()); dto.setArcId(chapter.getArcId());
dto.setOrder(chapter.getOrder()); dto.setOrder(chapter.getOrder());
dto.setIcon(chapter.getIcon());
dto.setGmNotes(chapter.getGmNotes()); dto.setGmNotes(chapter.getGmNotes());
dto.setPlayerObjectives(chapter.getPlayerObjectives()); dto.setPlayerObjectives(chapter.getPlayerObjectives());
dto.setNarrativeStakes(chapter.getNarrativeStakes()); dto.setNarrativeStakes(chapter.getNarrativeStakes());
@@ -44,6 +45,7 @@ public class ChapterMapper {
.description(dto.getDescription()) .description(dto.getDescription())
.arcId(dto.getArcId()) .arcId(dto.getArcId())
.order(dto.getOrder()) .order(dto.getOrder())
.icon(dto.getIcon())
.gmNotes(dto.getGmNotes()) .gmNotes(dto.getGmNotes())
.playerObjectives(dto.getPlayerObjectives()) .playerObjectives(dto.getPlayerObjectives())
.narrativeStakes(dto.getNarrativeStakes()) .narrativeStakes(dto.getNarrativeStakes())

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ public class SceneMapper {
dto.setDescription(scene.getDescription()); dto.setDescription(scene.getDescription());
dto.setChapterId(scene.getChapterId()); dto.setChapterId(scene.getChapterId());
dto.setOrder(scene.getOrder()); dto.setOrder(scene.getOrder());
dto.setIcon(scene.getIcon());
dto.setLocation(scene.getLocation()); dto.setLocation(scene.getLocation());
dto.setTiming(scene.getTiming()); dto.setTiming(scene.getTiming());
dto.setAtmosphere(scene.getAtmosphere()); dto.setAtmosphere(scene.getAtmosphere());
@@ -59,6 +60,7 @@ public class SceneMapper {
.description(dto.getDescription()) .description(dto.getDescription())
.chapterId(dto.getChapterId()) .chapterId(dto.getChapterId())
.order(dto.getOrder()) .order(dto.getOrder())
.icon(dto.getIcon())
.location(dto.getLocation()) .location(dto.getLocation())
.timing(dto.getTiming()) .timing(dto.getTiming())
.atmosphere(dto.getAtmosphere()) .atmosphere(dto.getAtmosphere())
@@ -85,18 +87,14 @@ public class SceneMapper {
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) { private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
if (branches == null) return new ArrayList<>(); if (branches == null) return new ArrayList<>();
return branches.stream() return branches.stream()
.map(b -> new SceneBranchDTO(b.getLabel(), b.getTargetSceneId(), b.getCondition())) .map(b -> new SceneBranchDTO(b.label(), b.targetSceneId(), b.condition()))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) { private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
if (dtos == null) return new ArrayList<>(); if (dtos == null) return new ArrayList<>();
return dtos.stream() return dtos.stream()
.map(d -> SceneBranch.builder() .map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition()))
.label(d.getLabel())
.targetSceneId(d.getTargetSceneId())
.condition(d.getCondition())
.build())
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
} }

View File

@@ -21,13 +21,13 @@ spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.format_sql=true
# Configuration CORS pour autoriser le Frontend Angular # Configuration CORS pour autoriser le Frontend Angular
spring.web.cors.allowed-origins=http://localhost:4200 spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
spring.web.cors.allowed-headers=* spring.web.cors.allowed-headers=*
spring.web.cors.allow-credentials=true spring.web.cors.allow-credentials=true
# Configuration du Brain (service IA Python) # Configuration du Brain (service IA Python)
brain.base-url=http://localhost:8000 brain.base-url=${BRAIN_BASE_URL:http://localhost:8000}
brain.timeout-seconds=120 brain.timeout-seconds=120
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret). # Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
@@ -50,3 +50,15 @@ minio.bucket=${MINIO_BUCKET:loremind-images}
# Limites d'upload d'images (MB) # Limites d'upload d'images (MB)
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB spring.servlet.multipart.max-request-size=10MB
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
app.demo-mode=${DEMO_MODE:false}
# Detection des mises a jour des conteneurs Docker (registry HTTP API + Watchtower).
# 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:}

View File

@@ -1,7 +1,11 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -14,6 +18,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
@@ -26,6 +31,10 @@ public class ArcServiceTest {
@Mock @Mock
private ArcRepository arcRepository; private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks @InjectMocks
private ArcService arcService; private ArcService arcService;
@@ -159,15 +168,48 @@ public class ArcServiceTest {
} }
@Test @Test
void testDeleteArc() { void testDeleteArc_EmptyArc() {
// Arrange // Aucun chapitre : Mockito renvoie List.of() par défaut.
doNothing().when(arcRepository).deleteById("arc-1");
// Act
arcService.deleteArc("arc-1"); arcService.deleteArc("arc-1");
// Assert verify(arcRepository).deleteById("arc-1");
verify(arcRepository, times(1)).deleteById("arc-1"); verify(chapterRepository, never()).deleteById(anyString());
verify(sceneRepository, never()).deleteById(anyString());
}
@Test
void testDeleteArc_CascadesChaptersAndScenes() {
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("C").build();
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chap-1").name("S2").build();
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1, s2));
arcService.deleteArc("arc-1");
verify(sceneRepository).deleteById("s-1");
verify(sceneRepository).deleteById("s-2");
verify(chapterRepository).deleteById("chap-1");
verify(arcRepository).deleteById("arc-1");
}
@Test
void testGetDeletionImpact() {
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
ArcService.DeletionImpact impact = arcService.getDeletionImpact("arc-1");
assertEquals(2, impact.chapters());
assertEquals(3, impact.scenes());
} }
@Test @Test

View File

@@ -1,7 +1,15 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -27,6 +35,14 @@ public class CampaignServiceTest {
@Mock @Mock
private CampaignRepository campaignRepository; private CampaignRepository campaignRepository;
@Mock
private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@Mock
private CharacterRepository characterRepository;
@InjectMocks @InjectMocks
private CampaignService campaignService; private CampaignService campaignService;
@@ -50,9 +66,13 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData( CampaignService.CampaignData data = new CampaignService.CampaignData(
"New Campaign", "New Campaign",
"Description", "Description",
"lore-123" "lore-123",
null
); );
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); // Le repo renvoie la Campaign telle que passée — on teste la normalisation
// du loreId dans le service, pas le comportement du repo.
when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// Act // Act
Campaign result = campaignService.createCampaign(data); Campaign result = campaignService.createCampaign(data);
@@ -69,9 +89,11 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData( CampaignService.CampaignData data = new CampaignService.CampaignData(
"New Campaign", "New Campaign",
"Description", "Description",
null,
null null
); );
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// Act // Act
Campaign result = campaignService.createCampaign(data); Campaign result = campaignService.createCampaign(data);
@@ -88,9 +110,11 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData( CampaignService.CampaignData data = new CampaignService.CampaignData(
"New Campaign", "New Campaign",
"Description", "Description",
" " " ",
null
); );
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// Act // Act
Campaign result = campaignService.createCampaign(data); Campaign result = campaignService.createCampaign(data);
@@ -151,7 +175,8 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData( CampaignService.CampaignData data = new CampaignService.CampaignData(
"Updated Campaign", "Updated Campaign",
"Updated Description", "Updated Description",
"lore-456" "lore-456",
null
); );
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign)); when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
@@ -171,7 +196,8 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData( CampaignService.CampaignData data = new CampaignService.CampaignData(
"Updated Campaign", "Updated Campaign",
"Updated Description", "Updated Description",
"lore-456" "lore-456",
null
); );
when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty()); when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty());
@@ -186,15 +212,75 @@ public class CampaignServiceTest {
} }
@Test @Test
void testDeleteCampaign() { void testDeleteCampaign_EmptyCampaign() {
// Arrange // Arrange : aucune dépendance ; Mockito renvoie List.of() par défaut.
doNothing().when(campaignRepository).deleteById("campaign-1");
// Act // Act
campaignService.deleteCampaign("campaign-1"); campaignService.deleteCampaign("campaign-1");
// Assert // Assert
verify(campaignRepository, times(1)).deleteById("campaign-1"); verify(campaignRepository, times(1)).deleteById("campaign-1");
verify(arcRepository, never()).deleteById(anyString());
verify(chapterRepository, never()).deleteById(anyString());
verify(sceneRepository, never()).deleteById(anyString());
verify(characterRepository, never()).deleteById(anyString());
}
@Test
void testDeleteCampaign_CascadesArcsChaptersScenes() {
// Arrange : campagne avec 1 arc → 1 chapitre → 2 scènes.
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("Chap 1").build();
Scene scene1 = Scene.builder().id("scene-1").chapterId("chap-1").name("Scene 1").build();
Scene scene2 = Scene.builder().id("scene-2").chapterId("chap-1").name("Scene 2").build();
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(scene1, scene2));
// Act
campaignService.deleteCampaign("campaign-1");
// Assert : tout disparaît, dans l'ordre feuilles → racine.
verify(sceneRepository).deleteById("scene-1");
verify(sceneRepository).deleteById("scene-2");
verify(chapterRepository).deleteById("chap-1");
verify(arcRepository).deleteById("arc-1");
verify(campaignRepository).deleteById("campaign-1");
}
@Test
void testDeleteCampaign_CascadesCharacters() {
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
campaignService.deleteCampaign("campaign-1");
verify(characterRepository).deleteById("char-1");
verify(campaignRepository).deleteById("campaign-1");
}
@Test
void testGetDeletionImpact() {
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
CampaignService.DeletionImpact impact = campaignService.getDeletionImpact("campaign-1");
assertEquals(1, impact.arcs());
assertEquals(2, impact.chapters());
assertEquals(3, impact.scenes());
assertEquals(1, impact.characters());
} }
@Test @Test

View File

@@ -1,7 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -14,6 +16,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
@@ -26,6 +29,8 @@ public class ChapterServiceTest {
@Mock @Mock
private ChapterRepository chapterRepository; private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks @InjectMocks
private ChapterService chapterService; private ChapterService chapterService;
@@ -157,15 +162,36 @@ public class ChapterServiceTest {
} }
@Test @Test
void testDeleteChapter() { void testDeleteChapter_EmptyChapter() {
// Arrange // Aucune scène : Mockito renvoie List.of() par défaut.
doNothing().when(chapterRepository).deleteById("chapter-1");
// Act
chapterService.deleteChapter("chapter-1"); chapterService.deleteChapter("chapter-1");
// Assert verify(chapterRepository).deleteById("chapter-1");
verify(chapterRepository, times(1)).deleteById("chapter-1"); verify(sceneRepository, never()).deleteById(anyString());
}
@Test
void testDeleteChapter_CascadesScenes() {
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
chapterService.deleteChapter("chapter-1");
verify(sceneRepository).deleteById("s-1");
verify(sceneRepository).deleteById("s-2");
verify(chapterRepository).deleteById("chapter-1");
}
@Test
void testGetDeletionImpact() {
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
ChapterService.DeletionImpact impact = chapterService.getDeletionImpact("chapter-1");
assertEquals(2, impact.scenes());
} }
@Test @Test

View File

@@ -178,10 +178,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithValidBranches() { void testUpdateScene_WithValidBranches() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Go to scene 2", "scene-2");
.targetSceneId("scene-2")
.label("Go to scene 2")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))
@@ -203,10 +200,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithBranchToSelf() { void testUpdateScene_WithBranchToSelf() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Self-reference", "scene-1");
.targetSceneId("scene-1")
.label("Self-reference")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))
@@ -228,10 +222,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithBranchToDifferentChapter() { void testUpdateScene_WithBranchToDifferentChapter() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Go to other chapter", "scene-other-chapter");
.targetSceneId("scene-other-chapter")
.label("Go to other chapter")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))
@@ -253,10 +244,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithBranchNullTarget() { void testUpdateScene_WithBranchNullTarget() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Null target", null);
.targetSceneId(null)
.label("Null target")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))
@@ -277,10 +265,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithBranchBlankTarget() { void testUpdateScene_WithBranchBlankTarget() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Blank target", " ");
.targetSceneId(" ")
.label("Blank target")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))

View File

@@ -0,0 +1,323 @@
package com.loremind.application.conversationcontext;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Tests unitaires de ConversationService.
* Focus sur :
* - la validation XOR de l'ancrage (loreId XOR campaignId),
* - la coherence entityType/entityId (tous deux null ou tous deux non-null),
* - le fallback du titre a la creation,
* - la validation des roles et contenus de message,
* - l'auto-generation de titre (cas succes + court-circuit si pas de messages).
*/
@ExtendWith(MockitoExtension.class)
class ConversationServiceTest {
@Mock
private ConversationRepository repository;
@Mock
private ConversationTitleGenerator titleGenerator;
@InjectMocks
private ConversationService service;
// ---------- create : validation XOR de l'ancrage -----------------------
@Test
void create_rejectsBothAnchorsNull() {
ConversationService.CreateData data = new ConversationService.CreateData(
"t", null, null, null, null);
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> service.create(data));
assertEquals("Exactement un parent attendu : loreId XOR campaignId", ex.getMessage());
verifyNoInteractions(repository);
}
@Test
void create_rejectsBothAnchorsPresent() {
ConversationService.CreateData data = new ConversationService.CreateData(
"t", "lore-1", "camp-1", null, null);
assertThrows(IllegalArgumentException.class, () -> service.create(data));
verifyNoInteractions(repository);
}
@Test
void create_rejectsBlankLoreIdAsAbsent() {
// Blank (espaces) = absent : c'est la regle du service.
ConversationService.CreateData data = new ConversationService.CreateData(
"t", " ", " ", null, null);
assertThrows(IllegalArgumentException.class, () -> service.create(data));
}
@Test
void create_rejectsEntityTypeWithoutEntityId() {
ConversationService.CreateData data = new ConversationService.CreateData(
"t", "lore-1", null, "page", null);
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> service.create(data));
assertEquals("entityType et entityId doivent etre tous deux null ou tous deux non-null", ex.getMessage());
}
@Test
void create_rejectsEntityIdWithoutEntityType() {
ConversationService.CreateData data = new ConversationService.CreateData(
"t", "lore-1", null, null, "page-42");
assertThrows(IllegalArgumentException.class, () -> service.create(data));
}
// ---------- create : cas nominaux --------------------------------------
@Test
void create_withLoreAnchor_persistsBuiltConversation() {
ConversationService.CreateData data = new ConversationService.CreateData(
"Discussion Thorin", "lore-1", null, "page", "page-42");
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Conversation result = service.create(data);
ArgumentCaptor<Conversation> captor = ArgumentCaptor.forClass(Conversation.class);
verify(repository).save(captor.capture());
Conversation saved = captor.getValue();
assertEquals("Discussion Thorin", saved.getTitle());
assertEquals("lore-1", saved.getLoreId());
assertEquals("page", saved.getEntityType());
assertEquals("page-42", saved.getEntityId());
assertEquals(saved, result);
}
@Test
void create_withCampaignAnchor_andNoEntityFocus_persistsRootLevel() {
ConversationService.CreateData data = new ConversationService.CreateData(
null, null, "camp-1", null, null);
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.create(data);
ArgumentCaptor<Conversation> captor = ArgumentCaptor.forClass(Conversation.class);
verify(repository).save(captor.capture());
assertEquals("Nouvelle conversation", captor.getValue().getTitle(),
"Titre absent -> fallback par defaut");
assertEquals("camp-1", captor.getValue().getCampaignId());
}
@Test
void create_trimsProvidedTitle() {
ConversationService.CreateData data = new ConversationService.CreateData(
" Mon titre ", "lore-1", null, null, null);
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.create(data);
ArgumentCaptor<Conversation> captor = ArgumentCaptor.forClass(Conversation.class);
verify(repository).save(captor.capture());
assertEquals("Mon titre", captor.getValue().getTitle());
}
@Test
void create_blankTitle_fallsBackToDefault() {
ConversationService.CreateData data = new ConversationService.CreateData(
" ", "lore-1", null, null, null);
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.create(data);
ArgumentCaptor<Conversation> captor = ArgumentCaptor.forClass(Conversation.class);
verify(repository).save(captor.capture());
assertEquals("Nouvelle conversation", captor.getValue().getTitle());
}
// ---------- getById / listByContext / delete --------------------------
@Test
void getById_delegatesToRepository() {
Conversation conv = Conversation.builder().id("c-1").build();
when(repository.findById("c-1")).thenReturn(Optional.of(conv));
assertEquals(Optional.of(conv), service.getById("c-1"));
}
@Test
void listByContext_validatesAnchorBeforeQuerying() {
assertThrows(IllegalArgumentException.class,
() -> service.listByContext(null, null, null, null));
verifyNoInteractions(repository);
}
@Test
void listByContext_delegates_whenAnchorValid() {
Conversation c = Conversation.builder().id("c-1").build();
when(repository.findByContext("lore-1", null, null, null)).thenReturn(List.of(c));
List<Conversation> result = service.listByContext("lore-1", null, null, null);
assertEquals(1, result.size());
}
@Test
void delete_delegatesToRepository() {
service.delete("c-1");
verify(repository).deleteById("c-1");
}
// ---------- rename -----------------------------------------------------
@Test
void rename_rejectsNullTitle() {
assertThrows(IllegalArgumentException.class, () -> service.rename("c-1", null));
verify(repository, never()).updateTitle(anyString(), anyString());
}
@Test
void rename_rejectsBlankTitle() {
assertThrows(IllegalArgumentException.class, () -> service.rename("c-1", " "));
verify(repository, never()).updateTitle(anyString(), anyString());
}
@Test
void rename_rejectsUnknownConversation() {
when(repository.findById("unknown")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> service.rename("unknown", "Nouveau titre"));
assertEquals("Conversation introuvable : unknown", ex.getMessage());
verify(repository, never()).updateTitle(anyString(), anyString());
}
@Test
void rename_trimsTitleBeforePersist() {
when(repository.findById("c-1")).thenReturn(Optional.of(Conversation.builder().id("c-1").build()));
service.rename("c-1", " Nouveau titre ");
verify(repository).updateTitle("c-1", "Nouveau titre");
}
// ---------- appendMessage ----------------------------------------------
@Test
void appendMessage_rejectsInvalidRole() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> service.appendMessage("c-1", "admin", "hello"));
assertEquals("Role invalide : admin", ex.getMessage());
verifyNoInteractions(repository);
}
@Test
void appendMessage_rejectsNullRole() {
assertThrows(IllegalArgumentException.class,
() -> service.appendMessage("c-1", null, "hello"));
}
@Test
void appendMessage_rejectsNullContent() {
assertThrows(IllegalArgumentException.class,
() -> service.appendMessage("c-1", "user", null));
}
@Test
void appendMessage_rejectsEmptyContent() {
assertThrows(IllegalArgumentException.class,
() -> service.appendMessage("c-1", "user", ""));
}
@Test
void appendMessage_acceptsAllThreeCanonicalRoles() {
ConversationMessage returned = ConversationMessage.builder().id("m").build();
when(repository.appendMessage(eq("c-1"), any())).thenReturn(returned);
for (String role : new String[]{"user", "assistant", "system"}) {
service.appendMessage("c-1", role, "contenu");
}
ArgumentCaptor<ConversationMessage> captor = ArgumentCaptor.forClass(ConversationMessage.class);
verify(repository, times(3)).appendMessage(eq("c-1"), captor.capture());
assertEquals("user", captor.getAllValues().get(0).getRole());
assertEquals("assistant", captor.getAllValues().get(1).getRole());
assertEquals("system", captor.getAllValues().get(2).getRole());
}
@Test
void appendMessage_passesContentVerbatim() {
ConversationMessage returned = ConversationMessage.builder().id("m-1").build();
when(repository.appendMessage(eq("c-1"), any())).thenReturn(returned);
service.appendMessage("c-1", "user", " hello avec espaces ");
ArgumentCaptor<ConversationMessage> captor = ArgumentCaptor.forClass(ConversationMessage.class);
verify(repository).appendMessage(eq("c-1"), captor.capture());
// Le service NE trim PAS le contenu — seul le titre est trim.
assertEquals(" hello avec espaces ", captor.getValue().getContent());
}
// ---------- autoGenerateTitle ------------------------------------------
@Test
void autoGenerateTitle_throws_whenConversationNotFound() {
when(repository.findById("unknown")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> service.autoGenerateTitle("unknown"));
verifyNoInteractions(titleGenerator);
}
@Test
void autoGenerateTitle_shortCircuits_whenNoMessages() {
Conversation conv = Conversation.builder().id("c-1").title("Titre existant").messages(List.of()).build();
when(repository.findById("c-1")).thenReturn(Optional.of(conv));
String result = service.autoGenerateTitle("c-1");
assertEquals("Titre existant", result);
verifyNoInteractions(titleGenerator);
verify(repository, never()).updateTitle(anyString(), anyString());
}
@Test
void autoGenerateTitle_shortCircuits_whenMessagesIsNull() {
Conversation conv = new Conversation(); // @NoArgsConstructor -> messages == null
conv.setId("c-1");
conv.setTitle("Titre");
when(repository.findById("c-1")).thenReturn(Optional.of(conv));
assertEquals("Titre", service.autoGenerateTitle("c-1"));
verifyNoInteractions(titleGenerator);
}
@Test
void autoGenerateTitle_generatesAndPersists_whenMessagesPresent() {
List<ConversationMessage> seeds = List.of(
ConversationMessage.builder().role("user").content("bonjour").build(),
ConversationMessage.builder().role("assistant").content("salut").build());
Conversation conv = Conversation.builder().id("c-1").title("Ancien").messages(seeds).build();
when(repository.findById("c-1")).thenReturn(Optional.of(conv));
when(titleGenerator.generate(seeds)).thenReturn("Premier echange poli");
String result = service.autoGenerateTitle("c-1");
assertEquals("Premier echange poli", result);
verify(repository).updateTitle("c-1", "Premier echange poli");
}
}

View File

@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -40,6 +41,8 @@ public class CampaignStructuralContextBuilderTest {
private ChapterRepository chapterRepository; private ChapterRepository chapterRepository;
@Mock @Mock
private SceneRepository sceneRepository; private SceneRepository sceneRepository;
@Mock
private CharacterRepository characterRepository;
@InjectMocks @InjectMocks
private CampaignStructuralContextBuilder builder; private CampaignStructuralContextBuilder builder;
@@ -71,9 +74,9 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1"); CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals("Les Terres Brisées", ctx.getCampaignName()); assertEquals("Les Terres Brisées", ctx.campaignName());
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription()); assertEquals("Campagne dark fantasy", ctx.campaignDescription());
assertTrue(ctx.getArcs().isEmpty()); assertTrue(ctx.arcs().isEmpty());
} }
@Test @Test
@@ -97,19 +100,19 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1"); CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().size()); assertEquals(2, ctx.arcs().size());
assertEquals("Arc A", ctx.getArcs().get(0).getName()); assertEquals("Arc A", ctx.arcs().get(0).name());
assertEquals("Arc B", ctx.getArcs().get(1).getName()); assertEquals("Arc B", ctx.arcs().get(1).name());
// Chapitres tries : ch2 (order 1) avant ch1 (order 2) // Chapitres tries : ch2 (order 1) avant ch1 (order 2)
assertEquals(2, ctx.getArcs().get(0).getChapters().size()); assertEquals(2, ctx.arcs().get(0).chapters().size());
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName()); assertEquals("Ch B", ctx.arcs().get(0).chapters().get(0).name());
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName()); assertEquals("Ch A", ctx.arcs().get(0).chapters().get(1).name());
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2) // Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
var chADto = ctx.getArcs().get(0).getChapters().get(1); var chADto = ctx.arcs().get(0).chapters().get(1);
assertEquals("Scene B", chADto.getScenes().get(0).getName()); assertEquals("Scene B", chADto.scenes().get(0).name());
assertEquals("Scene A", chADto.getScenes().get(1).getName()); assertEquals("Scene A", chADto.scenes().get(1).name());
} }
@Test @Test
@@ -117,15 +120,8 @@ public class CampaignStructuralContextBuilderTest {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build(); Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build(); Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
SceneBranch validBranch = SceneBranch.builder() SceneBranch validBranch = new SceneBranch("Si les joueurs fuient", "s-2", "en cas de combat perdu");
.label("Si les joueurs fuient") SceneBranch danglingBranch = SceneBranch.of("Vers l'inconnu", "s-inconnu");
.targetSceneId("s-2")
.condition("en cas de combat perdu")
.build();
SceneBranch danglingBranch = SceneBranch.builder()
.label("Vers l'inconnu")
.targetSceneId("s-inconnu")
.build();
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("") Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
.order(1) .order(1)
@@ -140,12 +136,12 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1"); CampaignStructuralContext ctx = builder.build("camp-1");
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0); var scene1Summary = ctx.arcs().get(0).chapters().get(0).scenes().get(0);
assertEquals(2, scene1Summary.getBranches().size()); assertEquals(2, scene1Summary.branches().size());
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName()); assertEquals("Fuite", scene1Summary.branches().get(0).targetSceneName());
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition()); assertEquals("en cas de combat perdu", scene1Summary.branches().get(0).condition());
// ID inconnu → libellé de fallback // ID inconnu → libellé de fallback
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName()); assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
} }
@Test @Test
@@ -167,9 +163,9 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1"); CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount()); assertEquals(2, ctx.arcs().get(0).illustrationCount());
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount()); assertEquals(0, ctx.arcs().get(0).chapters().get(0).illustrationCount());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount()); assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).illustrationCount());
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty()); assertTrue(ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().isEmpty());
} }
} }

View File

@@ -89,13 +89,13 @@ public class GeneratePageValuesUseCaseTest {
verify(aiProvider).generatePage(captor.capture()); verify(aiProvider).generatePage(captor.capture());
GenerationContext ctx = captor.getValue(); GenerationContext ctx = captor.getValue();
assertEquals("Aetheria", ctx.getLoreName()); assertEquals("Aetheria", ctx.loreName());
assertEquals("monde aérien", ctx.getLoreDescription()); assertEquals("monde aérien", ctx.loreDescription());
assertEquals("PNJ", ctx.getFolderName()); assertEquals("PNJ", ctx.folderName());
assertEquals("Personnage", ctx.getTemplateName()); assertEquals("Personnage", ctx.templateName());
assertEquals("Alice", ctx.getPageTitle()); assertEquals("Alice", ctx.pageTitle());
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE). // Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields()); assertEquals(List.of("Histoire", "Apparence"), ctx.templateFields());
} }
@Test @Test

View File

@@ -71,10 +71,10 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1"); LoreStructuralContext ctx = builder.build("lore-1");
assertEquals("Aetheria", ctx.getLoreName()); assertEquals("Aetheria", ctx.loreName());
assertEquals("Monde aérien", ctx.getLoreDescription()); assertEquals("Monde aérien", ctx.loreDescription());
assertTrue(ctx.getFolders().isEmpty()); assertTrue(ctx.folders().isEmpty());
assertTrue(ctx.getTags().isEmpty()); assertTrue(ctx.tags().isEmpty());
} }
@Test @Test
@@ -110,32 +110,32 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1"); LoreStructuralContext ctx = builder.build("lore-1");
assertEquals(2, ctx.getFolders().size()); assertEquals(2, ctx.folders().size());
assertTrue(ctx.getFolders().containsKey("PNJ")); assertTrue(ctx.folders().containsKey("PNJ"));
assertTrue(ctx.getFolders().containsKey("Lieux")); assertTrue(ctx.folders().containsKey("Lieux"));
var pnjPages = ctx.getFolders().get("PNJ"); var pnjPages = ctx.folders().get("PNJ");
assertEquals(1, pnjPages.size()); assertEquals(1, pnjPages.size());
var aliceSummary = pnjPages.get(0); var aliceSummary = pnjPages.get(0);
assertEquals("Alice", aliceSummary.getTitle()); assertEquals("Alice", aliceSummary.title());
assertEquals("Personnage", aliceSummary.getTemplateName()); assertEquals("Personnage", aliceSummary.templateName());
// Blank/null filtrés // Blank/null filtrés
assertEquals(1, aliceSummary.getValues().size()); assertEquals(1, aliceSummary.values().size());
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire")); assertEquals("Il était une fois...", aliceSummary.values().get("Histoire"));
assertEquals(List.of("hero", "magic"), aliceSummary.getTags()); assertEquals(List.of("hero", "magic"), aliceSummary.tags());
// p-2 resolved into title, p-ghost dropped silently // p-2 resolved into title, p-ghost dropped silently
assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles()); assertEquals(List.of("La Forêt"), aliceSummary.relatedPageTitles());
var forestSummary = ctx.getFolders().get("Lieux").get(0); var forestSummary = ctx.folders().get("Lieux").get(0);
// Template introuvable → "?" // Template introuvable → "?"
assertEquals("?", forestSummary.getTemplateName()); assertEquals("?", forestSummary.templateName());
assertTrue(forestSummary.getValues().isEmpty()); assertTrue(forestSummary.values().isEmpty());
assertTrue(forestSummary.getRelatedPageTitles().isEmpty()); assertTrue(forestSummary.relatedPageTitles().isEmpty());
// Tags uniques entre les 2 pages // Tags uniques entre les 2 pages
assertEquals(2, ctx.getTags().size()); assertEquals(2, ctx.tags().size());
assertTrue(ctx.getTags().contains("hero")); assertTrue(ctx.tags().contains("hero"));
assertTrue(ctx.getTags().contains("magic")); assertTrue(ctx.tags().contains("magic"));
} }
@Test @Test
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1"); LoreStructuralContext ctx = builder.build("lore-1");
String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire"); String truncated = ctx.folders().get("PNJ").get(0).values().get("Histoire");
assertNotNull(truncated); assertNotNull(truncated);
assertEquals(500 + 1, truncated.length()); // 500 + ellipse assertEquals(500 + 1, truncated.length()); // 500 + ellipse
assertTrue(truncated.endsWith("")); assertTrue(truncated.endsWith(""));
@@ -185,9 +185,9 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1"); LoreStructuralContext ctx = builder.build("lore-1");
var summary = ctx.getFolders().get("PNJ").get(0); var summary = ctx.folders().get("PNJ").get(0);
assertTrue(summary.getValues().isEmpty()); assertTrue(summary.values().isEmpty());
assertTrue(summary.getTags().isEmpty()); assertTrue(summary.tags().isEmpty());
assertTrue(summary.getRelatedPageTitles().isEmpty()); assertTrue(summary.relatedPageTitles().isEmpty());
} }
} }

View File

@@ -44,14 +44,14 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("arc", "arc-1"); NarrativeEntityContext ctx = builder.build("arc", "arc-1");
assertEquals("arc", ctx.getEntityType()); assertEquals("arc", ctx.entityType());
assertEquals("L'arc sombre", ctx.getTitle()); assertEquals("L'arc sombre", ctx.title());
assertEquals("synopsis", ctx.getFields().get("description (synopsis)")); assertEquals("synopsis", ctx.fields().get("description (synopsis)"));
assertEquals("trahison", ctx.getFields().get("themes")); assertEquals("trahison", ctx.fields().get("themes"));
assertEquals("vie ou mort", ctx.getFields().get("stakes")); assertEquals("vie ou mort", ctx.fields().get("stakes"));
assertEquals("pouvoir", ctx.getFields().get("rewards")); assertEquals("pouvoir", ctx.fields().get("rewards"));
assertEquals("le roi meurt", ctx.getFields().get("resolution")); assertEquals("le roi meurt", ctx.fields().get("resolution"));
assertEquals("secret", ctx.getFields().get("gmNotes")); assertEquals("secret", ctx.fields().get("gmNotes"));
} }
@Test @Test
@@ -64,12 +64,12 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("chapter", "ch-1"); NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
assertEquals("chapter", ctx.getEntityType()); assertEquals("chapter", ctx.entityType());
assertEquals("Chapitre 1", ctx.getTitle()); assertEquals("Chapitre 1", ctx.title());
assertEquals("", ctx.getFields().get("description (synopsis)")); assertEquals("", ctx.fields().get("description (synopsis)"));
assertEquals("", ctx.getFields().get("playerObjectives")); assertEquals("", ctx.fields().get("playerObjectives"));
assertEquals("haut", ctx.getFields().get("narrativeStakes")); assertEquals("haut", ctx.fields().get("narrativeStakes"));
assertEquals("", ctx.getFields().get("gmNotes")); assertEquals("", ctx.fields().get("gmNotes"));
} }
@Test @Test
@@ -85,17 +85,17 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("scene", "s-1"); NarrativeEntityContext ctx = builder.build("scene", "s-1");
assertEquals("scene", ctx.getEntityType()); assertEquals("scene", ctx.entityType());
assertEquals("L'auberge", ctx.getTitle()); assertEquals("L'auberge", ctx.title());
assertEquals("lieu calme", ctx.getFields().get("description")); assertEquals("lieu calme", ctx.fields().get("description"));
assertEquals("Taverne", ctx.getFields().get("location")); assertEquals("Taverne", ctx.fields().get("location"));
assertEquals("Soir", ctx.getFields().get("timing")); assertEquals("Soir", ctx.fields().get("timing"));
assertEquals("tendue", ctx.getFields().get("atmosphere")); assertEquals("tendue", ctx.fields().get("atmosphere"));
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration")); assertEquals("Vous entrez...", ctx.fields().get("playerNarration"));
assertEquals("option A...", ctx.getFields().get("choicesConsequences")); assertEquals("option A...", ctx.fields().get("choicesConsequences"));
assertEquals("moyen", ctx.getFields().get("combatDifficulty")); assertEquals("moyen", ctx.fields().get("combatDifficulty"));
assertEquals("3 bandits", ctx.getFields().get("enemies")); assertEquals("3 bandits", ctx.fields().get("enemies"));
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes")); assertEquals("trésor caché", ctx.fields().get("gmSecretNotes"));
} }
@Test @Test
@@ -104,7 +104,7 @@ public class NarrativeEntityContextBuilderTest {
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc)); when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1"); NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
assertEquals("arc", ctx.getEntityType()); assertEquals("arc", ctx.entityType());
} }
@Test @Test

View File

@@ -55,9 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@BeforeEach @BeforeEach
void setUp() { void setUp() {
campaignCtx = CampaignStructuralContext.builder() campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of());
.campaignName("X").campaignDescription("d")
.build();
messages = List.of(); messages = List.of();
onUsage = mock(Consumer.class); onUsage = mock(Consumer.class);
onToken = mock(Consumer.class); onToken = mock(Consumer.class);
@@ -85,10 +83,10 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError)); verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue(); ChatRequest req = captor.getValue();
assertSame(campaignCtx, req.getCampaignContext()); assertSame(campaignCtx, req.campaignContext());
assertNull(req.getLoreContext()); assertNull(req.loreContext());
assertNull(req.getNarrativeEntity()); assertNull(req.narrativeEntity());
assertNull(req.getPageContext()); assertNull(req.pageContext());
verifyNoInteractions(loreContextBuilder); verifyNoInteractions(loreContextBuilder);
verifyNoInteractions(narrativeEntityContextBuilder); verifyNoInteractions(narrativeEntityContextBuilder);
} }
@@ -96,8 +94,8 @@ public class StreamChatForCampaignUseCaseTest {
@Test @Test
void testExecute_LinkedCampaign_LoadsLoreContext() { void testExecute_LinkedCampaign_LoadsLoreContext() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build(); Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
LoreStructuralContext loreCtx = LoreStructuralContext.builder() LoreStructuralContext loreCtx = new LoreStructuralContext(
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build(); "L", "d", Collections.emptyMap(), List.of());
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked)); when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx); when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
@@ -107,7 +105,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(loreCtx, captor.getValue().getLoreContext()); assertSame(loreCtx, captor.getValue().loreContext());
} }
@Test @Test
@@ -122,15 +120,14 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getLoreContext()); assertNull(captor.getValue().loreContext());
// La requete doit tout de meme partir (pas d'exception). // La requete doit tout de meme partir (pas d'exception).
} }
@Test @Test
void testExecute_WithEntityFocus_BuildsNarrativeEntity() { void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build(); Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
NarrativeEntityContext entity = NarrativeEntityContext.builder() NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge", Map.of());
.entityType("scene").title("L'auberge").fields(Map.of()).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone)); when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx); when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
@@ -140,7 +137,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(entity, captor.getValue().getNarrativeEntity()); assertSame(entity, captor.getValue().narrativeEntity());
} }
@Test @Test
@@ -153,7 +150,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getNarrativeEntity()); assertNull(captor.getValue().narrativeEntity());
verifyNoInteractions(narrativeEntityContextBuilder); verifyNoInteractions(narrativeEntityContextBuilder);
} }
} }

View File

@@ -55,10 +55,7 @@ public class StreamChatForLoreUseCaseTest {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@BeforeEach @BeforeEach
void setUp() { void setUp() {
loreCtx = LoreStructuralContext.builder() loreCtx = new LoreStructuralContext("Aetheria", "d", Collections.emptyMap(), List.of());
.loreName("Aetheria").loreDescription("d")
.folders(Collections.emptyMap())
.build();
messages = List.of(); messages = List.of();
onUsage = mock(Consumer.class); onUsage = mock(Consumer.class);
onToken = mock(Consumer.class); onToken = mock(Consumer.class);
@@ -75,9 +72,9 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError)); verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue(); ChatRequest req = captor.getValue();
assertSame(loreCtx, req.getLoreContext()); assertSame(loreCtx, req.loreContext());
assertNull(req.getPageContext()); assertNull(req.pageContext());
assertNull(req.getCampaignContext()); assertNull(req.campaignContext());
} }
@Test @Test
@@ -88,7 +85,7 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getPageContext()); assertNull(captor.getValue().pageContext());
verifyNoInteractions(pageRepository); verifyNoInteractions(pageRepository);
} }
@@ -116,12 +113,12 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
ChatRequest req = captor.getValue(); ChatRequest req = captor.getValue();
assertNotNull(req.getPageContext()); assertNotNull(req.pageContext());
assertEquals("Alice", req.getPageContext().getTitle()); assertEquals("Alice", req.pageContext().title());
assertEquals("Personnage", req.getPageContext().getTemplateName()); assertEquals("Personnage", req.pageContext().templateName());
// Seuls les champs TEXT exposes // Seuls les champs TEXT exposes
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields()); assertEquals(List.of("Histoire"), req.pageContext().templateFields());
assertEquals(values, req.getPageContext().getValues()); assertEquals(values, req.pageContext().values());
} }
@Test @Test
@@ -137,12 +134,12 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext(); var pageCtx = captor.getValue().pageContext();
assertNotNull(pageCtx); assertNotNull(pageCtx);
assertEquals("Orphan", pageCtx.getTitle()); assertEquals("Orphan", pageCtx.title());
assertEquals("?", pageCtx.getTemplateName()); assertEquals("?", pageCtx.templateName());
assertTrue(pageCtx.getTemplateFields().isEmpty()); assertTrue(pageCtx.templateFields().isEmpty());
assertTrue(pageCtx.getValues().isEmpty()); assertTrue(pageCtx.values().isEmpty());
verifyNoInteractions(templateRepository); verifyNoInteractions(templateRepository);
} }
@@ -160,9 +157,9 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext(); var pageCtx = captor.getValue().pageContext();
assertEquals("?", pageCtx.getTemplateName()); assertEquals("?", pageCtx.templateName());
assertTrue(pageCtx.getTemplateFields().isEmpty()); assertTrue(pageCtx.templateFields().isEmpty());
} }
@Test @Test

View File

@@ -1,7 +1,9 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode; import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -15,6 +17,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
@@ -26,6 +29,7 @@ import static org.mockito.Mockito.*;
public class LoreNodeServiceTest { public class LoreNodeServiceTest {
@Mock private LoreNodeRepository loreNodeRepository; @Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@InjectMocks private LoreNodeService loreNodeService; @InjectMocks private LoreNodeService loreNodeService;
@@ -118,8 +122,66 @@ public class LoreNodeServiceTest {
} }
@Test @Test
void testDelete() { void testDelete_LeafFolder() {
// Aucun descendant, aucune page : seul le dossier est supprimé.
loreNodeService.deleteLoreNode("n-1"); loreNodeService.deleteLoreNode("n-1");
verify(loreNodeRepository).deleteById("n-1"); verify(loreNodeRepository).deleteById("n-1");
verify(pageRepository, never()).deleteById(anyString());
}
@Test
void testDelete_CascadesPagesOfRoot() {
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
Page p2 = Page.builder().id("p-2").nodeId("n-1").title("P2").build();
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1, p2));
loreNodeService.deleteLoreNode("n-1");
verify(pageRepository).deleteById("p-1");
verify(pageRepository).deleteById("p-2");
verify(loreNodeRepository).deleteById("n-1");
}
@Test
void testDelete_CascadesSubfoldersRecursive() {
// n-1 → n-1a → n-1a1 ; chaque feuille a une page.
LoreNode mid = LoreNode.builder().id("n-1a").parentId("n-1").loreId("lore-1").name("mid").build();
LoreNode leaf = LoreNode.builder().id("n-1a1").parentId("n-1a").loreId("lore-1").name("leaf").build();
Page pageOnLeaf = Page.builder().id("p-leaf").nodeId("n-1a1").title("P").build();
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(mid));
when(loreNodeRepository.findByParentId("n-1a")).thenReturn(List.of(leaf));
when(pageRepository.findByNodeId("n-1a1")).thenReturn(List.of(pageOnLeaf));
loreNodeService.deleteLoreNode("n-1");
// Feuilles d'abord (pages puis dossier leaf), puis mid, puis la racine.
verify(pageRepository).deleteById("p-leaf");
verify(loreNodeRepository).deleteById("n-1a1");
verify(loreNodeRepository).deleteById("n-1a");
verify(loreNodeRepository).deleteById("n-1");
}
@Test
void testGetDeletionImpact_CountsSubfoldersAndPages() {
LoreNode sub1 = LoreNode.builder().id("s-1").parentId("n-1").loreId("lore-1").name("s1").build();
LoreNode sub2 = LoreNode.builder().id("s-2").parentId("n-1").loreId("lore-1").name("s2").build();
LoreNode subsub = LoreNode.builder().id("s-1a").parentId("s-1").loreId("lore-1").name("s1a").build();
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
Page p2 = Page.builder().id("p-2").nodeId("s-1").title("P2").build();
Page p3 = Page.builder().id("p-3").nodeId("s-1a").title("P3").build();
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(sub1, sub2));
when(loreNodeRepository.findByParentId("s-1")).thenReturn(List.of(subsub));
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1));
when(pageRepository.findByNodeId("s-1")).thenReturn(List.of(p2));
when(pageRepository.findByNodeId("s-2")).thenReturn(List.of());
when(pageRepository.findByNodeId("s-1a")).thenReturn(List.of(p3));
LoreNodeService.DeletionImpact impact = loreNodeService.getDeletionImpact("n-1");
// 3 sous-dossiers (sub1, sub2, subsub) — on ne compte pas la racine n-1.
assertEquals(3, impact.folders());
assertEquals(3, impact.pages());
} }
} }

View File

@@ -1,9 +1,15 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.lorecontext.Lore; 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.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -17,6 +23,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
@@ -30,6 +37,8 @@ public class LoreServiceTest {
@Mock private LoreRepository loreRepository; @Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository; @Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository; @Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@Mock private CampaignRepository campaignRepository;
@InjectMocks private LoreService loreService; @InjectMocks private LoreService loreService;
@@ -134,8 +143,67 @@ public class LoreServiceTest {
} }
@Test @Test
void testDeleteLore_DelegatesToRepository() { void testDeleteLore_EmptyLore() {
// Aucun dossier / page / template / campagne : seul le Lore est supprimé.
loreService.deleteLore("lore-1"); loreService.deleteLore("lore-1");
verify(loreRepository).deleteById("lore-1"); verify(loreRepository).deleteById("lore-1");
verify(loreNodeRepository, never()).deleteById(anyString());
verify(pageRepository, never()).deleteById(anyString());
verify(templateRepository, never()).deleteById(anyString());
}
@Test
void testDeleteLore_CascadesFoldersPagesTemplates() {
LoreNode node = LoreNode.builder().id("n-1").loreId("lore-1").name("F").build();
Page page = Page.builder().id("p-1").loreId("lore-1").nodeId("n-1").title("P").build();
Template template = Template.builder().id("t-1").loreId("lore-1").name("T").build();
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(page));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(template));
loreService.deleteLore("lore-1");
verify(pageRepository).deleteById("p-1");
verify(loreNodeRepository).deleteById("n-1");
verify(templateRepository).deleteById("t-1");
verify(loreRepository).deleteById("lore-1");
}
@Test
void testDeleteLore_DetachesCampaignsInsteadOfDeleting() {
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C1").build();
Campaign other = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
Campaign orphan = Campaign.builder().id("c-3").loreId(null).name("C3").build();
when(campaignRepository.findAll()).thenReturn(List.of(attached, other, orphan));
when(campaignRepository.save(any(Campaign.class))).thenAnswer(inv -> inv.getArgument(0));
loreService.deleteLore("lore-1");
// Seule la campagne attachée est re-sauvegardée (avec loreId=null).
ArgumentCaptor<Campaign> captor = ArgumentCaptor.forClass(Campaign.class);
verify(campaignRepository, times(1)).save(captor.capture());
assertEquals("c-1", captor.getValue().getId());
assertNull(captor.getValue().getLoreId());
// Aucune campagne n'est supprimée.
verify(campaignRepository, never()).deleteById(anyString());
}
@Test
void testGetDeletionImpact() {
Template t1 = Template.builder().id("t-1").loreId("lore-1").name("T").build();
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C").build();
Campaign unrelated = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(4L);
when(pageRepository.countByLoreId("lore-1")).thenReturn(12L);
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(t1));
when(campaignRepository.findAll()).thenReturn(List.of(attached, unrelated));
LoreService.DeletionImpact impact = loreService.getDeletionImpact("lore-1");
assertEquals(4, impact.folders());
assertEquals(12, impact.pages());
assertEquals(1, impact.templates());
assertEquals(1, impact.detachedCampaigns());
} }
} }

View File

@@ -0,0 +1,64 @@
package com.loremind.domain.campaigncontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine pour Arc.
* Focus sur les @Builder.Default des collections : la moindre omission
* renverrait {@code null} et propagerait des NPE dans toute la pile.
*/
class ArcTest {
@Test
void builder_initializesAllCollectionsToEmptyList_whenNotSet() {
Arc arc = Arc.builder()
.id("arc-1")
.name("Acte I")
.campaignId("camp-1")
.order(0)
.build();
assertNotNull(arc.getRelatedPageIds(), "relatedPageIds ne doit jamais etre null");
assertNotNull(arc.getIllustrationImageIds(), "illustrationImageIds ne doit jamais etre null");
assertNotNull(arc.getMapImageIds(), "mapImageIds ne doit jamais etre null");
assertTrue(arc.getRelatedPageIds().isEmpty());
assertTrue(arc.getIllustrationImageIds().isEmpty());
assertTrue(arc.getMapImageIds().isEmpty());
}
@Test
void builder_preservesProvidedCollections() {
Arc arc = Arc.builder()
.relatedPageIds(List.of("page-a", "page-b"))
.illustrationImageIds(List.of("img-1"))
.mapImageIds(List.of("map-1", "map-2", "map-3"))
.build();
assertEquals(2, arc.getRelatedPageIds().size());
assertEquals(1, arc.getIllustrationImageIds().size());
assertEquals(3, arc.getMapImageIds().size());
}
@Test
void builder_preservesNarrativeEnrichmentFields() {
Arc arc = Arc.builder()
.themes("trahison, vengeance")
.stakes("la survie du royaume")
.gmNotes("secret : le roi est un imposteur")
.rewards("artefact ancien")
.resolution("couronnement du legitime heritier")
.build();
assertEquals("trahison, vengeance", arc.getThemes());
assertEquals("la survie du royaume", arc.getStakes());
assertEquals("secret : le roi est un imposteur", arc.getGmNotes());
assertEquals("artefact ancien", arc.getRewards());
assertEquals("couronnement du legitime heritier", arc.getResolution());
}
}

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