diff --git a/brain/app/application/import_campaign.py b/brain/app/application/import_campaign.py index c4b41fb..08061d1 100644 --- a/brain/app/application/import_campaign.py +++ b/brain/app/application/import_campaign.py @@ -25,6 +25,7 @@ from app.domain.models import ( ArcProposal, CampaignImportResult, ChapterProposal, + NpcImportProposal, RoomProposal, 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). - 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 : - Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour. - Schéma EXACT : @@ -91,7 +100,8 @@ Format de réponse : {{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...", "rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}} ]}}]}} - ]}} + ], + "npcs": [{{"name": "...", "description": "..."}}]}} - 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}". - 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: # arc_key -> {"name", "description", "chapters": {chap_key -> {...}}} 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: for arc in arcs_json or []: @@ -202,6 +214,22 @@ class _TreeMerger: self._fill_field(r, rm, "enemies") 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 def _fill_desc(node: dict, src: dict) -> None: if not node["description"]: @@ -358,13 +386,15 @@ class ImportCampaignUseCase: for i, c in wave )) for res in results: - merger.add(res) + merger.add(res["arcs"]) + merger.add_npcs(res["npcs"]) if total > 1: await self._consolidate(merger) return CampaignImportResult( arcs=merger.result(), page_count=doc.page_count, ocr_page_count=doc.ocr_page_count, + npcs=merger.npcs(), ) async def stream(self, pdf_bytes: bytes): @@ -429,7 +459,8 @@ class ImportCampaignUseCase: elif isinstance(res, BaseException): raise res # bug inattendu : ne pas l'avaler en silence 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() yield { "type": "progress", @@ -438,6 +469,7 @@ class ImportCampaignUseCase: "arc_count": arcs, "chapter_count": chapters, "scene_count": scenes, + "npc_count": len(merger.npcs()), "skipped": skipped, } @@ -459,6 +491,7 @@ class ImportCampaignUseCase: yield { "type": "done", "arcs": _serialize_arcs(merger.result()), + "npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()], "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count, "skipped": skipped, @@ -506,16 +539,17 @@ class ImportCampaignUseCase: async def _map_chunk( self, chunk: str, *, index: int, total: int, toc_block: str = "" - ) -> list[dict]: - return await self._extract_arcs( + ) -> dict: + """Phase MAP d'un morceau → {"arcs": [...], "npcs": [...]}.""" + return await self._extract_payload( 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 = "" - ) -> list[dict]: - """Extrait l'arborescence d'un texte. Si la SORTIE est tronquée, retraite le - texte en DEUX moitiés et concatène — le `_TreeMerger` final dédoublonne par - nom (un arc/chapitre coupé entre les moitiés est recollé).""" + ) -> dict: + """Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée, + retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final + 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 "" prompt = ( _MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) @@ -525,7 +559,7 @@ class ImportCampaignUseCase: ) raw = await generate_with_retry( 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: left, right = split_in_half(text) @@ -533,19 +567,20 @@ class ImportCampaignUseCase: logger.info( "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", 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) - b = await self._extract_arcs( + b = await self._extract_payload( 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: logger.warning( "Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index) - return arcs + return payload @staticmethod - def _parse_arcs(raw: str, *, index: int) -> tuple[list[dict], bool]: - """Parse robuste → (arcs, tronqué). `tronqué`=True si récupération partielle.""" + def _parse_payload(raw: str, *, index: int) -> tuple[dict, bool]: + """Parse robuste → ({"arcs", "npcs"}, tronqué). `tronqué`=True si partiel.""" + empty = {"arcs": [], "npcs": []} parsed, recovered = load_json_object(raw) if parsed is None: truncated = looks_like_truncated_json(raw) @@ -554,11 +589,15 @@ class ImportCampaignUseCase: "Morceau %s : aucun objet JSON exploitable, ignoré. " "Début de la réponse du modèle : %r", index, (raw or "").strip()[:300] or "(réponse VIDE)") - return [], truncated + return empty, truncated if isinstance(parsed, dict): arcs = parsed.get("arcs", []) - return (arcs if isinstance(arcs, list) else []), recovered - return [], recovered + npcs = parsed.get("npcs", []) + 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]: diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py index 924a55e..03b44f0 100644 --- a/brain/app/domain/models.py +++ b/brain/app/domain/models.py @@ -424,6 +424,18 @@ class ArcProposal: 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) class CampaignImportResult: """Proposition d'arborescence narrative extraite d'un PDF de campagne. @@ -435,6 +447,9 @@ class CampaignImportResult: arcs: list[ArcProposal] 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]: """(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression.""" diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java index 406c740..a297581 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java @@ -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 proposals) { + if (proposals == null || proposals.isEmpty()) return 0; + java.util.Set 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. */ diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProgress.java b/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProgress.java index d14ec1b..1fc398c 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProgress.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProgress.java @@ -14,5 +14,6 @@ public record CampaignImportProgress( int ocrPageCount, int arcCount, int chapterCount, - int sceneCount) { + int sceneCount, + int npcCount) { } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProposal.java b/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProposal.java index 0c601b8..de411fc 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProposal.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/CampaignImportProposal.java @@ -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 arcs) { +public record CampaignImportProposal(List arcs, List npcs) { /** * {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la @@ -37,4 +37,14 @@ public record CampaignImportProposal(List 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) { + } } + 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 ce49cd0..3a7ba76 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java @@ -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 toNpcs(JsonNode npcsNode) { + List 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) { diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html index 3ac9928..70d498c 100644 --- a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html @@ -33,7 +33,7 @@ } @if (importCounts) {

