Compare commits

..

1 Commits

Author SHA1 Message Date
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
243 changed files with 1877 additions and 1253 deletions

View File

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

View File

@@ -41,6 +41,16 @@ public class CampaignBriefBuilder {
sb.append("# Campagne : ").append(cc.campaignName()).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("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
.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");
}
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());
if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n");
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());
sb.append("\n");
for (SceneSummary sc : ch.scenes()) {
@@ -61,9 +81,9 @@ public class CampaignBriefBuilder {
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");
for (NpcSummary n : cc.npcs()) {
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) {
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).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;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.NpcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.generation.CampaignImportProgress;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.NpcProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.generation.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -83,14 +83,32 @@ public class CampaignImportService {
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
// via leur existingId dans l'arbre fusionné venu de la revue).
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
for (ArcProposal arcP : proposal.arcs()) {
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;
int arcsCreated = 0;
if (!isBlank(arcP.existingId())) {
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
} else {
@@ -103,13 +121,28 @@ public class CampaignImportService {
.type(parseArcType(arcP.type()))
.build());
arcId = arc.getId();
arcsCreated++;
arcsCreated = 1;
}
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
int chaptersCreated = 0;
int scenesCreated = 0;
for (ChapterProposal chapP : safe(arcP.chapters())) {
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;
int chaptersCreated = 0;
if (!isBlank(chapP.existingId())) {
chapId = chapP.existingId();
} else {
@@ -117,13 +150,13 @@ public class CampaignImportService {
Chapter chapter = chapterService.createChapter(
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
chapId = chapter.getId();
chaptersCreated++;
chaptersCreated = 1;
}
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
int scenesCreated = 0;
for (SceneProposal sceneP : safe(chapP.scenes())) {
if (isBlank(sceneP.name())) continue;
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
if (isBlank(sceneP.name()) || !isBlank(sceneP.existingId())) continue; // vide ou déjà présente
sceneOrder++;
sceneService.createScene(Scene.builder()
.name(sceneP.name().trim())
@@ -136,11 +169,7 @@ public class CampaignImportService {
.build());
scenesCreated++;
}
}
}
int npcsCreated = createNpcs(campaignId, proposal.npcs());
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
return new ChapterOutcome(chapterOrder, chaptersCreated, scenesCreated);
}
/**
@@ -182,7 +211,8 @@ public class CampaignImportService {
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
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). */

View File

@@ -1,6 +1,6 @@
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.Map;

View File

@@ -1,20 +1,19 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.ReadinessEntityType;
import com.loremind.domain.campaigncontext.ReadinessSeverity;
import com.loremind.domain.campaigncontext.ReadinessStatus;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.RoomBranch;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.quest.Prerequisite;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
import com.loremind.domain.campaigncontext.readiness.ReadinessStatus;
import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.structure.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
@@ -55,6 +54,8 @@ import java.util.stream.Collectors;
@Service
public class CampaignReadinessService {
private static final String SCENE_FALLBACK_NAME = "Scène";
private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
@@ -88,11 +89,6 @@ public class CampaignReadinessService {
Set<String> enemyIds = enemyRepository.findByCampaignId(campaignId).stream()
.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);
// 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).
@@ -102,20 +98,52 @@ public class CampaignReadinessService {
List<Arc> arcs = new ArrayList<>(arcRepository.findByCampaignId(campaignId));
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) {
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());
// Arc SYSTEM (conteneurs des quêtes libres) : jamais « vide » — c'est de la
// plomberie invisible. Ses chapitres restent analysés (CHAP-001 des conteneurs).
boolean hubCoveredByQuest = arc.getType() == ArcType.HUB && arcsWithQuests.contains(arc.getId());
if (chapters.isEmpty() && !hubCoveredByQuest && arc.getType() != ArcType.SYSTEM) {
String msg = arc.getType() == ArcType.HUB
? "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));
gaps.add(emptyArcGap(arc));
}
Set<String> chapterIds = new HashSet<>();
Set<String> sceneIds = new HashSet<>();
int sceneCount = 0;
for (Chapter chapter : chapters) {
allChapterIds.add(chapter.getId());
chapterIds.add(chapter.getId());
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
if (scenes.isEmpty()) {
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
@@ -125,59 +153,66 @@ public class CampaignReadinessService {
}
Set<String> chapterSceneIds = scenes.stream()
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
totalScenes += scenes.size();
sceneCount += scenes.size();
for (Scene scene : scenes) {
allSceneIds.add(scene.getId());
sceneIds.add(scene.getId());
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
}
}
return new ArcScan(chapterIds, sceneIds, sceneCount);
}
Set<String> questIds = quests.stream()
.map(Quest::getId).filter(Objects::nonNull).collect(Collectors.toSet());
// Campagne vide : ni scène jouable, ni quête porteuse de contenu (couvre le mode plat).
boolean anyQuestWithNodes = quests.stream()
.anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
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));
private ReadinessGap emptyArcGap(Arc arc) {
String msg = arc.getType() == ArcType.HUB
? "Arc vide : ajoutez une quête (ou un chapitre), ou supprimez-le."
: "Arc vide : ajoutez un chapitre, ou supprimez-le.";
return new ReadinessGap(ReadinessEntityType.ARC, arc.getId(), labelOr(arc.getName(), "Arc"),
"ARC-001-EMPTY", msg, ReadinessSeverity.BLOCKING, arc.getId(), null);
}
for (Quest quest : quests) {
checkQuest(quest, allChapterIds, allSceneIds, questIds, gaps);
}
return aggregate(campaignId, gaps);
private static boolean anyQuestHasNodes(List<Quest> quests) {
return quests.stream().anyMatch(q -> q.getNodes() != null && !q.getNodes().isEmpty());
}
private void checkScene(Scene scene, String arcId, String chapterId,
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())) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-001-NO-NAME",
"Scène sans titre : donnez-lui un nom pour l'identifier et la jouer.",
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();
if (branches != null && branches.stream().anyMatch(b ->
if (branches == null) return;
boolean invalid = branches.stream().anyMatch(b ->
isBlank(b.targetSceneId())
|| 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",
"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.",
ReadinessSeverity.BLOCKING));
}
}
// SCENE-011 — combat annoncé sans adversaire (règle produit clé).
if (!isBlank(scene.getCombatDifficulty())) {
/** SCENE-011 — combat annoncé sans adversaire (règle produit clé). */
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 hasResolvedEnemy = scene.getEnemyIds() != null
&& 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).
if (scene.getEnemyIds() != null
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && !enemyIds.contains(id))) {
/** SCENE-012 — référence d'ennemi cassée (fiche supprimée). */
private void checkSceneEnemyRefs(Scene scene, String arcId, String chapterId,
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",
"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.",
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();
if (rooms != null && !rooms.isEmpty()) {
if (rooms == null || rooms.isEmpty()) return;
String name = labelOr(scene.getName(), SCENE_FALLBACK_NAME);
Set<String> roomIds = rooms.stream()
.map(Room::getId).filter(Objects::nonNull).collect(Collectors.toSet());
boolean roomBranchInvalid = false;
boolean roomEnemyBroken = false;
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) {
if (rooms.stream().anyMatch(room -> hasInvalidBranch(room, roomIds))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-041-ROOMBRANCH-INVALID",
"Porte cassée : dans « " + name
+ " », une sortie de pièce pointe hors de la scène ou dans le vide.",
ReadinessSeverity.BLOCKING));
}
if (roomEnemyBroken) {
if (rooms.stream().anyMatch(room -> hasBrokenEnemyRef(room, enemyIds))) {
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-042-ROOM-ENEMY-BROKEN",
"Ennemi introuvable dans une pièce de « " + name
+ " » : la référence pointe vers une fiche supprimée.",
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,
@@ -244,8 +279,7 @@ public class CampaignReadinessService {
// QUEST-001 — quête sans nœud ; sinon QUEST-010 — nœud pointant dans le vide.
if (quest.getNodes() == null || quest.getNodes().isEmpty()) {
gaps.add(questGap(quest, "QUEST-001-NO-NODES",
"Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " ».",
ReadinessSeverity.BLOCKING));
"Quête sans contenu : ajoutez au moins un chapitre ou une scène à « " + name + " »."));
} else if (quest.getNodes().stream().anyMatch(n ->
n.nodeType() == null
|| isBlank(n.nodeId())
@@ -253,8 +287,7 @@ public class CampaignReadinessService {
|| (n.nodeType() == NodeType.SCENE && !allSceneIds.contains(n.nodeId())))) {
gaps.add(questGap(quest, "QUEST-010-NODE-REF-BROKEN",
"Nœud de quête cassé : dans « " + name
+ " », un chapitre ou une scène référencé n'existe plus.",
ReadinessSeverity.BLOCKING));
+ " », un chapitre ou une scène référencé n'existe plus."));
}
// CAMP-010 — prérequis QuestCompleted pointant une quête disparue.
@@ -264,8 +297,7 @@ public class CampaignReadinessService {
.anyMatch(qid -> isBlank(qid) || !questIds.contains(qid))) {
gaps.add(questGap(quest, "CAMP-010-DANGLING-QUEST-PREREQ",
"Prérequis cassé : « " + name
+ " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage.",
ReadinessSeverity.BLOCKING));
+ " » dépend d'une quête qui n'existe plus. Corrigez la condition de déblocage."));
}
}
@@ -294,12 +326,12 @@ public class CampaignReadinessService {
private ReadinessGap sceneGap(Scene scene, String arcId, String chapterId,
String ruleId, String message, ReadinessSeverity severity) {
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(),
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) {

View File

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

View File

@@ -1,9 +1,9 @@
package com.loremind.application.campaigncontext;
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.Chapter;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;

View File

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

View File

@@ -1,6 +1,6 @@
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 org.springframework.stereotype.Service;
@@ -41,9 +41,9 @@ public class CharacterService {
.name(data.name())
.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<>())
.values(copyStringMap(data.values()))
.imageValues(copyStringListMap(data.imageValues()))
.keyValueValues(copyStringMapMap(data.keyValueValues()))
.playthroughId(data.playthroughId())
.order(order)
.build();
@@ -64,9 +64,9 @@ public class CharacterService {
existing.setName(data.name());
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<>());
existing.setValues(copyStringMap(data.values()));
existing.setImageValues(copyStringListMap(data.imageValues()));
existing.setKeyValueValues(copyStringMapMap(data.keyValueValues()));
if (data.order() != null) {
existing.setOrder(data.order());
}
@@ -89,4 +89,16 @@ public class CharacterService {
.max()
.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;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.bestiary.Enemy;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.images.Image;
@@ -10,11 +10,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.*;
/**
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
@@ -48,18 +44,10 @@ public class EnemyService {
public Enemy createEnemy(EnemyData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
Enemy enemy = Enemy.builder()
.name(data.name())
.level(normalize(data.level()))
.folder(normalize(data.folder()))
.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();
Enemy enemy = Enemy.builder().build();
applyCommonEnemyFields(enemy, data);
enemy.setCampaignId(data.campaignId());
enemy.setOrder(order);
return enemyRepository.save(enemy);
}
@@ -74,17 +62,11 @@ public class EnemyService {
public Enemy updateEnemy(String id, EnemyData data) {
Enemy existing = enemyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
existing.setName(data.name());
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<>());
applyCommonEnemyFields(existing, data);
if (data.order() != null) {
existing.setOrder(data.order());
}
// campaignId immuable après création.
return enemyRepository.save(existing);
}
@@ -100,7 +82,7 @@ public class EnemyService {
public void reorderEnemies(String folder, List<String> orderedIds) {
String f = normalize(folder);
ReorderSupport.reorder(orderedIds,
id -> enemyRepository.findById(id).orElse(null),
enemyRepository::findById,
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
enemyRepository::save);
}
@@ -141,9 +123,7 @@ public class EnemyService {
return null;
}
if (bytes.length == 0) return null;
String ext = contentType.contains("png") ? "png"
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
: contentType.contains("gif") ? "gif" : "webp";
String ext = pickExtension(contentType);
// 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").
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) {}
/**
@@ -174,44 +162,49 @@ public class EnemyService {
}
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) {
if (m.foundryRef() == null || m.foundryRef().isBlank()
|| m.name() == null || m.name().isBlank()) {
continue;
}
if (isBlank(m.foundryRef()) || isBlank(m.name())) continue;
Map<String, String> stats = m.stats() != null ? new HashMap<>(m.stats()) : new HashMap<>();
String folder = foundryFolder(m.folder());
Enemy ex = byRef.get(m.foundryRef());
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.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).
if (ex.getPortraitImageId() == null) {
String portrait = storePortrait(m.imgData(), m.name());
if (portrait != null) ex.setPortraitImageId(portrait);
}
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())
.foundryRef(m.foundryRef())
.foundryStats(stats)
.folder(folder)
.folder(foundryFolder(m.folder()))
.portraitImageId(storePortrait(m.imgData(), m.name()))
.campaignId(campaignId)
.order(order++)
.order(order)
.values(new HashMap<>())
.imageValues(new HashMap<>())
.keyValueValues(new HashMap<>())
.build());
byRef.put(m.foundryRef(), saved);
created++;
}
}
return new MonsterImportResult(created, updated);
}
public List<Enemy> searchEnemies(String query) {
@@ -226,10 +219,37 @@ public class EnemyService {
return trimmed.isEmpty() ? null : trimmed;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private int nextOrderFor(String campaignId) {
return enemyRepository.findByCampaignId(campaignId).stream()
.mapToInt(Enemy::getOrder)
.max()
.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;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.CatalogItem;
import com.loremind.domain.campaigncontext.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.itemcatalog.CatalogItem;
import com.loremind.domain.campaigncontext.itemcatalog.ItemCatalog;
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -21,18 +18,15 @@ public class ItemCatalogService {
private final ItemCatalogRepository repository;
private final ItemCatalogGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
private final CampaignContextFormatter campaignContextFormatter;
public ItemCatalogService(
ItemCatalogRepository repository,
ItemCatalogGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
CampaignContextFormatter campaignContextFormatter) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
this.campaignContextFormatter = campaignContextFormatter;
}
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. */
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()
.name(g.name())
.description(g.description())
@@ -102,22 +97,6 @@ public class ItemCatalogService {
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) {
return repository.findByCampaignId(campaignId).stream()
.mapToInt(ItemCatalog::getOrder)

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.NodeType;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestNodeRef;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.quest.NodeType;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
@@ -119,15 +119,6 @@ public class QuestService {
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). */
@Transactional
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.
*
* <p>TODO (Phase 5) : signaler/nettoyer les {@code Prerequisite.QuestCompleted} pendants
* d'autres quêtes qui pointaient celle-ci. É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>
* <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
* 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
public void deleteQuest(String id) {
@@ -201,15 +193,11 @@ public class QuestService {
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. */
@Transactional
public void reorderQuests(String campaignId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> questRepository.findById(id).orElse(null),
questRepository::findById,
(quest, i) -> {
if (campaignId != null && !campaignId.isBlank()) quest.setCampaignId(campaignId);
quest.setOrder(i);

View File

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

View File

@@ -1,13 +1,10 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.randomtable.RandomTable;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.randomtable.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -22,18 +19,15 @@ public class RandomTableService {
private final RandomTableRepository repository;
private final RandomTableGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
private final CampaignContextFormatter campaignContextFormatter;
public RandomTableService(
RandomTableRepository repository,
RandomTableGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
CampaignContextFormatter campaignContextFormatter) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
this.campaignContextFormatter = campaignContextFormatter;
}
public record TableData(
@@ -90,8 +84,8 @@ public class RandomTableService {
@org.springframework.transaction.annotation.Transactional
public void reorderTables(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> repository.findById(id).orElse(null),
(table, i) -> table.setOrder(i),
repository::findById,
RandomTable::setOrder,
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. */
public RandomTable generateProposal(String campaignId, String description, String 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()
.name(g.name())
.description(g.description())
@@ -115,24 +110,7 @@ public class RandomTableService {
/** 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) {
return generator.improvise(tableName, resultLabel, resultDetail, buildContext(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();
return generator.improvise(tableName, resultLabel, resultDetail, campaignContextFormatter.format(campaignId));
}
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {

View File

@@ -1,7 +1,7 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.ReadinessEntityType;
import com.loremind.domain.campaigncontext.ReadinessSeverity;
import com.loremind.domain.campaigncontext.readiness.ReadinessEntityType;
import com.loremind.domain.campaigncontext.readiness.ReadinessSeverity;
/**
* 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;
import com.loremind.domain.campaigncontext.FieldProposal;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneDraft;
import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.generation.SceneDraft;
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 org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@@ -171,7 +171,7 @@ public class SceneService {
if (id.equals(sibling.getId()) || branches == null || branches.isEmpty()) continue;
List<SceneBranch> kept = branches.stream()
.filter(b -> !id.equals(b.targetSceneId()))
.collect(Collectors.toList());
.toList();
if (kept.size() != branches.size()) {
sibling.setBranches(kept);
sceneRepository.save(sibling);
@@ -193,7 +193,7 @@ public class SceneService {
@Transactional
public void reorderScenes(String chapterId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> sceneRepository.findById(id).orElse(null),
sceneRepository::findById,
(scene, i) -> {
if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) {
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.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Optional;
import java.util.Set;
@@ -24,18 +25,18 @@ import java.util.Set;
@Service
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/*}). */
private static final Set<String> ALLOWED_EXACT_MIME = Set.of(
"application/json",
"application/octet-stream",
DEFAULT_CONTENT_TYPE,
"text/plain"
);
/** 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 String DEFAULT_CONTENT_TYPE = "application/octet-stream";
private final StoredFileRepository repository;
private final FileStorage storage;
@@ -60,7 +61,7 @@ public class StoredFileService {
.contentType(resolvedType)
.sizeBytes(sizeBytes)
.storageKey(storageKey)
.uploadedAt(LocalDateTime.now())
.uploadedAt(LocalDateTime.now(ZoneId.systemDefault()))
.build();
return repository.save(file);
} catch (RuntimeException ex) {

View File

@@ -27,8 +27,13 @@ import java.util.regex.Pattern;
@Service
public class GameSystemContextBuilder {
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
/**
* 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;

View File

@@ -119,27 +119,29 @@ public class GameSystemService {
List<TemplateField> template = new ArrayList<>();
Set<String> usedNames = new HashSet<>();
for (FoundryStructField f : fields == null ? List.<FoundryStructField>of() : fields) {
if (f.path() == null || f.path().isBlank()) continue;
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())) 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()));
for (FoundryStructField f : fields != null ? fields : List.<FoundryStructField>of()) {
TemplateField field = toTemplateField(f, usedNames);
if (field != null) template.add(field);
}
gs.replaceEnemyTemplate(template);
gs.setFoundryActorType(normalize(actorType));
return gameSystemRepository.save(gs);
}
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
/** Convertit un champ Foundry en TemplateField, ou null si son chemin est vide ou son nom déjà pris. */
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) {
return gameSystemRepository.existsById(id);
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
}
public List<GameSystem> searchGameSystems(String query) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.EntityFieldPatchProposal;
import com.loremind.domain.campaigncontext.FieldProposal;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.application.campaigncontext.CampaignContextFormatter;
import com.loremind.domain.campaigncontext.generation.EntityFieldPatchProposal;
import com.loremind.domain.campaigncontext.generation.FieldProposal;
import com.loremind.domain.campaigncontext.ports.NarrativeFieldAssistant;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -26,18 +25,15 @@ import java.util.stream.Collectors;
public class NarrativeAssistFieldsUseCase {
private final NarrativeFieldCatalog catalog;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
private final CampaignContextFormatter campaignContextFormatter;
private final NarrativeFieldAssistant assistant;
public NarrativeAssistFieldsUseCase(
NarrativeFieldCatalog catalog,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository,
CampaignContextFormatter campaignContextFormatter,
NarrativeFieldAssistant assistant) {
this.catalog = catalog;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
this.campaignContextFormatter = campaignContextFormatter;
this.assistant = assistant;
}
@@ -46,7 +42,7 @@ public class NarrativeAssistFieldsUseCase {
List<NarrativeFieldAssistant.FieldSpec> specs = snap.defs().stream()
.map(d -> new NarrativeFieldAssistant.FieldSpec(d.key(), d.label()))
.collect(Collectors.toList());
.toList();
Set<String> allowed = snap.defs().stream()
.map(NarrativeFieldCatalog.FieldDef::key).collect(Collectors.toSet());
@@ -56,19 +52,26 @@ public class NarrativeAssistFieldsUseCase {
List<FieldProposal> fields = new ArrayList<>();
for (NarrativeFieldAssistant.ProposedField pf : proposed) {
if (pf.key() == null || !allowed.contains(pf.key())) continue;
if (pf.value() == null || pf.value().isBlank()) continue;
String current = snap.current().getOrDefault(pf.key(), "");
fields.add(new FieldProposal(pf.key(), current, pf.value()));
FieldProposal proposal = toFieldProposal(pf, allowed, snap);
if (proposal != null) fields.add(proposal);
}
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. */
private String buildContext(String campaignId, NarrativeFieldCatalog.Snapshot snap) {
StringBuilder sb = new StringBuilder();
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");
boolean any = false;
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");
}
if (campaignId != null && !campaignId.isBlank()) {
campaignRepository.findById(campaignId).ifPresent(c -> {
sb.append("Campagne : ").append(c.getName());
if (c.getDescription() != null && !c.getDescription().isBlank()) {
sb.append("").append(c.getDescription().trim());
String campaignBlock = campaignContextFormatter.format(campaignId);
if (!campaignBlock.isBlank()) {
sb.append(campaignBlock).append("\n");
}
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();
}
@@ -106,7 +102,7 @@ public class NarrativeAssistFieldsUseCase {
};
}
private static String blankToLabel(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
private static String blankToLabel(String value) {
return value == null || value.isBlank() ? "(sans titre)" : value;
}
}

View File

@@ -1,10 +1,10 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.bestiary.Character;
import com.loremind.domain.campaigncontext.bestiary.Npc;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
@@ -28,6 +28,9 @@ import java.util.Map;
@Component
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 ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
@@ -144,14 +147,14 @@ public class NarrativeEntityContextBuilder {
StringBuilder sb = new StringBuilder();
for (String enemyId : s.getEnemyIds()) {
enemyRepository.findById(enemyId).ifPresent(e -> {
if (sb.length() > 0) sb.append("\n");
if (!sb.isEmpty()) sb.append("\n");
sb.append("- ").append(e.getName());
if (e.getLevel() != null && !e.getLevel().isBlank()) {
sb.append(" (").append(e.getLevel().trim()).append(")");
}
String stats = e.getValues().entrySet().stream()
.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(" ; "));
if (!stats.isEmpty()) sb.append("").append(stats);
});
@@ -159,8 +162,8 @@ public class NarrativeEntityContextBuilder {
return sb.toString();
}
private static String truncate(String value, int maxLen) {
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "";
private static String truncate(String value) {
return value.length() <= STAT_VALUE_MAX_LEN ? value : value.substring(0, STAT_VALUE_MAX_LEN - 1).stripTrailing() + "";
}
private NarrativeEntityContext fromCharacter(Character c) {

View File

@@ -1,8 +1,8 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.structure.Arc;
import com.loremind.domain.campaigncontext.structure.Chapter;
import com.loremind.domain.campaigncontext.structure.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -29,22 +29,25 @@ public class NarrativeFieldCatalog {
public record Snapshot(String entityType, String title,
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(
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("stakes", "enjeux globaux pour les personnages"),
new FieldDef("rewards", "récompenses et progression"),
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(
new FieldDef("description", "synopsis du chapitre"),
new FieldDef(FIELD_DESCRIPTION, "synopsis du chapitre"),
new FieldDef("playerObjectives", "objectifs des joueurs"),
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(
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("timing", "moment / temporalité"),
new FieldDef("atmosphere", "ambiance (sons, odeurs, émotions, lumière)"),
@@ -80,12 +83,12 @@ public class NarrativeFieldCatalog {
Arc a = arcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
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("stakes", nz(a.getStakes()));
cur.put("rewards", nz(a.getRewards()));
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);
}
@@ -93,10 +96,10 @@ public class NarrativeFieldCatalog {
Chapter c = chapterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
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("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);
}
@@ -104,7 +107,7 @@ public class NarrativeFieldCatalog {
Scene s = sceneRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
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("timing", nz(s.getTiming()));
cur.put("atmosphere", nz(s.getAtmosphere()));

View File

@@ -1,9 +1,9 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.Quest;
import com.loremind.domain.campaigncontext.QuestStatus;
import com.loremind.domain.campaigncontext.quest.PrerequisiteEvaluator;
import com.loremind.domain.campaigncontext.quest.ProgressionStatus;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.quest.QuestStatus;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
@@ -26,7 +26,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
@@ -62,10 +61,6 @@ public class SessionStructuralContextBuilder {
this.questProgressionRepository = questProgressionRepository;
}
public Optional<SessionContext> buildOptional(String sessionId) {
return sessionRepository.findById(sessionId).map(this::toContext);
}
public SessionContext build(String sessionId) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -97,7 +92,7 @@ public class SessionStructuralContextBuilder {
return kept.stream()
.map(e -> toSummary(e, null))
.collect(Collectors.toList());
.toList();
}
/**
@@ -209,6 +204,6 @@ public class SessionStructuralContextBuilder {
.filter(Map.Entry::getValue)
.map(Map.Entry::getKey)
.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.ChatMessage;
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.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -15,7 +15,6 @@ import com.loremind.domain.generationcontext.ports.AiChatProvider;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
@@ -72,10 +71,7 @@ public class StreamChatForCampaignUseCase {
String entityType,
String entityId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
ChatStreamCallbacks callbacks) {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException(
@@ -94,7 +90,7 @@ public class StreamChatForCampaignUseCase {
.gameSystemContext(gameSystemContext)
.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.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.ChatStreamCallbacks;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
@@ -61,10 +60,7 @@ public class StreamChatForLoreUseCase {
String loreId,
String pageId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
ChatStreamCallbacks callbacks) {
LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
PageContext pageContext = (pageId == null || pageId.isBlank())
@@ -77,7 +73,7 @@ public class StreamChatForLoreUseCase {
.pageContext(pageContext)
.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.ChatMessage;
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.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext;
@@ -19,7 +19,6 @@ import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat IA pendant une Session de jeu.
@@ -71,10 +70,7 @@ public class StreamChatForSessionUseCase {
public void execute(
String sessionId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
ChatStreamCallbacks callbacks) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
@@ -102,7 +98,7 @@ public class StreamChatForSessionUseCase {
.sessionContext(sessionContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
aiChatProvider.streamChat(request, callbacks);
}
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Service d'application pour le contexte Lore.
@@ -72,7 +71,7 @@ public class LoreService {
public List<Lore> getAllLores() {
return loreRepository.findAll().stream()
.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 org.springframework.stereotype.Service;
import com.loremind.domain.shared.CollectionUtils;
import com.loremind.domain.shared.ReorderSupport;
import java.util.ArrayList;
@@ -84,14 +83,7 @@ public class PageService {
existing.setTitle(changes.getTitle());
existing.setNodeId(changes.getNodeId());
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
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()));
existing.applyEditableContent(changes);
return pageRepository.save(existing);
}
@@ -106,7 +98,7 @@ public class PageService {
@org.springframework.transaction.annotation.Transactional
public void reorderPages(String nodeId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> pageRepository.findById(id).orElse(null),
pageRepository::findById,
(page, i) -> {
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
page.setOrder(i);

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.bestiary;
import lombok.Builder;
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.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.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.generation;
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;

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).

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

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.generation;
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.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.itemcatalog;
import lombok.Builder;
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.Data;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.notebook;
import lombok.Builder;
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.Data;

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

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

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

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

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

@@ -16,11 +16,22 @@ public interface NotebookChatStreamer {
record Progress(int current, int total) {}
/**
* Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil
* de l'eau : {@code onSourcesJson} UNE fois avant le premier token (JSON brut des
* passages utilisés — transparence UI), {@code onToken} par fragment,
* {@code onProgress} (mode approfondi uniquement) pendant la lecture du document,
* {@code onDone} à la fin, {@code onError} en cas d'échec.
* Callbacks du stream, invoqués au fil de l'eau : {@code onSourcesJson} UNE fois
* avant le premier token (JSON brut des passages utilisés — transparence UI),
* {@code onToken} par fragment, {@code onProgress} (mode approfondi uniquement)
* pendant la lecture du document, {@code onDone} à la fin, {@code onError} en
* 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) ;
* false = chat RAG (top-k).
@@ -30,9 +41,5 @@ public interface NotebookChatStreamer {
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onSourcesJson,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError);
Callbacks callbacks);
}

View File

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

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

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

View File

@@ -1,6 +1,6 @@
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.Optional;

View File

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

View File

@@ -1,6 +1,6 @@
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.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,

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

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,

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).

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

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}.

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).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.quest;
import java.util.List;
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.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.quest;
import lombok.Builder;
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

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.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.randomtable;
import lombok.Builder;
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.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

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).

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 »).

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.structure;
import lombok.Builder;
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.
@@ -9,7 +9,6 @@ package com.loremind.domain.campaigncontext;
* 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
* scènes sont du vrai contenu jouable.
*
* Value Object du domaine (Bounded Context : Campaign).
*/
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.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.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.structure;
import lombok.AllArgsConstructor;
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.

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.campaigncontext;
package com.loremind.domain.campaigncontext.structure;
import lombok.Builder;
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 :

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.

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).

View File

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

View File

@@ -0,0 +1,25 @@
package com.loremind.domain.generationcontext;
import java.util.function.Consumer;
/**
* Callbacks du streaming chat IA, regroupés en un seul objet (Argument Object) :
* ils voyagent toujours ensemble, dans le même ordre, entre le port
* {@link com.loremind.domain.generationcontext.ports.AiChatProvider}, ses use
* cases appelants et le controller SSE.
*
* @param onUsage invoqué une fois au début du stream avec le bilan d'occupation
* de la fenêtre de contexte (tokens system / history / current /
* max). Peut ne jamais être invoqué si le provider ne supporte
* pas le comptage.
* @param onToken invoqué à chaque token reçu du LLM (peut être appelé de
* nombreuses fois)
* @param onComplete invoqué une fois le stream terminé avec succès
* @param onError invoqué en cas d'erreur (Brain injoignable, timeout, réponse
* invalide). Exclusif avec onComplete.
*/
public record ChatStreamCallbacks(
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {}

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