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

@@ -25,6 +25,7 @@ from app.domain.models import (
ArcProposal, ArcProposal,
CampaignImportResult, CampaignImportResult,
ChapterProposal, ChapterProposal,
NpcImportProposal,
RoomProposal, RoomProposal,
SceneProposal, SceneProposal,
) )
@@ -83,6 +84,14 @@ PIÈCES (rooms) — uniquement pour les scènes qui sont des lieux explorables :
- `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun). - `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun).
- Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide []. - Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide [].
PNJ ET CRÉATURES NOTABLES ("npcs", tableau au niveau racine) :
- Recense les PNJ NOMMÉS (alliés, marchands, antagonistes) et les créatures UNIQUES
(boss, monstre récurrent) présents dans l'extrait.
- `description` = courte fiche utile au MJ : rôle dans l'histoire, apparence,
motivations, où on le rencontre. 2 à 4 phrases, fidèles au livre.
- N'inclus PAS les monstres génériques sans nom (« 3 gobelins », « un loup »).
- Aucun PNJ nommé dans l'extrait → "npcs": [].
Format de réponse : Format de réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour. - Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Schéma EXACT : - Schéma EXACT :
@@ -91,7 +100,8 @@ Format de réponse :
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...", {{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}} "rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
]}}]}} ]}}]}}
]}} ],
"npcs": [{{"name": "...", "description": "..."}}]}}
- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase). - Utilise les VRAIS titres du livre pour les noms (pas de paraphrase).
- Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}". - Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}".
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait. - N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
@@ -157,6 +167,8 @@ class _TreeMerger:
def __init__(self) -> None: def __init__(self) -> None:
# arc_key -> {"name", "description", "chapters": {chap_key -> {...}}} # arc_key -> {"name", "description", "chapters": {chap_key -> {...}}}
self._arcs: dict[str, dict] = {} self._arcs: dict[str, dict] = {}
# npc_key (nom en minuscules) -> {"name", "description"}
self._npcs: dict[str, dict] = {}
def add(self, arcs_json: list[dict]) -> None: def add(self, arcs_json: list[dict]) -> None:
for arc in arcs_json or []: for arc in arcs_json or []:
@@ -202,6 +214,22 @@ class _TreeMerger:
self._fill_field(r, rm, "enemies") self._fill_field(r, rm, "enemies")
self._fill_field(r, rm, "loot") self._fill_field(r, rm, "loot")
def add_npcs(self, npcs_json: list[dict]) -> None:
"""Accumule les PNJ détectés. Un PNJ revu dans un autre morceau garde la
description la plus COMPLÈTE (la plus longue) — un PNJ récurrent est
souvent décrit en détail une seule fois."""
for npc in npcs_json or []:
name = str(npc.get("name", "")).strip()
if not name:
continue
desc = str(npc.get("description") or "").strip()
entry = self._npcs.setdefault(name.lower(), {"name": name, "description": ""})
if len(desc) > len(entry["description"]):
entry["description"] = desc
def npcs(self) -> list[NpcImportProposal]:
return [NpcImportProposal(n["name"], n["description"]) for n in self._npcs.values()]
@staticmethod @staticmethod
def _fill_desc(node: dict, src: dict) -> None: def _fill_desc(node: dict, src: dict) -> None:
if not node["description"]: if not node["description"]:
@@ -358,13 +386,15 @@ class ImportCampaignUseCase:
for i, c in wave for i, c in wave
)) ))
for res in results: for res in results:
merger.add(res) merger.add(res["arcs"])
merger.add_npcs(res["npcs"])
if total > 1: if total > 1:
await self._consolidate(merger) await self._consolidate(merger)
return CampaignImportResult( return CampaignImportResult(
arcs=merger.result(), arcs=merger.result(),
page_count=doc.page_count, page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count, ocr_page_count=doc.ocr_page_count,
npcs=merger.npcs(),
) )
async def stream(self, pdf_bytes: bytes): async def stream(self, pdf_bytes: bytes):
@@ -429,7 +459,8 @@ class ImportCampaignUseCase:
elif isinstance(res, BaseException): elif isinstance(res, BaseException):
raise res # bug inattendu : ne pas l'avaler en silence raise res # bug inattendu : ne pas l'avaler en silence
else: else:
merger.add(res or []) merger.add((res or {}).get("arcs") or [])
merger.add_npcs((res or {}).get("npcs") or [])
arcs, chapters, scenes = merger.counts() arcs, chapters, scenes = merger.counts()
yield { yield {
"type": "progress", "type": "progress",
@@ -438,6 +469,7 @@ class ImportCampaignUseCase:
"arc_count": arcs, "arc_count": arcs,
"chapter_count": chapters, "chapter_count": chapters,
"scene_count": scenes, "scene_count": scenes,
"npc_count": len(merger.npcs()),
"skipped": skipped, "skipped": skipped,
} }
@@ -459,6 +491,7 @@ class ImportCampaignUseCase:
yield { yield {
"type": "done", "type": "done",
"arcs": _serialize_arcs(merger.result()), "arcs": _serialize_arcs(merger.result()),
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
"page_count": doc.page_count, "page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count, "ocr_page_count": doc.ocr_page_count,
"skipped": skipped, "skipped": skipped,
@@ -506,16 +539,17 @@ class ImportCampaignUseCase:
async def _map_chunk( async def _map_chunk(
self, chunk: str, *, index: int, total: int, toc_block: str = "" self, chunk: str, *, index: int, total: int, toc_block: str = ""
) -> list[dict]: ) -> dict:
return await self._extract_arcs( """Phase MAP d'un morceau → {"arcs": [...], "npcs": [...]}."""
return await self._extract_payload(
chunk, index=index, total=total, depth=0, toc_block=toc_block) chunk, index=index, total=total, depth=0, toc_block=toc_block)
async def _extract_arcs( async def _extract_payload(
self, text: str, *, index: int, total: int, depth: int, toc_block: str = "" self, text: str, *, index: int, total: int, depth: int, toc_block: str = ""
) -> list[dict]: ) -> dict:
"""Extrait l'arborescence d'un texte. Si la SORTIE est tronquée, retraite le """Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée,
texte en DEUX moitiés et concatène — le `_TreeMerger` final dédoublonne par retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final
nom (un arc/chapitre coupé entre les moitiés est recollé).""" dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé)."""
toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else "" toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else ""
prompt = ( prompt = (
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) _MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
@@ -525,7 +559,7 @@ class ImportCampaignUseCase:
) )
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE) self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
arcs, truncated = self._parse_arcs(raw, index=index) payload, truncated = self._parse_payload(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH: if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text) left, right = split_in_half(text)
@@ -533,19 +567,20 @@ class ImportCampaignUseCase:
logger.info( logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1) index, depth + 1)
a = await self._extract_arcs( a = await self._extract_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block) left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_arcs( b = await self._extract_payload(
right, index=index, total=total, depth=depth + 1, toc_block=toc_block) right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
return a + b return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
if truncated: if truncated:
logger.warning( logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index) "Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return arcs return payload
@staticmethod @staticmethod
def _parse_arcs(raw: str, *, index: int) -> tuple[list[dict], bool]: def _parse_payload(raw: str, *, index: int) -> tuple[dict, bool]:
"""Parse robuste → (arcs, tronqué). `tronqué`=True si récupération partielle.""" """Parse robuste → ({"arcs", "npcs"}, tronqué). `tronqué`=True si partiel."""
empty = {"arcs": [], "npcs": []}
parsed, recovered = load_json_object(raw) parsed, recovered = load_json_object(raw)
if parsed is None: if parsed is None:
truncated = looks_like_truncated_json(raw) truncated = looks_like_truncated_json(raw)
@@ -554,11 +589,15 @@ class ImportCampaignUseCase:
"Morceau %s : aucun objet JSON exploitable, ignoré. " "Morceau %s : aucun objet JSON exploitable, ignoré. "
"Début de la réponse du modèle : %r", "Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)") index, (raw or "").strip()[:300] or "(réponse VIDE)")
return [], truncated return empty, truncated
if isinstance(parsed, dict): if isinstance(parsed, dict):
arcs = parsed.get("arcs", []) arcs = parsed.get("arcs", [])
return (arcs if isinstance(arcs, list) else []), recovered npcs = parsed.get("npcs", [])
return [], recovered return {
"arcs": arcs if isinstance(arcs, list) else [],
"npcs": npcs if isinstance(npcs, list) else [],
}, recovered
return empty, recovered
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]: def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:

