Import campagne : detection des PNJ et creatures notables (bout en bout)

Brain : la phase MAP recense les PNJ NOMMES et creatures uniques (pas les
monstres generiques) avec une courte fiche fidele au livre ; dedoublonnage
inter-morceaux (description la plus complete gardee) ; payload done + npc_count.

Core : CampaignImportProposal.npcs + NpcProposal ; a l apply, creation comme
Npc de la campagne (description -> values[Description], meme convention que
les cartes d action des ateliers) avec anti-doublon par nom.

Web : section de revue PNJ a cases a cocher (coches par defaut, grises si
deja presents), compteurs de progression et recapitulatif mis a jour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:36:17 +02:00
parent 5061457f76
commit 092898e379
11 changed files with 272 additions and 40 deletions

View File

@@ -6,6 +6,7 @@ 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;
@@ -37,22 +38,26 @@ public class CampaignImportService {
private final ArcService arcService;
private final ChapterService chapterService;
private final SceneService sceneService;
private final NpcService npcService;
public CampaignImportService(
CampaignPdfImporter campaignPdfImporter,
CampaignService campaignService,
ArcService arcService,
ChapterService chapterService,
SceneService sceneService) {
SceneService sceneService,
NpcService npcService) {
this.campaignPdfImporter = campaignPdfImporter;
this.campaignService = campaignService;
this.arcService = arcService;
this.chapterService = chapterService;
this.sceneService = sceneService;
this.npcService = npcService;
}
/** Résumé de ce qui a été créé par {@link #applyStructure}. */
public record ApplyResult(int arcsCreated, int chaptersCreated, int scenesCreated) {}
public record ApplyResult(
int arcsCreated, int chaptersCreated, int scenesCreated, int npcsCreated) {}
/** Génère la proposition d'arbre (streamée). Ne persiste rien. */
public void importStructureStreaming(
@@ -130,7 +135,36 @@ public class CampaignImportService {
}
}
}
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated);
int npcsCreated = createNpcs(campaignId, proposal.npcs());
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
}
/**
* 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
* dans la campagne sont ignorés (ré-import sans doublon).
*/
private int createNpcs(String campaignId, List<NpcProposal> proposals) {
if (proposals == null || proposals.isEmpty()) return 0;
java.util.Set<String> existingNames = new java.util.HashSet<>();
for (var npc : npcService.getNpcsByCampaignId(campaignId)) {
existingNames.add(npc.getName().trim().toLowerCase());
}
int created = 0;
for (NpcProposal p : proposals) {
if (isBlank(p.name()) || !existingNames.add(p.name().trim().toLowerCase())) {
continue;
}
npcService.createNpc(new NpcService.NpcData(
p.name().trim(), null, null,
isBlank(p.description())
? java.util.Map.of()
: java.util.Map.of("Description", p.description().trim()),
null, null, campaignId, null, null));
created++;
}
return created;
}
/** Compte les nœuds déjà présents (existingId non vide) d'une liste. */

View File

@@ -14,5 +14,6 @@ public record CampaignImportProgress(
int ocrPageCount,
int arcCount,
int chapterCount,
int sceneCount) {
int sceneCount,
int npcCount) {
}

View File

@@ -8,7 +8,7 @@ import java.util.List;
* PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
* avant la création effective des arcs/chapitres/scènes. Records purs (domaine).
*/
public record CampaignImportProposal(List<ArcProposal> arcs) {
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
/**
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
@@ -37,4 +37,14 @@ public record CampaignImportProposal(List<ArcProposal> arcs) {
public record RoomProposal(String name, String description, String enemies, String loot) {
}
/**
* PNJ/creature notable detecte dans le PDF (PNJ nommes, boss). Propose a la
* revue (coche par defaut) ; cree comme Npc de la campagne a l''apply avec
* sa description dans values["Description"] (meme convention que les cartes
* d''action des ateliers).
*/
public record NpcProposal(String name, String description) {
}
}

View File

@@ -6,6 +6,7 @@ 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.ports.CampaignImportException;
@@ -119,7 +120,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
return;
}
if ("extracting".equals(event)) {
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0));
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0));
return;
}
@@ -130,7 +131,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new CampaignImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0));
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0, 0));
} else if ("progress".equals(event)) {
onProgress.accept(new CampaignImportProgress(
node.path("current").asInt(),
@@ -139,10 +140,12 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
ocrPageCount[0],
node.path("arc_count").asInt(),
node.path("chapter_count").asInt(),
node.path("scene_count").asInt()));
node.path("scene_count").asInt(),
node.path("npc_count").asInt()));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.accept(new CampaignImportProposal(toArcs(node.path("arcs"))));
onDone.accept(new CampaignImportProposal(
toArcs(node.path("arcs")), toNpcs(node.path("npcs"))));
}
}
@@ -202,6 +205,16 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
return rooms;
}
private List<NpcProposal> toNpcs(JsonNode npcsNode) {
List<NpcProposal> npcs = new ArrayList<>();
if (npcsNode != null && npcsNode.isArray()) {
for (JsonNode n : npcsNode) {
npcs.add(new NpcProposal(text(n, "name"), text(n, "description")));
}
}
return npcs;
}
// --- Helpers -------------------------------------------------------------
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {