Compare commits

..

23 Commits

Author SHA1 Message Date
504c4b7b6c Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
2026-06-03 15:44:48 +02:00
e3fc96a1bc Ajout d'un mode "jeu" (possibilité de lancer des sessions dans une campagne). Cela permet de faire de prendre des notes en live au cours d'une partie et d'avoir plusieurs outils sous la main pour aider le mj :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de parler à une IA pour règle de jeu ou élément de lore / campagne au cours d'une partie comme aide mémoire
- Onglet dédié aux personnages de la campagne
- Onglet dédié aux scènes
- Onglet avec dès pour ceux qui souhaitent ;

Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
2026-05-20 14:59:26 +02:00
bc05b1536d Correction du bug de switch entre lore / campagne et la sidebar qui ne s'actualise pas en conséquence. Ajout d'un test playwright pour éviter toute régression à l'avenir 2026-05-19 19:15:00 +02:00
0e350142de mise à jour vers 0.8.7-beta
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:45:56 +02:00
11cb708de9 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
Some checks failed
E2E Tests / e2e (push) Failing after 29s
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:36:00 +02:00
53343c0a75 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:25:54 +02:00
43f45d68a1 Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable 2026-05-19 18:05:17 +02:00
4bbc6333fc Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
dc908b8d93 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-05-19 14:06:57 +02:00
ae9455c244 Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

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

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
e346bb4b85 Changement du readme 2026-05-17 18:04:24 +02:00
a1c9e67013 Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Passage V0.8.3
2026-04-30 15:53:38 +02:00
b0edfb5aab Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
117334315f Refonte du système JDR + système de personnage joueurs / non joueurs :
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
8e0c1c1017 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
cb9eb14054 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
fd14e1e572 Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-28 19:12:09 +02:00
f3c445cfa1 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0 2026-04-28 19:04:11 +02:00
1a5a87629d Autre patch dockerfile
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-27 22:11:43 +02:00
e61bcd5dbb Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build 2026-04-27 21:56:04 +02:00
bc8fb30398 Patch dockerfile pour ne plus que le build plante 2026-04-27 21:43:13 +02:00
ec45104a80 Correction package-lock 2026-04-27 19:17:01 +02:00
3f8cdd177b Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément 2026-04-27 19:03:58 +02:00
340 changed files with 17699 additions and 1319 deletions

View File

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

11
.gitignore vendored
View File

@@ -7,6 +7,11 @@
brain/data/settings.json brain/data/settings.json
*.key *.key
*.pem *.pem
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
# publique par construction). Sans cette exception, le module licensing
# est silencieusement desactive dans les builds CI.
!core/src/main/resources/licensing/jwt-public-key.pem
# ============================================================================ # ============================================================================
# Java / Spring Boot / Maven # Java / Spring Boot / Maven
@@ -97,3 +102,9 @@ loremind-docs/
# Docker Compose override (dev uniquement, non-distribue aux end users) # Docker Compose override (dev uniquement, non-distribue aux end users)
# ============================================================================ # ============================================================================
docker-compose.override.yml docker-compose.override.yml
# ============================================================================
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
# ============================================================================
relay/
scripts/bump-version.mjs

View File

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

View File

@@ -23,10 +23,13 @@ from app.domain.models import (
CharacterSummary, CharacterSummary,
NpcSummary, NpcSummary,
GameSystemContext, GameSystemContext,
JournalEntrySummary,
LoreStructuralContext, LoreStructuralContext,
NarrativeEntityContext, NarrativeEntityContext,
PageContext, PageContext,
PageSummary, PageSummary,
QuestSummary,
SessionContext,
) )
from app.domain.ports import LLMChatProvider from app.domain.ports import LLMChatProvider
@@ -67,6 +70,7 @@ class ChatUseCase:
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, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | 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.
@@ -76,7 +80,7 @@ class ChatUseCase:
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, game_system_context lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
) )
async for token in self._llm.stream_chat( async for token in self._llm.stream_chat(
messages, messages,
@@ -92,12 +96,13 @@ class ChatUseCase:
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, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | 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, game_system_context lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
) )
# --- Construction du system prompt -------------------------------------- # --- Construction du system prompt --------------------------------------
@@ -109,6 +114,7 @@ class ChatUseCase:
campaign: CampaignStructuralContext | None, campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None, narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None, game_system: GameSystemContext | None = None,
session: SessionContext | None = None,
) -> str: ) -> str:
sections = [_BASE_SYSTEM] sections = [_BASE_SYSTEM]
if lore is not None: if lore is not None:
@@ -121,6 +127,8 @@ class ChatUseCase:
sections.append(self._format_page(page)) sections.append(self._format_page(page))
if narrative is not None: if narrative is not None:
sections.append(self._format_narrative_entity(narrative)) sections.append(self._format_narrative_entity(narrative))
if session is not None:
sections.append(self._format_session(session))
return "\n\n".join(sections) return "\n\n".join(sections)
# --- Blocs Lore --------------------------------------------------------- # --- Blocs Lore ---------------------------------------------------------
@@ -288,7 +296,8 @@ class ChatUseCase:
else: else:
for scene in chapter.scenes: for scene in chapter.scenes:
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count) sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
block.append(f" - {scene.name} (scène){sc_hint}") scene_kind = " (lieu explorable)" if scene.rooms else " (scène)"
block.append(f" - {scene.name}{scene_kind}{sc_hint}")
if scene.description: if scene.description:
block.append(f" Description : {scene.description}") block.append(f" Description : {scene.description}")
for br in scene.branches: for br in scene.branches:
@@ -296,6 +305,19 @@ class ChatUseCase:
block.append( block.append(
f'"{br.label}" vers {br.target_scene_name}{cond}' f'"{br.label}" vers {br.target_scene_name}{cond}'
) )
# Pièces du lieu explorable (mode donjon)
for room in scene.rooms:
floor = f" [étage {room.floor}]" if room.floor is not None else ""
block.append(f"{room.name}{floor}")
if room.description:
block.append(f" {room.description}")
if room.enemies:
block.append(f" Ennemis : {room.enemies}")
for rb in room.branches:
cond = f" (si : {rb.condition})" if rb.condition else ""
block.append(
f'"{rb.label}" vers {rb.target_room_name}{cond}'
)
return block return block
@staticmethod @staticmethod
@@ -342,6 +364,141 @@ class ChatUseCase:
f"{sections_block}" f"{sections_block}"
) )
# --- Bloc Session de jeu (Play Context) ---------------------------------
_ENTRY_TYPE_LABELS = {
"NOTE": "Note du MJ",
"EVENT": "Évènement",
"DICE_ROLL": "Jet de dés",
"PLAYER_ACTION": "Action joueur",
}
@staticmethod
def _format_session(sc: SessionContext) -> str:
"""Bloc journal de la session en cours + résumé des sessions précédentes.
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
rappeler un évènement antérieur, ou rebondir sur une action joueur.
Pour les sessions PRÉCÉDENTES de la même campagne, on ne remonte que
les EVENTs (les moments marquants) pour préserver le contexte LLM.
"""
status = "EN COURS" if sc.active else "TERMINÉE"
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
previous_block = ChatUseCase._format_previous_events(sc.previous_events)
hub_block = ChatUseCase._format_hub_status(sc)
if not sc.entries:
current_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
else:
lines: list[str] = []
for e in sc.entries:
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
content = e.content.replace("\n", "\n ")
lines.append(f"- {label}{ts} : {content}")
current_block = "\n".join(lines)
return (
"--- SESSION DE JEU EN COURS ---\n"
f"Nom : {sc.session_name}\n"
f"Statut : {status}{started}\n"
f"{hub_block}"
f"{previous_block}"
"\nJournal chronologique de la session courante (du plus ancien au plus récent) :\n"
f"{current_block}\n\n"
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
"- Tenir compte des évènements déjà capturés (sessions précédentes + journal courant).\n"
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
"qui s'enchaînent à ce qui vient de se passer.\n"
"- Éviter les longs développements : le MJ est en train d'animer une partie, "
"il a besoin d'idées immédiatement actionnables."
)
@staticmethod
def _format_hub_status(sc: SessionContext) -> str:
"""Bloc Hub : quêtes ouvertes + flags actifs.
Vide si la campagne n'a aucun Arc HUB (toutes les listes vides côté Core).
Les quêtes LOCKED apparaissent par leur TITRE uniquement : l'IA sait
qu'elles existent (utile pour les teasers, les rumeurs en jeu) mais ne
peut pas spoiler leurs détails.
"""
if (not sc.available_quests
and not sc.in_progress_quests
and not sc.locked_quest_titles
and not sc.active_flags):
return ""
lines = ["", "État du Hub (quêtes parallèles et faits narratifs) :"]
if sc.in_progress_quests:
lines.append(" Quêtes en cours :")
lines.extend(ChatUseCase._format_quest_lines(sc.in_progress_quests))
if sc.available_quests:
lines.append(" Quêtes disponibles (non démarrées, prêtes à être lancées) :")
lines.extend(ChatUseCase._format_quest_lines(sc.available_quests))
if sc.locked_quest_titles:
titles = ", ".join(f'"{t}"' for t in sc.locked_quest_titles)
lines.append(
" Quêtes encore verrouillées (existent mais non accessibles — "
f"tu peux y faire allusion sous forme de rumeurs sans spoiler leur contenu) : {titles}"
)
if sc.active_flags:
lines.append(
" Faits actifs : " + ", ".join(f"`{f}`" for f in sc.active_flags)
)
lines.append(
" Conseille des actions cohérentes avec ces quêtes ouvertes. Ne fais "
"PAS comme si une quête verrouillée était déjà accessible aux PJ."
)
lines.append("") # séparateur visuel avant le récap des sessions précédentes
return "\n".join(lines)
@staticmethod
def _format_quest_lines(quests: list[QuestSummary]) -> list[str]:
"""Sérialise une liste de QuestSummary en lignes indentées."""
out: list[str] = []
for q in quests:
arc = f" [arc : {q.arc_name}]" if q.arc_name else ""
out.append(f" - {q.name}{arc}")
if q.description:
out.append(f" Synopsis : {q.description}")
return out
@staticmethod
def _format_previous_events(events: list[JournalEntrySummary]) -> str:
"""Bloc "Story so far" : EVENTs marquants des sessions antérieures.
Vide si la campagne en est à sa première session. On groupe par
session source pour aider l'IA à situer chaque évènement temporellement.
"""
if not events:
return ""
# Groupement par session source en préservant l'ordre d'apparition.
grouped: dict[str, list[JournalEntrySummary]] = {}
for e in events:
key = e.source_session_name or "(session inconnue)"
grouped.setdefault(key, []).append(e)
lines = ["\nRécapitulatif des sessions précédentes (évènements marquants uniquement) :"]
for session_name, items in grouped.items():
lines.append(f"{session_name} :")
for e in items:
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
content = e.content.replace("\n", "\n ")
lines.append(f" - {content}{ts}")
lines.append("") # ligne vide avant le bloc journal courant
return "\n".join(lines) + "\n"
@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."""

View File

@@ -122,9 +122,29 @@ class SceneBranchHint:
condition: str | None = None condition: str | None = None
@dataclass(frozen=True)
class RoomBranchHint:
"""Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
label: str
target_room_name: str
condition: str | None = None
@dataclass(frozen=True)
class RoomSummary:
"""Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHint] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
class SceneSummary: class SceneSummary:
"""Résumé d'une scène : nom + description courte + illustrations + branches.""" """Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
name: str name: str
description: str | None description: str | None
@@ -133,6 +153,8 @@ class SceneSummary:
illustration_count: int = 0 illustration_count: int = 0
# Connexions narratives sortantes (livre dont vous etes le heros). # Connexions narratives sortantes (livre dont vous etes le heros).
branches: list[SceneBranchHint] = field(default_factory=list) branches: list[SceneBranchHint] = field(default_factory=list)
# Pièces du lieu explorable (vide = scène classique).
rooms: list[RoomSummary] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -229,3 +251,53 @@ class GameSystemContext:
system_name: str system_name: str
system_description: str | None system_description: str | None
sections: dict[str, str] sections: dict[str, str]
@dataclass(frozen=True)
class JournalEntrySummary:
"""Une entrée du journal d'une Session.
`source_session_name` n'est renseigné que pour les entrées issues de
sessions précédentes (option 3 : continuité narrative entre séances).
"""
type: str
content: str
occurred_at: str | None
source_session_name: str | None = None
@dataclass(frozen=True)
class QuestSummary:
"""Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
dans available_quests / in_progress_quests / locked_quest_titles.
"""
name: str
arc_name: str
description: str | None = None
@dataclass(frozen=True)
class SessionContext:
"""Contexte d'une Session de jeu en cours (Play Context).
Combine plusieurs niveaux :
- `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
- `previous_events` : EVENTs marquants des sessions précédentes (continuité)
- `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
- `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
- `active_flags` : noms des flags de campagne actuellement à true
"""
session_name: str
active: bool
started_at: str | None
entries: list[JournalEntrySummary]
previous_events: list[JournalEntrySummary]
available_quests: list[QuestSummary] = field(default_factory=list)
in_progress_quests: list[QuestSummary] = field(default_factory=list)
locked_quest_titles: list[str] = field(default_factory=list)
active_flags: list[str] = field(default_factory=list)

View File

