Compare commits

..

3 Commits

Author SHA1 Message Date
587e0fe097 passage 1.0.1-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m58s
Build & Push Images / build (core) (push) Successful in 3m39s
Build & Push Images / build-switcher (push) Successful in 20s
2026-07-06 15:25:48 +02:00
5c3c58265f Première fournée correction dette technique.
Refactoring du code pour améliorer la lisibilité de ce dernier coté core
Correction sur l'export / import : les images n'étaient pas bien liées à la campagne lors de l'export / import et provoquait un bug d'affichage des images
lorsqu'on voulait ouvrir une campagne importée.
2026-07-06 14:39:55 +02:00
f72161e41e Correction sur l'export PDF : problème d'échappement de caractères qui faisait péter un test unitaire
Some checks failed
Build & Push Images / build (core) (push) Successful in 3m45s
Build & Push Images / build (web) (push) Successful in 1m53s
E2E Tests / e2e (push) Failing after 9m2s
Build & Push Images / build-switcher (push) Successful in 42s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m18s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 18s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / build (brain) (push) Successful in 1m13s
2026-07-04 11:42:18 +02:00
247 changed files with 1885 additions and 1259 deletions

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
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="1.0.0", version="1.0.1-beta",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

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

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -169,8 +169,8 @@ public class ArcService {
@Transactional @Transactional
public void reorderArcs(List<String> orderedIds) { public void reorderArcs(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> arcRepository.findById(id).orElse(null), arcRepository::findById,
(arc, i) -> arc.setOrder(i), Arc::setOrder,
arcRepository::save); arcRepository::save);
} }
} }

View File

@@ -41,6 +41,16 @@ public class CampaignBriefBuilder {
sb.append("# Campagne : ").append(cc.campaignName()).append("\n"); sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
appendStructure(sb, cc);
appendNpcs(sb, cc);
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendStructure(StringBuilder sb, CampaignStructuralContext cc) {
sb.append("\n## Structure (arcs → chapitres → scènes)\n"); sb.append("\n## Structure (arcs → chapitres → scènes)\n");
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ") sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
.append("un arc LINEAR contient des chapitres en séquence._\n"); .append("un arc LINEAR contient des chapitres en séquence._\n");
@@ -48,11 +58,21 @@ public class CampaignBriefBuilder {
sb.append("_(aucun arc pour le moment)_\n"); sb.append("_(aucun arc pour le moment)_\n");
} }
for (ArcSummary arc : cc.arcs()) { for (ArcSummary arc : cc.arcs()) {
appendArc(sb, arc);
}
}
private void appendArc(StringBuilder sb, ArcSummary arc) {
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name()); sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description()); if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n"); sb.append("\n");
for (ChapterSummary ch : arc.chapters()) { for (ChapterSummary ch : arc.chapters()) {
sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name()); appendChapter(sb, arc.hub(), ch);
}
}
private void appendChapter(StringBuilder sb, boolean hub, ChapterSummary ch) {
sb.append(hub ? "- Quête : " : "- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description()); if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n"); sb.append("\n");
for (SceneSummary sc : ch.scenes()) { for (SceneSummary sc : ch.scenes()) {
@@ -61,9 +81,9 @@ public class CampaignBriefBuilder {
sb.append("\n"); sb.append("\n");
} }
} }
}
if (!cc.npcs().isEmpty()) { private void appendNpcs(StringBuilder sb, CampaignStructuralContext cc) {
if (cc.npcs().isEmpty()) return;
sb.append("\n## PNJ existants\n"); sb.append("\n## PNJ existants\n");
for (NpcSummary n : cc.npcs()) { for (NpcSummary n : cc.npcs()) {
sb.append("- ").append(n.name()); sb.append("- ").append(n.name());
@@ -72,12 +92,6 @@ public class CampaignBriefBuilder {
} }
} }
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendLore(StringBuilder sb, LoreStructuralContext lore) { private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n"); sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n"); if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");

View File

@@ -0,0 +1,40 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Component;
/**
* Formateur de contexte campagne pour les prompts IA.
* Centralise la construction du bloc "nom + description + système de jeu".
*/
@Component
public class CampaignContextFormatter {
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
public CampaignContextFormatter(CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
}
/** Contexte compact : nom de campagne + description + système de jeu. */
public String format(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
}
}

View File

@@ -1,17 +1,17 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.CampaignImportProgress; import com.loremind.domain.campaigncontext.generation.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.NpcProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.NpcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Room; import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter; import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -83,14 +83,32 @@ public class CampaignImportService {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId); throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
} }
int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0; int arcsCreated = 0;
int chaptersCreated = 0;
int scenesCreated = 0;
// Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés // Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés
// via leur existingId dans l'arbre fusionné venu de la revue). // via leur existingId dans l'arbre fusionné venu de la revue).
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId); int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
for (ArcProposal arcP : proposal.arcs()) { for (ArcProposal arcP : proposal.arcs()) {
if (isBlank(arcP.name())) continue; if (isBlank(arcP.name())) continue;
ArcOutcome outcome = applyArc(campaignId, arcP, arcOrder);
arcOrder = outcome.nextOrder();
arcsCreated += outcome.arcsCreated();
chaptersCreated += outcome.chaptersCreated();
scenesCreated += outcome.scenesCreated();
}
int npcsCreated = createNpcs(campaignId, proposal.npcs());
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
}
/** Ordre d'arc suivant + compteurs créés (arc et, en cascade, ses chapitres/scènes). */
private record ArcOutcome(int nextOrder, int arcsCreated, int chaptersCreated, int scenesCreated) {}
private ArcOutcome applyArc(String campaignId, ArcProposal arcP, int arcOrder) {
String arcId; String arcId;
int arcsCreated = 0;
if (!isBlank(arcP.existingId())) { if (!isBlank(arcP.existingId())) {
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
} else { } else {
@@ -103,13 +121,28 @@ public class CampaignImportService {
.type(parseArcType(arcP.type())) .type(parseArcType(arcP.type()))
.build()); .build());
arcId = arc.getId(); arcId = arc.getId();
arcsCreated++; arcsCreated = 1;
} }
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId); int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
int chaptersCreated = 0;
int scenesCreated = 0;
for (ChapterProposal chapP : safe(arcP.chapters())) { for (ChapterProposal chapP : safe(arcP.chapters())) {
if (isBlank(chapP.name())) continue; if (isBlank(chapP.name())) continue;
ChapterOutcome outcome = applyChapter(arcId, chapP, chapterOrder);
chapterOrder = outcome.nextOrder();
chaptersCreated += outcome.chaptersCreated();
scenesCreated += outcome.scenesCreated();
}
return new ArcOutcome(arcOrder, arcsCreated, chaptersCreated, scenesCreated);
}
/** Ordre de chapitre suivant + compteurs créés (chapitre et, en cascade, ses scènes). */
private record ChapterOutcome(int nextOrder, int chaptersCreated, int scenesCreated) {}
private ChapterOutcome applyChapter(String arcId, ChapterProposal chapP, int chapterOrder) {
String chapId; String chapId;
int chaptersCreated = 0;
if (!isBlank(chapP.existingId())) { if (!isBlank(chapP.existingId())) {
chapId = chapP.existingId(); chapId = chapP.existingId();
} else { } else {
@@ -117,13 +150,13 @@ public class CampaignImportService {
Chapter chapter = chapterService.createChapter( Chapter chapter = chapterService.createChapter(
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder); chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
chapId = chapter.getId(); chapId = chapter.getId();
chaptersCreated++; chaptersCreated = 1;
} }
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId); int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
int scenesCreated = 0;
for (SceneProposal sceneP : safe(chapP.scenes())) { for (SceneProposal sceneP : safe(chapP.scenes())) {
if (isBlank(sceneP.name())) continue; if (isBlank(sceneP.name()) || !isBlank(sceneP.existingId())) continue; // vide ou déjà présente
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
sceneOrder++; sceneOrder++;
sceneService.createScene(Scene.builder() sceneService.createScene(Scene.builder()
.name(sceneP.name().trim()) .name(sceneP.name().trim())
@@ -136,11 +169,7 @@ public class CampaignImportService {
.build()); .build());
scenesCreated++; scenesCreated++;
} }
} return new ChapterOutcome(chapterOrder, chaptersCreated, scenesCreated);
}
int npcsCreated = createNpcs(campaignId, proposal.npcs());
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
} }
/** /**
@@ -182,7 +211,8 @@ public class CampaignImportService {
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */ /** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
private static ArcType parseArcType(String type) { private static ArcType parseArcType(String type) {
return "HUB".equalsIgnoreCase(type == null ? "" : type.trim()) ? ArcType.HUB : ArcType.LINEAR; String normalized = type == null ? "" : type.trim();
return "HUB".equalsIgnoreCase(normalized) ? ArcType.HUB : ArcType.LINEAR;
} }
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */ /** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */

View File

@@ -1,6 +1,6 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.ReadinessStatus; import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;

View File

