npcs) {
/**
- * {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
- * campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le
- * recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer.
+ * {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply).
+ *
+ * {@code existingId} (nullable, porté aussi par Chapter/SceneProposal) : si présent,
+ * le nœud existe DÉJÀ dans la campagne (rempli côté UI lors de la revue pré-chargée)
+ * → l'apply ne le recrée pas, il l'utilise comme parent des nouveaux enfants.
+ * Null = à créer.
*/
-
- /** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
public record ArcProposal(
String name, String description, String type,
List chapters, String existingId) {
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/quest/Prerequisite.java b/core/src/main/java/com/loremind/domain/campaigncontext/quest/Prerequisite.java
index 0f8fa8f..9a68915 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/quest/Prerequisite.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/quest/Prerequisite.java
@@ -2,11 +2,9 @@ package com.loremind.domain.campaigncontext.quest;
/**
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
- *
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
* {@link PrerequisiteEvaluator}.
- *
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
* (pas de OR pour le moment).
*/
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/quest/PrerequisiteEvaluator.java b/core/src/main/java/com/loremind/domain/campaigncontext/quest/PrerequisiteEvaluator.java
index e47fb6f..b2fc96b 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/quest/PrerequisiteEvaluator.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/quest/PrerequisiteEvaluator.java
@@ -7,7 +7,6 @@ import java.util.Set;
/**
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
* et en dérive le {@link QuestStatus} effectif.
- *
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
* compile-time est compensée par le throw final qui fait crasher tout nouvel
@@ -51,15 +50,12 @@ public final class PrerequisiteEvaluator {
List prerequisites,
EvaluationContext ctx
) {
- switch (progression) {
- case COMPLETED: return QuestStatus.COMPLETED;
- case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
- case NOT_STARTED:
- return areAllSatisfied(prerequisites, ctx)
- ? QuestStatus.AVAILABLE
- : QuestStatus.LOCKED;
- default:
- throw new IllegalStateException("ProgressionStatus non géré : " + progression);
- }
+ return switch (progression) {
+ case COMPLETED -> QuestStatus.COMPLETED;
+ case IN_PROGRESS -> QuestStatus.IN_PROGRESS;
+ case NOT_STARTED -> areAllSatisfied(prerequisites, ctx)
+ ? QuestStatus.AVAILABLE
+ : QuestStatus.LOCKED;
+ };
}
}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/quest/ProgressionStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/quest/ProgressionStatus.java
index 9ae0fbd..daad8c1 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/quest/ProgressionStatus.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/quest/ProgressionStatus.java
@@ -2,12 +2,10 @@ package com.loremind.domain.campaigncontext.quest;
/**
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
- *
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
* selon les prérequis — voir {@link QuestStatus}.
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
* COMPLETED : marquée terminée par le MJ.
- *
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
*/
public enum ProgressionStatus {
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/quest/QuestStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/quest/QuestStatus.java
index c4cfe0a..28b6281 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/quest/QuestStatus.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/quest/QuestStatus.java
@@ -4,7 +4,6 @@ package com.loremind.domain.campaigncontext.quest;
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
- *
* Table de vérité :
* NOT_STARTED + prérequis non remplis -> LOCKED
* NOT_STARTED + prérequis remplis -> AVAILABLE
diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java
index 881d204..2f9915a 100644
--- a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java
+++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java
@@ -158,7 +158,7 @@ public class GameSystem {
}
private static boolean equalsIgnoreCase(String a, String b) {
- if (a == null || b == null) return a == b;
+ if (a == null || b == null) return a == null && b == null;
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
}
}
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java
index 0f48853..b941aa9 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java
@@ -26,22 +26,25 @@ import java.util.function.Consumer;
@Component
public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
- private static final String ADAPT_PATH = "/adapt/campaign/stream";
private static final ParameterizedTypeReference> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final long timeoutSeconds;
+ // Route du Brain surchargeable par config (défaut = contrat d'API actuel).
+ private final String adaptPath;
public BrainCampaignAdaptClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
- @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) {
+ @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds,
+ @Value("${brain.paths.adapt-campaign:/adapt/campaign/stream}") String adaptPath) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.timeoutSeconds = timeoutSeconds;
+ this.adaptPath = adaptPath;
}
@Override
@@ -65,7 +68,7 @@ public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson);
Flux> flux = webClient.post()
- .uri(ADAPT_PATH)
+ .uri(adaptPath)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java
index b9717c2..0cfc4c0 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java
@@ -36,24 +36,37 @@ import java.util.function.Consumer;
@Component
public class BrainCampaignImportClient implements CampaignPdfImporter {
- private static final String IMPORT_CAMPAIGN_STREAM_PATH = "/import/campaign/stream";
private static final ParameterizedTypeReference> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
+ /** Champ JSON répété du proposal d'arbre (arc/chapitre/scène/salle/PNJ). */
+ private static final String FIELD_DESCRIPTION = "description";
private final WebClient webClient;
private final BrainSseImportSupport sse;
private final long importTimeoutSeconds;
+ // Route du Brain surchargeable par config (défaut = contrat d'API actuel).
+ private final String importCampaignStreamPath;
public BrainCampaignImportClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
- @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
+ @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
+ @Value("${brain.paths.import-campaign:/import/campaign/stream}") String importCampaignStreamPath) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.sse = new BrainSseImportSupport(objectMapper);
this.importTimeoutSeconds = importTimeoutSeconds;
+ this.importCampaignStreamPath = importCampaignStreamPath;
}
+ /** Callbacks de streaming groupés (réduit le nombre de paramètres de handleEvent). */
+ private record ImportCallbacks(
+ Consumer onProgress,
+ Runnable onHeartbeat,
+ Consumer onStatus,
+ Consumer onDone,
+ Consumer onError) {}
+
@Override
public void importCampaignStreaming(
byte[] pdfBytes,
@@ -69,7 +82,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
Flux> flux = webClient.post()
- .uri(IMPORT_CAMPAIGN_STREAM_PATH)
+ .uri(importCampaignStreamPath)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
@@ -80,12 +93,11 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] pageCount = {0};
int[] ocrPageCount = {0};
boolean[] terminated = {false};
+ ImportCallbacks callbacks = new ImportCallbacks(onProgress, onHeartbeat, onStatus, onDone, onError);
sse.runStream(
flux, importTimeoutSeconds, terminated,
- event -> handleEvent(
- event, pageCount, ocrPageCount, terminated,
- onProgress, onHeartbeat, onStatus, onDone, onError),
+ event -> handleEvent(event, pageCount, ocrPageCount, terminated, callbacks),
onError, CampaignImportException::new);
}
@@ -94,11 +106,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
- Consumer onProgress,
- Runnable onHeartbeat,
- Consumer onStatus,
- Consumer onDone,
- Consumer onError) {
+ ImportCallbacks callbacks) {
String event = ssEvent.event();
String data = ssEvent.data() == null ? "" : ssEvent.data();
@@ -106,27 +114,27 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front.
- onHeartbeat.run();
+ callbacks.onHeartbeat().run();
return;
}
if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
- onStatus.accept(sse.readMessage(data));
+ callbacks.onStatus().accept(sse.readMessage(data));
return;
}
if ("chunk_failed".equals(event)) {
- onStatus.accept(sse.chunkFailedStatus(data));
+ callbacks.onStatus().accept(sse.chunkFailedStatus(data));
return;
}
if ("error".equals(event)) {
terminated[0] = true;
- onError.accept(new CampaignImportException(
+ callbacks.onError().accept(new CampaignImportException(
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
return;
}
if ("extracting".equals(event)) {
- onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0));
+ callbacks.onProgress().accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0));
return;
}
@@ -136,10 +144,10 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if ("start".equals(event)) {
pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt();
- onProgress.accept(new CampaignImportProgress(
+ callbacks.onProgress().accept(new CampaignImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0, 0));
} else if ("progress".equals(event)) {
- onProgress.accept(new CampaignImportProgress(
+ callbacks.onProgress().accept(new CampaignImportProgress(
node.path("current").asInt(),
node.path("total").asInt(),
pageCount[0],
@@ -150,7 +158,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
node.path("npc_count").asInt()));
} else if ("done".equals(event)) {
terminated[0] = true;
- onDone.accept(new CampaignImportProposal(
+ callbacks.onDone().accept(new CampaignImportProposal(
toArcs(node.path("arcs")), toNpcs(node.path("npcs"))));
}
}
@@ -163,7 +171,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
for (JsonNode arc : arcsNode) {
arcs.add(new ArcProposal(
text(arc, "name"),
- text(arc, "description"),
+ text(arc, FIELD_DESCRIPTION),
text(arc, "type"),
toChapters(arc.path("chapters")),
null));
@@ -178,7 +186,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
for (JsonNode ch : chaptersNode) {
chapters.add(new ChapterProposal(
text(ch, "name"),
- text(ch, "description"),
+ text(ch, FIELD_DESCRIPTION),
toScenes(ch.path("scenes")),
null));
}
@@ -191,7 +199,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if (scenesNode != null && scenesNode.isArray()) {
for (JsonNode sc : scenesNode) {
scenes.add(new SceneProposal(
- text(sc, "name"), text(sc, "description"),
+ text(sc, "name"), text(sc, FIELD_DESCRIPTION),
text(sc, "player_narration"), text(sc, "gm_notes"),
toRooms(sc.path("rooms")), null));
}
@@ -204,7 +212,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if (roomsNode != null && roomsNode.isArray()) {
for (JsonNode rm : roomsNode) {
rooms.add(new RoomProposal(
- text(rm, "name"), text(rm, "description"),
+ text(rm, "name"), text(rm, FIELD_DESCRIPTION),
text(rm, "enemies"), text(rm, "loot")));
}
}
@@ -215,7 +223,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
List npcs = new ArrayList<>();
if (npcsNode != null && npcsNode.isArray()) {
for (JsonNode n : npcsNode) {
- npcs.add(new NpcProposal(text(n, "name"), text(n, "description")));
+ npcs.add(new NpcProposal(text(n, "name"), text(n, FIELD_DESCRIPTION)));
}
}
return npcs;
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
index 7cbaa35..776cfd7 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
@@ -24,7 +24,7 @@ import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Function;
-import java.util.stream.Collectors;
+import java.util.function.ToIntFunction;
/**
* Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON
@@ -40,11 +40,14 @@ import java.util.stream.Collectors;
@Component
public class BrainChatPayloadBuilder {
+ private static final String KEY_DESCRIPTION = "description";
+ private static final String KEY_TITLE = "title";
+
public Map build(ChatRequest request) {
Map root = new LinkedHashMap<>();
root.put("messages", request.messages().stream()
.map(this::messageToMap)
- .collect(Collectors.toList()));
+ .toList());
if (request.loreContext() != null) {
root.put("lore_context", loreContextToMap(request.loreContext()));
@@ -75,25 +78,25 @@ public class BrainChatPayloadBuilder {
map.put("started_at", sc.startedAt().toString());
}
map.put("entries", sc.entries() != null
- ? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList())
+ ? sc.entries().stream().map(this::journalEntryToMap).toList()
: List.of());
// Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session).
if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) {
map.put("previous_events", sc.previousEvents().stream()
.map(this::journalEntryToMap)
- .collect(Collectors.toList()));
+ .toList());
}
// État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas
// saturer le prompt sur les campagnes sans Hub.
if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) {
map.put("available_quests", sc.availableQuests().stream()
.map(this::questSummaryToMap)
- .collect(Collectors.toList()));
+ .toList());
}
if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) {
map.put("in_progress_quests", sc.inProgressQuests().stream()
.map(this::questSummaryToMap)
- .collect(Collectors.toList()));
+ .toList());
}
if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) {
map.put("locked_quest_titles", sc.lockedQuestTitles());
@@ -108,7 +111,7 @@ public class BrainChatPayloadBuilder {
Map map = new LinkedHashMap<>();
map.put("name", q.name());
map.put("arc_name", q.arcName());
- putIfText(map, "description", q.description());
+ putIfText(map, KEY_DESCRIPTION, q.description());
return map;
}
@@ -147,7 +150,7 @@ public class BrainChatPayloadBuilder {
for (Map.Entry> e : ctx.folders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
- .collect(Collectors.toList()));
+ .toList());
}
map.put("folders", foldersMap);
map.put("tags", ctx.tags());
@@ -156,7 +159,7 @@ public class BrainChatPayloadBuilder {
private Map pageSummaryToMap(PageSummary ps) {
Map map = new LinkedHashMap<>();
- map.put("title", ps.title());
+ map.put(KEY_TITLE, ps.title());
map.put("template_name", ps.templateName());
// values/tags/related_page_titles : omis si vides pour alléger le payload.
if (ps.values() != null && !ps.values().isEmpty()) {
@@ -173,7 +176,7 @@ public class BrainChatPayloadBuilder {
private Map pageContextToMap(PageContext pc) {
Map map = new LinkedHashMap<>();
- map.put("title", pc.title());
+ map.put(KEY_TITLE, pc.title());
map.put("template_name", pc.templateName());
map.put("template_fields", pc.templateFields());
map.put("values", pc.values());
@@ -186,18 +189,18 @@ public class BrainChatPayloadBuilder {
map.put("campaign_description", ctx.campaignDescription());
map.put("arcs", ctx.arcs().stream()
.map(this::arcSummaryToMap)
- .collect(Collectors.toList()));
+ .toList());
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
map.put("characters", ctx.characters().stream()
.map(this::characterSummaryToMap)
- .collect(Collectors.toList()));
+ .toList());
}
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
map.put("npcs", ctx.npcs().stream()
.map(this::npcSummaryToMap)
- .collect(Collectors.toList()));
+ .toList());
}
return map;
}
@@ -224,13 +227,14 @@ public class BrainChatPayloadBuilder {
T entity,
Function nameExtractor,
Function descriptionExtractor,
- Function illustrationCountExtractor,
+ ToIntFunction illustrationCountExtractor,
BiConsumer