@@ -26,13 +26,18 @@ from app.domain.models import (
NpcSummary, NpcSummary,
ChatMessage, ChatMessage,
GameSystemContext, GameSystemContext,
JournalEntrySummary,
LoreStructuralContext, LoreStructuralContext,
NarrativeEntityContext, NarrativeEntityContext,
PageContext, PageContext,
PageGenerationContext, PageGenerationContext,
PageSummary, PageSummary,
QuestSummary,
RoomBranchHint,
RoomSummary,
SceneBranchHint, SceneBranchHint,
SceneSummary, SceneSummary,
SessionContext,
) )
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider from app.infrastructure.ollama_adapter import OllamaLLMProvider
@@ -41,7 +46,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.6.6", version="0.9.1-beta",
) )
@@ -169,6 +174,24 @@ class SceneBranchHintDTO(BaseModel):
condition: str | None = None condition: str | None = None
class RoomBranchHintDTO(BaseModel):
"""Sortie d'une pièce vers une autre pièce du même lieu (donjon)."""
label: str
target_room_name: str
condition: str | None = None
class RoomSummaryDTO(BaseModel):
"""Pièce d'un lieu explorable. Omise par le Core si la scène est classique."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHintDTO] = Field(default_factory=list)
class SceneSummaryDTO(BaseModel): class SceneSummaryDTO(BaseModel):
"""Résumé d'une scène : nom + description courte (synopsis).""" """Résumé d'une scène : nom + description courte (synopsis)."""
@@ -179,6 +202,8 @@ class SceneSummaryDTO(BaseModel):
illustration_count: int = 0 illustration_count: int = 0
# Branches narratives sortantes, omises cote Core si vides. # Branches narratives sortantes, omises cote Core si vides.
branches: list[SceneBranchHintDTO] = Field(default_factory=list) branches: list[SceneBranchHintDTO] = Field(default_factory=list)
# Pièces du lieu explorable, omises par Core si scène classique.
rooms: list[RoomSummaryDTO] = Field(default_factory=list)
class ChapterSummaryDTO(BaseModel): class ChapterSummaryDTO(BaseModel):
@@ -243,13 +268,54 @@ class GameSystemContextDTO(BaseModel):
sections: dict[str, str] = Field(default_factory=dict) sections: dict[str, str] = Field(default_factory=dict)
class JournalEntrySummaryDTO(BaseModel):
"""Une entrée du journal de session.
`source_session_name` est présent uniquement pour les évènements issus
des sessions précédentes — sert à ancrer temporellement dans le prompt.
"""
type: str
content: str
occurred_at: str | None = None
source_session_name: str | None = None
class QuestSummaryDTO(BaseModel):
"""Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine."""
name: str
arc_name: str
description: str | None = None
class SessionContextDTO(BaseModel):
"""Contexte d'une Session de jeu en cours (Play Context).
Combine le journal complet (`entries`), les EVENTs des sessions précédentes
(`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes
Hub de la campagne (disponibles / en cours / verrouillées) plus les flags
narratifs actuellement actifs.
"""
session_name: str
active: bool
started_at: str | None = None
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list)
available_quests: list[QuestSummaryDTO] = Field(default_factory=list)
in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list)
locked_quest_titles: list[str] = Field(default_factory=list)
active_flags: list[str] = Field(default_factory=list)
class ChatStreamRequestDTO(BaseModel): class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels. """Requête de chat streamé : historique + contextes structurels.
Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels, Les contextes (lore, page, campaign, narrative_entity, session) sont
mais au moins l'un des deux "niveaux haut" (lore_context ou optionnels, mais au moins l'un des contextes "racines" (lore_context,
campaign_context) doit être fourni. Le validateur `check_scope` applique campaign_context ou session_context) doit être fourni. Le validateur
cette règle à la frontière HTTP. `check_scope` applique cette règle à la frontière HTTP.
""" """
messages: list[ChatMessageDTO] = Field(min_length=1) messages: list[ChatMessageDTO] = Field(min_length=1)
@@ -258,10 +324,15 @@ class ChatStreamRequestDTO(BaseModel):
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 game_system_context: GameSystemContextDTO | None = None
session_context: SessionContextDTO | 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, Campagne ou Session) est fourni."""
return self.lore_context is not None or self.campaign_context is not None return (
self.lore_context is not None
or self.campaign_context is not None
or self.session_context is not None
)
# --- Factories d'injection de dépendance --- # --- Factories d'injection de dépendance ---
@@ -385,6 +456,7 @@ async def chat_stream(
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) game_system_context = _to_game_system_context(body.game_system_context)
session_context = _to_session_context(body.session_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
@@ -397,6 +469,7 @@ async def chat_stream(
campaign_context=campaign_context, campaign_context=campaign_context,
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context, game_system_context=game_system_context,
session_context=session_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
@@ -421,6 +494,7 @@ async def chat_stream(
campaign_context=campaign_context, campaign_context=campaign_context,
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context, game_system_context=game_system_context,
session_context=session_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"
@@ -549,6 +623,23 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
) )
for br in sc.branches for br in sc.branches
], ],
rooms=[
RoomSummary(
name=room.name,
floor=room.floor,
description=room.description,
enemies=room.enemies,
branches=[
RoomBranchHint(
label=rb.label,
target_room_name=rb.target_room_name,
condition=rb.condition,
)
for rb in room.branches
],
)
for room in sc.rooms
],
) )
for sc in ch.scenes for sc in ch.scenes
], ],
@@ -867,3 +958,36 @@ def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemConte
system_description=dto.system_description, system_description=dto.system_description,
sections=dict(dto.sections), sections=dict(dto.sections),
) )
def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
if dto is None:
return None
return SessionContext(
session_name=dto.session_name,
active=dto.active,
started_at=dto.started_at,
entries=[_to_journal_entry(e) for e in dto.entries],
previous_events=[_to_journal_entry(e) for e in dto.previous_events],
available_quests=[_to_quest_summary(q) for q in dto.available_quests],
in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests],
locked_quest_titles=list(dto.locked_quest_titles),
active_flags=list(dto.active_flags),
)
def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary:
return QuestSummary(
name=dto.name,
arc_name=dto.arc_name,
description=dto.description,
)
def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary:
return JournalEntrySummary(
type=dto.type,
content=dto.content,
occurred_at=dto.occurred_at,
source_session_name=dto.source_session_name,
)

View File

@@ -8,13 +8,13 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version> <version>3.2.12</version>
<relativePath/> <relativePath/>
</parent> </parent>
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.7.1</version> <version>0.9.1-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -83,6 +83,28 @@
<artifactId>minio</artifactId> <artifactId>minio</artifactId>
<version>8.5.11</version> <version>8.5.11</version>
</dependency> </dependency>
<!-- Nimbus JOSE+JWT — verification des JWT Ed25519 (EdDSA) emis par le relais
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle. -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.40</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.14.1</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -98,6 +120,16 @@
</exclude> </exclude>
</excludes> </excludes>
</configuration> </configuration>
<executions>
<!-- Genere META-INF/build-info.properties (project.version)
consomme par Spring BuildProperties pour exposer la
version courante a l'application (UpdateCheckService). -->
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin> </plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires. <!-- JaCoCo : rapport de couverture des tests unitaires.

View File

@@ -2,12 +2,14 @@ package com.loremind;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* Classe principale de l'application LoreMind. * Classe principale de l'application LoreMind.
* Point d'entrée Spring Boot qui démarre l'application. * Point d'entrée Spring Boot qui démarre l'application.
*/ */
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class LoreMindApplication { public class LoreMindApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -50,6 +50,15 @@ public class ArcService {
return arcRepository.save(arc); return arcRepository.save(arc);
} }
/**
* Création à partir d'un Arc complet (utilisé par le controller pour faire passer
* les nouveaux champs comme type sans démultiplier les paramètres).
*/
public Arc createArc(Arc input) {
input.setId(null);
return arcRepository.save(input);
}
public Optional<Arc> getArcById(String id) { public Optional<Arc> getArcById(String id) {
return arcRepository.findById(id); return arcRepository.findById(id);
} }

View File

@@ -0,0 +1,49 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.TreeSet;
/**
* Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet})
* référencés par les chapitres d'une Campagne.
*
* <p>Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés
* globalement. Un fait existe dès qu'au moins une quête le référence dans ses
* prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans
* la Partie, autocomplete dans l'éditeur de prérequis).</p>
*/
@Service
public class CampaignReferencedFlagsService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
public CampaignReferencedFlagsService(ArcRepository arcRepository,
ChapterRepository chapterRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
}
/** Retourne la liste triée alphabétiquement des noms de faits référencés. */
public List<String> listForCampaign(String campaignId) {
TreeSet<String> unique = new TreeSet<>();
for (Arc arc : arcRepository.findByCampaignId(campaignId)) {
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
if (chapter.getPrerequisites() == null) continue;
for (Prerequisite p : chapter.getPrerequisites()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
}
}
}
return List.copyOf(unique);
}
}

View File

@@ -1,13 +1,15 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.application.playcontext.PlaythroughService;
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.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.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -15,9 +17,11 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
* Service d'application pour le contexte Campaign. * Service d'application pour le contexte Campaign (scénario).
* Orchestre la logique métier en utilisant le Port CampaignRepository. *
* Fait partie de la couche Application de l'Architecture Hexagonale. * <p>Depuis Playthrough : les PJ et les sessions ne sont plus rattachés directement
* à la Campagne ; ils dépendent d'un Playthrough (Partie). La cascade de suppression
* d'une campagne englobe donc ses Playthroughs (qui à leur tour cascadent).</p>
*/ */
@Service @Service
public class CampaignService { public class CampaignService {
@@ -26,35 +30,27 @@ public class CampaignService {
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; private final PlaythroughRepository playthroughRepository;
private final PlaythroughService playthroughService;
public CampaignService( public CampaignService(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository) { PlaythroughRepository playthroughRepository,
PlaythroughService playthroughService) {
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; this.playthroughRepository = playthroughRepository;
this.playthroughService = playthroughService;
} }
/**
* Parameter Object pour la création / mise à jour d'une Campaign.
* Évite une signature à rallonge et rend les évolutions futures (theme,
* coverImageUrl, etc.) sans casser les appelants.
*
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
*/
public record CampaignData(String name, String description, String loreId, String gameSystemId) {} public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
/** public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
* 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()
@@ -64,7 +60,12 @@ public class CampaignService {
.gameSystemId(normalizeId(data.gameSystemId())) .gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0) .arcsCount(0)
.build(); .build();
return campaignRepository.save(campaign); Campaign saved = campaignRepository.save(campaign);
// Une campagne sans Partie n'a pas de sens jouable : on crée d'office
// une "Partie principale" pour que l'utilisateur puisse jouer immédiatement.
playthroughService.create(saved.getId(), "Partie principale", null);
return saved;
} }
public Optional<Campaign> getCampaignById(String id) { public Optional<Campaign> getCampaignById(String id) {
@@ -89,19 +90,10 @@ public class CampaignService {
return campaignRepository.save(campaign); return campaignRepository.save(campaign);
} }
/**
* 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.
*/
private String normalizeId(String id) { private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id; 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) { public DeletionImpact getDeletionImpact(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id); List<Arc> arcs = arcRepository.findByCampaignId(id);
int chapterTotal = 0; int chapterTotal = 0;
@@ -113,22 +105,21 @@ public class CampaignService {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size(); sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
} }
} }
int characterTotal = characterRepository.findByCampaignId(id).size(); int playthroughTotal = playthroughRepository.findByCampaignId(id).size();
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal); return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, playthroughTotal);
} }
/**
* 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 @Transactional
public void deleteCampaign(String id) { public void deleteCampaign(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id); // 1. Cascade des Playthroughs (qui cascadent eux-mêmes sur PJ/sessions/valeurs flags/progressions).
for (Arc arc : arcs) { for (Playthrough p : playthroughRepository.findByCampaignId(id)) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId()); playthroughService.delete(p.getId());
for (Chapter chapter : chapters) { }
// 2. Cascade du scénario : arcs → chapitres → scènes
// (Pas de déclarations globales de faits : ils existent implicitement via les
// prérequis FLAG_SET des chapitres, qui partent avec les chapitres.)
for (Arc arc : arcRepository.findByCampaignId(id)) {
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) { for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId()); sceneRepository.deleteById(scene.getId());
} }
@@ -136,9 +127,6 @@ public class CampaignService {
} }
arcRepository.deleteById(arc.getId()); arcRepository.deleteById(arc.getId());
} }
for (var character : characterRepository.findByCampaignId(id)) {
characterRepository.deleteById(character.getId());
}
campaignRepository.deleteById(id); campaignRepository.deleteById(id);
} }

View File

@@ -44,6 +44,16 @@ public class ChapterService {
return chapterRepository.save(chapter); return chapterRepository.save(chapter);
} }
/**
* Création à partir d'un Chapter complet (utilisé par le controller pour faire passer
* les nouveaux champs comme progressionStatus / prerequisites sans démultiplier les
* paramètres). L'id est forcé à null pour laisser la DB le générer.
*/
public Chapter createChapter(Chapter input) {
input.setId(null);
return chapterRepository.save(input);
}
public Optional<Chapter> getChapterById(String id) { public Optional<Chapter> getChapterById(String id) {
return chapterRepository.findById(id); return chapterRepository.findById(id);
} }

View File

@@ -0,0 +1,104 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Service applicatif : enrichit des ChapterDTO avec leur {@link QuestStatus} effectif,
* relatif à un Playthrough donné.
*
* <p>Depuis l'introduction de Playthrough : la progression et les flags vivent au niveau
* de la Partie, plus de la Campagne. L'enrichissement nécessite donc un playthroughId.</p>
*/
@Service
public class ChapterStatusEnricher {
private final PlaythroughRepository playthroughRepository;
private final QuestProgressionRepository progressionRepository;
private final PlaythroughFlagRepository flagRepository;
private final SessionRepository sessionRepository;
private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator();
public ChapterStatusEnricher(PlaythroughRepository playthroughRepository,
QuestProgressionRepository progressionRepository,
PlaythroughFlagRepository flagRepository,
SessionRepository sessionRepository) {
this.playthroughRepository = playthroughRepository;
this.progressionRepository = progressionRepository;
this.flagRepository = flagRepository;
this.sessionRepository = sessionRepository;
}
/** Contexte d'évaluation + map chapterId -> ProgressionStatus pour ce Playthrough. */
public record PlaythroughEvalSnapshot(
PrerequisiteEvaluator.EvaluationContext ctx,
Map<String, ProgressionStatus> progressionByChapterId
) {}
/** Construit le snapshot d'évaluation pour un Playthrough. */
public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) {
if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) {
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()),
Collections.emptyMap()
);
}
Map<String, Boolean> flags = flagRepository.findByPlaythroughId(playthroughId);
Set<String> completedQuestIds = progressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
Map<String, ProgressionStatus> progressionMap = new HashMap<>();
for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
progressionMap.put(qp.getChapterId(), qp.getStatus());
}
return new PlaythroughEvalSnapshot(
new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
progressionMap
);
}
/** Calcule le statut effectif d'un seul chapitre relatif à un Playthrough. */
public QuestStatus computeFor(Chapter chapter, String playthroughId) {
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
ProgressionStatus progression = snap.progressionByChapterId()
.getOrDefault(chapter.getId(), ProgressionStatus.NOT_STARTED);
return evaluator.computeStatus(progression, chapter.getPrerequisites(), snap.ctx());
}
/**
* Injecte le {@code effectiveStatus} et le {@code progressionStatus} dans une liste de DTOs.
* Un seul build du snapshot pour toute la liste (optimal pour les vues qui listent un arc).
*/
public void enrich(List<ChapterDTO> dtos, List<Chapter> domain, String playthroughId) {
if (dtos == null || dtos.isEmpty()) return;
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
Map<String, Chapter> byId = domain.stream()
.collect(Collectors.toMap(Chapter::getId, c -> c));
for (ChapterDTO dto : dtos) {
Chapter c = byId.get(dto.getId());
if (c == null) continue;
ProgressionStatus progression = snap.progressionByChapterId()
.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = evaluator.computeStatus(progression, c.getPrerequisites(), snap.ctx());
dto.setProgressionStatus(progression.name());
dto.setEffectiveStatus(status.name());
}
}
}

View File

@@ -4,11 +4,14 @@ import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
* Service d'application pour les fiches de personnages (PJ). * Service d'application pour les fiches de personnages (PJ).
* Les PJ appartiennent désormais à un Playthrough (Partie), pas à la Campagne.
*/ */
@Service @Service
public class CharacterService { public class CharacterService {
@@ -19,20 +22,29 @@ public class CharacterService {
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
} }
/** public record CharacterData(
* Parameter Object pour la création / mise à jour d'un Character. String name,
* `order` est fourni par le controller ; si absent, le service le calcule. String portraitImageId,
*/ String headerImageId,
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {} Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String playthroughId,
Integer order
) {}
public Character createCharacter(CharacterData data) { public Character createCharacter(CharacterData data) {
int order = data.order() != null int order = data.order() != null
? data.order() ? data.order()
: nextOrderFor(data.campaignId()); : nextOrderFor(data.playthroughId());
Character character = Character.builder() Character character = Character.builder()
.name(data.name()) .name(data.name())
.markdownContent(data.markdownContent()) .portraitImageId(data.portraitImageId())
.campaignId(data.campaignId()) .headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.playthroughId(data.playthroughId())
.order(order) .order(order)
.build(); .build();
return characterRepository.save(character); return characterRepository.save(character);
@@ -42,19 +54,23 @@ public class CharacterService {
return characterRepository.findById(id); return characterRepository.findById(id);
} }
public List<Character> getCharactersByCampaignId(String campaignId) { public List<Character> getCharactersByPlaythroughId(String playthroughId) {
return characterRepository.findByCampaignId(campaignId); return characterRepository.findByPlaythroughId(playthroughId);
} }
public Character updateCharacter(String id, CharacterData data) { public Character updateCharacter(String id, CharacterData data) {
Character existing = characterRepository.findById(id) Character existing = characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
existing.setName(data.name()); existing.setName(data.name());
existing.setMarkdownContent(data.markdownContent()); existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }
// campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP). // playthroughId immuable après création.
return characterRepository.save(existing); return characterRepository.save(existing);
} }
@@ -62,9 +78,8 @@ public class CharacterService {
characterRepository.deleteById(id); characterRepository.deleteById(id);
} }
/** Renvoie la prochaine position libre — append en fin de liste. */ private int nextOrderFor(String playthroughId) {
private int nextOrderFor(String campaignId) { return characterRepository.findByPlaythroughId(playthroughId).stream()
return characterRepository.findByCampaignId(campaignId).stream()
.mapToInt(Character::getOrder) .mapToInt(Character::getOrder)
.max() .max()
.orElse(-1) + 1; .orElse(-1) + 1;

View File

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

View File

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

View File

@@ -18,6 +18,8 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHin
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.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomBranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -66,11 +68,18 @@ public class CampaignStructuralContextBuilder {
private static final int CHARACTER_SNIPPET_MAX_LEN = 160; 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. Sans playthroughId, les PJ
* nom + description courte à chaque niveau). * sont omis (ils sont propres à une Partie).
* @throws IllegalArgumentException si la Campagne est introuvable
*/ */
public CampaignStructuralContext build(String campaignId) { public CampaignStructuralContext build(String campaignId) {
return build(campaignId, null);
}
/**
* Variante avec playthroughId : injecte les PJ de la Partie indiquée.
* Les PNJ restent campagne-scope (donnée de scénario).
*/
public CampaignStructuralContext build(String campaignId, String playthroughId) {
Campaign campaign = campaignRepository.findById(campaignId) Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId)); "Campagne non trouvée avec l'ID: " + campaignId));
@@ -80,10 +89,12 @@ public class CampaignStructuralContextBuilder {
.map(this::toArcSummary) .map(this::toArcSummary)
.collect(Collectors.toList()); .collect(Collectors.toList());
List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream() List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
.sorted(Comparator.comparingInt(Character::getOrder)) ? List.of()
.map(this::toCharacterSummary) : characterRepository.findByPlaythroughId(playthroughId).stream()
.collect(Collectors.toList()); .sorted(Comparator.comparingInt(Character::getOrder))
.map(this::toCharacterSummary)
.collect(Collectors.toList());
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream() List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Npc::getOrder)) .sorted(Comparator.comparingInt(Npc::getOrder))
@@ -104,23 +115,32 @@ public class CampaignStructuralContextBuilder {
* sans injecter toute sa fiche. * sans injecter toute sa fiche.
*/ */
private CharacterSummary toCharacterSummary(Character c) { private CharacterSummary toCharacterSummary(Character c) {
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent())); return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
} }
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */ /** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
private NpcSummary toNpcSummary(Npc n) { private NpcSummary toNpcSummary(Npc n) {
return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent())); return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
} }
private static String extractSnippet(String markdown) { /**
if (markdown == null || markdown.isBlank()) return ""; * Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
String firstLine = markdown.lines() * du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
.map(String::strip) */
.filter(l -> !l.isEmpty() && !l.startsWith("#")) private static String extractSnippet(java.util.Map<String, String> values) {
.findFirst() if (values == null || values.isEmpty()) return "";
.orElse(""); for (String value : values.values()) {
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine; if (value == null || value.isBlank()) continue;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + ""; String firstLine = value.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
if (firstLine.isEmpty()) continue;
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
}
return "";
} }
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc) {
@@ -166,11 +186,42 @@ public class CampaignStructuralContextBuilder {
b.condition())) b.condition()))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<RoomSummary> rooms = toRoomSummaries(scene);
return new SceneSummary( return new SceneSummary(
scene.getName(), scene.getName(),
scene.getDescription(), scene.getDescription(),
countImages(scene.getIllustrationImageIds()), countImages(scene.getIllustrationImageIds()),
hints); hints,
rooms);
}
/**
* Projette les pièces d'une scène en RoomSummary pour le contexte IA.
* Pas de notes MJ, pas de loot ni pièges : le prompt reste lisible. L'IA
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
* suffisant pour proposer de la narration ou anticiper les choix.
*/
private List<RoomSummary> toRoomSummaries(Scene scene) {
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Room::getId,
com.loremind.domain.campaigncontext.Room::getName,
(a, b) -> a));
return scene.getRooms().stream()
.map(r -> {
List<RoomBranchHint> hints = r.getBranches() == null
? List.of()
: r.getBranches().stream()
.map(b -> new RoomBranchHint(
b.label(),
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition()))
.collect(Collectors.toList());
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints);
})
.collect(Collectors.toList());
} }
/** Helper defensif : compte les illustrations attachees (null-safe). */ /** Helper defensif : compte les illustrations attachees (null-safe). */

View File

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

View File

@@ -0,0 +1,233 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
*
* <p>Depuis la refonte Playthrough : la session connaît son playthroughId ; la progression
* et les flags viennent du Playthrough (pas plus de la Campagne ni du Chapter).</p>
*/
@Component
public class SessionStructuralContextBuilder {
private static final int MAX_CURRENT_ENTRIES = 80;
private static final int MAX_PREVIOUS_EVENTS = 60;
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final PlaythroughFlagRepository playthroughFlagRepository;
private final QuestProgressionRepository questProgressionRepository;
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
PlaythroughFlagRepository playthroughFlagRepository,
QuestProgressionRepository questProgressionRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.playthroughFlagRepository = playthroughFlagRepository;
this.questProgressionRepository = questProgressionRepository;
}
public Optional<SessionContext> buildOptional(String sessionId) {
return sessionRepository.findById(sessionId).map(this::toContext);
}
public SessionContext build(String sessionId) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
return toContext(session);
}
private SessionContext toContext(Session session) {
List<JournalEntrySummary> currentEntries = loadCurrentEntries(session);
List<JournalEntrySummary> previousEvents = loadPreviousEvents(session);
HubStatus hub = computeHubStatus(session.getPlaythroughId());
return new SessionContext(
session.getName(),
session.isActive(),
session.getStartedAt(),
currentEntries,
previousEvents,
hub.available(),
hub.inProgress(),
hub.lockedTitles(),
hub.activeFlags());
}
private List<JournalEntrySummary> loadCurrentEntries(Session session) {
List<SessionEntry> allEntries = entryRepository.findBySessionId(session.getId());
List<SessionEntry> kept = allEntries.size() <= MAX_CURRENT_ENTRIES
? allEntries
: allEntries.subList(allEntries.size() - MAX_CURRENT_ENTRIES, allEntries.size());
return kept.stream()
.map(e -> toSummary(e, null))
.collect(Collectors.toList());
}
/**
* EVENTs des sessions précédentes du MÊME Playthrough (même table).
* On ne mélange jamais les EVENTs de tables différentes.
*/
private List<JournalEntrySummary> loadPreviousEvents(Session current) {
if (current.getPlaythroughId() == null) return List.of();
List<Session> siblingSessions = sessionRepository.findByPlaythroughId(current.getPlaythroughId());
List<JournalEntrySummary> events = new ArrayList<>();
for (Session past : siblingSessions) {
if (past.getId().equals(current.getId())) continue;
for (SessionEntry entry : entryRepository.findBySessionId(past.getId())) {
if (entry.getType() == EntryType.EVENT) {
events.add(toSummary(entry, past.getName()));
}
}
}
events.sort(Comparator.comparing(
JournalEntrySummary::occurredAt,
Comparator.nullsLast(Comparator.naturalOrder())));
if (events.size() > MAX_PREVIOUS_EVENTS) {
return events.subList(events.size() - MAX_PREVIOUS_EVENTS, events.size());
}
return events;
}
private JournalEntrySummary toSummary(SessionEntry entry, String sourceSessionName) {
return new JournalEntrySummary(
entry.getType() != null ? entry.getType().name() : "NOTE",
entry.getContent(),
entry.getOccurredAt(),
sourceSessionName);
}
/** Agrégat interne des données Hub à injecter dans le SessionContext. */
private record HubStatus(
List<QuestSummary> available,
List<QuestSummary> inProgress,
List<String> lockedTitles,
List<String> activeFlags
) {}
/**
* Calcule l'état des quêtes Hub du Playthrough courant :
* - AVAILABLE / IN_PROGRESS → résumé complet
* - LOCKED → titre uniquement (anti-spoiler)
* - COMPLETED → omis (déjà raconté par les EVENTs)
*/
private HubStatus computeHubStatus(String playthroughId) {
if (playthroughId == null) {
return new HubStatus(List.of(), List.of(), List.of(), List.of());
}
Optional<Playthrough> maybePlaythrough = playthroughRepository.findById(playthroughId);
if (maybePlaythrough.isEmpty()) {
return new HubStatus(List.of(), List.of(), List.of(), List.of());
}
String campaignId = maybePlaythrough.get().getCampaignId();
Map<String, Boolean> flags = playthroughFlagRepository.findByPlaythroughId(playthroughId);
List<String> activeFlags = buildActiveFlags(flags);
List<Arc> arcs = arcRepository.findByCampaignId(campaignId);
Map<String, Arc> arcsById = arcs.stream()
.filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a));
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
if (!anyHub) {
return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
}
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
progressionByChapter.put(qp.getChapterId(), qp.getStatus());
}
// IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted)
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
PrerequisiteEvaluator.EvaluationContext ctx =
new PrerequisiteEvaluator.EvaluationContext(completedIds, sessionCount, flags);
List<QuestSummary> available = new ArrayList<>();
List<QuestSummary> inProgress = new ArrayList<>();
List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
if (arc.getType() != ArcType.HUB) continue;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId());
String arcName = parent != null ? parent.getName() : null;
switch (status) {
case AVAILABLE:
available.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
break;
case IN_PROGRESS:
inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
break;
case LOCKED:
lockedTitles.add(c.getName());
break;
case COMPLETED:
// Omis (déjà dans le journal des EVENTs).
break;
}
}
}
return new HubStatus(available, inProgress, lockedTitles, activeFlags);
}
private List<String> buildActiveFlags(Map<String, Boolean> flags) {
return flags.entrySet().stream()
.filter(Map.Entry::getValue)
.map(Map.Entry::getKey)
.sorted()
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,122 @@
package com.loremind.application.generationcontext;
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat IA pendant une Session de jeu.
* <p>
* Orchestre la composition des contextes :
* 1. Charge la Session puis la Campagne associée (weak reference).
* 2. Construit le CampaignStructuralContext (carte narrative + PJ/PNJ).
* 3. Construit le LoreStructuralContext si la campagne est liée à un Lore.
* 4. Construit le GameSystemContext si elle a un système de JDR.
* 5. Construit le SessionContext (journal horodaté, statut).
* 6. Délègue au port {@link AiChatProvider} pour le streaming.
* </p>
*
* <p>La conversation est éphémère (pas de persistance) : pendant une partie,
* l'utilité est d'avoir une assistance immédiate, pas de garder un historique.
* Le journal de session joue déjà ce rôle de mémoire persistante.</p>
*/
@Service
public class StreamChatForSessionUseCase {
private final SessionRepository sessionRepository;
private final PlaythroughRepository playthroughRepository;
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final GameSystemContextBuilder gameSystemContextBuilder;
private final SessionStructuralContextBuilder sessionContextBuilder;
private final AiChatProvider aiChatProvider;
public StreamChatForSessionUseCase(
SessionRepository sessionRepository,
PlaythroughRepository playthroughRepository,
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
GameSystemContextBuilder gameSystemContextBuilder,
SessionStructuralContextBuilder sessionContextBuilder,
AiChatProvider aiChatProvider) {
this.sessionRepository = sessionRepository;
this.playthroughRepository = playthroughRepository;
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.gameSystemContextBuilder = gameSystemContextBuilder;
this.sessionContextBuilder = sessionContextBuilder;
this.aiChatProvider = aiChatProvider;
}
public void execute(
String sessionId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
// Chaîne de résolution : Session → Playthrough → Campaign.
Playthrough playthrough = playthroughRepository.findById(session.getPlaythroughId())
.orElseThrow(() -> new IllegalArgumentException(
"Partie associée à la session introuvable : " + session.getPlaythroughId()));
Campaign campaign = campaignRepository.findById(playthrough.getCampaignId())
.orElseThrow(() -> new IllegalArgumentException(
"Campagne associée à la partie introuvable : " + playthrough.getCampaignId()));
// Le campaign context inclut les PJ de CE Playthrough (les PJ sont par-table).
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId(), playthrough.getId());
LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
SessionContext sessionContext = sessionContextBuilder.build(sessionId);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.campaignContext(campaignContext)
.gameSystemContext(gameSystemContext)
.sessionContext(sessionContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
}
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
if (!campaign.isLinkedToLore()) return null;
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
}
/**
* Pendant une session active, on injecte les sections les plus utiles en partie
* (combats, PNJ, mécaniques) — intent SCENE est le plus proche de ce besoin.
*/
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign) {
if (!campaign.isLinkedToGameSystem()) return null;
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), GenerationIntent.SCENE)
.orElse(null);
}
}

View File

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

View File

@@ -0,0 +1,261 @@
package com.loremind.application.licensing;
import com.loremind.domain.licensing.License;
import com.loremind.domain.licensing.LicenseClaims;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.JwtVerifier;
import com.loremind.domain.licensing.ports.LicenseRelay;
import com.loremind.domain.licensing.ports.LicenseRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
/**
* Service application pour la gestion de la licence Patreon.
* <p>
* Responsabilites :
* <ul>
* <li>Installer un nouveau JWT recu du relais (apres OAuth utilisateur)</li>
* <li>Calculer le {@link LicenseStatus} courant en respectant la grace period</li>
* <li>Renouveler le JWT avant expiration en appelant le relais</li>
* <li>Activer/desactiver le toggle "canal beta" cote utilisateur</li>
* <li>Distribuer les credentials registry pour le pull beta</li>
* </ul>
*/
@Service
public class LicenseService {
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
private final LicenseRepository repository;
private final JwtVerifier jwtVerifier;
private final LicenseRelay relay;
private final long gracePeriodSeconds;
private final long refreshBeforeExpirySeconds;
public LicenseService(
LicenseRepository repository,
JwtVerifier jwtVerifier,
LicenseRelay relay,
@Value("${licensing.grace-period-days:14}") int gracePeriodDays,
@Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) {
this.repository = repository;
this.jwtVerifier = jwtVerifier;
this.relay = relay;
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
}
/**
* @return true si le verifier est configure (cle publique presente).
* L'UI peut masquer toute la section Patreon si false.
*/
public boolean isLicensingEnabled() {
return jwtVerifier.isConfigured();
}
/**
* Genere ou retourne l'instance_id stable de cette installation.
* Stocke dans la licence elle-meme. Si pas de licence, en cree un volatil
* (sera persiste a la prochaine connexion).
*/
public String getOrCreateInstanceId() {
return repository.findCurrent()
.map(License::getInstanceId)
.orElseGet(() -> "li-" + UUID.randomUUID());
}
/**
* Construit l'URL OAuth pour ouvrir dans le navigateur de l'utilisateur.
*/
public String buildConnectUrl() {
return relay.buildConnectUrl(getOrCreateInstanceId());
}
/**
* Installe un JWT recu du relais (l'utilisateur l'a colle dans l'UI ou
* recu via deep-link). Verifie la signature, extrait les claims, persiste.
*/
public LicenseSnapshot installToken(String rawJwt) throws InstallException {
if (!jwtVerifier.isConfigured()) {
throw new InstallException("Licensing feature not enabled (no public key configured)");
}
LicenseClaims claims;
try {
claims = jwtVerifier.verify(rawJwt);
} catch (JwtVerifier.JwtVerificationException e) {
throw new InstallException("Invalid JWT: " + e.getMessage());
}
Instant now = Instant.now();
if (claims.expiresAt().isBefore(now)) {
throw new InstallException("JWT already expired");
}
Optional<License> existing = repository.findCurrent();
License toSave = License.builder()
.id("current")
.rawJwt(rawJwt)
.patreonUserId(claims.subject())
.tierId(claims.tierId())
.instanceId(claims.instanceId())
.issuedAt(claims.issuedAt())
.expiresAt(claims.expiresAt())
.lastRefreshAttemptAt(now)
.lastRefreshSucceeded(true)
// Au premier install, on active le canal beta par defaut.
// Sur reinstall apres deconnexion, on respecte la valeur precedente.
.betaChannelEnabled(existing.map(License::isBetaChannelEnabled).orElse(true))
.createdAt(existing.map(License::getCreatedAt).orElse(now))
.build();
License saved = repository.save(toSave);
log.info("Patreon license installed for user={} tier={} expires={}",
saved.getPatreonUserId(), saved.getTierId(), saved.getExpiresAt());
return snapshotOf(saved, now);
}
/**
* Etat courant de la licence pour exposition UI / decision technique.
*/
public LicenseSnapshot getCurrentSnapshot() {
Optional<License> opt = repository.findCurrent();
if (opt.isEmpty()) return LicenseSnapshot.none();
return snapshotOf(opt.get(), Instant.now());
}
/**
* Supprime la licence (deconnexion volontaire de Patreon par l'utilisateur).
*/
public void disconnect() {
repository.deleteCurrent();
log.info("Patreon license removed (user disconnect)");
}
/**
* Active ou desactive le canal beta. Necessite une licence valide ou en grace.
*/
public LicenseSnapshot setBetaChannelEnabled(boolean enabled) {
License current = repository.findCurrent()
.orElseThrow(() -> new IllegalStateException("No license installed"));
current.setBetaChannelEnabled(enabled);
License saved = repository.save(current);
return snapshotOf(saved, Instant.now());
}
/**
* Tente un refresh si la licence est proche de l'expiration. Idempotent.
* Appele par le daemon planifie + manuellement via l'UI ("Reessayer").
*
* @return true si un refresh a ete tente (avec ou sans succes)
*/
public boolean refreshIfNeeded() {
Optional<License> opt = repository.findCurrent();
if (opt.isEmpty()) return false;
License current = opt.get();
Instant now = Instant.now();
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
return false;
}
return doRefresh(current, now);
}
/**
* Force un refresh immediat (bouton UI "Reessayer maintenant").
*/
public boolean forceRefresh() {
return repository.findCurrent()
.map(license -> doRefresh(license, Instant.now()))
.orElse(false);
}
private boolean doRefresh(License current, Instant now) {
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
try {
String newJwt = relay.refreshToken(current.getRawJwt());
LicenseClaims claims = jwtVerifier.verify(newJwt);
current.setRawJwt(newJwt);
current.setIssuedAt(claims.issuedAt());
current.setExpiresAt(claims.expiresAt());
current.setTierId(claims.tierId());
current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(true);
repository.save(current);
log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
return true;
} catch (LicenseRelay.RelayException e) {
current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false);
repository.save(current);
if (e.getKind() == LicenseRelay.RelayErrorKind.REJECTED) {
log.warn("Relay rejected refresh ({}): tier may have been cancelled", e.getMessage());
} else {
log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
}
return true;
} catch (JwtVerifier.JwtVerificationException e) {
current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false);
repository.save(current);
log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
return true;
}
}
/**
* Recupere les credentials registry pour pull du canal beta.
* @return empty si pas de licence valide ou relais en echec
*/
public Optional<RegistryCredentials> fetchRegistryCredentials() {
LicenseSnapshot snap = getCurrentSnapshot();
if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
return Optional.empty();
}
License current = repository.findCurrent().orElse(null);
if (current == null) return Optional.empty();
try {
return Optional.of(relay.fetchRegistryCredentials(current.getRawJwt()));
} catch (LicenseRelay.RelayException e) {
log.warn("Cannot fetch registry credentials ({}): {}", e.getKind(), e.getMessage());
return Optional.empty();
}
}
private LicenseSnapshot snapshotOf(License l, Instant now) {
LicenseStatus status = computeStatus(l, now);
return new LicenseSnapshot(
status,
l.getPatreonUserId(),
l.getTierId(),
l.getInstanceId(),
l.getExpiresAt(),
l.getLastRefreshAttemptAt(),
l.isLastRefreshSucceeded(),
l.isBetaChannelEnabled()
);
}
private LicenseStatus computeStatus(License l, Instant now) {
if (l.getExpiresAt() == null) return LicenseStatus.NONE;
if (now.isBefore(l.getExpiresAt())) return LicenseStatus.VALID;
long secondsPastExpiry = Duration.between(l.getExpiresAt(), now).getSeconds();
if (secondsPastExpiry <= gracePeriodSeconds) return LicenseStatus.GRACE;
return LicenseStatus.EXPIRED;
}
public static class InstallException extends Exception {
public InstallException(String message) {
super(message);
}
}
}

View File

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

View File

@@ -0,0 +1,110 @@
package com.loremind.application.playcontext;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* Service applicatif pour le cycle de vie d'un Playthrough (Partie).
*
* <p>Cascade de suppression : un Playthrough supprime ses flags, ses progressions,
* ses PJ et ses sessions (avec leurs entrées de journal).</p>
*/
@Service
public class PlaythroughService {
private final PlaythroughRepository playthroughRepository;
private final CampaignRepository campaignRepository;
private final PlaythroughFlagRepository flagRepository;
private final QuestProgressionRepository progressionRepository;
private final CharacterRepository characterRepository;
private final SessionRepository sessionRepository;
private final SessionService sessionService;
public PlaythroughService(PlaythroughRepository playthroughRepository,
CampaignRepository campaignRepository,
PlaythroughFlagRepository flagRepository,
QuestProgressionRepository progressionRepository,
CharacterRepository characterRepository,
SessionRepository sessionRepository,
SessionService sessionService) {
this.playthroughRepository = playthroughRepository;
this.campaignRepository = campaignRepository;
this.flagRepository = flagRepository;
this.progressionRepository = progressionRepository;
this.characterRepository = characterRepository;
this.sessionRepository = sessionRepository;
this.sessionService = sessionService;
}
/** Compte des entités qui seront supprimées en cascade avec la Partie. */
public record DeletionImpact(int sessions, int characters, int flags, int progressions) {}
public Playthrough create(String campaignId, String name, String description) {
if (campaignId == null || campaignId.isBlank()) {
throw new IllegalArgumentException("campaignId requis.");
}
if (!campaignRepository.existsById(campaignId)) {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
}
Playthrough p = Playthrough.builder()
.campaignId(campaignId)
.name((name == null || name.isBlank()) ? "Partie principale" : name.trim())
.description(description)
.build();
return playthroughRepository.save(p);
}
public Playthrough update(String id, Playthrough updated) {
Playthrough existing = playthroughRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Partie introuvable : " + id));
BeanUtils.copyProperties(updated, existing, "id", "campaignId", "createdAt");
return playthroughRepository.save(existing);
}
public Optional<Playthrough> getById(String id) {
return playthroughRepository.findById(id);
}
public List<Playthrough> getByCampaignId(String campaignId) {
return playthroughRepository.findByCampaignId(campaignId);
}
public DeletionImpact getDeletionImpact(String id) {
int sessions = sessionRepository.findByPlaythroughId(id).size();
int characters = characterRepository.findByPlaythroughId(id).size();
int flags = flagRepository.findByPlaythroughId(id).size();
int progressions = progressionRepository.findByPlaythroughId(id).size();
return new DeletionImpact(sessions, characters, flags, progressions);
}
@Transactional
public void delete(String id) {
if (!playthroughRepository.existsById(id)) {
throw new IllegalArgumentException("Partie introuvable : " + id);
}
// Cascade : sessions (et leurs entries), PJ, flags, progressions
for (Session s : sessionRepository.findByPlaythroughId(id)) {
sessionService.deleteSession(s.getId());
}
characterRepository.findByPlaythroughId(id).forEach(c -> characterRepository.deleteById(c.getId()));
flagRepository.deleteAllByPlaythroughId(id);
progressionRepository.deleteAllByPlaythroughId(id);
playthroughRepository.deleteById(id);
}
public boolean exists(String id) {
return playthroughRepository.existsById(id);
}
}

View File

@@ -0,0 +1,81 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le journal d'une Session.
* Gère le cycle CRUD des entrées (note, évènement, jet, action joueur).
*/
@Service
public class SessionEntryService {
private final SessionEntryRepository entryRepository;
private final SessionRepository sessionRepository;
public SessionEntryService(SessionEntryRepository entryRepository,
SessionRepository sessionRepository) {
this.entryRepository = entryRepository;
this.sessionRepository = sessionRepository;
}
/** Données fournies par l'API pour créer ou éditer une entrée. */
public record EntryData(EntryType type, String content, LocalDateTime occurredAt) {}
public SessionEntry createEntry(String sessionId, EntryData data) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("sessionId est requis.");
}
if (!sessionRepository.existsById(sessionId)) {
throw new IllegalArgumentException("Session introuvable : " + sessionId);
}
validateContent(data.content());
LocalDateTime now = LocalDateTime.now();
SessionEntry entry = SessionEntry.builder()
.sessionId(sessionId)
.type(data.type() != null ? data.type() : EntryType.NOTE)
.content(data.content().trim())
.occurredAt(data.occurredAt() != null ? data.occurredAt() : now)
.build();
return entryRepository.save(entry);
}
public SessionEntry updateEntry(String id, EntryData data) {
validateContent(data.content());
SessionEntry existing = entryRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Entrée introuvable : " + id));
if (data.type() != null) existing.setType(data.type());
existing.setContent(data.content().trim());
if (data.occurredAt() != null) existing.setOccurredAt(data.occurredAt());
return entryRepository.save(existing);
}
public Optional<SessionEntry> getById(String id) {
return entryRepository.findById(id);
}
public List<SessionEntry> getBySessionId(String sessionId) {
return entryRepository.findBySessionId(sessionId);
}
public void deleteEntry(String id) {
if (!entryRepository.existsById(id)) {
throw new IllegalArgumentException("Entrée introuvable : " + id);
}
entryRepository.deleteById(id);
}
private void validateContent(String content) {
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("Le contenu d'une entrée ne peut pas être vide.");
}
}
}

View File

@@ -0,0 +1,118 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le Play Context.
* Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
*
* <p>Règle métier : une seule Session peut être active (endedAt null) à la fois.</p>
* <p>Depuis Playthrough : une Session appartient à un Playthrough (pas directement à une Campaign).</p>
*/
@Service
public class SessionService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final PlaythroughRepository playthroughRepository;
public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
PlaythroughRepository playthroughRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.playthroughRepository = playthroughRepository;
}
/**
* Lance une nouvelle session sur le Playthrough donné.
* Échoue si une session est déjà active ou si le Playthrough n'existe pas.
*/
public Session startSession(String playthroughId) {
if (playthroughId == null || playthroughId.isBlank()) {
throw new IllegalArgumentException("playthroughId est requis pour démarrer une session.");
}
if (!playthroughRepository.existsById(playthroughId)) {
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
}
// Règle métier : une seule session active par Partie (pas de verrou global cross-Partie).
sessionRepository.findActiveByPlaythroughId(playthroughId).ifPresent(s -> {
throw new IllegalStateException(
"Une session est déjà en cours pour cette Partie (id=" + s.getId() +
"). Termine-la avant d'en lancer une nouvelle.");
});
LocalDateTime now = LocalDateTime.now();
Session session = Session.builder()
.name(generateDefaultName(now))
.playthroughId(playthroughId)
.startedAt(now)
.build();
return sessionRepository.save(session);
}
public Session endSession(String id) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
if (!session.isActive()) {
throw new IllegalStateException("Cette session est déjà terminée.");
}
session.setEndedAt(LocalDateTime.now());
return sessionRepository.save(session);
}
public Session renameSession(String id, String newName) {
if (newName == null || newName.isBlank()) {
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
}
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
session.setName(newName.trim());
return sessionRepository.save(session);
}
public Optional<Session> getById(String id) {
return sessionRepository.findById(id);
}
public Optional<Session> getActive() {
return sessionRepository.findActive();
}
public Optional<Session> getActiveByPlaythrough(String playthroughId) {
return sessionRepository.findActiveByPlaythroughId(playthroughId);
}
public List<Session> getAll() {
return sessionRepository.findAll();
}
public List<Session> getByPlaythroughId(String playthroughId) {
return sessionRepository.findByPlaythroughId(playthroughId);
}
@Transactional
public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) {
throw new IllegalArgumentException("Session introuvable : " + id);
}
entryRepository.deleteBySessionId(id);
sessionRepository.deleteById(id);
}
private String generateDefaultName(LocalDateTime startedAt) {
return "Session du " + startedAt.format(DATE_FORMATTER);
}
}

View File

@@ -21,6 +21,14 @@ 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
/**
* Type structurel de l'arc. Détermine son rendu UI et la sémantique de ses chapitres
* (séquence narrative LINEAR vs. quêtes parallèles d'un HUB).
* Défaut LINEAR pour rétro-compatibilité avec les arcs existants.
*/
@Builder.Default
private ArcType type = ArcType.LINEAR;
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */ /** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon; private String icon;

View File

@@ -0,0 +1,14 @@
package com.loremind.domain.campaigncontext;
/**
* Type structurel d'un Arc.
* - LINEAR : narration séquentielle classique (chapitres joués dans l'ordre).
* - HUB : narration non linéaire ; les chapitres sont des "quêtes" satellites
* potentiellement parallèles, soumises à des prérequis pour être débloquées.
*
* Value Object du domaine (Bounded Context : Campaign).
*/
public enum ArcType {
LINEAR,
HUB
}

View File

@@ -6,8 +6,12 @@ import java.time.LocalDateTime;
/** /**
* Entité de domaine représentant une Campaign. * Entité de domaine représentant une Campaign.
* Conteneur global pour organiser la narration d'une campagne. * Conteneur du SCÉNARIO (générique, ré-utilisable par plusieurs tables).
* Entité pure du domaine, sans dépendance technique. *
* <p>Toute donnée dynamique propre à une table jouée (progression des quêtes,
* flags narratifs, sessions, PJ) vit dans un {@link com.loremind.domain.playcontext.Playthrough}.</p>
*
* <p>Entité pure du domaine, sans dépendance technique.</p>
*/ */
@Data @Data
@Builder @Builder
@@ -21,17 +25,13 @@ public class Campaign {
private int arcsCount; private int arcsCount;
/** /**
* Référence faible (weak reference) vers un Lore. * Référence faible vers un Lore. Nullable.
* Nullable : une campagne peut exister sans univers associé (one-shot, test, pitch libre). * Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context
* (respect des Bounded Contexts en DDD).
*/ */
private String loreId; private String loreId;
/** /**
* Référence faible (weak reference) vers un GameSystem. * Référence faible vers un GameSystem. Nullable.
* 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; private String gameSystemId;

View File

@@ -21,6 +21,14 @@ 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
/**
* Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE.
* Pertinent surtout pour les chapitres d'un Arc HUB ; ignoré pour LINEAR.
* Donnée de SCÉNARIO — partagée par toutes les Parties de la campagne.
*/
@Builder.Default
private List<Prerequisite> prerequisites = new ArrayList<>();
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */ /** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon; private String icon;

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.campaigncontext;
/**
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
*
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
* {@link PrerequisiteEvaluator}.
*
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
* (pas de OR pour le moment).
*/
public sealed interface Prerequisite
permits Prerequisite.QuestCompleted,
Prerequisite.SessionReached,
Prerequisite.FlagSet {
/** La quête référencée par {@code questId} doit être en COMPLETED. */
record QuestCompleted(String questId) implements Prerequisite {}
/** Le compteur de sessions de la campagne doit avoir atteint {@code minSessionNumber}. */
record SessionReached(int minSessionNumber) implements Prerequisite {}
/** Le flag campagne nommé {@code flagName} doit être à true. */
record FlagSet(String flagName) implements Prerequisite {}
}

View File

@@ -0,0 +1,65 @@
package com.loremind.domain.campaigncontext;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
* et en dérive le {@link QuestStatus} effectif.
*
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
* compile-time est compensée par le throw final qui fait crasher tout nouvel
* implémentant non câblé.
*/
public final class PrerequisiteEvaluator {
/**
* Contexte minimal nécessaire à l'évaluation. On ne passe pas la Campaign entière
* pour ne pas créer de couplage fort ; juste les faits nécessaires.
*/
public record EvaluationContext(
Set<String> completedQuestIds,
int currentSessionCount,
Map<String, Boolean> campaignFlags
) {}
/** True si TOUS les prérequis sont satisfaits (ET logique). Vide => true. */
public boolean areAllSatisfied(List<Prerequisite> prerequisites, EvaluationContext ctx) {
if (prerequisites == null || prerequisites.isEmpty()) return true;
return prerequisites.stream().allMatch(p -> isSatisfied(p, ctx));
}
/** Évalue un seul prérequis. */
public boolean isSatisfied(Prerequisite prereq, EvaluationContext ctx) {
if (prereq instanceof Prerequisite.QuestCompleted q) {
return ctx.completedQuestIds().contains(q.questId());
}
if (prereq instanceof Prerequisite.SessionReached s) {
return ctx.currentSessionCount() >= s.minSessionNumber();
}
if (prereq instanceof Prerequisite.FlagSet f) {
return Boolean.TRUE.equals(ctx.campaignFlags().get(f.flagName()));
}
throw new IllegalStateException("Prerequisite non géré : " + prereq.getClass().getName());
}
/** Dérive le statut effectif à partir de la progression manuelle + des prérequis. */
public QuestStatus computeStatus(
ProgressionStatus progression,
List<Prerequisite> prerequisites,
EvaluationContext ctx
) {
switch (progression) {
case COMPLETED: return QuestStatus.COMPLETED;
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
case NOT_STARTED:
return areAllSatisfied(prerequisites, ctx)
? QuestStatus.AVAILABLE
: QuestStatus.LOCKED;
default:
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.campaigncontext;
/**
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
*
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
* selon les prérequis — voir {@link QuestStatus}.
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
* COMPLETED : marquée terminée par le MJ.
*
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
*/
public enum ProgressionStatus {
NOT_STARTED,
IN_PROGRESS,
COMPLETED
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.campaigncontext;
/**
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
*
* Table de vérité :
* NOT_STARTED + prérequis non remplis -> LOCKED
* NOT_STARTED + prérequis remplis -> AVAILABLE
* IN_PROGRESS -> IN_PROGRESS
* COMPLETED -> COMPLETED
*/
public enum QuestStatus {
LOCKED,
AVAILABLE,
IN_PROGRESS,
COMPLETED
}

View File

@@ -0,0 +1,69 @@
package com.loremind.domain.campaigncontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* Pièce d'un lieu explorable (donjon, crypte…) attaché à une Scene.
*
* <p>Une Scene devient « explorable » dès qu'elle a au moins une Room. Tant
* qu'elle n'en a pas, elle se comporte comme un beat narratif classique.</p>
*
* <p>Pas un record Java parce que la liste {@code branches} est mutable côté
* builder ; on garde la classe Lombok pour la cohérence avec le reste du
* domaine (Arc, Chapter, Scene). L'ID est généré côté front (UUID) au moment
* de la création — pas d'auto-increment DB puisque c'est un Value Object
* sérialisé en JSONB sur Scene.</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Room {
/** ID stable (UUID généré côté client). Sert de cible aux {@link RoomBranch}. */
private String id;
/** Nom de la pièce (« Antichambre », « Salle du trône »). */
private String name;
/** Narration / description lue ou résumée aux joueurs en entrant. */
private String description;
/** Énemis, créatures, boss éventuels (markdown libre). */
private String enemies;
/** Loot / récompenses présentes dans la pièce. */
private String loot;
/** Pièges / dangers environnementaux. */
private String traps;
/** Notes privées du MJ (cachées des joueurs). */
private String gmNotes;
/** Étage / niveau de la pièce. 0 = rez-de-chaussée. Nullable = pas d'étage défini. */
private Integer floor;
/** Ordre d'affichage dans la liste (au sein d'un même étage le cas échéant). */
private int order;
/** IDs d'images d'illustration / ambiance. */
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/** ID de l'image « plan » de la pièce (1 image dédiée, schéma tactique). */
private String mapImageId;
/**
* Sorties vers d'autres pièces. {@link RoomBranch#targetRoomId()} doit pointer
* vers une Room de la même Scene.
*/
@Builder.Default
private List<RoomBranch> branches = new ArrayList<>();
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext;
/**
* Sortie d'une pièce vers une autre pièce de la même Scene explorable.
* Équivalent inter-pièces de {@link SceneBranch}.
*
* <p>Record Java immuable, sérialisé via Jackson dans la liste JSONB
* {@code rooms} de la Scene.</p>
*
* <p>Règle métier : {@code targetRoomId} doit pointer vers une Room de la
* MÊME Scene (validation côté service).</p>
*
* @param label Libellé visible (« Porte nord », « Trappe au sol »).
* @param targetRoomId ID stable de la Room de destination (UUID Room.id).
* @param condition Condition optionnelle (« si les PJ ont la clé en argent »).
*/
public record RoomBranch(String label, String targetRoomId, String condition) {
public static RoomBranch of(String label, String targetRoomId) {
return new RoomBranch(label, targetRoomId, null);
}
}

View File

@@ -72,6 +72,15 @@ public class Scene {
@Builder.Default @Builder.Default
private List<SceneBranch> branches = new ArrayList<>(); private List<SceneBranch> branches = new ArrayList<>();
/**
* Pièces du lieu explorable représenté par cette scène (donjon, crypte, manoir…).
* Vide => scène classique « beat narratif » (comportement inchangé).
* Non vide => la scène devient explorable, l'UI affiche un layout dédié pièce-par-pièce.
* Sérialisé en JSONB.
*/
@Builder.Default
private List<Room> rooms = new ArrayList<>();
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@@ -14,7 +14,7 @@ public interface CharacterRepository {
Optional<Character> findById(String id); Optional<Character> findById(String id);
List<Character> findByCampaignId(String campaignId); List<Character> findByPlaythroughId(String playthroughId);
void deleteById(String id); void deleteById(String id);

View File

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

View File

@@ -69,12 +69,13 @@ public record CampaignStructuralContext(
List<SceneSummary> scenes) { List<SceneSummary> scenes) {
} }
/** Résumé d'une scène : nom + description courte + branches narratives. */ /** Résumé d'une scène : nom + description courte + branches + pièces explorables. */
public record SceneSummary( public record SceneSummary(
String name, String name,
String description, String description,
int illustrationCount, int illustrationCount,
List<BranchHint> branches) { List<BranchHint> branches,
List<RoomSummary> rooms) {
} }
/** /**
@@ -86,4 +87,27 @@ public record CampaignStructuralContext(
*/ */
public record BranchHint(String label, String targetSceneName, String condition) { public record BranchHint(String label, String targetSceneName, String condition) {
} }
/**
* Pièce d'un lieu explorable (donjon, crypte). Projection volontairement plate
* pour le prompt IA : pas de notes MJ (jamais leakées dans le contexte campagne),
* la narration et les ennemis suffisent à camper la pièce.
*
* @param name Nom de la pièce.
* @param floor Étage (nullable).
* @param description Narration courte.
* @param enemies Ennemis (texte libre).
* @param branches Sorties vers d'autres pièces (noms résolus).
*/
public record RoomSummary(
String name,
Integer floor,
String description,
String enemies,
List<RoomBranchHint> branches) {
}
/** Indice d'une sortie entre pièces ; {@code targetRoomName} déjà résolu. */
public record RoomBranchHint(String label, String targetRoomName, String condition) {
}
} }

View File

@@ -36,7 +36,8 @@ public record ChatRequest(
PageContext pageContext, PageContext pageContext,
CampaignStructuralContext campaignContext, CampaignStructuralContext campaignContext,
NarrativeEntityContext narrativeEntity, NarrativeEntityContext narrativeEntity,
GameSystemContext gameSystemContext) { GameSystemContext gameSystemContext,
SessionContext sessionContext) {
public static Builder builder() { public static Builder builder() {
return new Builder(); return new Builder();
@@ -50,6 +51,7 @@ public record ChatRequest(
private CampaignStructuralContext campaignContext; private CampaignStructuralContext campaignContext;
private NarrativeEntityContext narrativeEntity; private NarrativeEntityContext narrativeEntity;
private GameSystemContext gameSystemContext; private GameSystemContext gameSystemContext;
private SessionContext sessionContext;
private Builder() {} private Builder() {}
@@ -83,9 +85,14 @@ public record ChatRequest(
return this; return this;
} }
public Builder sessionContext(SessionContext sessionContext) {
this.sessionContext = sessionContext;
return this;
}
public ChatRequest build() { public ChatRequest build() {
return new ChatRequest(messages, loreContext, pageContext, return new ChatRequest(messages, loreContext, pageContext,
campaignContext, narrativeEntity, gameSystemContext); campaignContext, narrativeEntity, gameSystemContext, sessionContext);
} }
} }
} }

View File

@@ -0,0 +1,58 @@
package com.loremind.domain.generationcontext;
import java.time.LocalDateTime;
import java.util.List;
/**
* Contexte structurel d'une Session de jeu — injecté dans le system prompt
* de l'IA pour qu'elle ait conscience de la partie en cours et de son journal.
*
* <p>Pendant qu'une session se joue, l'IA reçoit en plus du Lore/Campagne/GameSystem :
* le nom de la session, son statut, un résumé chronologique du journal,
* et — depuis l'ajout du mode Hub — l'état des quêtes ouvertes de la campagne et
* les flags actifs.</p>
*
* <p>Value Object du Generation Context — record Java immutable.</p>
*
* @param sessionName Nom de la session courante telle qu'affichée au MJ.
* @param active True si la session est en cours, false si terminée.
* @param startedAt Horodatage de démarrage de la session courante.
* @param entries Entrées du journal de la session courante (cap côté builder).
* @param previousEvents Évènements marquants des sessions précédentes (continuité narrative).
* @param availableQuests Quêtes Hub actuellement débloquées et non démarrées.
* @param inProgressQuests Quêtes Hub en cours.
* @param lockedQuestTitles Titres des quêtes Hub verrouillées — uniquement le titre
* pour signaler leur existence sans spoiler.
* @param activeFlags Noms des flags de campagne à true.
*/
public record SessionContext(
String sessionName,
boolean active,
LocalDateTime startedAt,
List<JournalEntrySummary> entries,
List<JournalEntrySummary> previousEvents,
List<QuestSummary> availableQuests,
List<QuestSummary> inProgressQuests,
List<String> lockedQuestTitles,
List<String> activeFlags) {
/**
* Résumé d'une entrée de journal — type + contenu + horodatage + (optionnel) session source.
* {@code sourceSessionName} renseigné uniquement pour les évènements issus de sessions
* précédentes, pour aider l'IA à les ancrer temporellement.
*/
public record JournalEntrySummary(
String type,
String content,
LocalDateTime occurredAt,
String sourceSessionName) {}
/**
* Résumé d'une quête (= Chapter dans un Arc HUB) telle qu'exposée à l'IA.
* On omet volontairement les notes MJ : pas de fuite côté prompt.
*/
public record QuestSummary(
String name,
String arcName,
String description) {}
}

View File

@@ -0,0 +1,48 @@
package com.loremind.domain.licensing;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
/**
* Licence Patreon installee dans cette instance LoreMind.
* <p>
* Singleton (une seule licence par instance, identifiee logiquement par
* {@code id = "current"}). Contient le JWT brut emis par le relais OAuth
* + les claims extraits a la verification, plus l'etat operationnel
* (derniere tentative de refresh, succes/echec).
* <p>
* <b>Note securite :</b> {@link #rawJwt} est stocke tel quel ; sa signature
* Ed25519 est verifiee a chaque lecture. Pas besoin de chiffrement au repos
* supplementaire — un attaquant qui a acces a la base a deja l'instance,
* et le JWT ne donne aucun pouvoir au-dela du canal beta de cette instance.
*/
@Data
@Builder
public class License {
private String id;
private String rawJwt;
private String patreonUserId;
private String tierId;
private String instanceId;
private Instant issuedAt;
private Instant expiresAt;
private Instant lastRefreshAttemptAt;
private boolean lastRefreshSucceeded;
private boolean betaChannelEnabled;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Claims extraits d'un JWT licence apres verification de signature.
* Immuable.
*/
public record LicenseClaims(
String subject,
String tierId,
String instanceId,
Instant issuedAt,
Instant expiresAt
) {}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Vue immuable de la licence pour exposition vers les couches superieures.
* Decouple le domaine du DTO web et permet de calculer le {@link LicenseStatus}
* a un instant donne sans muter l'entite.
*/
public record LicenseSnapshot(
LicenseStatus status,
String patreonUserId,
String tierId,
String instanceId,
Instant expiresAt,
Instant lastRefreshAttemptAt,
boolean lastRefreshSucceeded,
boolean betaChannelEnabled
) {
public static LicenseSnapshot none() {
return new LicenseSnapshot(LicenseStatus.NONE, null, null, null, null, null, false, false);
}
}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.licensing;
/**
* Etat operationnel de la licence vis-a-vis de l'acces beta.
* <p>
* Calcule a partir de la presence de licence + son JWT exp + grace period.
* <ul>
* <li>{@link #NONE} : aucune licence installee</li>
* <li>{@link #VALID} : JWT non expire, acces beta autorise</li>
* <li>{@link #GRACE} : JWT expire mais dans la periode de tolerance ;
* acces beta toujours autorise, l'UI doit avertir</li>
* <li>{@link #EXPIRED} : au-dela de la grace period, acces beta refuse</li>
* <li>{@link #UNVERIFIABLE} : JWT impossible a verifier (cle publique manquante,
* signature invalide, claims malformes) — traite comme NONE pour la securite</li>
* </ul>
*/
public enum LicenseStatus {
NONE,
VALID,
GRACE,
EXPIRED,
UNVERIFIABLE
}

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Credentials de pull pour un registry Docker, distribues par le relais
* apres verification d'un JWT licence valide.
* <p>
* {@code expiresAt} peut etre {@code null} si le credential est statique
* (cas du PAT GHCR partage en MVP) ; sinon, l'instance doit re-demander
* de nouveaux credentials avant cette date.
*/
public record RegistryCredentials(
String registry,
String username,
String password,
Instant expiresAt
) {}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.RegistryCredentials;
import java.io.IOException;
/**
* Port de sortie : ecriture du docker config.json partage avec Watchtower.
* <p>
* Le fichier sert a Watchtower pour s'authentifier au registry prive (GHCR)
* lors du pull des images du canal beta. Volume Docker {@code docker-config}
* monte sur Core (en ecriture) et sur Watchtower (en lecture, via la variable
* {@code DOCKER_CONFIG}).
*/
public interface DockerConfigWriter {
/**
* Ecrit ou met a jour les credentials pour le registry indique.
* Cree le fichier s'il n'existe pas, conserve les autres registries deja
* presents (en theorie : aucun, mais defensif).
*/
void writeCredentials(RegistryCredentials credentials) throws IOException;
/**
* Supprime le fichier de credentials. Appele quand la licence est invalidee
* ou que le toggle beta passe a OFF.
*/
void clear() throws IOException;
/**
* @return true si le fichier de creds existe actuellement.
*/
boolean isPresent();
}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.LicenseClaims;
/**
* Port de sortie : verification de signature et extraction des claims
* d'un JWT emis par le relais.
* <p>
* Implemente cote infrastructure avec la cle publique Ed25519 embarquee
* (SPKI PEM via configuration {@code licensing.jwt.public-key}).
*/
public interface JwtVerifier {
/**
* Verifie la signature, l'issuer, l'audience et l'expiration du JWT.
* @throws JwtVerificationException si la signature est invalide ou les claims malformes
*/
LicenseClaims verify(String rawJwt) throws JwtVerificationException;
/**
* @return true si la cle publique est configuree et utilisable.
* Permet a l'application de masquer la feature licensing si pas configuree.
*/
boolean isConfigured();
class JwtVerificationException extends Exception {
public JwtVerificationException(String message) {
super(message);
}
public JwtVerificationException(String message, Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -0,0 +1,59 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.RegistryCredentials;
/**
* Port de sortie vers le service relais OAuth Patreon.
* Encapsule les appels HTTP : refresh JWT et fetch registry credentials.
*/
public interface LicenseRelay {
/**
* Demande au relais l'URL OAuth a ouvrir pour connecter le compte Patreon.
*/
String buildConnectUrl(String instanceId);
/**
* Demande au relais de renouveler un JWT existant. Le relais re-verifie
* le tier Patreon de l'utilisateur ; renvoie un nouveau JWT si toujours
* actif, ou leve {@link RelayException} sinon.
*/
String refreshToken(String currentJwt) throws RelayException;
/**
* Demande au relais les credentials de pull du registry beta.
*/
RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException;
/**
* Erreurs distinctes emises par le relais. Permet au service application
* de differencier "tier expire" (action utilisateur) de "relais down"
* (action transitoire, garde la grace period).
*/
class RelayException extends Exception {
private final RelayErrorKind kind;
public RelayException(RelayErrorKind kind, String message) {
super(message);
this.kind = kind;
}
public RelayException(RelayErrorKind kind, String message, Throwable cause) {
super(message, cause);
this.kind = kind;
}
public RelayErrorKind getKind() {
return kind;
}
}
enum RelayErrorKind {
/** Le relais est joignable mais refuse : tier non actif, JWT trop ancien, etc. */
REJECTED,
/** Le relais a renvoye un JWT mais il est invalide / non parsable. */
BAD_RESPONSE,
/** Le relais est injoignable / 5xx / timeout. */
TRANSIENT
}
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.License;
import java.util.Optional;
/**
* Port de sortie pour la persistance de la licence installee.
* <p>
* Une seule licence par instance ({@code id = "current"} par convention).
*/
public interface LicenseRepository {
Optional<License> findCurrent();
License save(License license);
void deleteCurrent();
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.playcontext;
/**
* Type d'entrée du journal de session.
* Permet à l'UI de catégoriser visuellement la timeline (icône, couleur).
*/
public enum EntryType {
/** Note libre du MJ (défaut). */
NOTE,
/** Moment marquant du scénario (combat gagné, décision majeure...). */
EVENT,
/** Jet de dés / test de caractéristique. */
DICE_ROLL,
/** Action déclarée par un joueur. */
PLAYER_ACTION
}

View File

@@ -0,0 +1,36 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Instance jouée d'une Campagne par une table donnée.
*
* <p>Sépare clairement le SCÉNARIO (Campaign : arcs, chapitres, prérequis) de
* l'ÉTAT DE JEU d'une table précise (progression des quêtes, flags narratifs,
* sessions tenues, PJ). Permet à plusieurs tables de jouer la même campagne
* indépendamment.</p>
*
* <p>Fait partie du Play Context. Référence la Campagne par weak reference
* (campaignId) pour respecter les Bounded Contexts.</p>
*/
@Data
@Builder
public class Playthrough {
private String id;
/** Weak reference vers la Campagne (le scénario joué). */
private String campaignId;
/** Nom donné par le MJ à cette partie (ex. : "Table du vendredi"). */
private String name;
/** Notes libres sur la partie / la table — facultatif. */
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,29 @@
package com.loremind.domain.playcontext;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import lombok.Builder;
import lombok.Data;
/**
* État de progression d'une quête (Chapter) pour un Playthrough donné.
*
* <p>Remplace l'ancien champ {@code Chapter.progressionStatus} qui mélangeait
* le scénario et l'état de jeu : ici, la progression est exclusivement
* propre à une instance jouée (Playthrough).</p>
*
* <p>Référence le Chapter par weak reference (chapterId) pour respecter les
* Bounded Contexts. Le type {@link ProgressionStatus} reste défini dans
* Campaign Context (c'est un Value Object générique, partageable).</p>
*
* <p>Sémantique : l'absence de ligne dans le repo équivaut à NOT_STARTED.
* On ne persiste donc que les transitions explicites IN_PROGRESS / COMPLETED.</p>
*/
@Data
@Builder
public class QuestProgression {
private String id;
private String playthroughId;
private String chapterId;
private ProgressionStatus status;
}

View File

@@ -0,0 +1,40 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entité de domaine représentant une Session de jeu (une soirée).
*
* <p>Une Session appartient à un {@link Playthrough} (une instance jouée d'une
* campagne par une table). Un Playthrough a typiquement plusieurs sessions
* dans le temps ; la progression et les flags persistent entre elles via le
* Playthrough parent.</p>
*
* <p>{@code endedAt == null} signifie que la session est en cours.
* Une seule session peut être en cours dans l'application à la fois.</p>
*/
@Data
@Builder
public class Session {
private String id;
private String name;
/** Weak reference vers le Playthrough parent. */
private String playthroughId;
private LocalDateTime startedAt;
/** Null = session en cours ; renseigné = session terminée. */
private LocalDateTime endedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public boolean isActive() {
return this.endedAt == null;
}
}

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entrée du journal d'une Session.
* Représente un évènement horodaté capturé pendant ou après une partie :
* note libre du MJ, évènement marquant, jet de dés, action de joueur.
*
* <p>Fait partie du Play Context. Référence la Session par weak reference
* (sessionId) — l'orchestration en cascade est gérée par le service applicatif.</p>
*/
@Data
@Builder
public class SessionEntry {
private String id;
/** Weak reference vers Session (intra-contexte mais reste découplée). */
private String sessionId;
private EntryType type;
/** Contenu texte brut saisi par le MJ. */
private String content;
/**
* Horodatage métier de l'évènement.
* Distinct de {@code createdAt} : utile si le MJ rédige a posteriori
* une note rétroactive sur quelque chose qui s'est passé plus tôt.
*/
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.playcontext.ports;
import java.util.Map;
/**
* Port de sortie pour les flags narratifs d'un Playthrough.
*
* <p>Anciennement {@code CampaignFlagRepository} : les flags suivent maintenant
* une partie (Playthrough), pas un scénario (Campaign).</p>
*/
public interface PlaythroughFlagRepository {
Map<String, Boolean> findByPlaythroughId(String playthroughId);
void setFlag(String playthroughId, String name, boolean value);
void deleteFlag(String playthroughId, String name);
void deleteAllByPlaythroughId(String playthroughId);
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.Playthrough;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Playthroughs (parties jouées).
*/
public interface PlaythroughRepository {
Playthrough save(Playthrough playthrough);
Optional<Playthrough> findById(String id);
List<Playthrough> findByCampaignId(String campaignId);
List<Playthrough> findAll();
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -0,0 +1,31 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.playcontext.QuestProgression;
import java.util.List;
import java.util.Set;
/**
* Port de sortie pour la persistance des progressions de quêtes d'un Playthrough.
*
* <p>Modèle "absence = NOT_STARTED" : on ne stocke que les transitions
* explicites IN_PROGRESS / COMPLETED.</p>
*/
public interface QuestProgressionRepository {
/** Liste toutes les progressions explicites d'un Playthrough. */
List<QuestProgression> findByPlaythroughId(String playthroughId);
/** Set des IDs de chapitres en COMPLETED pour un Playthrough donné (fast path éval). */
Set<String> findCompletedChapterIdsByPlaythroughId(String playthroughId);
/**
* Crée ou met à jour le statut d'une quête pour un Playthrough.
* Si {@code status == NOT_STARTED}, la ligne est supprimée (sémantique "absence").
*/
void setStatus(String playthroughId, String chapterId, ProgressionStatus status);
/** Supprime toutes les progressions d'un Playthrough (cascade applicative). */
void deleteAllByPlaythroughId(String playthroughId);
}

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.SessionEntry;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des entrées de journal de session.
*/
public interface SessionEntryRepository {
SessionEntry save(SessionEntry entry);
Optional<SessionEntry> findById(String id);
/** Renvoie les entrées d'une session, triées par occurredAt croissant (chronologique). */
List<SessionEntry> findBySessionId(String sessionId);
void deleteById(String id);
/** Supprime toutes les entrées d'une session — utilisé pour la cascade à la suppression. */
void deleteBySessionId(String sessionId);
boolean existsById(String id);
}

View File

@@ -0,0 +1,30 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.Session;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Sessions.
*/
public interface SessionRepository {
Session save(Session session);
Optional<Session> findById(String id);
List<Session> findAll();
List<Session> findByPlaythroughId(String playthroughId);
/** Retourne UNE session active dans l'app (sémantique « legacy » — multi-actives possibles). */
Optional<Session> findActive();
/** Retourne la session active du Playthrough donné, s'il y en a une. */
Optional<Session> findActiveByPlaythroughId(String playthroughId);
void deleteById(String id);
boolean existsById(String id);
}

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,9 @@ import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext; import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -58,9 +61,72 @@ public class BrainChatPayloadBuilder {
if (request.gameSystemContext() != null) { if (request.gameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext())); root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
} }
if (request.sessionContext() != null) {
root.put("session_context", sessionContextToMap(request.sessionContext()));
}
return root; return root;
} }
private Map<String, Object> sessionContextToMap(SessionContext sc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("session_name", sc.sessionName());
map.put("active", sc.active());
if (sc.startedAt() != null) {
map.put("started_at", sc.startedAt().toString());
}
map.put("entries", sc.entries() != null
? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList())
: List.of());
// Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session).
if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) {
map.put("previous_events", sc.previousEvents().stream()
.map(this::journalEntryToMap)
.collect(Collectors.toList()));
}
// État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas
// saturer le prompt sur les campagnes sans Hub.
if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) {
map.put("available_quests", sc.availableQuests().stream()
.map(this::questSummaryToMap)
.collect(Collectors.toList()));
}
if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) {
map.put("in_progress_quests", sc.inProgressQuests().stream()
.map(this::questSummaryToMap)
.collect(Collectors.toList()));
}
if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) {
map.put("locked_quest_titles", sc.lockedQuestTitles());
}
if (sc.activeFlags() != null && !sc.activeFlags().isEmpty()) {
map.put("active_flags", sc.activeFlags());
}
return map;
}
private Map<String, Object> questSummaryToMap(QuestSummary q) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", q.name());
map.put("arc_name", q.arcName());
if (q.description() != null && !q.description().isBlank()) {
map.put("description", q.description());
}
return map;
}
private Map<String, Object> journalEntryToMap(JournalEntrySummary e) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("type", e.type());
map.put("content", e.content());
if (e.occurredAt() != null) {
map.put("occurred_at", e.occurredAt().toString());
}
if (e.sourceSessionName() != null && !e.sourceSessionName().isBlank()) {
map.put("source_session_name", e.sourceSessionName());
}
return map;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) { private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.systemName()); map.put("system_name", gs.systemName());
@@ -215,6 +281,12 @@ public class BrainChatPayloadBuilder {
.map(this::branchHintToMap) .map(this::branchHintToMap)
.collect(Collectors.toList())); .collect(Collectors.toList()));
} }
// Pièces du lieu explorable : omises si scène classique.
if (s.rooms() != null && !s.rooms().isEmpty()) {
map.put("rooms", s.rooms().stream()
.map(this::roomSummaryToMap)
.collect(Collectors.toList()));
}
}); });
} }
@@ -228,6 +300,24 @@ public class BrainChatPayloadBuilder {
return map; return map;
} }
private Map<String, Object> roomSummaryToMap(com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary r) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", r.name());
if (r.floor() != null) map.put("floor", r.floor());
if (r.description() != null && !r.description().isBlank()) map.put("description", r.description());
if (r.enemies() != null && !r.enemies().isBlank()) map.put("enemies", r.enemies());
if (r.branches() != null && !r.branches().isEmpty()) {
map.put("branches", r.branches().stream().map(b -> {
Map<String, Object> bm = new LinkedHashMap<>();
bm.put("label", b.label());
bm.put("target_room_name", b.targetRoomName());
if (b.condition() != null && !b.condition().isBlank()) bm.put("condition", b.condition());
return bm;
}).collect(Collectors.toList()));
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) { private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.entityType()); map.put("entity_type", ne.entityType());

View File

@@ -0,0 +1,111 @@
package com.loremind.infrastructure.licensing;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.DockerConfigWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Base64;
/**
* Implementation : ecriture du fichier {@code config.json} au format Docker
* standard, dans un volume partage avec Watchtower.
* <p>
* Format produit :
* <pre>{@code
* {
* "auths": {
* "ghcr.io": {
* "auth": "<base64(username:password)>"
* }
* }
* }
* }</pre>
*/
@Component
public class FileDockerConfigWriter implements DockerConfigWriter {
private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class);
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
public FileDockerConfigWriter(
@Value("${licensing.docker-config-path:/shared/docker/config.json}") String pathStr) {
this.configPath = Path.of(pathStr);
}
@Override
public void writeCredentials(RegistryCredentials credentials) throws IOException {
ensureParentDirectory();
ObjectNode root;
if (Files.exists(configPath)) {
try {
JsonNode existing = mapper.readTree(configPath.toFile());
root = existing.isObject() ? (ObjectNode) existing : mapper.createObjectNode();
} catch (IOException e) {
log.warn("Existing docker config unreadable, overwriting: {}", e.getMessage());
root = mapper.createObjectNode();
}
} else {
root = mapper.createObjectNode();
}
ObjectNode auths = root.has("auths") && root.get("auths").isObject()
? (ObjectNode) root.get("auths")
: root.putObject("auths");
String b64 = Base64.getEncoder().encodeToString(
(credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8));
ObjectNode entry = mapper.createObjectNode();
entry.put("auth", b64);
auths.set(credentials.registry(), entry);
Files.writeString(configPath, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root),
StandardCharsets.UTF_8);
applyRestrictivePermissions();
log.info("Docker config written at {} for registry {}", configPath, credentials.registry());
}
@Override
public void clear() throws IOException {
if (Files.exists(configPath)) {
Files.delete(configPath);
log.info("Docker config cleared at {}", configPath);
}
}
@Override
public boolean isPresent() {
return Files.exists(configPath);
}
private void ensureParentDirectory() throws IOException {
Path parent = configPath.getParent();
if (parent != null && !Files.exists(parent)) {
Files.createDirectories(parent);
}
}
/** 0600 sur POSIX. Sur Windows (dev), no-op silencieux. */
private void applyRestrictivePermissions() {
try {
Files.setPosixFilePermissions(configPath, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException | IOException e) {
// Windows / FS qui ne supporte pas POSIX => ignore (le conteneur tourne sous Linux en prod)
}
}
}

View File

@@ -0,0 +1,146 @@
package com.loremind.infrastructure.licensing;
import com.fasterxml.jackson.databind.JsonNode;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.LicenseRelay;
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.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClientException;
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.Map;
/**
* Client HTTP du relais OAuth Patreon (deploye sur Cloudflare Workers).
* Voir {@code relay/} pour le code du relais.
*/
@Component
public class HttpLicenseRelay implements LicenseRelay {
private static final Logger log = LoggerFactory.getLogger(HttpLicenseRelay.class);
private final RestTemplate http;
private final String baseUrl;
public HttpLicenseRelay(
RestTemplateBuilder builder,
@Value("${licensing.relay.base-url:}") String baseUrl) {
this.http = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15))
.build();
this.baseUrl = stripTrailingSlash(baseUrl);
}
@Override
public String buildConnectUrl(String instanceId) {
if (baseUrl.isBlank()) {
throw new IllegalStateException("Licensing relay base URL not configured");
}
String encoded = URLEncoder.encode(instanceId, StandardCharsets.UTF_8);
return baseUrl + "/oauth/start?instance_id=" + encoded;
}
@Override
public String refreshToken(String currentJwt) throws RelayException {
if (baseUrl.isBlank()) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> body = Map.of("jwt", currentJwt);
ResponseEntity<JsonNode> resp;
try {
resp = http.exchange(
baseUrl + "/token/refresh",
HttpMethod.POST,
new HttpEntity<>(body, headers),
JsonNode.class);
} catch (HttpClientErrorException e) {
throw new RelayException(RelayErrorKind.REJECTED,
"relay rejected refresh: " + e.getStatusCode() + " " + e.getStatusText());
} catch (HttpServerErrorException e) {
throw new RelayException(RelayErrorKind.TRANSIENT,
"relay 5xx: " + e.getStatusCode());
} catch (RestClientException e) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
}
JsonNode payload = resp.getBody();
if (payload == null || !payload.hasNonNull("jwt")) {
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "missing jwt in refresh response");
}
return payload.get("jwt").asText();
}
@Override
public RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException {
if (baseUrl.isBlank()) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> body = Map.of("jwt", currentJwt);
ResponseEntity<JsonNode> resp;
try {
resp = http.exchange(
baseUrl + "/registry/credentials",
HttpMethod.POST,
new HttpEntity<>(body, headers),
JsonNode.class);
} catch (HttpClientErrorException e) {
throw new RelayException(RelayErrorKind.REJECTED,
"relay rejected creds: " + e.getStatusCode() + " " + e.getStatusText());
} catch (HttpServerErrorException e) {
throw new RelayException(RelayErrorKind.TRANSIENT,
"relay 5xx: " + e.getStatusCode());
} catch (RestClientException e) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
}
JsonNode payload = resp.getBody();
if (payload == null
|| !payload.hasNonNull("registry")
|| !payload.hasNonNull("username")
|| !payload.hasNonNull("password")) {
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "incomplete credentials response");
}
Instant expiresAt = null;
if (payload.hasNonNull("expires_at")) {
try {
expiresAt = Instant.parse(payload.get("expires_at").asText());
} catch (Exception e) {
log.warn("Cannot parse expires_at from relay creds response: {}", e.getMessage());
}
}
return new RegistryCredentials(
payload.get("registry").asText(),
payload.get("username").asText(),
payload.get("password").asText(),
expiresAt
);
}
private static String stripTrailingSlash(String s) {
if (s == null) return "";
String v = s.trim();
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
return v;
}
}

View File

@@ -0,0 +1,93 @@
package com.loremind.infrastructure.licensing;
import com.loremind.application.licensing.LicenseService;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.DockerConfigWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Optional;
/**
* Daemon planifie qui :
* <ul>
* <li>renouvelle le JWT licence via le relais avant expiration (J-2)</li>
* <li>met a jour les credentials registry GHCR pour Watchtower
* (volume partage docker-config) tant que le canal beta est ON</li>
* <li>nettoie les credentials si la licence est invalidee ou le toggle OFF</li>
* </ul>
* Idempotent : peut tourner toutes les 6h sans risque, fait du no-op
* la plupart du temps.
*/
@Component
public class LicenseRefreshDaemon {
private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class);
/** 6 heures entre chaque cycle. Suffisant pour rattraper un J-2 sans surcharger. */
private static final long FIXED_DELAY_MS = 6L * 60L * 60L * 1000L;
/** Premier run apres 30s pour laisser le contexte Spring se stabiliser. */
private static final long INITIAL_DELAY_MS = 30_000L;
private final LicenseService licenseService;
private final DockerConfigWriter dockerConfigWriter;
public LicenseRefreshDaemon(LicenseService licenseService,
DockerConfigWriter dockerConfigWriter) {
this.licenseService = licenseService;
this.dockerConfigWriter = dockerConfigWriter;
}
@Scheduled(initialDelay = INITIAL_DELAY_MS, fixedDelay = FIXED_DELAY_MS)
public void tick() {
if (!licenseService.isLicensingEnabled()) {
return;
}
try {
licenseService.refreshIfNeeded();
syncDockerConfig();
} catch (Exception e) {
log.error("LicenseRefreshDaemon tick failed: {}", e.getMessage(), e);
}
}
/**
* Aligne le fichier docker config avec l'etat de la licence et le toggle :
* <ul>
* <li>VALID/GRACE + beta ON -> ecrit/refresh les creds</li>
* <li>tout autre cas -> efface le fichier</li>
* </ul>
*/
private void syncDockerConfig() {
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
boolean shouldHaveCreds = snap.betaChannelEnabled()
&& (snap.status() == LicenseStatus.VALID || snap.status() == LicenseStatus.GRACE);
if (!shouldHaveCreds) {
try {
if (dockerConfigWriter.isPresent()) {
dockerConfigWriter.clear();
}
} catch (IOException e) {
log.warn("Cannot clear docker config: {}", e.getMessage());
}
return;
}
Optional<RegistryCredentials> creds = licenseService.fetchRegistryCredentials();
if (creds.isEmpty()) {
log.warn("Beta enabled but cannot fetch registry credentials (relay down or rejected)");
return;
}
try {
dockerConfigWriter.writeCredentials(creds.get());
} catch (IOException e) {
log.error("Cannot write docker config: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,188 @@
package com.loremind.infrastructure.licensing;
import com.loremind.domain.licensing.LicenseClaims;
import com.loremind.domain.licensing.ports.JwtVerifier;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.Ed25519Verifier;
import com.nimbusds.jose.jwk.OctetKeyPair;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.Base64;
import java.util.Date;
/**
* Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon.
* <p>
* La cle publique est fournie en PEM SPKI via la propriete
* {@code licensing.jwt.public-key} (env {@code LICENSING_JWT_PUBLIC_KEY}).
* Si la cle est absente ou invalide, {@link #isConfigured()} retourne false
* et {@link #verify} echoue systematiquement — la feature licensing est
* desactivee silencieusement.
*/
@Component
public class NimbusJwtVerifier implements JwtVerifier {
private static final Logger log = LoggerFactory.getLogger(NimbusJwtVerifier.class);
private final String expectedIssuer;
private final String expectedAudience;
private final OctetKeyPair publicKey;
public NimbusJwtVerifier(
@Value("${licensing.jwt.public-key:}") String publicKeyPemFromEnv,
@Value("${licensing.jwt.expected-issuer:loremind-auth}") String expectedIssuer,
@Value("${licensing.jwt.expected-audience:loremind-instance}") String expectedAudience) {
this.expectedIssuer = expectedIssuer;
this.expectedAudience = expectedAudience;
// Strategie : env var en priorite (rotation possible sans rebuild),
// sinon ressource classpath embarquee dans le binaire.
String pem = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank())
? publicKeyPemFromEnv
: loadEmbeddedKey();
this.publicKey = parsePemSpki(pem);
if (publicKey == null) {
log.info("Licensing JWT verifier disabled (no public key found)");
} else {
String source = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank()) ? "env" : "embedded";
log.info("Licensing JWT verifier enabled (issuer={}, audience={}, key source={})",
expectedIssuer, expectedAudience, source);
}
}
/**
* Charge la cle publique embarquee dans le binaire (resource classpath).
* Le fichier est un PEM SPKI standard, fourni a la build pour chaque
* release. Si absent, la feature licensing est desactivee.
*/
private static String loadEmbeddedKey() {
ClassPathResource resource = new ClassPathResource("licensing/jwt-public-key.pem");
if (!resource.exists()) {
return null;
}
try (InputStream in = resource.getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
log.warn("Cannot read embedded JWT public key: {}", e.getMessage());
return null;
}
}
@Override
public boolean isConfigured() {
return publicKey != null;
}
@Override
public LicenseClaims verify(String rawJwt) throws JwtVerificationException {
if (publicKey == null) {
throw new JwtVerificationException("JWT verifier not configured");
}
if (rawJwt == null || rawJwt.isBlank()) {
throw new JwtVerificationException("JWT is empty");
}
SignedJWT signed;
try {
signed = SignedJWT.parse(rawJwt);
} catch (ParseException e) {
throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e);
}
JWSAlgorithm alg = signed.getHeader().getAlgorithm();
if (!JWSAlgorithm.EdDSA.equals(alg)) {
throw new JwtVerificationException("Unexpected JWT algorithm: " + alg);
}
try {
JWSVerifier verifier = new Ed25519Verifier(publicKey);
if (!signed.verify(verifier)) {
throw new JwtVerificationException("JWT signature invalid");
}
} catch (Exception e) {
throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e);
}
JWTClaimsSet claims;
try {
claims = signed.getJWTClaimsSet();
} catch (ParseException e) {
throw new JwtVerificationException("JWT claims parse error", e);
}
if (!expectedIssuer.equals(claims.getIssuer())) {
throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer());
}
if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) {
throw new JwtVerificationException("JWT audience mismatch");
}
Date exp = claims.getExpirationTime();
Date iat = claims.getIssueTime();
String sub = claims.getSubject();
if (exp == null || iat == null || sub == null) {
throw new JwtVerificationException("JWT missing required claims");
}
// Note : on ne refuse pas un JWT expire ici. C'est au LicenseService
// de decider ce qu'il fait d'un JWT expire (grace period, refresh, etc.).
// La verification de signature reste valide tant que la cle existe.
String tierId;
String instanceId;
try {
tierId = claims.getStringClaim("tier_id");
instanceId = claims.getStringClaim("instance_id");
} catch (ParseException e) {
throw new JwtVerificationException("JWT custom claim parse error", e);
}
if (tierId == null || tierId.isBlank() || instanceId == null || instanceId.isBlank()) {
throw new JwtVerificationException("JWT missing tier_id or instance_id");
}
return new LicenseClaims(
sub,
tierId,
instanceId,
iat.toInstant(),
exp.toInstant()
);
}
/**
* Parse une cle publique Ed25519 au format PEM SPKI vers un Nimbus
* {@link OctetKeyPair} (forme JWK utilisee pour la verification).
*/
private static OctetKeyPair parsePemSpki(String pem) {
if (pem == null || pem.isBlank()) return null;
try {
String base64 = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s+", "");
byte[] der = Base64.getDecoder().decode(base64);
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Sequence.fromByteArray(der));
byte[] keyBytes = spki.getPublicKeyData().getOctets();
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);
return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x))
.build();
} catch (IOException | IllegalArgumentException e) {
log.warn("Cannot parse licensing JWT public key: {}", e.getMessage());
return null;
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Prerequisite;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Convertit une List<Prerequisite> (sealed type du domaine) en JSON stocké en base.
* <p>
* On utilise un discriminant {@code kind} explicite pour éviter de polluer le domaine
* avec des annotations Jackson (@JsonTypeInfo / @JsonSubTypes). Le format on-disk est
* stable et indépendant des noms de classes Java.
* <p>
* Format JSON :
* [
* {"kind": "QUEST_COMPLETED", "questId": "42"},
* {"kind": "SESSION_REACHED", "minSessionNumber": 3},
* {"kind": "FLAG_SET", "flagName": "forgeron_rencontre"}
* ]
*/
@Converter
public class PrerequisiteListJsonConverter implements AttributeConverter<List<Prerequisite>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String KIND = "kind";
private static final String KIND_QUEST_COMPLETED = "QUEST_COMPLETED";
private static final String KIND_SESSION_REACHED = "SESSION_REACHED";
private static final String KIND_FLAG_SET = "FLAG_SET";
@Override
public String convertToDatabaseColumn(List<Prerequisite> attribute) {
if (attribute == null || attribute.isEmpty()) return "[]";
try {
List<Map<String, Object>> raw = new ArrayList<>(attribute.size());
for (Prerequisite p : attribute) {
raw.add(toMap(p));
}
return MAPPER.writeValueAsString(raw);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<Prerequisite> → JSON", e);
}
}
@Override
public List<Prerequisite> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return new ArrayList<>();
try {
List<Map<String, Object>> raw = MAPPER.readValue(dbData, new TypeReference<>() {});
List<Prerequisite> result = new ArrayList<>(raw.size());
for (Map<String, Object> m : raw) {
result.add(fromMap(m));
}
return result;
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → List<Prerequisite>", e);
}
}
private Map<String, Object> toMap(Prerequisite p) {
Map<String, Object> m = new HashMap<>();
if (p instanceof Prerequisite.QuestCompleted q) {
m.put(KIND, KIND_QUEST_COMPLETED);
m.put("questId", q.questId());
} else if (p instanceof Prerequisite.SessionReached s) {
m.put(KIND, KIND_SESSION_REACHED);
m.put("minSessionNumber", s.minSessionNumber());
} else if (p instanceof Prerequisite.FlagSet f) {
m.put(KIND, KIND_FLAG_SET);
m.put("flagName", f.flagName());
} else {
throw new IllegalStateException("Prerequisite non géré : " + p.getClass().getName());
}
return m;
}
private Prerequisite fromMap(Map<String, Object> m) {
String kind = String.valueOf(m.get(KIND));
switch (kind) {
case KIND_QUEST_COMPLETED:
return new Prerequisite.QuestCompleted(String.valueOf(m.get("questId")));
case KIND_SESSION_REACHED:
Object n = m.get("minSessionNumber");
return new Prerequisite.SessionReached(((Number) n).intValue());
case KIND_FLAG_SET:
return new Prerequisite.FlagSet(String.valueOf(m.get("flagName")));
default:
throw new IllegalStateException("Kind Prerequisite inconnu : " + kind);
}
}
}

View File

@@ -0,0 +1,43 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Room;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
/**
* Convertit la liste de pièces explorables d'une Scene en JSON pour la persistance.
*
* <p>Une Room contient elle-même une liste de {@link com.loremind.domain.campaigncontext.RoomBranch}
* (record Java) ; Jackson 2.12+ sait sérialiser/désérialiser les records nativement
* via le constructeur canonique, donc rien de spécial à faire pour les branches.</p>
*/
@Converter
public class RoomListJsonConverter implements AttributeConverter<List<Room>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<Room> attribute) {
if (attribute == null || attribute.isEmpty()) return "[]";
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException("Erreur sérialisation List<Room> → JSON", e);
}
}
@Override
public List<Room> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return Collections.emptyList();
try {
return MAPPER.readValue(dbData, new TypeReference<>() {});
} catch (Exception e) {
throw new IllegalStateException("Erreur désérialisation JSON → List<Room>", e);
}
}
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
@@ -37,6 +38,15 @@ public class ArcJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
/**
* Type structurel de l'arc (LINEAR par défaut).
* Stocké en STRING pour rester lisible en DB et résistant aux refactos d'ordre.
*/
@Enumerated(EnumType.STRING)
@Column(nullable = false, columnDefinition = "VARCHAR(16) DEFAULT 'LINEAR'")
@Builder.Default
private ArcType type = ArcType.LINEAR;
@Column @Column
private String icon; private String icon;

View File

@@ -1,5 +1,7 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
@@ -37,6 +39,16 @@ public class ChapterJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
/**
* Conditions de déblocage (combinées en ET). Sérialisées en JSON dans une colonne TEXT
* via {@link PrerequisiteListJsonConverter} (sealed type côté domaine).
* Donnée de SCÉNARIO — l'état réel est sur Playthrough.QuestProgression.
*/
@Column(name = "prerequisites", columnDefinition = "TEXT")
@Convert(converter = PrerequisiteListJsonConverter.class)
@Builder.Default
private List<Prerequisite> prerequisites = new ArrayList<>();
@Column @Column
private String icon; private String icon;

View File

@@ -1,5 +1,8 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -7,11 +10,18 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** /**
* Entité JPA pour les fiches de personnages (PJ) d'une campagne. * Entité JPA pour les fiches de personnages (PJ).
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte : * <p>
* on reste dans le Campaign Context, mais l'agrégat Character est autonome). * Refonte 2026-04-30 : ancien champ markdownContent migré vers {@code values["Notes"]}
* via Hibernate au demarrage. Hibernate ddl-auto=update ajoute les nouvelles colonnes
* sans dropper {@code markdown_content} — les donnees existantes sont conservees mais
* plus mappees au domaine. Migration manuelle prevue (script SQL one-shot) si le
* deploiement passe en bluegreen.
*/ */
@Entity @Entity
@Table(name = "characters") @Table(name = "characters")
@@ -28,12 +38,38 @@ public class CharacterJpaEntity {
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT") @Column(name = "portrait_image_id")
private String markdownContent; private String portraitImageId;
@Column(name = "campaign_id", nullable = false) @Column(name = "header_image_id")
private String headerImageId;
/** Valeurs TEXT/NUMBER serialisees JSON. */
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
/** Valeurs IMAGE serialisees JSON. */
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST serialisees JSON (fieldName -> label -> value). */
@Convert(converter = StringMapMapJsonConverter.class)
@Column(name = "key_value_values", columnDefinition = "TEXT")
private Map<String, Map<String, String>> keyValueValues;
/**
* Ancienne référence directe vers la Campaign. Conservée nullable pour la
* migration ; le code utilise playthrough_id désormais.
*/
@Column(name = "campaign_id")
private Long campaignId; private Long campaignId;
/** ID du Playthrough (partie) auquel ce PJ appartient. Weak ref cross-context. */
@Column(name = "playthrough_id")
private Long playthroughId;
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@@ -47,6 +83,9 @@ public class CharacterJpaEntity {
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>();
} }
@PreUpdate @PreUpdate

View File

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

View File

@@ -0,0 +1,72 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* Entite JPA pour la licence Patreon installee.
* <p>
* Singleton : une seule ligne par instance (id = "current"). Ce design permet
* de ne jamais avoir de licence "fantome" en base et de simplifier les queries.
*/
@Entity
@Table(name = "licenses")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LicenseJpaEntity {
@Id
private String id;
@Column(name = "raw_jwt", columnDefinition = "TEXT", nullable = false)
private String rawJwt;
@Column(name = "patreon_user_id", nullable = false)
private String patreonUserId;
@Column(name = "tier_id", nullable = false)
private String tierId;
@Column(name = "instance_id", nullable = false)
private String instanceId;
@Column(name = "issued_at", nullable = false)
private Instant issuedAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "last_refresh_attempt_at")
private Instant lastRefreshAttemptAt;
@Column(name = "last_refresh_succeeded", nullable = false)
private boolean lastRefreshSucceeded;
@Column(name = "beta_channel_enabled", nullable = false)
private boolean betaChannelEnabled;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
Instant now = Instant.now();
if (createdAt == null) createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
}

View File

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

View File

@@ -0,0 +1,42 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Entité JPA pour les flags narratifs d'un Playthrough.
*
* <p>Remplace l'ancienne {@code CampaignFlagJpaEntity} : les flags suivent
* désormais la partie (table jouée), pas le scénario.</p>
*/
@Entity
@Table(
name = "playthrough_flag",
uniqueConstraints = @UniqueConstraint(
name = "uk_playthrough_flag_name",
columnNames = {"playthrough_id", "name"}
),
indexes = @Index(name = "ix_playthrough_flag_playthrough", columnList = "playthrough_id")
)
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PlaythroughFlagJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "playthrough_id", nullable = false)
private Long playthroughId;
@Column(nullable = false, length = 128)
private String name;
@Column(nullable = false)
private boolean value;
}

View File

@@ -0,0 +1,54 @@
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 table {@code playthroughs} — instances jouées d'une Campagne.
*/
@Entity
@Table(
name = "playthroughs",
indexes = @Index(name = "ix_playthrough_campaign", columnList = "campaign_id")
)
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PlaythroughJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@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,44 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Entité JPA pour la progression d'une quête (Chapter) au sein d'un Playthrough.
*
* <p>Contrainte d'unicité sur (playthrough_id, chapter_id) : une seule ligne par
* couple quête × partie. L'absence de ligne = NOT_STARTED.</p>
*/
@Entity
@Table(
name = "quest_progression",
uniqueConstraints = @UniqueConstraint(
name = "uk_quest_progression_unique",
columnNames = {"playthrough_id", "chapter_id"}
),
indexes = @Index(name = "ix_quest_progression_playthrough", columnList = "playthrough_id")
)
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QuestProgressionJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "playthrough_id", nullable = false)
private Long playthroughId;
@Column(name = "chapter_id", nullable = false)
private Long chapterId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private ProgressionStatus status;
}

View File

@@ -1,6 +1,8 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.infrastructure.persistence.converter.RoomListJsonConverter;
import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter; import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
@@ -95,6 +97,15 @@ public class SceneJpaEntity {
@Builder.Default @Builder.Default
private List<SceneBranch> branches = new ArrayList<>(); private List<SceneBranch> branches = new ArrayList<>();
/**
* Pièces explorables de cette scène (donjon, crypte…). Vide = scène classique.
* Sérialisé en JSON dans une colonne TEXT.
*/
@Column(name = "rooms", columnDefinition = "TEXT")
@Convert(converter = RoomListJsonConverter.class)
@Builder.Default
private List<Room> rooms = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@@ -0,0 +1,60 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.playcontext.EntryType;
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 entrées de journal de session.
*/
@Entity
@Table(name = "session_entries", indexes = {
@Index(name = "idx_session_entries_session_id", columnList = "session_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionEntryJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Weak reference — pas de FK DB pour rester cohérent avec le reste du projet. */
@Column(name = "session_id", nullable = false)
private String sessionId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private EntryType type;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@Column(name = "occurred_at", nullable = false)
private LocalDateTime occurredAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,69 @@
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 Sessions en PostgreSQL.
* Adaptateur d'infrastructure — n'est PAS dans le domaine.
*/
@Entity
@Table(name = "sessions")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
/**
* Ancienne référence directe vers la Campaign. Conservée (nullable) pour la
* rétrocompatibilité de la migration, mais plus utilisée par le code.
* À supprimer manuellement quand toutes les sessions auront leur playthrough_id.
*/
@Column(name = "campaign_id")
private String campaignId;
/**
* ID du Playthrough (partie) auquel cette session appartient.
* Weak reference inter-contexte.
*/
@Column(name = "playthrough_id")
private Long playthroughId;
@Column(name = "started_at", nullable = false)
private LocalDateTime startedAt;
/** Null = session en cours. */
@Column(name = "ended_at")
private LocalDateTime endedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

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

View File

@@ -9,5 +9,5 @@ import java.util.List;
@Repository @Repository
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> { public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
List<CharacterJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
} }

View File

@@ -0,0 +1,9 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LicenseJpaRepository extends JpaRepository<LicenseJpaEntity, String> {
}

View File

@@ -0,0 +1,25 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.PlaythroughFlagJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Repository
public interface PlaythroughFlagJpaRepository extends JpaRepository<PlaythroughFlagJpaEntity, Long> {
List<PlaythroughFlagJpaEntity> findByPlaythroughId(Long playthroughId);
Optional<PlaythroughFlagJpaEntity> findByPlaythroughIdAndName(Long playthroughId, String name);
@Modifying
@Transactional
@Query("DELETE FROM PlaythroughFlagJpaEntity f WHERE f.playthroughId = :playthroughId")
void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId);
}

View File

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

View File

@@ -0,0 +1,25 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.QuestProgressionJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Repository
public interface QuestProgressionJpaRepository extends JpaRepository<QuestProgressionJpaEntity, Long> {
List<QuestProgressionJpaEntity> findByPlaythroughId(Long playthroughId);
Optional<QuestProgressionJpaEntity> findByPlaythroughIdAndChapterId(Long playthroughId, Long chapterId);
@Modifying
@Transactional
@Query("DELETE FROM QuestProgressionJpaEntity q WHERE q.playthroughId = :playthroughId")
void deleteByPlaythroughId(@Param("playthroughId") Long playthroughId);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface SessionEntryJpaRepository extends JpaRepository<SessionEntryJpaEntity, Long> {
List<SessionEntryJpaEntity> findBySessionIdOrderByOccurredAtAsc(String sessionId);
void deleteBySessionId(String sessionId);
}

View File

@@ -0,0 +1,21 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.SessionJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* Repository Spring Data JPA pour SessionJpaEntity.
*/
@Repository
public interface SessionJpaRepository extends JpaRepository<SessionJpaEntity, Long> {
List<SessionJpaEntity> findByPlaythroughIdOrderByStartedAtDesc(Long playthroughId);
Optional<SessionJpaEntity> findFirstByEndedAtIsNull();
Optional<SessionJpaEntity> findFirstByPlaythroughIdAndEndedAtIsNull(Long playthroughId);
}

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