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

@@ -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>
}

View File

@@ -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;
}
}

View File

@@ -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({