@@ -1,20 +1,19 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Enemy; import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.campaigncontext.NodeType; import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.quest.Prerequisite;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.ReadinessEntityType; import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.ReadinessSeverity; import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
import com.loremind.domain.campaigncontext.ReadinessStatus; import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
import com.loremind.domain.campaigncontext.Room; import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.RoomBranch; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.SceneBranch;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -55,6 +54,8 @@ import java.util.stream.Collectors;
@Service @Service
public class CampaignReadinessService { public class CampaignReadinessService {
private static final String SCENE_FALLBACK_NAME = "Scène";
private final CampaignRepository campaignRepository; private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
@@ -88,11 +89,6 @@ public class CampaignReadinessService {
Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream() Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream()
.map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet()); .map(Enemy::getId).filter(id -> !isBlank(id)).collect(Collectors.toSet());
// Index global chapitres / scènes (cibles possibles des nœuds de quête).
Set<String> allChapterIds = new HashSet<>();
Set<String> allSceneIds = new HashSet<>();
int totalScenes = 0;
List<Quest> quests = questRepository.findByCampaignId(campaignId); List<Quest> quests = questRepository.findByCampaignId(campaignId);
// Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides // Arcs HUB « portés » par ≥1 quête rattachée : ne comptent PAS comme vides
// (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres). // (un arc HUB contient des quêtes, un arc LINÉAIRE des chapitres).
@@ -102,20 +98,52 @@ public class CampaignReadinessService {
List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId)); List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId));
arcs.sort(Comparator.comparingInt(Arc::getOrder)); arcs.sort(Comparator.comparingInt(Arc::getOrder));
// Index global chapitres / scènes (cibles possibles des nœuds de quête).
Set<String> allChapterIds = new HashSet<>();
Set<String> allSceneIds = new HashSet<>();
int totalScenes = 0;
for (Arc arc : arcs) { for (Arc arc : arcs) {
ArcScan scan = checkArc(arc, arcsWithQuests, enemyIds, gaps);
allChapterIds.addAll(scan.chapterIds());
allSceneIds.addAll(scan.sceneIds());
totalScenes += scan.sceneCount();
}
// Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat).
if (totalScenes == 0 && !anyQuestHasNodes(quests)) {
gaps.add(new ReadinessGap(ReadinessEntityType.CAMPAIGN, campaignId, campaignName,
"CAMP-001-NO-CONTENT",
"Campagne vide : ajoutez un arc avec une scène, ou créez une quête, pour commencer à jouer.",
ReadinessSeverity.BLOCKING, null, null));
}
Set<String> questIds = quests.stream()
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet());
for (Quest quest : quests) {
checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps);
}
return aggregate(campaignId, gaps);
}
/** Résultat du scan d'un arc : chapitres/scènes indexés (cibles des nœuds de quête) + total scènes. */
private record ArcScan(Set<String> chapterIds, Set<String> sceneIds, int sceneCount) {}
private ArcScan checkArc(Arc arc, Set<String> arcsWithQuests, Set<String> enemyIds, List<ReadinessGap> gaps) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId()); List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
// Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la // Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la
// plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs). // plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs).
boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId()); boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId());
if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) { if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) {
String msg = arc.getType() == ArcType.HUB gaps.add(emptyArcGap(arc));
? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le."
: "Arc vide : ajoutez un chapitre, ou supprimez-le.";
gaps.add(new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
"ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null));
} }
Set<String> chapterIds = new HashSet<>();
Set<String> sceneIds = new HashSet<>();
int sceneCount = 0;
for (Chapter chapter : chapters) { for (Chapter chapter : chapters) {
allChapterIds.add(chapter.getId()); chapterIds.add(chapter.getId());
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()); List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
if (scenes.isEmpty()) { if (scenes.isEmpty()) {
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(), gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
@@ -125,59 +153,66 @@ public class CampaignReadinessService {
} }
Set<String> chapterSceneIds = scenes.stream() Set<String> chapterSceneIds = scenes.stream()
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet()); .map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
totalScenes += scenes.size(); sceneCount += scenes.size();
for (Scene scene : scenes) { for (Scene scene : scenes) {
allSceneIds.add(scene.getId()); sceneIds.add(scene.getId());
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps); checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
} }
} }
return new ArcScan(chapterIds, sceneIds, sceneCount);
} }
Set<String> questIds = quests.stream() private ReadinessGap emptyArcGap(Arc arc) {
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet()); String msg = arc.getType() == ArcType.HUB
? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le."
// Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat). : "Arc vide : ajoutez un chapitre, ou supprimez-le.";
boolean anyQuestWithNodes = quests.stream() return new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
.anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty()); "ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null);
if (totalScenes == 0 && !anyQuestWithNodes) {
gaps.add(new ReadinessGap(ReadinessEntityType.CAMPAIGN, campaignId, campaignName,
"CAMP-001-NO-CONTENT",
"Campagne vide : ajoutez un arc avec une scène, ou créez une quête, pour commencer à jouer.",
ReadinessSeverity.BLOCKING, null, null));
} }
for (Quest quest : quests) { private static boolean anyQuestHasNodes(List<Quest> quests) {
checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps); return quests.stream().anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
}
return aggregate(campaignId, gaps);
} }
private void checkScene(Scene scene, String arcId, String chapterId, private void checkScene(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) { Set<String> chapterSceneIds, Set<String> enemyIds, List<ReadinessGap> gaps) {
String name = labelOr(scene.getName(), "Scène"); checkSceneName(scene, arcId, chapterId, gaps);
checkSceneBranches(scene, arcId, chapterId, chapterSceneIds, gaps);
checkSceneCombat(scene, arcId, chapterId, enemyIds, gaps);
checkSceneEnemyRefs(scene, arcId, chapterId, enemyIds, gaps);
checkSceneRooms(scene, arcId, chapterId, enemyIds, gaps);
}
// SCENE-001 — scène sans titre. /** SCENE-001 — scène sans titre. */
private void checkSceneName(Scene scene, String arcId, String chapterId, List<ReadinessGap> gaps) {
if (isBlank(scene.getName())) { if (isBlank(scene.getName())) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME", gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME",
"Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.", "Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.",
ReadinessSeverity.BLOCKING)); ReadinessSeverity.BLOCKING));
} }
}
// SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence). /** SCENE-010 — branche de sortie cassée (vide / hors chapitre / auto-référence). */
private void checkSceneBranches(Scene scene, String arcId, String chapterId,
Set<String> chapterSceneIds, List<ReadinessGap> gaps) {
List<SceneBranch> branches = scene.getBranches(); List<SceneBranch> branches = scene.getBranches();
if (branches != null && branches.stream().anyMatch(b -> if (branches == null) return;
boolean invalid = branches.stream().anyMatch(b ->
isBlank(b.targetSceneId()) isBlank(b.targetSceneId())
|| b.targetSceneId().equals(scene.getId()) || b.targetSceneId().equals(scene.getId())
|| !chapterSceneIds.contains(b.targetSceneId()))) { || !chapterSceneIds.contains(b.targetSceneId()));
if (invalid) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID", gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-010-BRANCH-INVALID",
"Branche cassée : une sortie de « " + name "Branche cassée : une sortie de « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
+ " » pointe dans le vide, hors du chapitre, ou sur elle-même.", + " » pointe dans le vide, hors du chapitre, ou sur elle-même.",
ReadinessSeverity.BLOCKING)); ReadinessSeverity.BLOCKING));
} }
}
// SCENE-011 — combat annoncé sans adversaire (règle produit clé). /** SCENE-011 — combat annoncé sans adversaire (règle produit clé). */
if (!isBlank(scene.getCombatDifficulty())) { private void checkSceneCombat(Scene scene, String arcId, String chapterId,
Set<String> enemyIds, List<ReadinessGap> gaps) {
if (isBlank(scene.getCombatDifficulty())) return;
boolean hasEnemyText = !isBlank(scene.getEnemies()); boolean hasEnemyText = !isBlank(scene.getEnemies());
boolean hasResolvedEnemy = scene.getEnemyIds() != null boolean hasResolvedEnemy = scene.getEnemyIds() != null
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id)); && scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id));
@@ -188,53 +223,53 @@ public class CampaignReadinessService {
} }
} }
// SCENE-012 — référence d'ennemi cassée (fiche supprimée). /** SCENE-012 — référence d'ennemi cassée (fiche supprimée). */
if (scene.getEnemyIds() != null private void checkSceneEnemyRefs(Scene scene, String arcId, String chapterId,
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id))) { Set<String> enemyIds, List<ReadinessGap> gaps) {
if (scene.getEnemyIds() == null) return;
boolean broken = scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
if (broken) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN", gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-012-ENEMY-REF-BROKEN",
"Ennemi introuvable : « " + name "Ennemi introuvable : « " + labelOr(scene.getName(), SCENE_FALLBACK_NAME)
+ " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.", + " » référence une fiche du bestiaire supprimée. Retirez la référence ou recréez la fiche.",
ReadinessSeverity.RECOMMENDED)); ReadinessSeverity.RECOMMENDED));
} }
}
// SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes. /** SCENE-041 / SCENE-042 — pièces explorables : portes cassées + ennemis fantômes. */
private void checkSceneRooms(Scene scene, String arcId, String chapterId,
Set<String> enemyIds, List<ReadinessGap> gaps) {
List<Room> rooms = scene.getRooms(); List<Room> rooms = scene.getRooms();
if (rooms != null && !rooms.isEmpty()) { if (rooms == null || rooms.isEmpty()) return;
String name = labelOr(scene.getName(), SCENE_FALLBACK_NAME);
Set<String> roomIds = rooms.stream() Set<String> roomIds = rooms.stream()
.map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet()); .map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet());
boolean roomBranchInvalid = false;
boolean roomEnemyBroken = false; if (rooms.stream().anyMatch(room -> hasInvalidBranch(room, roomIds))) {
for (Room room : rooms) {
if (room.getBranches() != null) {
for (RoomBranch rb : room.getBranches()) {
if (isBlank(rb.targetRoomId())
|| rb.targetRoomId().equals(room.getId())
|| !roomIds.contains(rb.targetRoomId())) {
roomBranchInvalid = true;
}
}
}
if (room.getEnemyIds() != null) {
for (String id : room.getEnemyIds()) {
if (!isBlank(id) && !enemyIds.contains(id)) {
roomEnemyBroken = true;
}
}
}
}
if (roomBranchInvalid) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID", gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID",
"Porte cassée : dans « " + name "Porte cassée : dans « " + name
+ " », une sortie de pièce pointe hors de la scène ou dans le vide.", + " », une sortie de pièce pointe hors de la scène ou dans le vide.",
ReadinessSeverity.BLOCKING)); ReadinessSeverity.BLOCKING));
} }
if (roomEnemyBroken) { if (rooms.stream().anyMatch(room -> hasBrokenEnemyRef(room, enemyIds))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN", gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN",
"Ennemi introuvable dans une pièce de « " + name "Ennemi introuvable dans une pièce de « " + name
+ " » : la référence pointe vers une fiche supprimée.", + " » : la référence pointe vers une fiche supprimée.",
ReadinessSeverity.RECOMMENDED)); ReadinessSeverity.RECOMMENDED));
} }
} }
private static boolean hasInvalidBranch(Room room, Set<String> roomIds) {
if (room.getBranches() == null) return false;
return room.getBranches().stream().anyMatch(rb ->
isBlank(rb.targetRoomId())
|| rb.targetRoomId().equals(room.getId())
|| !roomIds.contains(rb.targetRoomId()));
}
private static boolean hasBrokenEnemyRef(Room room, Set<String> enemyIds) {
if (room.getEnemyIds() == null) return false;
return room.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id));
} }
private void checkQuest(Quest quest, Set<String> allChapterIds, Set<String> allSceneIds, private void checkQuest(Quest quest, Set<String> allChapterIds, Set<String> allSceneIds,
@@ -244,8 +279,7 @@ public class CampaignReadinessService {
// QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide. // QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide.
if (quest.getNodes() == null || quest.getNodes().isEmpty()) { if (quest.getNodes() == null || quest.getNodes().isEmpty()) {
gaps.add(questGap(quest, "QUEST-001-NO-NODES", gaps.add(questGap(quest, "QUEST-001-NO-NODES",
"Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " ».", "Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " »."));
ReadinessSeverity.BLOCKING));
} else if (quest.getNodes().stream().anyMatch(n -> } else if (quest.getNodes().stream().anyMatch(n ->
n.nodeType() == null n.nodeType() == null
|| isBlank(n.nodeId()) || isBlank(n.nodeId())
@@ -253,8 +287,7 @@ public class CampaignReadinessService {
|| (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) { || (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) {
gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN", gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN",
"Nœud de quête cassé : dans « " + name "Nœud de quête cassé : dans « " + name
+ " », un chapitre ou une scène référencé n'existe plus.", + " », un chapitre ou une scène référencé n'existe plus."));
ReadinessSeverity.BLOCKING));
} }
// CAMP-010 — prérequis QuestCompleted pointant une quête disparue. // CAMP-010 — prérequis QuestCompleted pointant une quête disparue.
@@ -264,8 +297,7 @@ public class CampaignReadinessService {
.anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) { .anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) {
gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ", gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ",
"Prérequis cassé : « " + name "Prérequis cassé : « " + name
+ " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage.", + " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage."));
ReadinessSeverity.BLOCKING));
} }
} }
@@ -294,12 +326,12 @@ public class CampaignReadinessService {
private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId, private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId,
String ruleId, String message, ReadinessSeverity severity) { String ruleId, String message, ReadinessSeverity severity) {
return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(), return new ReadinessGap(ReadinessEntityType.SCENE, scene.getId(),
labelOr(scene.getName(), "Scène"), ruleId, message, severity, arcId, chapterId); labelOr(scene.getName(), SCENE_FALLBACK_NAME), ruleId, message, severity, arcId, chapterId);
} }
private ReadinessGap questGap(Quest quest, String ruleId, String message, ReadinessSeverity severity) { private ReadinessGap questGap(Quest quest, String ruleId, String message) {
return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(), return new ReadinessGap(ReadinessEntityType.QUEST, quest.getId(),
labelOr(quest.getName(), "Quête"), ruleId, message, severity, null, null); labelOr(quest.getName(), "Quête"), ruleId, message, ReadinessSeverity.BLOCKING, null, null);
} }
private static int severityRank(ReadinessSeverity severity) { private static int severityRank(ReadinessSeverity severity) {

View File

@@ -1,7 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.quest.Prerequisite;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.QuestRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.application.playcontext.PlaythroughService; import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.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;

View File

@@ -1,7 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -136,7 +136,7 @@ public class ChapterService {
@Transactional @Transactional
public void reorderChapters(String arcId, List<String> orderedIds) { public void reorderChapters(String arcId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> chapterRepository.findById(id).orElse(null), chapterRepository::findById,
(chapter, i) -> { (chapter, i) -> {
if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId); if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId);
chapter.setOrder(i); chapter.setOrder(i);

View File

@@ -1,6 +1,6 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.bestiary.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;
@@ -41,9 +41,9 @@ public class CharacterService {
.name(data.name()) .name(data.name())
.portraitImageId(data.portraitImageId()) .portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId()) .headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()) .values(copyStringMap(data.values()))
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()) .imageValues(copyStringListMap(data.imageValues()))
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()) .keyValueValues(copyStringMapMap(data.keyValueValues()))
.playthroughId(data.playthroughId()) .playthroughId(data.playthroughId())
.order(order) .order(order)
.build(); .build();
@@ -64,9 +64,9 @@ public class CharacterService {
existing.setName(data.name()); existing.setName(data.name());
existing.setPortraitImageId(data.portraitImageId()); existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId()); existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()); existing.setValues(copyStringMap(data.values()));
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()); existing.setImageValues(copyStringListMap(data.imageValues()));
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()); existing.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }
@@ -89,4 +89,16 @@ public class CharacterService {
.max() .max()
.orElse(-1) + 1; .orElse(-1) + 1;
} }
private static Map<String, String> copyStringMap(Map<String, String> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, List<String>> copyStringListMap(Map<String, List<String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, Map<String, String>> copyStringMapMap(Map<String, Map<String, String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
} }

View File

@@ -1,6 +1,6 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy; import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.EnemyRepository; import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.images.Image; import com.loremind.domain.images.Image;
@@ -10,11 +10,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.util.Base64; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** /**
* Service d'application pour les fiches d'ennemis (bestiaire de campagne). * Service d'application pour les fiches d'ennemis (bestiaire de campagne).
@@ -48,18 +44,10 @@ public class EnemyService {
public Enemy createEnemy(EnemyData data) { public Enemy createEnemy(EnemyData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId()); int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
Enemy enemy = Enemy.builder() Enemy enemy = Enemy.builder().build();
.name(data.name()) applyCommonEnemyFields(enemy, data);
.level(normalize(data.level())) enemy.setCampaignId(data.campaignId());
.folder(normalize(data.folder())) enemy.setOrder(order);
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
return enemyRepository.save(enemy); return enemyRepository.save(enemy);
} }
@@ -74,17 +62,11 @@ public class EnemyService {
public Enemy updateEnemy(String id, EnemyData data) { public Enemy updateEnemy(String id, EnemyData data) {
Enemy existing = enemyRepository.findById(id) Enemy existing = enemyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
existing.setName(data.name()); applyCommonEnemyFields(existing, data);
existing.setLevel(normalize(data.level()));
existing.setFolder(normalize(data.folder()));
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 immuable après création.
return enemyRepository.save(existing); return enemyRepository.save(existing);
} }
@@ -100,7 +82,7 @@ public class EnemyService {
public void reorderEnemies(String folder, List<String> orderedIds) { public void reorderEnemies(String folder, List<String> orderedIds) {
String f = normalize(folder); String f = normalize(folder);
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> enemyRepository.findById(id).orElse(null), enemyRepository::findById,
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); }, (enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
enemyRepository::save); enemyRepository::save);
} }
@@ -141,9 +123,7 @@ public class EnemyService {
return null; return null;
} }
if (bytes.length == 0) return null; if (bytes.length == 0) return null;
String ext = contentType.contains("png") ? "png" String ext = pickExtension(contentType);
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
: contentType.contains("gif") ? "gif" : "webp";
// Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non // Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non
// standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg"). // standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg").
String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext; String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext;
@@ -158,6 +138,14 @@ public class EnemyService {
} }
} }
/** Extension déduite du content-type ; "webp" par défaut si non reconnu. */
private static String pickExtension(String contentType) {
if (contentType.contains("png")) return "png";
if (contentType.contains("jpeg") || contentType.contains("jpg")) return "jpg";
if (contentType.contains("gif")) return "gif";
return "webp";
}
public record MonsterImportResult(int created, int updated) {} public record MonsterImportResult(int created, int updated) {}
/** /**
@@ -174,44 +162,49 @@ public class EnemyService {
} }
int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1; int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1;
int created = 0, updated = 0; int created = 0;
int updated = 0;
for (MonsterImport m : monsters) { for (MonsterImport m : monsters) {
if (m.foundryRef() == null || m.foundryRef().isBlank() if (isBlank(m.foundryRef()) || isBlank(m.name())) continue;
|| m.name() == null || m.name().isBlank()) {
continue;
}
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>(); Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
String folder = foundryFolder(m.folder());
Enemy ex = byRef.get(m.foundryRef()); Enemy ex = byRef.get(m.foundryRef());
if (ex != null) { if (ex != null) {
updateMonster(ex, m, stats);
updated++;
} else {
byRef.put(m.foundryRef(), createMonster(campaignId, m, stats, order++));
created++;
}
}
return new MonsterImportResult(created, updated);
}
/** Met à jour un monstre déjà importé : nom, snapshot de stats, dossier, portrait manquant. */
private void updateMonster(Enemy ex, MonsterImport m, Map<String, String> stats) {
ex.setName(m.name()); ex.setName(m.name());
ex.setFoundryStats(stats); // rafraîchit le snapshot ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(folder); // ré-aligne sur l'arborescence Foundry ex.setFolder(foundryFolder(m.folder())); // ré-aligne sur l'arborescence Foundry
// Portrait : seulement s'il manque (pas de ré-upload à chaque import). // Portrait : seulement s'il manque (pas de ré-upload à chaque import).
if (ex.getPortraitImageId() == null) { if (ex.getPortraitImageId() == null) {
String portrait = storePortrait(m.imgData(), m.name()); String portrait = storePortrait(m.imgData(), m.name());
if (portrait != null) ex.setPortraitImageId(portrait); if (portrait != null) ex.setPortraitImageId(portrait);
} }
enemyRepository.save(ex); enemyRepository.save(ex);
updated++; }
} else {
Enemy saved = enemyRepository.save(Enemy.builder() private Enemy createMonster(String campaignId, MonsterImport m, Map<String, String> stats, int order) {
return enemyRepository.save(Enemy.builder()
.name(m.name()) .name(m.name())
.foundryRef(m.foundryRef()) .foundryRef(m.foundryRef())
.foundryStats(stats) .foundryStats(stats)
.folder(folder) .folder(foundryFolder(m.folder()))
.portraitImageId(storePortrait(m.imgData(), m.name())) .portraitImageId(storePortrait(m.imgData(), m.name()))
.campaignId(campaignId) .campaignId(campaignId)
.order(order++) .order(order)
.values(new HashMap<>()) .values(new HashMap<>())
.imageValues(new HashMap<>()) .imageValues(new HashMap<>())
.keyValueValues(new HashMap<>()) .keyValueValues(new HashMap<>())
.build()); .build());
byRef.put(m.foundryRef(), saved);
created++;
}
}
return new MonsterImportResult(created, updated);
} }
public List<Enemy> searchEnemies(String query) { public List<Enemy> searchEnemies(String query) {
@@ -226,10 +219,37 @@ public class EnemyService {
return trimmed.isEmpty() ? null : trimmed; return trimmed.isEmpty() ? null : trimmed;
} }
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private int nextOrderFor(String campaignId) { private int nextOrderFor(String campaignId) {
return enemyRepository.findByCampaignId(campaignId).stream() return enemyRepository.findByCampaignId(campaignId).stream()
.mapToInt(Enemy::getOrder) .mapToInt(Enemy::getOrder)
.max() .max()
.orElse(-1) + 1; .orElse(-1) + 1;
} }
private static Map<String, String> copyStringMap(Map<String, String> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, List<String>> copyStringListMap(Map<String, List<String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static Map<String, Map<String, String>> copyStringMapMap(Map<String, Map<String, String>> map) {
return map != null ? new HashMap<>(map) : new HashMap<>();
}
private static void applyCommonEnemyFields(Enemy enemy, EnemyData data) {
enemy.setName(data.name());
enemy.setLevel(normalize(data.level()));
enemy.setFolder(normalize(data.folder()));
enemy.setPortraitImageId(data.portraitImageId());
enemy.setHeaderImageId(data.headerImageId());
enemy.setValues(copyStringMap(data.values()));
enemy.setImageValues(copyStringListMap(data.imageValues()));
enemy.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
}
} }

View File

@@ -1,12 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.itemcatalog.CatalogItem;
import com.loremind.domain.campaigncontext.CatalogItem; import com.loremind.domain.campaigncontext.itemcatalog.ItemCatalog;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator; import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository; import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@@ -21,18 +18,15 @@ public class ItemCatalogService {
private final ItemCatalogRepository repository; private final ItemCatalogRepository repository;
private final ItemCatalogGenerator generator; private final ItemCatalogGenerator generator;
private final CampaignRepository campaignRepository; private final CampaignContextFormatter campaignContextFormatter;
private final GameSystemRepository gameSystemRepository;
public ItemCatalogService( public ItemCatalogService(
ItemCatalogRepository repository, ItemCatalogRepository repository,
ItemCatalogGenerator generator, ItemCatalogGenerator generator,
CampaignRepository campaignRepository, CampaignContextFormatter campaignContextFormatter) {
GameSystemRepository gameSystemRepository) {
this.repository = repository; this.repository = repository;
this.generator = generator; this.generator = generator;
this.campaignRepository = campaignRepository; this.campaignContextFormatter = campaignContextFormatter;
this.gameSystemRepository = gameSystemRepository;
} }
public record CatalogData( public record CatalogData(
@@ -89,7 +83,8 @@ public class ItemCatalogService {
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
public ItemCatalog generateProposal(String campaignId, String description) { public ItemCatalog generateProposal(String campaignId, String description) {
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId)); ItemCatalogGenerator.GeneratedCatalog g = generator.generate(
description, campaignContextFormatter.format(campaignId));
return ItemCatalog.builder() return ItemCatalog.builder()
.name(g.name()) .name(g.name())
.description(g.description()) .description(g.description())
@@ -102,22 +97,6 @@ public class ItemCatalogService {
return items != null ? new ArrayList<>(items) : new ArrayList<>(); return items != null ? new ArrayList<>(items) : new ArrayList<>();
} }
private String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
}
private int nextOrderFor(String campaignId) { private int nextOrderFor(String campaignId) {
return repository.findByCampaignId(campaignId).stream() return repository.findByCampaignId(campaignId).stream()
.mapToInt(ItemCatalog::getOrder) .mapToInt(ItemCatalog::getOrder)

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Notebook; import com.loremind.domain.campaigncontext.notebook.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage; import com.loremind.domain.campaigncontext.notebook.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource; import com.loremind.domain.campaigncontext.notebook.NotebookSource;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NotebookIndexer; import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
import com.loremind.domain.campaigncontext.ports.NotebookRepository; import com.loremind.domain.campaigncontext.ports.NotebookRepository;

View File

@@ -1,15 +1,11 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
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.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** /**
* Service d'application pour les fiches de PNJ (campagne). * Service d'application pour les fiches de PNJ (campagne).
@@ -92,7 +88,7 @@ public class NpcService {
public void reorderNpcs(String folder, List<String> orderedIds) { public void reorderNpcs(String folder, List<String> orderedIds) {
String f = normalizeFolder(folder); String f = normalizeFolder(folder);
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> npcRepository.findById(id).orElse(null), npcRepository::findById,
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); }, (npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
npcRepository::save); npcRepository::save);
} }

View File

@@ -1,11 +1,11 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.NodeType; import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef; import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.QuestRepository;
@@ -119,15 +119,6 @@ public class QuestService {
return questRepository.findByCampaignId(campaignId); return questRepository.findByCampaignId(campaignId);
} }
/** Quêtes rattachées à un arc HUB. */
public List<Quest> getQuestsByArcId(String arcId) {
return questRepository.findByArcId(arcId);
}
public List<Quest> getAllQuests() {
return questRepository.findAll();
}
/** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */ /** Met à jour une Quest (Parameter Object pattern, comme ChapterService). */
@Transactional @Transactional
public Quest updateQuest(String id, Quest updated) { public Quest updateQuest(String id, Quest updated) {
@@ -165,9 +156,10 @@ public class QuestService {
/** /**
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties. * Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
* *
* <p>TODO (Phase 5) : signaler/nettoyer les {@code Prerequisite.QuestCompleted} pendants * <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
* d'autres quêtes qui pointaient celle-ci. Échec sûr aujourd'hui : un prérequis vers une * d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
* quête supprimée n'est jamais satisfait → la quête dépendante reste LOCKED (pas de corruption).</p> * nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
* satisfait → la quête dépendante reste LOCKED (pas de corruption).</p>
*/ */
@Transactional @Transactional
public void deleteQuest(String id) { public void deleteQuest(String id) {
@@ -201,15 +193,11 @@ public class QuestService {
return nodes != null ? nodes : List.of(); return nodes != null ? nodes : List.of();
} }
public boolean questExists(String id) {
return questRepository.existsById(id);
}
/** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */ /** Réordonne les quêtes d'une campagne : {@code order} = position. Transactionnel. */
@Transactional @Transactional
public void reorderQuests(String campaignId, List<String> orderedIds) { public void reorderQuests(String campaignId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> questRepository.findById(id).orElse(null), questRepository::findById,
(quest, i) -> { (quest, i) -> {
if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId); if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId);
quest.setOrder(i); quest.setOrder(i);

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator; import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus; import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.QuestStatus; import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.playcontext.QuestProgression; import com.loremind.domain.playcontext.QuestProgression;
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
import com.loremind.domain.playcontext.ports.PlaythroughRepository; import com.loremind.domain.playcontext.ports.PlaythroughRepository;

View File

@@ -1,13 +1,10 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.randomtable.RandomTable;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.RandomTableEntry; import com.loremind.domain.campaigncontext.randomtable.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator; import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository; import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@@ -22,18 +19,15 @@ public class RandomTableService {
private final RandomTableRepository repository; private final RandomTableRepository repository;
private final RandomTableGenerator generator; private final RandomTableGenerator generator;
private final CampaignRepository campaignRepository; private final CampaignContextFormatter campaignContextFormatter;
private final GameSystemRepository gameSystemRepository;
public RandomTableService( public RandomTableService(
RandomTableRepository repository, RandomTableRepository repository,
RandomTableGenerator generator, RandomTableGenerator generator,
CampaignRepository campaignRepository, CampaignContextFormatter campaignContextFormatter) {
GameSystemRepository gameSystemRepository) {
this.repository = repository; this.repository = repository;
this.generator = generator; this.generator = generator;
this.campaignRepository = campaignRepository; this.campaignContextFormatter = campaignContextFormatter;
this.gameSystemRepository = gameSystemRepository;
} }
public record TableData( public record TableData(
@@ -90,8 +84,8 @@ public class RandomTableService {
@org.springframework.transaction.annotation.Transactional @org.springframework.transaction.annotation.Transactional
public void reorderTables(List<String> orderedIds) { public void reorderTables(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> repository.findById(id).orElse(null), repository::findById,
(table, i) -> table.setOrder(i), RandomTable::setOrder,
repository::save); repository::save);
} }
@@ -103,7 +97,8 @@ public class RandomTableService {
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
public RandomTable generateProposal(String campaignId, String description, String diceFormula) { public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula; String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
RandomTableGenerator.GeneratedTable g = generator.generate(description, formula, buildContext(campaignId)); RandomTableGenerator.GeneratedTable g = generator.generate(
description, formula, campaignContextFormatter.format(campaignId));
return RandomTable.builder() return RandomTable.builder()
.name(g.name()) .name(g.name())
.description(g.description()) .description(g.description())
@@ -115,24 +110,7 @@ public class RandomTableService {
/** Brode un court récit IA sur un résultat tiré (pour la partie). */ /** Brode un court récit IA sur un résultat tiré (pour la partie). */
public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) { public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) {
return generator.improvise(tableName, resultLabel, resultDetail, buildContext(campaignId)); return generator.improvise(tableName, resultLabel, resultDetail, campaignContextFormatter.format(campaignId));
}
/** Contexte libre (nom de campagne + description + système) pour orienter l'IA. */
private String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
} }
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) { private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {

View File

@@ -1,7 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.ReadinessEntityType; import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.ReadinessSeverity; import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
/** /**
* Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B). * Un manque de préparation détecté sur une entité de scénario (read-model, Pilier B).

View File

@@ -1,10 +1,10 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.SceneDraft; import com.loremind.domain.campaigncontext.generation.SceneDraft;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.structure.SceneBranch;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -171,7 +171,7 @@ public class SceneService {
if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue; if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue;
List<SceneBranch> kept = branches.stream() List<SceneBranch> kept = branches.stream()
.filter(b -> !id.equals(b.targetSceneId())) .filter(b -> !id.equals(b.targetSceneId()))
.collect(Collectors.toList()); .toList();
if (kept.size() != branches.size()) { if (kept.size() != branches.size()) {
sibling.setBranches(kept); sibling.setBranches(kept);
sceneRepository.save(sibling); sceneRepository.save(sibling);
@@ -193,7 +193,7 @@ public class SceneService {
@Transactional @Transactional
public void reorderScenes(String chapterId, List<String> orderedIds) { public void reorderScenes(String chapterId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> sceneRepository.findById(id).orElse(null), sceneRepository::findById,
(scene, i) -> { (scene, i) -> {
if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) { if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) {
scene.setChapterId(chapterId); scene.setChapterId(chapterId);

View File

@@ -0,0 +1,45 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
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 quêtes 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 de quête).</p>
*
* <p>Niveau 1 : lit désormais les quêtes (entité de première classe), plus les
* chapitres HUB.</p>
*/
@Service
public class CampaignReferencedFlagsService {
private final QuestRepository questRepository;
public CampaignReferencedFlagsService(QuestRepository questRepository) {
this.questRepository = questRepository;
}
/** 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 (Quest quest : questRepository.findByCampaignId(campaignId)) {
if (quest.getPrerequisites() == null) continue;
for (Prerequisite p : quest.getPrerequisites()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
}
}
return List.copyOf(unique);
}
}

View File

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import java.io.InputStream; import java.io.InputStream;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -24,18 +25,18 @@ import java.util.Set;
@Service @Service
public class StoredFileService { public class StoredFileService {
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
/** Types MIME explicitement autorises (en plus de tout {@code image/*} et {@code video/*}). */ /** Types MIME explicitement autorises (en plus de tout {@code image/*} et {@code video/*}). */
private static final Set<String> ALLOWED_EXACT_MIME = Set.of( private static final Set<String> ALLOWED_EXACT_MIME = Set.of(
"application/json", "application/json",
"application/octet-stream", DEFAULT_CONTENT_TYPE,
"text/plain" "text/plain"
); );
/** Coherent avec spring.servlet.multipart.max-file-size (application.properties). */ /** Coherent avec spring.servlet.multipart.max-file-size (application.properties). */
private static final long MAX_SIZE_BYTES = 128L * 1024 * 1024; // 128 Mo private static final long MAX_SIZE_BYTES = 128L * 1024 * 1024; // 128 Mo
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
private final StoredFileRepository repository; private final StoredFileRepository repository;
private final FileStorage storage; private final FileStorage storage;
@@ -60,7 +61,7 @@ public class StoredFileService {
.contentType(resolvedType) .contentType(resolvedType)
.sizeBytes(sizeBytes) .sizeBytes(sizeBytes)
.storageKey(storageKey) .storageKey(storageKey)
.uploadedAt(LocalDateTime.now()) .uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
.build(); .build();
return repository.save(file); return repository.save(file);
} catch (RuntimeException ex) { } catch (RuntimeException ex) {

View File

@@ -27,8 +27,13 @@ import java.util.regex.Pattern;
@Service @Service
public class GameSystemContextBuilder { public class GameSystemContextBuilder {
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */ /**
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$"); * Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1.
* Le titre est forcé à commencer par un caractère non-blanc ({@code \S.*}) : la
* frontière entre les espaces de tête et le titre devient non ambiguë (classes de
* caractères disjointes), ce qui élimine tout risque de backtracking polynomial.
*/
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(\\S.*)$");
private final GameSystemRepository gameSystemRepository; private final GameSystemRepository gameSystemRepository;

View File

@@ -119,27 +119,29 @@ public class GameSystemService {
List<TemplateField> template = new ArrayList<>(); List<TemplateField> template = new ArrayList<>();
Set<String> usedNames = new HashSet<>(); Set<String> usedNames = new HashSet<>();
for (FoundryStructField f : fields == null ? List.<FoundryStructField>of() : fields) { for (FoundryStructField f : fields != null ? fields : List.<FoundryStructField>of()) {
if (f.path() == null || f.path().isBlank()) continue; TemplateField field = toTemplateField(f, usedNames);
String label = (f.label() != null && !f.label().isBlank()) ? f.label().trim() : f.path(); if (field != null) template.add(field);
// Nom unique : libellé si libre, sinon le chemin (toujours unique).
String name = usedNames.contains(label.toLowerCase()) ? f.path() : label;
if (usedNames.contains(name.toLowerCase())) continue;
usedNames.add(name.toLowerCase());
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
template.add(new TemplateField(name, type, null, null, f.path()));
} }
gs.replaceEnemyTemplate(template); gs.replaceEnemyTemplate(template);
gs.setFoundryActorType(normalize(actorType)); gs.setFoundryActorType(normalize(actorType));
return gameSystemRepository.save(gs); return gameSystemRepository.save(gs);
} }
public void deleteGameSystem(String id) { /** Convertit un champ Foundry en TemplateField, ou null si son chemin est vide ou son nom déjà pris. */
gameSystemRepository.deleteById(id); private static TemplateField toTemplateField(FoundryStructField f, Set<String> usedNames) {
if (f.path() == null || f.path().isBlank()) return null;
String label = (f.label() != null && !f.label().isBlank()) ? f.label().trim() : f.path();
// Nom unique : libellé si libre, sinon le chemin (toujours unique).
String name = usedNames.contains(label.toLowerCase()) ? f.path() : label;
if (usedNames.contains(name.toLowerCase())) return null;
usedNames.add(name.toLowerCase());
FieldType type = "number".equalsIgnoreCase(f.type()) ? FieldType.NUMBER : FieldType.TEXT;
return new TemplateField(name, type, null, null, f.path());
} }
public boolean gameSystemExists(String id) { public void deleteGameSystem(String id) {
return gameSystemRepository.existsById(id); gameSystemRepository.deleteById(id);
} }
public List<GameSystem> searchGameSystems(String query) { public List<GameSystem> searchGameSystems(String query) {

View File

@@ -1,12 +1,12 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -93,26 +93,26 @@ public class CampaignStructuralContextBuilder {
// les enemyIds des pièces sans N+1 sur le repo. // les enemyIds des pièces sans N+1 sur le repo.
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream() Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Enemy::getId, com.loremind.domain.campaigncontext.bestiary.Enemy::getId,
CampaignStructuralContextBuilder::enemyLabel, CampaignStructuralContextBuilder::enemyLabel,
(a, b) -> a)); (a, b) -> a));
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream() List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Arc::getOrder)) .sorted(Comparator.comparingInt(Arc::getOrder))
.map(arc -> toArcSummary(arc, enemyLabelById)) .map(arc -> toArcSummary(arc, enemyLabelById))
.collect(Collectors.toList()); .toList();
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank()) List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
? List.of() ? List.of()
: characterRepository.findByPlaythroughId(playthroughId).stream() : characterRepository.findByPlaythroughId(playthroughId).stream()
.sorted(Comparator.comparingInt(Character::getOrder)) .sorted(Comparator.comparingInt(Character::getOrder))
.map(this::toCharacterSummary) .map(this::toCharacterSummary)
.collect(Collectors.toList()); .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))
.map(this::toNpcSummary) .map(this::toNpcSummary)
.collect(Collectors.toList()); .toList();
return new CampaignStructuralContext( return new CampaignStructuralContext(
campaign.getName(), campaign.getName(),
@@ -140,27 +140,36 @@ public class CampaignStructuralContextBuilder {
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide * Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown). * du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
*/ */
private static String extractSnippet(java.util.Map<String, String> values) { private static String extractSnippet(Map<String, String> values) {
if (values == null || values.isEmpty()) return ""; if (values == null || values.isEmpty()) return "";
for (String value : values.values()) { return values.values().stream()
if (value == null || value.isBlank()) continue; .filter(v -> v != null && !v.isBlank())
String firstLine = value.lines() .map(CampaignStructuralContextBuilder::firstMeaningfulLine)
.filter(l -> !l.isEmpty())
.findFirst()
.map(CampaignStructuralContextBuilder::truncateSnippet)
.orElse("");
}
/** 1re ligne non vide et non-titre ("# ...") d'une valeur de template, ou "" si aucune. */
private static String firstMeaningfulLine(String value) {
return value.lines()
.map(String::strip) .map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#")) .filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst() .findFirst()
.orElse(""); .orElse("");
if (firstLine.isEmpty()) continue; }
private static String truncateSnippet(String firstLine) {
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine; if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + ""; return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
} }
return "";
}
private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) { private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream() List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(chapter -> toChapterSummary(chapter, enemyLabelById)) .map(chapter -> toChapterSummary(chapter, enemyLabelById))
.collect(Collectors.toList()); .toList();
return new ArcSummary( return new ArcSummary(
arc.getName(), arc.getName(),
arc.getDescription(), arc.getDescription(),
@@ -181,7 +190,7 @@ public class CampaignStructuralContextBuilder {
List<SceneSummary> summaries = scenes.stream() List<SceneSummary> summaries = scenes.stream()
.map(s -> toSceneSummary(s, nameById, enemyLabelById)) .map(s -> toSceneSummary(s, nameById, enemyLabelById))
.collect(Collectors.toList()); .toList();
return new ChapterSummary( return new ChapterSummary(
chapter.getName(), chapter.getName(),
@@ -199,7 +208,7 @@ public class CampaignStructuralContextBuilder {
b.label(), b.label(),
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"), nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.condition())) b.condition()))
.collect(Collectors.toList()); .toList();
List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById); List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
@@ -221,8 +230,8 @@ public class CampaignStructuralContextBuilder {
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of(); if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream() Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Room::getId, com.loremind.domain.campaigncontext.structure.Room::getId,
com.loremind.domain.campaigncontext.Room::getName, com.loremind.domain.campaigncontext.structure.Room::getName,
(a, b) -> a)); (a, b) -> a));
return scene.getRooms().stream() return scene.getRooms().stream()
.map(r -> { .map(r -> {
@@ -233,12 +242,12 @@ public class CampaignStructuralContextBuilder {
b.label(), b.label(),
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"), nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition())) b.condition()))
.collect(Collectors.toList()); .toList();
return new RoomSummary( return new RoomSummary(
r.getName(), r.getFloor(), r.getDescription(), r.getName(), r.getFloor(), r.getDescription(),
roomEnemiesText(r, enemyLabelById), hints); roomEnemiesText(r, enemyLabelById), hints);
}) })
.collect(Collectors.toList()); .toList();
} }
/** /**
@@ -247,7 +256,7 @@ public class CampaignStructuralContextBuilder {
* libre. L'un ou l'autre peut être vide. * libre. L'un ou l'autre peut être vide.
*/ */
private static String roomEnemiesText( private static String roomEnemiesText(
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) { com.loremind.domain.campaigncontext.structure.Room room, Map<String, String> enemyLabelById) {
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream() String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
.map(enemyLabelById::get) .map(enemyLabelById::get)
.filter(l -> l != null && !l.isBlank()) .filter(l -> l != null && !l.isBlank())
@@ -259,7 +268,7 @@ public class CampaignStructuralContextBuilder {
} }
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */ /** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) { private static String enemyLabel(com.loremind.domain.campaigncontext.bestiary.Enemy enemy) {
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip(); String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")"; return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
} }

View File

@@ -1,14 +1,13 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.application.campaigncontext.CampaignContextFormatter;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.SceneDraft; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.SceneDraftProposal; import com.loremind.domain.campaigncontext.generation.SceneDraft;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.generation.SceneDraftProposal;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant; import com.loremind.domain.campaigncontext.ports.SceneDraftAssistant;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -30,19 +29,16 @@ public class DraftScenesUseCase {
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CampaignRepository campaignRepository; private final CampaignContextFormatter campaignContextFormatter;
private final GameSystemRepository gameSystemRepository;
private final SceneDraftAssistant assistant; private final SceneDraftAssistant assistant;
public DraftScenesUseCase(ChapterRepository chapterRepository, public DraftScenesUseCase(ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CampaignRepository campaignRepository, CampaignContextFormatter campaignContextFormatter,
GameSystemRepository gameSystemRepository,
SceneDraftAssistant assistant) { SceneDraftAssistant assistant) {
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.campaignRepository = campaignRepository; this.campaignContextFormatter = campaignContextFormatter;
this.gameSystemRepository = gameSystemRepository;
this.assistant = assistant; this.assistant = assistant;
} }
@@ -55,13 +51,13 @@ public class DraftScenesUseCase {
List<SceneDraft> drafts = assistant.draftScenes(context, instruction, n).stream() List<SceneDraft> drafts = assistant.draftScenes(context, instruction, n).stream()
.filter(d -> d != null && d.name() != null && !d.name().isBlank()) .filter(d -> d != null && d.name() != null && !d.name().isBlank())
.limit(n) .limit(n)
.collect(Collectors.toList()); .toList();
return new SceneDraftProposal(chapterId, drafts); return new SceneDraftProposal(chapterId, drafts);
} }
private String buildContext(Chapter chapter, String campaignId) { private String buildContext(Chapter chapter, String campaignId) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("Chapitre : ").append(blankToLabel(chapter.getName(), "(sans titre)")).append("\n"); sb.append("Chapitre : ").append(blankToLabel(chapter.getName())).append("\n");
appendIf(sb, "Synopsis", chapter.getDescription()); appendIf(sb, "Synopsis", chapter.getDescription());
appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives()); appendIf(sb, "Objectifs des joueurs", chapter.getPlayerObjectives());
appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes()); appendIf(sb, "Enjeux narratifs", chapter.getNarrativeStakes());
@@ -78,17 +74,10 @@ public class DraftScenesUseCase {
} }
if (campaignId != null && !campaignId.isBlank()) { if (campaignId != null && !campaignId.isBlank()) {
campaignRepository.findById(campaignId).ifPresent(c -> { String campaignBlock = campaignContextFormatter.format(campaignId);
sb.append("Campagne : ").append(c.getName()); if (!campaignBlock.isBlank()) {
if (c.getDescription() != null && !c.getDescription().isBlank()) { sb.append(campaignBlock).append("\n");
sb.append("").append(c.getDescription().trim());
} }
sb.append("\n");
if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) {
gameSystemRepository.findById(c.getGameSystemId())
.ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).append("\n"));
}
});
} }
return sb.toString(); return sb.toString();
} }
@@ -99,7 +88,7 @@ public class DraftScenesUseCase {
} }
} }
private static String blankToLabel(String value, String fallback) { private static String blankToLabel(String value) {
return value == null || value.isBlank() ? fallback : value; return value == null || value.isBlank() ? "(sans titre)" : value;
} }
} }

View File

@@ -30,6 +30,8 @@ import java.util.Map;
@Service @Service
public class GeneratePageValuesUseCase { public class GeneratePageValuesUseCase {
private static final String FOR_PAGE = ") pour la page ";
private final PageRepository pageRepository; private final PageRepository pageRepository;
private final TemplateRepository templateRepository; private final TemplateRepository templateRepository;
private final LoreRepository loreRepository; private final LoreRepository loreRepository;
@@ -96,22 +98,19 @@ public class GeneratePageValuesUseCase {
} }
return templateRepository.findById(templateId) return templateRepository.findById(templateId)
.orElseThrow(() -> new IllegalStateException( .orElseThrow(() -> new IllegalStateException(
"Template introuvable (id=" + templateId "Template introuvable (id=" + templateId + FOR_PAGE + pageId));
+ ") pour la page " + pageId));
} }
private Lore loadLore(String loreId, String pageId) { private Lore loadLore(String loreId, String pageId) {
return loreRepository.findById(loreId) return loreRepository.findById(loreId)
.orElseThrow(() -> new IllegalStateException( .orElseThrow(() -> new IllegalStateException(
"Lore introuvable (id=" + loreId "Lore introuvable (id=" + loreId + FOR_PAGE + pageId));
+ ") pour la page " + pageId));
} }
private LoreNode loadFolder(String nodeId, String pageId) { private LoreNode loadFolder(String nodeId, String pageId) {
return loreNodeRepository.findById(nodeId) return loreNodeRepository.findById(nodeId)
.orElseThrow(() -> new IllegalStateException( .orElseThrow(() -> new IllegalStateException(
"Dossier parent introuvable (id=" + nodeId "Dossier parent introuvable (id=" + nodeId + FOR_PAGE + pageId));
+ ") pour la page " + pageId));
} }
private void requireNonEmptyFields(Template template) { private void requireNonEmptyFields(Template template) {

View File

@@ -110,7 +110,7 @@ public class LoreStructuralContextBuilder {
return allPages.stream() return allPages.stream()
.filter(p -> nodeId.equals(p.getNodeId())) .filter(p -> nodeId.equals(p.getNodeId()))
.map(p -> toPageSummary(p, templateNameById, pageTitleById)) .map(p -> toPageSummary(p, templateNameById, pageTitleById))
.collect(Collectors.toList()); .toList();
} }
private PageSummary toPageSummary( private PageSummary toPageSummary(
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilder {
return relatedIds.stream() return relatedIds.stream()
.map(pageTitleById::get) .map(pageTitleById::get)
.filter(title -> title != null && !title.isBlank()) .filter(title -> title != null && !title.isBlank())
.collect(Collectors.toList()); .toList();
} }
private List<String> extractUniqueTags(List<Page> pages) { private List<String> extractUniqueTags(List<Page> pages) {
@@ -168,6 +168,6 @@ public class LoreStructuralContextBuilder {
.filter(p -> p.getTags() != null) .filter(p -> p.getTags() != null)
.flatMap(p -> p.getTags().stream()) .flatMap(p -> p.getTags().stream())
.distinct() .distinct()
.collect(Collectors.toList()); .toList();
} }
} }

View File

@@ -1,10 +1,9 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.EntityFieldPatchProposal; import com.loremind.application.campaigncontext.CampaignContextFormatter;
import com.loremind.domain.campaigncontext.FieldProposal; import com.loremind.domain.campaigncontext.generation.EntityFieldPatchProposal;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant; import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@@ -26,18 +25,15 @@ import java.util.stream.Collectors;
public class NarrativeAssistFieldsUseCase { public class NarrativeAssistFieldsUseCase {
private final NarrativeFieldCatalog catalog; private final NarrativeFieldCatalog catalog;
private final CampaignRepository campaignRepository; private final CampaignContextFormatter campaignContextFormatter;
private final GameSystemRepository gameSystemRepository;
private final NarrativeFieldAssistant assistant; private final NarrativeFieldAssistant assistant;
public NarrativeAssistFieldsUseCase( public NarrativeAssistFieldsUseCase(
NarrativeFieldCatalog catalog, NarrativeFieldCatalog catalog,
CampaignRepository campaignRepository, CampaignContextFormatter campaignContextFormatter,
GameSystemRepository gameSystemRepository,
NarrativeFieldAssistant assistant) { NarrativeFieldAssistant assistant) {
this.catalog = catalog; this.catalog = catalog;
this.campaignRepository = campaignRepository; this.campaignContextFormatter = campaignContextFormatter;
this.gameSystemRepository = gameSystemRepository;
this.assistant = assistant; this.assistant = assistant;
} }
@@ -46,7 +42,7 @@ public class NarrativeAssistFieldsUseCase {
List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream() List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream()
.map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label())) .map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label()))
.collect(Collectors.toList()); .toList();
Set<String> allowed = snap.defs().stream() Set<String> allowed = snap.defs().stream()
.map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet()); .map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet());
@@ -56,19 +52,26 @@ public class NarrativeAssistFieldsUseCase {
List<FieldProposal> fields = new ArrayList<>(); List<FieldProposal> fields = new ArrayList<>();
for (NarrativeFieldAssistant.ProposedField pf : proposed) { for (NarrativeFieldAssistant.ProposedField pf : proposed) {
if (pf.key() == null || !allowed.contains(pf.key())) continue; FieldProposal proposal = toFieldProposal(pf, allowed, snap);
if (pf.value() == null || pf.value().isBlank()) continue; if (proposal != null) fields.add(proposal);
String current = snap.current().getOrDefault(pf.key(), "");
fields.add(new FieldProposal(pf.key(), current, pf.value()));
} }
return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields); return new EntityFieldPatchProposal(snap.entityType(), entityId, "patch", fields);
} }
/** Convertit un champ proposé par l'IA en FieldProposal, ou null si sa clé n'est pas autorisée ou sa valeur vide. */
private static FieldProposal toFieldProposal(
NarrativeFieldAssistant.ProposedField pf, Set<String> allowed, NarrativeFieldCatalog.Snapshot snap) {
if (pf.key() == null || !allowed.contains(pf.key())) return null;
if (pf.value() == null || pf.value().isBlank()) return null;
String current = snap.current().getOrDefault(pf.key(), "");
return new FieldProposal(pf.key(), current, pf.value());
}
/** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */ /** Contexte compact : type + titre + valeurs actuelles non vides + méta campagne. */
private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) { private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(entityLabel(snap.entityType())).append(" : ") sb.append(entityLabel(snap.entityType())).append(" : ")
.append(blankToLabel(snap.title(), "(sans titre)")).append("\n"); .append(blankToLabel(snap.title())).append("\n");
sb.append("État actuel des champs :\n"); sb.append("État actuel des champs :\n");
boolean any = false; boolean any = false;
for (Map.Entry<String, String> e : snap.current().entrySet()) { for (Map.Entry<String, String> e : snap.current().entrySet()) {
@@ -82,17 +85,10 @@ public class NarrativeAssistFieldsUseCase {
sb.append("- (tous les champs sont vides — à créer de zéro)\n"); sb.append("- (tous les champs sont vides — à créer de zéro)\n");
} }
if (campaignId != null && !campaignId.isBlank()) { if (campaignId != null && !campaignId.isBlank()) {
campaignRepository.findById(campaignId).ifPresent(c -> { String campaignBlock = campaignContextFormatter.format(campaignId);
sb.append("Campagne : ").append(c.getName()); if (!campaignBlock.isBlank()) {
if (c.getDescription() != null && !c.getDescription().isBlank()) { sb.append(campaignBlock).append("\n");
sb.append("").append(c.getDescription().trim());
} }
sb.append("\n");
if (c.getGameSystemId() != null && !c.getGameSystemId().isBlank()) {
gameSystemRepository.findById(c.getGameSystemId())
.ifPresent(gs -> sb.append("Système de jeu : ").append(gs.getName()).append("\n"));
}
});
} }
return sb.toString(); return sb.toString();
} }
@@ -106,7 +102,7 @@ public class NarrativeAssistFieldsUseCase {
}; };
} }
private static String blankToLabel(String value, String fallback) { private static String blankToLabel(String value) {
return value == null || value.isBlank() ? fallback : value; return value == null || value.isBlank() ? "(sans titre)" : value;
} }
} }

View File

@@ -1,10 +1,10 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
@@ -28,6 +28,9 @@ import java.util.Map;
@Component @Component
public class NarrativeEntityContextBuilder { public class NarrativeEntityContextBuilder {
/** Longueur max d'une valeur de stat dans le résumé d'ennemi lié (contexte focus compact). */
private static final int STAT_VALUE_MAX_LEN = 100;
private final ArcRepository arcRepository; private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository; private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
@@ -144,14 +147,14 @@ public class NarrativeEntityContextBuilder {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (String enemyId : s.getEnemyIds()) { for (String enemyId : s.getEnemyIds()) {
enemyRepository.findById(enemyId).ifPresent(e -> { enemyRepository.findById(enemyId).ifPresent(e -> {
if (sb.length() > 0) sb.append("\n"); if (!sb.isEmpty()) sb.append("\n");
sb.append("- ").append(e.getName()); sb.append("- ").append(e.getName());
if (e.getLevel() != null && !e.getLevel().isBlank()) { if (e.getLevel() != null && !e.getLevel().isBlank()) {
sb.append(" (").append(e.getLevel().trim()).append(")"); sb.append(" (").append(e.getLevel().trim()).append(")");
} }
String stats = e.getValues().entrySet().stream() String stats = e.getValues().entrySet().stream()
.filter(en -> en.getValue() != null && !en.getValue().isBlank()) .filter(en -> en.getValue() != null && !en.getValue().isBlank())
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100)) .map(en -> en.getKey() + ": " + truncate(en.getValue().trim()))
.collect(java.util.stream.Collectors.joining(" ; ")); .collect(java.util.stream.Collectors.joining(" ; "));
if (!stats.isEmpty()) sb.append("").append(stats); if (!stats.isEmpty()) sb.append("").append(stats);
}); });
@@ -159,8 +162,8 @@ public class NarrativeEntityContextBuilder {
return sb.toString(); return sb.toString();
} }
private static String truncate(String value, int maxLen) { private static String truncate(String value) {
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + ""; return value.length() <= STAT_VALUE_MAX_LEN ? value : value.substring(0, STAT_VALUE_MAX_LEN - 1).stripTrailing() + "";
} }
private NarrativeEntityContext fromCharacter(Character c) { private NarrativeEntityContext fromCharacter(Character c) {

View File

@@ -1,8 +1,8 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -29,22 +29,25 @@ public class NarrativeFieldCatalog {
public record Snapshot(String entityType, String title, public record Snapshot(String entityType, String title,
LinkedHashMap<String, String> current, List<FieldDef> defs) {} LinkedHashMap<String, String> current, List<FieldDef> defs) {}
private static final String FIELD_DESCRIPTION = "description";
private static final String FIELD_GM_NOTES = "gmNotes";
private static final List<FieldDef> ARC_DEFS = List.of( private static final List<FieldDef> ARC_DEFS = List.of(
new FieldDef("description", "description / synopsis de l'arc"), new FieldDef(FIELD_DESCRIPTION, "description / synopsis de l'arc"),
new FieldDef("themes", "thèmes explorés"), new FieldDef("themes", "thèmes explorés"),
new FieldDef("stakes", "enjeux globaux pour les personnages"), new FieldDef("stakes", "enjeux globaux pour les personnages"),
new FieldDef("rewards", "récompenses et progression"), new FieldDef("rewards", "récompenses et progression"),
new FieldDef("resolution", "dénouement prévu"), new FieldDef("resolution", "dénouement prévu"),
new FieldDef("gmNotes", "notes privées du MJ")); new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
private static final List<FieldDef> CHAPTER_DEFS = List.of( private static final List<FieldDef> CHAPTER_DEFS = List.of(
new FieldDef("description", "synopsis du chapitre"), new FieldDef(FIELD_DESCRIPTION, "synopsis du chapitre"),
new FieldDef("playerObjectives", "objectifs des joueurs"), new FieldDef("playerObjectives", "objectifs des joueurs"),
new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"), new FieldDef("narrativeStakes", "enjeux narratifs dramatiques"),
new FieldDef("gmNotes", "notes privées du MJ")); new FieldDef(FIELD_GM_NOTES, "notes privées du MJ"));
private static final List<FieldDef> SCENE_DEFS = List.of( private static final List<FieldDef> SCENE_DEFS = List.of(
new FieldDef("description", "description courte de la scène"), new FieldDef(FIELD_DESCRIPTION, "description courte de la scène"),
new FieldDef("location", "lieu où se déroule la scène"), new FieldDef("location", "lieu où se déroule la scène"),
new FieldDef("timing", "moment / temporalité"), new FieldDef("timing", "moment / temporalité"),
new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"), new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"),
@@ -80,12 +83,12 @@ public class NarrativeFieldCatalog {
Arc a = arcRepository.findById(id) Arc a = arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id)); .orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>(); LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put("description", nz(a.getDescription())); cur.put(FIELD_DESCRIPTION, nz(a.getDescription()));
cur.put("themes", nz(a.getThemes())); cur.put("themes", nz(a.getThemes()));
cur.put("stakes", nz(a.getStakes())); cur.put("stakes", nz(a.getStakes()));
cur.put("rewards", nz(a.getRewards())); cur.put("rewards", nz(a.getRewards()));
cur.put("resolution", nz(a.getResolution())); cur.put("resolution", nz(a.getResolution()));
cur.put("gmNotes", nz(a.getGmNotes())); cur.put(FIELD_GM_NOTES, nz(a.getGmNotes()));
return new Snapshot("arc", a.getName(), cur, ARC_DEFS); return new Snapshot("arc", a.getName(), cur, ARC_DEFS);
} }
@@ -93,10 +96,10 @@ public class NarrativeFieldCatalog {
Chapter c = chapterRepository.findById(id) Chapter c = chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id)); .orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>(); LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put("description", nz(c.getDescription())); cur.put(FIELD_DESCRIPTION, nz(c.getDescription()));
cur.put("playerObjectives", nz(c.getPlayerObjectives())); cur.put("playerObjectives", nz(c.getPlayerObjectives()));
cur.put("narrativeStakes", nz(c.getNarrativeStakes())); cur.put("narrativeStakes", nz(c.getNarrativeStakes()));
cur.put("gmNotes", nz(c.getGmNotes())); cur.put(FIELD_GM_NOTES, nz(c.getGmNotes()));
return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS); return new Snapshot("chapter", c.getName(), cur, CHAPTER_DEFS);
} }
@@ -104,7 +107,7 @@ public class NarrativeFieldCatalog {
Scene s = sceneRepository.findById(id) Scene s = sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id)); .orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
LinkedHashMap<String, String> cur = new LinkedHashMap<>(); LinkedHashMap<String, String> cur = new LinkedHashMap<>();
cur.put("description", nz(s.getDescription())); cur.put(FIELD_DESCRIPTION, nz(s.getDescription()));
cur.put("location", nz(s.getLocation())); cur.put("location", nz(s.getLocation()));
cur.put("timing", nz(s.getTiming())); cur.put("timing", nz(s.getTiming()));
cur.put("atmosphere", nz(s.getAtmosphere())); cur.put("atmosphere", nz(s.getAtmosphere()));

View File

@@ -1,9 +1,9 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator; import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus; import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.QuestStatus; import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.generationcontext.SessionContext; import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary; import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
@@ -26,7 +26,6 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Construit le SessionContext injecté dans le prompt IA pendant une partie. * Construit le SessionContext injecté dans le prompt IA pendant une partie.
@@ -62,10 +61,6 @@ public class SessionStructuralContextBuilder {
this.questProgressionRepository = questProgressionRepository; this.questProgressionRepository = questProgressionRepository;
} }
public Optional<SessionContext> buildOptional(String sessionId) {
return sessionRepository.findById(sessionId).map(this::toContext);
}
public SessionContext build(String sessionId) { public SessionContext build(String sessionId) {
Session session = sessionRepository.findById(sessionId) Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId)); .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -97,7 +92,7 @@ public class SessionStructuralContextBuilder {
return kept.stream() return kept.stream()
.map(e -> toSummary(e, null)) .map(e -> toSummary(e, null))
.collect(Collectors.toList()); .toList();
} }
/** /**
@@ -209,6 +204,6 @@ public class SessionStructuralContextBuilder {
.filter(Map.Entry::getValue) .filter(Map.Entry::getValue)
.map(Map.Entry::getKey) .map(Map.Entry::getKey)
.sorted() .sorted()
.collect(Collectors.toList()); .toList();
} }
} }

View File

@@ -7,7 +7,7 @@ import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.GameSystemContext; import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -15,7 +15,6 @@ import com.loremind.domain.generationcontext.ports.AiChatProvider;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
/** /**
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context. * Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
@@ -72,10 +71,7 @@ public class StreamChatForCampaignUseCase {
String entityType, String entityType,
String entityId, String entityId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage, ChatStreamCallbacks callbacks) {
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Campaign campaign = campaignRepository.findById(campaignId) Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
@@ -94,7 +90,7 @@ public class StreamChatForCampaignUseCase {
.gameSystemContext(gameSystemContext) .gameSystemContext(gameSystemContext)
.build(); .build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError); aiChatProvider.streamChat(request, callbacks);
} }
/** /**

View File

@@ -2,7 +2,7 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.PageContext; import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Service;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer;
/** /**
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore. * Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
@@ -61,10 +60,7 @@ public class StreamChatForLoreUseCase {
String loreId, String loreId,
String pageId, String pageId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage, ChatStreamCallbacks callbacks) {
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
LoreStructuralContext loreContext = loreContextBuilder.build(loreId); LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
PageContext pageContext = (pageId == null || pageId.isBlank()) PageContext pageContext = (pageId == null || pageId.isBlank())
@@ -77,7 +73,7 @@ public class StreamChatForLoreUseCase {
.pageContext(pageContext) .pageContext(pageContext)
.build(); .build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError); aiChatProvider.streamChat(request, callbacks);
} }
/** /**

View File

@@ -7,7 +7,7 @@ import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.GameSystemContext; import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext; import com.loremind.domain.generationcontext.SessionContext;
@@ -19,7 +19,6 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
/** /**
* Use case applicatif : chat IA pendant une Session de jeu. * Use case applicatif : chat IA pendant une Session de jeu.
@@ -71,10 +70,7 @@ public class StreamChatForSessionUseCase {
public void execute( public void execute(
String sessionId, String sessionId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage, ChatStreamCallbacks callbacks) {
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Session session = sessionRepository.findById(sessionId) Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId)); .orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -102,7 +98,7 @@ public class StreamChatForSessionUseCase {
.sessionContext(sessionContext) .sessionContext(sessionContext)
.build(); .build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError); aiChatProvider.streamChat(request, callbacks);
} }
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) { private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {

View File

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import java.io.InputStream; import java.io.InputStream;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -60,7 +61,7 @@ public class ImageService {
.contentType(contentType) .contentType(contentType)
.sizeBytes(sizeBytes) .sizeBytes(sizeBytes)
.storageKey(storageKey) .storageKey(storageKey)
.uploadedAt(LocalDateTime.now()) .uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
.build(); .build();
return imageRepository.save(image); return imageRepository.save(image);
} catch (RuntimeException ex) { } catch (RuntimeException ex) {

View File

@@ -52,8 +52,8 @@ public class LicenseService {
this.repository = repository; this.repository = repository;
this.jwtVerifier = jwtVerifier; this.jwtVerifier = jwtVerifier;
this.relay = relay; this.relay = relay;
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L; this.gracePeriodSeconds = gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L; this.refreshBeforeExpirySeconds = refreshBeforeExpiryDays * 86_400L;
this.licensingEnabled = licensingEnabled; this.licensingEnabled = licensingEnabled;
} }
@@ -134,9 +134,9 @@ public class LicenseService {
* Etat courant de la licence pour exposition UI / decision technique. * Etat courant de la licence pour exposition UI / decision technique.
*/ */
public LicenseSnapshot getCurrentSnapshot() { public LicenseSnapshot getCurrentSnapshot() {
Optional<License> opt = repository.findCurrent(); return repository.findCurrent()
if (opt.isEmpty()) return LicenseSnapshot.none(); .map(l -> snapshotOf(l, Instant.now()))
return snapshotOf(opt.get(), Instant.now()); .orElseGet(LicenseSnapshot::none);
} }
/** /**
@@ -161,31 +161,32 @@ public class LicenseService {
/** /**
* Tente un refresh si la licence est proche de l'expiration. Idempotent. * Tente un refresh si la licence est proche de l'expiration. Idempotent.
* Appele par le daemon planifie + manuellement via l'UI ("Reessayer"). * 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() { public void refreshIfNeeded() {
Optional<License> opt = repository.findCurrent(); Optional<License> opt = repository.findCurrent();
if (opt.isEmpty()) return false; if (opt.isEmpty()) return;
License current = opt.get(); License current = opt.get();
Instant now = Instant.now(); Instant now = Instant.now();
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds(); long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
if (secondsUntilExpiry > refreshBeforeExpirySeconds) { if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
return false; return;
} }
return doRefresh(current, now); doRefresh(current, now);
} }
/** /**
* Force un refresh immediat (bouton UI "Reessayer maintenant"). * Force un refresh immediat (bouton UI "Reessayer maintenant").
*/ */
public boolean forceRefresh() { public void forceRefresh() {
return repository.findCurrent() repository.findCurrent().ifPresent(license -> doRefresh(license, Instant.now()));
.map(license -> doRefresh(license, Instant.now()))
.orElse(false);
} }
private boolean doRefresh(License current, Instant now) { /**
* Tente le refresh aupres du relais et persiste le resultat (succes ou echec) sur
* la licence. Le succes/echec est lu depuis {@code lastRefreshSucceeded}, pas
* depuis un retour de methode : le tentative est deja actee cote persistance.
*/
private void doRefresh(License current, Instant now) {
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt()); log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
try { try {
String newJwt = relay.refreshToken(current.getRawJwt()); String newJwt = relay.refreshToken(current.getRawJwt());
@@ -199,7 +200,6 @@ public class LicenseService {
current.setLastRefreshSucceeded(true); current.setLastRefreshSucceeded(true);
repository.save(current); repository.save(current);
log.info("License refreshed successfully (new expiry {})", claims.expiresAt()); log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
return true;
} catch (LicenseRelay.RelayException e) { } catch (LicenseRelay.RelayException e) {
current.setLastRefreshAttemptAt(now); current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false); current.setLastRefreshSucceeded(false);
@@ -209,13 +209,11 @@ public class LicenseService {
} else { } else {
log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage()); log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
} }
return true;
} catch (JwtVerifier.JwtVerificationException e) { } catch (JwtVerifier.JwtVerificationException e) {
current.setLastRefreshAttemptAt(now); current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false); current.setLastRefreshSucceeded(false);
repository.save(current); repository.save(current);
log.error("Relay returned a JWT that fails verification: {}", e.getMessage()); log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
return true;
} }
} }

View File

@@ -77,7 +77,7 @@ public class LoreNodeService {
public void reorderNodes(String parentId, List<String> orderedIds) { public void reorderNodes(String parentId, List<String> orderedIds) {
String parent = (parentId == null || parentId.isBlank()) ? null : parentId; String parent = (parentId == null || parentId.isBlank()) ? null : parentId;
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> loreNodeRepository.findById(id).orElse(null), loreNodeRepository::findById,
(node, i) -> { (node, i) -> {
if (parent == null || !isSelfOrDescendant(parent, node.getId())) { if (parent == null || !isSelfOrDescendant(parent, node.getId())) {
node.setParentId(parent); node.setParentId(parent);

View File

@@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Service d'application pour le contexte Lore. * Service d'application pour le contexte Lore.
@@ -72,7 +71,7 @@ public class LoreService {
public List<Lore> getAllLores() { public List<Lore> getAllLores() {
return loreRepository.findAll().stream() return loreRepository.findAll().stream()
.map(this::withCounts) .map(this::withCounts)
.collect(Collectors.toList()); .toList();
} }
/** /**

View File

@@ -4,7 +4,6 @@ import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.loremind.domain.shared.CollectionUtils;
import com.loremind.domain.shared.ReorderSupport; import com.loremind.domain.shared.ReorderSupport;
import java.util.ArrayList; import java.util.ArrayList;
@@ -84,14 +83,7 @@ public class PageService {
existing.setTitle(changes.getTitle()); existing.setTitle(changes.getTitle());
existing.setNodeId(changes.getNodeId()); existing.setNodeId(changes.getNodeId());
existing.setValues(CollectionUtils.copyMap(changes.getValues())); existing.applyEditableContent(changes);
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
existing.setImageFraming(CollectionUtils.copyMap(changes.getImageFraming()));
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
existing.setNotes(changes.getNotes());
existing.setTags(CollectionUtils.copyList(changes.getTags()));
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
return pageRepository.save(existing); return pageRepository.save(existing);
} }
@@ -106,7 +98,7 @@ public class PageService {
@org.springframework.transaction.annotation.Transactional @org.springframework.transaction.annotation.Transactional
public void reorderPages(String nodeId, List<String> orderedIds) { public void reorderPages(String nodeId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds, ReorderSupport.reorder(orderedIds,
id -> pageRepository.findById(id).orElse(null), pageRepository::findById,
(page, i) -> { (page, i) -> {
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId); if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
page.setOrder(i); page.setOrder(i);

View File

@@ -7,6 +7,7 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -38,7 +39,7 @@ public class SessionEntryService {
} }
validateContent(data.content()); validateContent(data.content());
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
SessionEntry entry = SessionEntry.builder() SessionEntry entry = SessionEntry.builder()
.sessionId(sessionId) .sessionId(sessionId)
.type(data.type() != null ? data.type() : EntryType.NOTE) .type(data.type() != null ? data.type() : EntryType.NOTE)

View File

@@ -4,10 +4,10 @@ import com.loremind.application.campaigncontext.CampaignReadinessAssessment;
import com.loremind.application.campaigncontext.CampaignReadinessService; import com.loremind.application.campaigncontext.CampaignReadinessService;
import com.loremind.application.campaigncontext.QuestStatusEnricher; import com.loremind.application.campaigncontext.QuestStatusEnricher;
import com.loremind.application.campaigncontext.ReadinessGap; import com.loremind.application.campaigncontext.ReadinessGap;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef; import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.QuestStatus; import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository; import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -20,7 +20,6 @@ import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import com.loremind.domain.playcontext.ports.SessionRepository; import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
@@ -111,7 +110,7 @@ public class SessionPrepService {
} else { } else {
focused = assessment.gaps().stream() focused = assessment.gaps().stream()
.filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds)) .filter(g -> isFocused(g, hotspotChapterIds, hotspotSceneIds, hotspotQuestIds))
.collect(Collectors.toList()); .toList();
} }
int otherGapCount = assessment.gaps().size() - focused.size(); int otherGapCount = assessment.gaps().size() - focused.size();
@@ -122,7 +121,7 @@ public class SessionPrepService {
.filter(c -> c.getFilled() > 0) .filter(c -> c.getFilled() > 0)
.map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(), .map(c -> new SessionPrepReport.ClockInfo(c.getId(), c.getName(), c.getSegments(),
c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null)) c.getFilled(), c.getFrontId() != null ? frontNames.get(c.getFrontId()) : null))
.collect(Collectors.toList()); .toList();
return new SessionPrepReport( return new SessionPrepReport(
playthroughId, playthroughId,
@@ -173,7 +172,7 @@ public class SessionPrepService {
List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId); List<Session> sessions = sessionRepository.findByPlaythroughId(playthroughId);
return sessions.stream() return sessions.stream()
.max(Comparator.comparing(Session::getStartedAt, .max(Comparator.comparing(Session::getStartedAt,
Comparator.nullsFirst(Comparator.<LocalDateTime>naturalOrder()))) Comparator.nullsFirst(Comparator.naturalOrder())))
.map(s -> new SessionPrepReport.LastSessionInfo( .map(s -> new SessionPrepReport.LastSessionInfo(
s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive())) s.getId(), s.getName(), s.getStartedAt(), s.getEndedAt(), s.isActive()))
.orElse(null); .orElse(null);
@@ -183,13 +182,13 @@ public class SessionPrepService {
return quests.stream() return quests.stream()
.filter(q -> statusById.get(q.getId()) == wanted) .filter(q -> statusById.get(q.getId()) == wanted)
.sorted(Comparator.comparingInt(Quest::getOrder)) .sorted(Comparator.comparingInt(Quest::getOrder))
.collect(Collectors.toList()); .toList();
} }
private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) { private static List<SessionPrepReport.QuestInfo> toQuestInfos(List<Quest> quests) {
return quests.stream() return quests.stream()
.map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon())) .map(q -> new SessionPrepReport.QuestInfo(q.getId(), q.getName(), q.getIcon()))
.collect(Collectors.toList()); .toList();
} }
private static List<Quest> concat(List<Quest> a, List<Quest> b) { private static List<Quest> concat(List<Quest> a, List<Quest> b) {

View File

@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -23,6 +24,7 @@ import java.util.Optional;
public class SessionService { public class SessionService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final String SESSION_NOT_FOUND = "Session introuvable : ";
private final SessionRepository sessionRepository; private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository; private final SessionEntryRepository entryRepository;
@@ -57,7 +59,7 @@ public class SessionService {
"). Termine-la avant d'en lancer une nouvelle."); "). Termine-la avant d'en lancer une nouvelle.");
}); });
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
Session session = Session.builder() Session session = Session.builder()
.name(generateDefaultName(now)) .name(generateDefaultName(now))
.playthroughId(playthroughId) .playthroughId(playthroughId)
@@ -68,11 +70,11 @@ public class SessionService {
public Session endSession(String id) { public Session endSession(String id) {
Session session = sessionRepository.findById(id) Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id)); .orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
if (!session.isActive()) { if (!session.isActive()) {
throw new IllegalStateException("Cette session est déjà terminée."); throw new IllegalStateException("Cette session est déjà terminée.");
} }
session.setEndedAt(LocalDateTime.now()); session.setEndedAt(LocalDateTime.now(ZoneId.systemDefault()));
Session saved = sessionRepository.save(session); Session saved = sessionRepository.save(session);
// Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie. // Co-MJ : la séance se clôture -> avancer les horloges « fin de séance » de la Partie.
clockService.onSessionEnded(saved.getPlaythroughId()); clockService.onSessionEnded(saved.getPlaythroughId());
@@ -86,7 +88,7 @@ public class SessionService {
*/ */
public Session setCurrentScene(String id, String sceneId) { public Session setCurrentScene(String id, String sceneId) {
Session session = sessionRepository.findById(id) Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id)); .orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null); session.setCurrentSceneId(sceneId != null && !sceneId.isBlank() ? sceneId : null);
return sessionRepository.save(session); return sessionRepository.save(session);
} }
@@ -96,7 +98,7 @@ public class SessionService {
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide."); throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
} }
Session session = sessionRepository.findById(id) Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id)); .orElseThrow(() -> new IllegalArgumentException(SESSION_NOT_FOUND + id));
session.setName(newName.trim()); session.setName(newName.trim());
return sessionRepository.save(session); return sessionRepository.save(session);
} }
@@ -124,7 +126,7 @@ public class SessionService {
@Transactional @Transactional
public void deleteSession(String id) { public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) { if (!sessionRepository.existsById(id)) {
throw new IllegalArgumentException("Session introuvable : " + id); throw new IllegalArgumentException(SESSION_NOT_FOUND + id);
} }
entryRepository.deleteBySessionId(id); entryRepository.deleteBySessionId(id);
sessionRepository.deleteById(id); sessionRepository.deleteById(id);

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
/** /**
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne. * Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
/** /**
* Proposition IA pour UN champ textuel d'une entité (Pilier A co-création). * Proposition IA pour UN champ textuel d'une entité (Pilier A co-création).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
/** /**
* Ébauche de scène proposée par l'IA (Pilier A capacité « create »). Value Object * Ébauche de scène proposée par l'IA (Pilier A capacité « create »). Value Object

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.generation;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.itemcatalog;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.itemcatalog;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.notebook;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.notebook;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.notebook;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.structure.Arc;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,7 +1,7 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.CampaignImportProgress; import com.loremind.domain.campaigncontext.generation.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal; import com.loremind.domain.campaigncontext.generation.CampaignImportProposal;
import java.util.function.Consumer; import java.util.function.Consumer;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.structure.Chapter;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.bestiary.Character;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Enemy; import com.loremind.domain.campaigncontext.bestiary.Enemy;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.CatalogItem; import com.loremind.domain.campaigncontext.itemcatalog.CatalogItem;
import java.util.List; import java.util.List;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.ItemCatalog; import com.loremind.domain.campaigncontext.itemcatalog.ItemCatalog;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -16,11 +16,22 @@ public interface NotebookChatStreamer {
record Progress(int current, int total) {} record Progress(int current, int total) {}
/** /**
* Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil * Callbacks du stream, invoqués au fil de l'eau : {@code onSourcesJson} UNE fois
* de l'eau : {@code onSourcesJson} UNE fois avant le premier token (JSON brut des * avant le premier token (JSON brut des passages utilisés — transparence UI),
* passages utilisés — transparence UI), {@code onToken} par fragment, * {@code onToken} par fragment, {@code onProgress} (mode approfondi uniquement)
* {@code onProgress} (mode approfondi uniquement) pendant la lecture du document, * pendant la lecture du document, {@code onDone} à la fin, {@code onError} en
* {@code onDone} à la fin, {@code onError} en cas d'échec. * cas d'échec. Regroupés en un seul objet (Argument Object) : ils voyagent
* toujours ensemble entre ce port, son adaptateur et le controller SSE.
*/
record Callbacks(
Consumer<String> onSourcesJson,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError) {}
/**
* Streame la réponse ancrée sur les sources.
* *
* @param deep true = analyse approfondie (map-reduce sur tout le document) ; * @param deep true = analyse approfondie (map-reduce sur tout le document) ;
* false = chat RAG (top-k). * false = chat RAG (top-k).
@@ -30,9 +41,5 @@ public interface NotebookChatStreamer {
List<Msg> messages, List<Msg> messages,
String context, String context,
boolean deep, boolean deep,
Consumer<String> onSourcesJson, Callbacks callbacks);
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError);
} }

View File

@@ -1,8 +1,8 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Notebook; import com.loremind.domain.campaigncontext.notebook.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage; import com.loremind.domain.campaigncontext.notebook.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource; import com.loremind.domain.campaigncontext.notebook.NotebookSource;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.bestiary.Npc;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Quest; import com.loremind.domain.campaigncontext.quest.Quest;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTableEntry; import com.loremind.domain.campaigncontext.randomtable.RandomTableEntry;
import java.util.List; import java.util.List;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTable; import com.loremind.domain.campaigncontext.randomtable.RandomTable;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.SceneDraft; import com.loremind.domain.campaigncontext.generation.SceneDraft;
import java.util.List; import java.util.List;

View File

@@ -1,6 +1,6 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.structure.Scene;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible, * Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible,

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Échec de génération IA d'un catalogue d'objets (Brain injoignable, erreur du * Échec de génération IA d'un catalogue d'objets (Brain injoignable, erreur du

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Erreur de génération lors de l'étoffage IA d'une entité narrative (Brain injoignable, * Erreur de génération lors de l'étoffage IA d'une entité narrative (Brain injoignable,

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle). * Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext.ports; package com.loremind.domain.campaigncontext.ports.exceptions;
/** /**
* Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du * Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
/** /**
* Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}. * Type de nœud narratif référencé par une {@link Quest} via {@link QuestNodeRef}.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
/** /**
* Condition de déblocage d'une quête (Chapter dans un Arc HUB). * Condition de déblocage d'une quête (Chapter dans un Arc HUB).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
/** /**
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ. * Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
/** /**
* Value Object : lien faible d'une {@link Quest} vers un nœud narratif * Value Object : lien faible d'une {@link Quest} vers un nœud narratif

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.quest;
/** /**
* Statut effectif d'une quête tel qu'affiché dans la vue Hub. * Statut effectif d'une quête tel qu'affiché dans la vue Hub.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.randomtable;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.randomtable;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.readiness;
/** /**
* Type de l'entité de scénario ciblée par un manque de readiness. Le front s'en * Type de l'entité de scénario ciblée par un manque de readiness. Le front s'en

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.readiness;
/** /**
* Gravité d'un manque de préparation détecté par le guidage (Pilier B). * Gravité d'un manque de préparation détecté par le guidage (Pilier B).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.readiness;
/** /**
* Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »). * Statut de PRÉPARATION d'une entité de scénario (Pilier B « guidage / readiness »).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
/** /**
* Type structurel d'un Arc. * Type structurel d'un Arc.
@@ -9,7 +9,6 @@ package com.loremind.domain.campaigncontext;
* narration dans l'arbre (ses quêtes s'affichent sous « Quêtes ») ; dans * narration dans l'arbre (ses quêtes s'affichent sous « Quêtes ») ; dans
* les exports (PDF / Foundry / backup) il apparaît sous son nom ses * les exports (PDF / Foundry / backup) il apparaît sous son nom ses
* scènes sont du vrai contenu jouable. * scènes sont du vrai contenu jouable.
*
* Value Object du domaine (Bounded Context : Campaign). * Value Object du domaine (Bounded Context : Campaign).
*/ */
public enum ArcType { public enum ArcType {

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
/** /**
* Type d'un lien narratif entre nœuds ({@link SceneBranch}) Niveau 2. * Type d'un lien narratif entre nœuds ({@link SceneBranch}) Niveau 2.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
/** /**
* Sortie d'une pièce vers une autre pièce de la même Scene explorable. * Sortie d'une pièce vers une autre pièce de la même Scene explorable.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
/** /**
* Value Object représentant UNE battlemap d'une Scene destinée à l'export Foundry : * Value Object représentant UNE battlemap d'une Scene destinée à l'export Foundry :

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
/** /**
* Value Object représentant une "sortie" narrative depuis une Scene. * Value Object représentant une "sortie" narrative depuis une Scene.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext; package com.loremind.domain.campaigncontext.structure;
/** /**
* Type narratif d'un nœud (Scène) Niveau 2 (graphe de nœuds typés). * Type narratif d'un nœud (Scène) Niveau 2 (graphe de nœuds typés).

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