View File

@@ -424,6 +424,18 @@ class ArcProposal:
chapters: list[ChapterProposal] = field(default_factory=list) chapters: list[ChapterProposal] = field(default_factory=list)
@dataclass(frozen=True)
class NpcImportProposal:
"""PNJ/créature notable détecté à l'import d'un PDF de campagne.
PNJ NOMMÉS et créatures uniques (boss) — pas les monstres génériques.
`description` = courte fiche (rôle, apparence, motivations, où on le croise).
"""
name: str
description: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
class CampaignImportResult: class CampaignImportResult:
"""Proposition d'arborescence narrative extraite d'un PDF de campagne. """Proposition d'arborescence narrative extraite d'un PDF de campagne.
@@ -435,6 +447,9 @@ class CampaignImportResult:
arcs: list[ArcProposal] arcs: list[ArcProposal]
page_count: int page_count: int
ocr_page_count: int ocr_page_count: int
# PNJ/créatures notables détectés au fil des morceaux (proposition, à cocher
# dans l'écran de revue avant création).
npcs: list[NpcImportProposal] = field(default_factory=list)
def counts(self) -> tuple[int, int, int]: def counts(self) -> tuple[int, int, int]:
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression.""" """(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""

View File

@@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal; import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal; import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal; 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.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal; import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
@@ -37,22 +38,26 @@ public class CampaignImportService {
private final ArcService arcService; private final ArcService arcService;
private final ChapterService chapterService; private final ChapterService chapterService;
private final SceneService sceneService; private final SceneService sceneService;
private final NpcService npcService;
public CampaignImportService( public CampaignImportService(
CampaignPdfImporter campaignPdfImporter, CampaignPdfImporter campaignPdfImporter,
CampaignService campaignService, CampaignService campaignService,
ArcService arcService, ArcService arcService,
ChapterService chapterService, ChapterService chapterService,
SceneService sceneService) { SceneService sceneService,
NpcService npcService) {
this.campaignPdfImporter = campaignPdfImporter; this.campaignPdfImporter = campaignPdfImporter;
this.campaignService = campaignService; this.campaignService = campaignService;
this.arcService = arcService; this.arcService = arcService;
this.chapterService = chapterService; this.chapterService = chapterService;
this.sceneService = sceneService; this.sceneService = sceneService;
this.npcService = npcService;
} }
/** Résumé de ce qui a été créé par {@link #applyStructure}. */ /** 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. */ /** Génère la proposition d'arbre (streamée). Ne persiste rien. */
public void importStructureStreaming( 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. */ /** 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 ocrPageCount,
int arcCount, int arcCount,
int chapterCount, 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 * 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). * 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 * {@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) { 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;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal; import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal; 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.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal; import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.ports.CampaignImportException; import com.loremind.domain.campaigncontext.ports.CampaignImportException;
@@ -119,7 +120,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
return; return;
} }
if ("extracting".equals(event)) { 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; return;
} }
@@ -130,7 +131,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
pageCount[0] = node.path("page_count").asInt(); pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt(); ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new CampaignImportProgress( 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)) { } else if ("progress".equals(event)) {
onProgress.accept(new CampaignImportProgress( onProgress.accept(new CampaignImportProgress(
node.path("current").asInt(), node.path("current").asInt(),
@@ -139,10 +140,12 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
ocrPageCount[0], ocrPageCount[0],
node.path("arc_count").asInt(), node.path("arc_count").asInt(),
node.path("chapter_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)) { } else if ("done".equals(event)) {
terminated[0] = true; 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; 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 ------------------------------------------------------------- // --- Helpers -------------------------------------------------------------
private ByteArrayResource filePart(byte[] pdfBytes, String filename) { private ByteArrayResource filePart(byte[] pdfBytes, String filename) {

View File

@@ -33,7 +33,7 @@
} }
@if (importCounts) { @if (importCounts) {
<p class="import-counts"> <p class="import-counts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ
</p> </p>
} }
</div> </div>
@@ -51,7 +51,8 @@
<p class="review-summary"> <p class="review-summary">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s), À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
<strong>{{ chapterCount }}</strong> chapitre(s), <strong>{{ chapterCount }}</strong> chapitre(s),
<strong>{{ sceneCount }}</strong> scène(s). <strong>{{ sceneCount }}</strong> scène(s)@if (npcs.length > 0) {<span>,
<strong>{{ npcCount }}</strong> PNJ</span>}.
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ; Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
ils ne seront pas recréés. Modifiez les nouveaux, puis créez. ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
</p> </p>
@@ -203,6 +204,34 @@
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc <lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
</button> </button>
</div> </div>
<!-- PNJ détectés dans le PDF : revue par cases à cocher -->
@if (npcs.length > 0) {
<div class="npc-review">
<h3>PNJ et créatures détectés ({{ npcs.length }})</h3>
<p class="hint">
Cochez ceux à créer dans la campagne (description issue du livre).
Ceux déjà présents dans la campagne sont grisés et ne seront pas recréés.
</p>
<ul class="npc-list">
@for (npc of npcs; track npc.name) {
<li class="npc-row" [class.npc-existing]="npc.existing">
<label class="npc-check">
<input type="checkbox" [(ngModel)]="npc.selected" [disabled]="npc.existing">
<strong>{{ npc.name }}</strong>
@if (npc.existing) {
<em class="npc-tag">déjà présent</em>
}
</label>
@if (npc.description) {
<p class="npc-desc">{{ npc.description }}</p>
}
</li>
}
</ul>
</div>
}
@if (applyError) { @if (applyError) {
<p class="apply-error">{{ applyError }}</p> <p class="apply-error">{{ applyError }}</p>
} }

View File

@@ -321,3 +321,49 @@
gap: 0.6rem; gap: 0.6rem;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
// --- Revue des PNJ détectés dans le PDF ------------------------------------
.npc-review {
margin-top: 1.25rem;
padding: 1rem;
border: 1px solid var(--border-color, #2e2e3a);
border-radius: 8px;
h3 { margin: 0 0 0.25rem; font-size: 1rem; }
.hint { margin: 0 0 0.75rem; font-size: 0.85rem; opacity: 0.75; }
}
.npc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.npc-row {
padding: 0.5rem 0.65rem;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
&.npc-existing { opacity: 0.55; }
.npc-check {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.npc-tag {
font-size: 0.75rem;
opacity: 0.7;
}
.npc-desc {
margin: 0.3rem 0 0 1.6rem;
font-size: 0.85rem;
opacity: 0.85;
}
}

View File

@@ -13,7 +13,7 @@ import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model'; import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model';
import { CampaignImportProposal } from '../../../services/campaign-import.model'; import { CampaignImportProposal } from '../../../services/campaign-import.model';
import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper'; import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper';
import { of } from 'rxjs'; import { of } from 'rxjs';
@@ -37,6 +37,11 @@ interface ArcNode {
name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean; name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean;
existing: boolean; existingId?: string; existing: boolean; existingId?: string;
} }
/**
* PNJ détecté dans le PDF, à cocher à la revue. `existing` = un PNJ du même nom
* existe déjà dans la campagne (décoché et non créable, anti-doublon).
*/
interface NpcNode { name: string; description: string; selected: boolean; existing: boolean; }
/** /**
* Page d'import d'un PDF de campagne → arbre arc/chapitre/scène. * Page d'import d'un PDF de campagne → arbre arc/chapitre/scène.
@@ -69,7 +74,7 @@ export class CampaignImportComponent implements OnInit {
importing = false; importing = false;
importPhase = ''; importPhase = '';
importProgress: { current: number; total: number } | null = null; importProgress: { current: number; total: number } | null = null;
importCounts: { arcs: number; chapters: number; scenes: number } | null = null; importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null;
importError: string | null = null; importError: string | null = null;
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */ /** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
reviewing = false; reviewing = false;
@@ -77,6 +82,9 @@ export class CampaignImportComponent implements OnInit {
// --- Arbre éditable --- // --- Arbre éditable ---
tree: ArcNode[] = []; tree: ArcNode[] = [];
/** PNJ détectés dans le PDF (revue par cases à cocher). */
npcs: NpcNode[] = [];
/** Structure actuelle de la campagne (chargée pour la fusion à la revue). */ /** Structure actuelle de la campagne (chargée pour la fusion à la revue). */
private existingData: CampaignTreeData | null = null; private existingData: CampaignTreeData | null = null;
@@ -136,17 +144,21 @@ export class CampaignImportComponent implements OnInit {
} else { } else {
this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`; this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`;
this.importProgress = { current: ev.current, total: ev.total }; this.importProgress = { current: ev.current, total: ev.total };
this.importCounts = { arcs: ev.arcCount, chapters: ev.chapterCount, scenes: ev.sceneCount }; this.importCounts = {
arcs: ev.arcCount, chapters: ev.chapterCount,
scenes: ev.sceneCount, npcs: ev.npcCount ?? 0
};
} }
} else if (ev.type === 'done') { } else if (ev.type === 'done') {
this.importing = false; this.importing = false;
this.importPhase = ''; this.importPhase = '';
this.importProgress = null; this.importProgress = null;
if ((ev.arcs ?? []).length === 0) { if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
this.importError = "Aucune structure narrative détectée dans ce PDF."; this.importError = "Aucune structure narrative détectée dans ce PDF.";
this.reviewing = false; this.reviewing = false;
} else { } else {
this.tree = this.buildMergedTree(ev.arcs); this.tree = this.buildMergedTree(ev.arcs ?? []);
this.npcs = this.buildNpcNodes(ev.npcs ?? []);
this.reviewing = true; this.reviewing = true;
} }
} }
@@ -232,6 +244,21 @@ export class CampaignImportComponent implements OnInit {
return (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase(); return (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase();
} }
/**
* Construit la liste de revue des PNJ : cochés par défaut, sauf ceux dont le
* nom existe déjà dans la campagne (anti-doublon, affichés grisés).
*/
private buildNpcNodes(proposals: NpcProposal[]): NpcNode[] {
const existingNames = new Set(
(this.existingData?.npcs ?? []).map(n => (n.name ?? '').trim().toLowerCase()));
return proposals
.filter(p => (p.name ?? '').trim())
.map(p => {
const existing = existingNames.has(p.name.trim().toLowerCase());
return { name: p.name.trim(), description: p.description ?? '', selected: !existing, existing };
});
}
// --- Mappers proposition → nœud NEUF ------------------------------------- // --- Mappers proposition → nœud NEUF -------------------------------------
private newArcNode(a: ArcProposal): ArcNode { private newArcNode(a: ArcProposal): ArcNode {
@@ -310,9 +337,14 @@ export class CampaignImportComponent implements OnInit {
n + a.chapters.reduce((m, c) => m + c.scenes.filter(s => !s.existing && s.name.trim()).length, 0), 0); n + a.chapters.reduce((m, c) => m + c.scenes.filter(s => !s.existing && s.name.trim()).length, 0), 0);
} }
/** Nombre de PNJ cochés (= qui seront créés). */
get npcCount(): number {
return this.npcs.filter(n => n.selected && !n.existing).length;
}
/** Vrai s'il y a au moins un nœud nouveau à créer (sinon « Créer » désactivé). */ /** Vrai s'il y a au moins un nœud nouveau à créer (sinon « Créer » désactivé). */
get hasNewContent(): boolean { get hasNewContent(): boolean {
return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0; return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0 || this.npcCount > 0;
} }
// --- Application (création) ---------------------------------------------- // --- Application (création) ----------------------------------------------
@@ -357,7 +389,10 @@ export class CampaignImportComponent implements OnInit {
})) }))
})) }))
})) }))
})) })),
npcs: this.npcs
.filter(n => n.selected && !n.existing && n.name.trim())
.map(n => ({ name: n.name.trim(), description: n.description.trim() }))
}; };
this.service.applyStructure(this.campaignId, proposal).subscribe({ this.service.applyStructure(this.campaignId, proposal).subscribe({

View File

@@ -40,8 +40,16 @@ export interface ArcProposal {
existingId?: string | null; existingId?: string | null;
} }
/** PNJ/créature notable détecté dans le PDF (PNJ nommés, boss). */
export interface NpcProposal {
name: string;
description: string;
}
export interface CampaignImportProposal { export interface CampaignImportProposal {
arcs: ArcProposal[]; arcs: ArcProposal[];
/** PNJ cochés à la revue (créés comme Npc de la campagne à l'apply). */
npcs?: NpcProposal[];
} }
/** Récapitulatif renvoyé après création effective des entités. */ /** Récapitulatif renvoyé après création effective des entités. */
@@ -49,6 +57,7 @@ export interface CampaignImportApplyResult {
arcsCreated: number; arcsCreated: number;
chaptersCreated: number; chaptersCreated: number;
scenesCreated: number; scenesCreated: number;
npcsCreated: number;
} }
/** /**
@@ -67,6 +76,7 @@ export type CampaignImportStreamEvent =
arcCount: number; arcCount: number;
chapterCount: number; chapterCount: number;
sceneCount: number; sceneCount: number;
npcCount: number;
} }
| { type: 'done'; arcs: ArcProposal[] } | { type: 'done'; arcs: ArcProposal[]; npcs: NpcProposal[] }
| { type: 'error'; message: string }; | { type: 'error'; message: string };

View File

@@ -72,7 +72,7 @@ export class CampaignImportService {
try { try {
const obj = JSON.parse(currentData); const obj = JSON.parse(currentData);
if (name === 'done') { if (name === 'done') {
subscriber.next({ type: 'done', arcs: obj.arcs ?? [] }); subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] });
subscriber.complete(); subscriber.complete();
} else { } else {
subscriber.next({ type: 'progress', ...obj }); subscriber.next({ type: 'progress', ...obj });