Compare commits
5 Commits
v1.0.0-bet
...
587e0fe097
| Author | SHA1 | Date | |
|---|---|---|---|
| 587e0fe097 | |||
| 5c3c58265f | |||
| f72161e41e | |||
| 4d05aa5118 | |||
| beafbc2fa9 |
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="1.0.0-beta",
|
||||
version="1.0.1-beta",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>1.0.0-beta</version>
|
||||
<version>1.0.1-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,34 +58,38 @@ public class CampaignBriefBuilder {
|
||||
sb.append("_(aucun arc pour le moment)_\n");
|
||||
}
|
||||
for (ArcSummary arc : cc.arcs()) {
|
||||
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
|
||||
if (notBlank(arc.description())) sb.append(" — ").append(arc.description());
|
||||
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()) {
|
||||
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()) {
|
||||
sb.append(" - Scène : ").append(sc.name());
|
||||
if (notBlank(sc.description())) sb.append(" — ").append(sc.description());
|
||||
sb.append("\n");
|
||||
for (ChapterSummary ch : arc.chapters()) {
|
||||
sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name());
|
||||
if (notBlank(ch.description())) sb.append(" — ").append(ch.description());
|
||||
sb.append("\n");
|
||||
for (SceneSummary sc : ch.scenes()) {
|
||||
sb.append(" - Scène : ").append(sc.name());
|
||||
if (notBlank(sc.description())) sb.append(" — ").append(sc.description());
|
||||
sb.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!cc.npcs().isEmpty()) {
|
||||
sb.append("\n## PNJ existants\n");
|
||||
for (NpcSummary n : cc.npcs()) {
|
||||
sb.append("- ").append(n.name());
|
||||
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
|
||||
sb.append("\n");
|
||||
}
|
||||
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());
|
||||
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
|
||||
sb.append("\n");
|
||||
}
|
||||
|
||||
if (campaign.isLinkedToLore()) {
|
||||
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,66 +83,95 @@ 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;
|
||||
String arcId;
|
||||
if (!isBlank(arcP.existingId())) {
|
||||
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
|
||||
} else {
|
||||
arcOrder++;
|
||||
Arc arc = arcService.createArc(Arc.builder()
|
||||
.name(arcP.name().trim())
|
||||
.description(nullIfBlank(arcP.description()))
|
||||
.campaignId(campaignId)
|
||||
.order(arcOrder)
|
||||
.type(parseArcType(arcP.type()))
|
||||
.build());
|
||||
arcId = arc.getId();
|
||||
arcsCreated++;
|
||||
}
|
||||
|
||||
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
|
||||
for (ChapterProposal chapP : safe(arcP.chapters())) {
|
||||
if (isBlank(chapP.name())) continue;
|
||||
String chapId;
|
||||
if (!isBlank(chapP.existingId())) {
|
||||
chapId = chapP.existingId();
|
||||
} else {
|
||||
chapterOrder++;
|
||||
Chapter chapter = chapterService.createChapter(
|
||||
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
|
||||
chapId = chapter.getId();
|
||||
chaptersCreated++;
|
||||
}
|
||||
|
||||
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
|
||||
for (SceneProposal sceneP : safe(chapP.scenes())) {
|
||||
if (isBlank(sceneP.name())) continue;
|
||||
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
|
||||
sceneOrder++;
|
||||
sceneService.createScene(Scene.builder()
|
||||
.name(sceneP.name().trim())
|
||||
.description(nullIfBlank(sceneP.description()))
|
||||
.playerNarration(nullIfBlank(sceneP.playerNarration()))
|
||||
.gmSecretNotes(nullIfBlank(sceneP.gmNotes()))
|
||||
.chapterId(chapId)
|
||||
.order(sceneOrder)
|
||||
.rooms(toRooms(sceneP.rooms()))
|
||||
.build());
|
||||
scenesCreated++;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
arcOrder++;
|
||||
Arc arc = arcService.createArc(Arc.builder()
|
||||
.name(arcP.name().trim())
|
||||
.description(nullIfBlank(arcP.description()))
|
||||
.campaignId(campaignId)
|
||||
.order(arcOrder)
|
||||
.type(parseArcType(arcP.type()))
|
||||
.build());
|
||||
arcId = arc.getId();
|
||||
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 {
|
||||
chapterOrder++;
|
||||
Chapter chapter = chapterService.createChapter(
|
||||
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
|
||||
chapId = chapter.getId();
|
||||
chaptersCreated = 1;
|
||||
}
|
||||
|
||||
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
|
||||
int scenesCreated = 0;
|
||||
for (SceneProposal sceneP : safe(chapP.scenes())) {
|
||||
if (isBlank(sceneP.name()) || !isBlank(sceneP.existingId())) continue; // vide ou déjà présente
|
||||
sceneOrder++;
|
||||
sceneService.createScene(Scene.builder()
|
||||
.name(sceneP.name().trim())
|
||||
.description(nullIfBlank(sceneP.description()))
|
||||
.playerNarration(nullIfBlank(sceneP.playerNarration()))
|
||||
.gmSecretNotes(nullIfBlank(sceneP.gmNotes()))
|
||||
.chapterId(chapId)
|
||||
.order(sceneOrder)
|
||||
.rooms(toRooms(sceneP.rooms()))
|
||||
.build());
|
||||
scenesCreated++;
|
||||
}
|
||||
return new ChapterOutcome(chapterOrder, chaptersCreated, scenesCreated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée les PNJ proposés (description → values["Description"], même convention
|
||||
* que les cartes d'action des ateliers). Les PNJ portant un nom déjà présent
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,50 +98,28 @@ 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) {
|
||||
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));
|
||||
}
|
||||
for (Chapter chapter : chapters) {
|
||||
allChapterIds.add(chapter.getId());
|
||||
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
|
||||
if (scenes.isEmpty()) {
|
||||
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
|
||||
labelOr(chapter.getName(), "Chapitre"), "CHAP-001-NO-SCENE",
|
||||
"Chapitre vide : ajoutez au moins une scène pour pouvoir le jouer.",
|
||||
ReadinessSeverity.BLOCKING, arc.getId(), chapter.getId()));
|
||||
}
|
||||
Set<String> chapterSceneIds = scenes.stream()
|
||||
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
totalScenes += scenes.size();
|
||||
for (Scene scene : scenes) {
|
||||
allSceneIds.add(scene.getId());
|
||||
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
|
||||
}
|
||||
}
|
||||
ArcScan scan = checkArc(arc, arcsWithQuests, enemyIds, gaps);
|
||||
allChapterIds.addAll(scan.chapterIds());
|
||||
allSceneIds.addAll(scan.sceneIds());
|
||||
totalScenes += scan.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) {
|
||||
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);
|
||||
}
|
||||
@@ -153,88 +127,149 @@ public class CampaignReadinessService {
|
||||
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) {
|
||||
gaps.add(emptyArcGap(arc));
|
||||
}
|
||||
|
||||
Set<String> chapterIds = new HashSet<>();
|
||||
Set<String> sceneIds = new HashSet<>();
|
||||
int sceneCount = 0;
|
||||
for (Chapter chapter : chapters) {
|
||||
chapterIds.add(chapter.getId());
|
||||
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId());
|
||||
if (scenes.isEmpty()) {
|
||||
gaps.add(new ReadinessGap(ReadinessEntityType.CHAPTER, chapter.getId(),
|
||||
labelOr(chapter.getName(), "Chapitre"), "CHAP-001-NO-SCENE",
|
||||
"Chapitre vide : ajoutez au moins une scène pour pouvoir le jouer.",
|
||||
ReadinessSeverity.BLOCKING, arc.getId(), chapter.getId()));
|
||||
}
|
||||
Set<String> chapterSceneIds = scenes.stream()
|
||||
.map(Scene::getId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
sceneCount += scenes.size();
|
||||
for (Scene scene : scenes) {
|
||||
sceneIds.add(scene.getId());
|
||||
checkScene(scene, arc.getId(), chapter.getId(), chapterSceneIds, enemyIds, gaps);
|
||||
}
|
||||
}
|
||||
return new ArcScan(chapterIds, sceneIds, sceneCount);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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())) {
|
||||
boolean hasEnemyText = !isBlank(scene.getEnemies());
|
||||
boolean hasResolvedEnemy = scene.getEnemyIds() != null
|
||||
&& scene.getEnemyIds().stream().anyMatch(id -> !isBlank(id) && enemyIds.contains(id));
|
||||
if (!hasEnemyText && !hasResolvedEnemy) {
|
||||
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-011-COMBAT-NO-ENEMY",
|
||||
"Combat annoncé sans adversaire : ajoutez une fiche du bestiaire ou décrivez les ennemis.",
|
||||
ReadinessSeverity.RECOMMENDED));
|
||||
}
|
||||
/** 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));
|
||||
if (!hasEnemyText && !hasResolvedEnemy) {
|
||||
gaps.add(sceneGap(scene, arcId, chapterId, "SCENE-011-COMBAT-NO-ENEMY",
|
||||
"Combat annoncé sans adversaire : ajoutez une fiche du bestiaire ou décrivez les ennemis.",
|
||||
ReadinessSeverity.RECOMMENDED));
|
||||
}
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
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) {
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
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());
|
||||
|
||||
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 (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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -92,6 +92,22 @@ public class CampaignService {
|
||||
return campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde les positions du graphe de campagne (JSON opaque, état de
|
||||
* présentation possédé par le front). Null/vide = retour à la disposition
|
||||
* automatique. Taille bornée : ce champ ne doit pas devenir un fourre-tout.
|
||||
*/
|
||||
public void updateGraphPositions(String id, String positionsJson) {
|
||||
Campaign campaign = campaignRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Campaign non trouvé avec l'ID: " + id));
|
||||
String value = (positionsJson == null || positionsJson.isBlank()) ? null : positionsJson;
|
||||
if (value != null && value.length() > 200_000) {
|
||||
throw new IllegalArgumentException("Positions de graphe trop volumineuses");
|
||||
}
|
||||
campaign.setGraphPositions(value);
|
||||
campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
private String normalizeId(String id) {
|
||||
return (id == null || id.isBlank()) ? null : id;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,46 +162,51 @@ 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) {
|
||||
ex.setName(m.name());
|
||||
ex.setFoundryStats(stats); // rafraîchit le snapshot
|
||||
ex.setFolder(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);
|
||||
updateMonster(ex, m, stats);
|
||||
updated++;
|
||||
} else {
|
||||
Enemy saved = enemyRepository.save(Enemy.builder()
|
||||
.name(m.name())
|
||||
.foundryRef(m.foundryRef())
|
||||
.foundryStats(stats)
|
||||
.folder(folder)
|
||||
.portraitImageId(storePortrait(m.imgData(), m.name()))
|
||||
.campaignId(campaignId)
|
||||
.order(order++)
|
||||
.values(new HashMap<>())
|
||||
.imageValues(new HashMap<>())
|
||||
.keyValueValues(new HashMap<>())
|
||||
.build());
|
||||
byRef.put(m.foundryRef(), saved);
|
||||
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(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);
|
||||
}
|
||||
|
||||
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(foundryFolder(m.folder()))
|
||||
.portraitImageId(storePortrait(m.imgData(), m.name()))
|
||||
.campaignId(campaignId)
|
||||
.order(order)
|
||||
.values(new HashMap<>())
|
||||
.imageValues(new HashMap<>())
|
||||
.keyValueValues(new HashMap<>())
|
||||
.build());
|
||||
}
|
||||
|
||||
public List<Enemy> searchEnemies(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return enemyRepository.searchByName(query.trim());
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
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.CampaignRepository;
|
||||
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).
|
||||
@@ -20,11 +14,9 @@ import java.util.Optional;
|
||||
public class NpcService {
|
||||
|
||||
private final NpcRepository npcRepository;
|
||||
private final CampaignRepository campaignRepository;
|
||||
|
||||
public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) {
|
||||
public NpcService(NpcRepository npcRepository) {
|
||||
this.npcRepository = npcRepository;
|
||||
this.campaignRepository = campaignRepository;
|
||||
}
|
||||
|
||||
public record NpcData(
|
||||
@@ -67,21 +59,6 @@ public class NpcService {
|
||||
return npcRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
/**
|
||||
* PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}).
|
||||
* Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent.
|
||||
* Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé.
|
||||
*/
|
||||
public List<Npc> getNpcsByLoreId(String loreId) {
|
||||
List<Npc> out = new ArrayList<>();
|
||||
for (Campaign campaign : campaignRepository.findAll()) {
|
||||
if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) {
|
||||
out.addAll(npcRepository.findByCampaignId(campaign.getId()));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public Npc updateNpc(String id, NpcData data) {
|
||||
Npc existing = npcRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
||||
@@ -111,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
.map(String::strip)
|
||||
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
if (firstLine.isEmpty()) continue;
|
||||
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
||||
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
||||
}
|
||||
return "";
|
||||
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("");
|
||||
}
|
||||
|
||||
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() + "…";
|
||||
}
|
||||
|
||||
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 + ")";
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
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"));
|
||||
}
|
||||
});
|
||||
String campaignBlock = campaignContextFormatter.format(campaignId);
|
||||
if (!campaignBlock.isBlank()) {
|
||||
sb.append(campaignBlock).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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
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"));
|
||||
}
|
||||
});
|
||||
String campaignBlock = campaignContextFormatter.format(campaignId);
|
||||
if (!campaignBlock.isBlank()) {
|
||||
sb.append(campaignBlock).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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -38,6 +38,12 @@ public class Campaign {
|
||||
*/
|
||||
private String gameSystemId;
|
||||
|
||||
/**
|
||||
* Positions des nœuds du graphe de campagne (JSON opaque, état de
|
||||
* présentation possédé par le front). Null = disposition automatique.
|
||||
*/
|
||||
private String graphPositions;
|
||||
|
||||
public boolean isLinkedToLore() {
|
||||
return this.loreId != null && !this.loreId.isBlank();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.bestiary;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.bestiary;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.bestiary;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.generation;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.generation;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.generation;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.itemcatalog;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.itemcatalog;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.notebook;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.notebook;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.notebook;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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…).
|
||||
@@ -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
|
||||
@@ -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}.
|
||||
@@ -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).
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.quest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.quest;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.randomtable;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.randomtable;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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 »).
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.structure;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -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 {
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.structure;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.structure;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
package com.loremind.domain.campaigncontext.structure;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -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 :
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user