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:
@@ -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]:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -14,5 +14,6 @@ public record CampaignImportProgress(
|
||||
int ocrPageCount,
|
||||
int arcCount,
|
||||
int chapterCount,
|
||||
int sceneCount) {
|
||||
int sceneCount,
|
||||
int npcCount) {
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
@if (importCounts) {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -51,7 +51,8 @@
|
||||
<p class="review-summary">
|
||||
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(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 ;
|
||||
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
|
||||
</p>
|
||||
@@ -203,6 +204,34 @@
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
|
||||
</button>
|
||||
</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) {
|
||||
<p class="apply-error">{{ applyError }}</p>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user