- 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

} @@ -51,7 +51,8 @@

À créer : {{ arcCount }} nouvel(s) arc(s), {{ chapterCount }} chapitre(s), - {{ sceneCount }} scène(s). + {{ sceneCount }} scène(s)@if (npcs.length > 0) {, + {{ npcCount }} PNJ}. Les éléments « déjà présent » (grisés) sont affichés pour situer les ajouts ; ils ne seront pas recréés. Modifiez les nouveaux, puis créez.

@@ -203,6 +204,34 @@ Ajouter un arc + + + @if (npcs.length > 0) { +
+

PNJ et créatures détectés ({{ npcs.length }})

+

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

+
    + @for (npc of npcs; track npc.name) { +
  • + + @if (npc.description) { +

    {{ npc.description }}

    + } +
  • + } +
+
+ } + @if (applyError) {

{{ applyError }}

} diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss index b818dac..82fc027 100644 --- a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss @@ -321,3 +321,49 @@ gap: 0.6rem; 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; + } +} diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts index 7409025..3b26920 100644 --- a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts @@ -13,7 +13,7 @@ import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.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 { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper'; import { of } from 'rxjs'; @@ -37,6 +37,11 @@ interface ArcNode { name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean; 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. @@ -69,7 +74,7 @@ export class CampaignImportComponent implements OnInit { importing = false; importPhase = ''; 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; /** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */ reviewing = false; @@ -77,6 +82,9 @@ export class CampaignImportComponent implements OnInit { // --- Arbre éditable --- 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). */ private existingData: CampaignTreeData | null = null; @@ -136,17 +144,21 @@ export class CampaignImportComponent implements OnInit { } else { this.importPhase = `Analyse de la campagne… (${ev.current}/${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') { this.importing = false; this.importPhase = ''; 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.reviewing = false; } else { - this.tree = this.buildMergedTree(ev.arcs); + this.tree = this.buildMergedTree(ev.arcs ?? []); + this.npcs = this.buildNpcNodes(ev.npcs ?? []); this.reviewing = true; } } @@ -232,6 +244,21 @@ export class CampaignImportComponent implements OnInit { 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 ------------------------------------- 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); } + /** 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é). */ 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) ---------------------------------------------- @@ -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({ diff --git a/web/src/app/services/campaign-import.model.ts b/web/src/app/services/campaign-import.model.ts index 611a9e5..84adb95 100644 --- a/web/src/app/services/campaign-import.model.ts +++ b/web/src/app/services/campaign-import.model.ts @@ -40,8 +40,16 @@ export interface ArcProposal { 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 { 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. */ @@ -49,6 +57,7 @@ export interface CampaignImportApplyResult { arcsCreated: number; chaptersCreated: number; scenesCreated: number; + npcsCreated: number; } /** @@ -67,6 +76,7 @@ export type CampaignImportStreamEvent = arcCount: number; chapterCount: number; sceneCount: number; + npcCount: number; } - | { type: 'done'; arcs: ArcProposal[] } + | { type: 'done'; arcs: ArcProposal[]; npcs: NpcProposal[] } | { type: 'error'; message: string }; diff --git a/web/src/app/services/campaign-import.service.ts b/web/src/app/services/campaign-import.service.ts index 40bd390..697fe4a 100644 --- a/web/src/app/services/campaign-import.service.ts +++ b/web/src/app/services/campaign-import.service.ts @@ -72,7 +72,7 @@ export class CampaignImportService { try { const obj = JSON.parse(currentData); if (name === 'done') { - subscriber.next({ type: 'done', arcs: obj.arcs ?? [] }); + subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] }); subscriber.complete(); } else { subscriber.next({ type: 'progress', ...obj });