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