Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
All checks were successful
All checks were successful
mode séance, battlemaps multiples et export Foundry ciblé
- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
Fusion quête/conteneur dans la sidebar, progression par partie (statuts
disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
automatiquement (arc technique « Quêtes libres », V21 : levée de la
contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
brouillons de scènes en propose→applique (brain : narrative-fields,
scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
épinglée (V19), récap « précédemment » (brain : session-recap), onglet
« Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
(respect du périmètre + une Scene Foundry par variante de carte,
rétro-compatible anciens bundles).
This commit is contained in:
@@ -15,10 +15,10 @@ function data(partial: Partial<CampaignTreeData> = {}): CampaignTreeData {
|
||||
}
|
||||
|
||||
describe('buildCampaignTree', () => {
|
||||
it('produit les nœuds fixes (PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => {
|
||||
it('produit les nœuds fixes (quêtes, PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => {
|
||||
const tree = buildCampaignTree('c1', data(), t);
|
||||
expect(tree.map((n) => n.id)).toEqual([
|
||||
'npcs-root', 'enemies-root', 'random-tables-root',
|
||||
'quests-root', 'npcs-root', 'enemies-root', 'random-tables-root',
|
||||
'notebooks-root', 'item-catalogs-root', 'import-pdf-root',
|
||||
]);
|
||||
});
|
||||
@@ -34,16 +34,16 @@ describe('buildCampaignTree', () => {
|
||||
expect(arcLabels).toEqual(['Intro', 'Voyage', 'Final']);
|
||||
});
|
||||
|
||||
it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => {
|
||||
it('imbrique chapitres et scènes', () => {
|
||||
const tree = buildCampaignTree('camp', data({
|
||||
arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never,
|
||||
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap', prerequisites: [{}] }] } as never,
|
||||
// 2 chapitres pour éviter le mode plat (Niveau 0), qui masque arc + chapitre
|
||||
// quand il n'y a qu'un seul arc d'un seul chapitre.
|
||||
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }] } as never,
|
||||
scenesByChapter: { c1: [{ id: 's1', name: 'Scene' }] } as never,
|
||||
}), t);
|
||||
const arc = tree.find((n) => n.id === 'arc-a1')!;
|
||||
const chapter = arc.children![0];
|
||||
expect(chapter.id).toBe('chapter-c1');
|
||||
expect(chapter.meta).toBe('🔒'); // cadenas si prérequis
|
||||
const chapter = arc.children!.find((c) => c.id === 'chapter-c1')!;
|
||||
expect(chapter.children![0].id).toBe('scene-s1');
|
||||
});
|
||||
|
||||
@@ -73,13 +73,96 @@ describe('buildCampaignTree', () => {
|
||||
expect(folder.children![0].meta).toBe('Niv. 3');
|
||||
});
|
||||
|
||||
it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => {
|
||||
const hub = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never }), t)
|
||||
.find((n) => n.id === 'arc-a')!;
|
||||
expect(hub.createActions![0].label).toBe('campaignTree.newQuest');
|
||||
it('arc LINÉAIRE = chapitres ; arc HUB = quêtes (bouton « nouvelle quête » uniquement)', () => {
|
||||
const hub = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never,
|
||||
quests: [{ id: 'q1', name: 'Enquête', arcId: 'a' }, { id: 'q2', name: 'Transverse', arcId: null }] as never,
|
||||
}), t);
|
||||
const hubArc = hub.find((n) => n.id === 'arc-a')!;
|
||||
expect(hubArc.children!.some((c) => c.id === 'quest-q1')).toBe(true); // rattachée → enfant
|
||||
expect(hubArc.children!.some((c) => c.id === 'quest-q2')).toBe(false); // transverse → pas ici
|
||||
// HUB : on ne crée QUE des quêtes (pas de « nouveau chapitre »).
|
||||
expect(hubArc.createActions!.map((a) => a.label)).toEqual(['campaignTree.newQuest']);
|
||||
expect(hubArc.createActions![0].queryParams).toEqual({ arcId: 'a' });
|
||||
|
||||
const linear = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never }), t)
|
||||
.find((n) => n.id === 'arc-a')!;
|
||||
expect(linear.createActions![0].label).toBe('campaignTree.newChapter');
|
||||
// LINEAR : uniquement des chapitres ; une quête même arcId=arc n'apparaît PAS sous l'arc.
|
||||
const linear = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never,
|
||||
quests: [{ id: 'q3', name: 'X', arcId: 'a' }] as never,
|
||||
}), t);
|
||||
const linArc = linear.find((n) => n.id === 'arc-a')!;
|
||||
expect(linArc.children!.some((c) => c.id === 'quest-q3')).toBe(false);
|
||||
expect(linArc.createActions!.map((a) => a.label)).toEqual(['campaignTree.newChapter']);
|
||||
|
||||
// Le nœud « Quêtes » dédié conserve son action de création.
|
||||
expect(hub.find((n) => n.id === 'quests-root')!.createActions![0].label).toBe('campaignTree.newQuest');
|
||||
});
|
||||
|
||||
it('pastilles : un manque de QUÊTE n\'allume PAS une scène/PNJ portant le même id (séquences par table)', () => {
|
||||
// Chaque table a sa propre séquence IDENTITY : quête id 7 et scène id 7 coexistent.
|
||||
const gaps = [{
|
||||
entityType: 'QUEST', entityId: '7', entityName: 'Q', ruleId: 'QUEST-001-NO-NODES',
|
||||
message: 'm', severity: 'BLOCKING', arcId: null, chapterId: null,
|
||||
}];
|
||||
const tree = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never,
|
||||
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }] } as never,
|
||||
scenesByChapter: { c1: [{ id: '7', name: 'Scène homonyme' }] } as never,
|
||||
npcs: [{ id: '7', name: 'PNJ homonyme' }] as never,
|
||||
quests: [{ id: '7', name: 'Q', arcId: null }] as never,
|
||||
}), t, gaps as never);
|
||||
const arc = tree.find((n) => n.id === 'arc-a1')!;
|
||||
const scene = arc.children!.find((c) => c.id === 'chapter-c1')!.children!.find((s) => s.id === 'scene-7')!;
|
||||
expect(scene.statusDot).toBeUndefined(); // pas de faux positif
|
||||
expect(tree.find((n) => n.id === 'npcs-root')!.statusDot).toBeUndefined(); // idem PNJ
|
||||
expect(tree.find((n) => n.id === 'quests-root')!.statusDot).toBe('blocking'); // le vrai coupable
|
||||
});
|
||||
|
||||
it('arc HUB : une quête FUSIONNE avec son chapitre jumeau (un seul nœud, scènes dessous)', () => {
|
||||
// Héritage V10 : la quête référence son chapitre jumeau via un nœud CHAPTER.
|
||||
const tree = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never,
|
||||
chaptersByArc: { a: [{ id: 'c1', name: 'Jumeau' }, { id: 'c2', name: 'Chapitre libre' }] } as never,
|
||||
scenesByChapter: { c1: [{ id: 's1', name: 'Scène du jumeau' }] } as never,
|
||||
quests: [{ id: 'q1', name: 'La quête', arcId: 'a', nodes: [{ nodeType: 'CHAPTER', nodeId: 'c1', order: 0 }] }] as never,
|
||||
}), t);
|
||||
const hubArc = tree.find((n) => n.id === 'arc-a')!;
|
||||
// La quête absorbe le jumeau : pas de nœud chapter-c1, ses scènes vivent sous quest-q1.
|
||||
expect(hubArc.children!.some((c) => c.id === 'chapter-c1')).toBe(false);
|
||||
const questNode = hubArc.children!.find((c) => c.id === 'quest-q1')!;
|
||||
expect(questNode.children!.some((c) => c.id === 'scene-s1')).toBe(true);
|
||||
expect(questNode.dropParentId).toBe('c1'); // le jumeau reste le conteneur des scènes
|
||||
// Le chapitre NON consommé reste affiché tel quel (contenu mixte/historique).
|
||||
expect(hubArc.children!.some((c) => c.id === 'chapter-c2')).toBe(true);
|
||||
});
|
||||
|
||||
it("l'arc SYSTEM (« Quêtes libres ») est masqué de la narration", () => {
|
||||
const tree = buildCampaignTree('c', data({
|
||||
arcs: [
|
||||
{ id: 'a1', name: 'Arc visible', type: 'LINEAR' },
|
||||
{ id: 'sys', name: 'Quêtes libres', type: 'SYSTEM' },
|
||||
] as never,
|
||||
chaptersByArc: {
|
||||
a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }],
|
||||
sys: [{ id: 'cf', name: 'Conteneur' }],
|
||||
} as never,
|
||||
}), t);
|
||||
expect(tree.some((n) => n.id === 'arc-sys')).toBe(false); // pas dans l'arbre
|
||||
expect(tree.some((n) => n.id === 'arc-a1')).toBe(true);
|
||||
});
|
||||
|
||||
it('quête LIBRE : fusionnée sous « Quêtes » avec les scènes de son conteneur SYSTEM et « + scène »', () => {
|
||||
const tree = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'sys', name: 'Quêtes libres', type: 'SYSTEM' }] as never,
|
||||
chaptersByArc: { sys: [{ id: 'cf', name: 'Libre', arcId: 'sys' }] } as never,
|
||||
scenesByChapter: { cf: [{ id: 's1', name: 'Scène à la volée' }] } as never,
|
||||
quests: [{ id: 'q1', name: 'Libre', arcId: null, nodes: [{ nodeType: 'CHAPTER', nodeId: 'cf', order: 0 }] }] as never,
|
||||
}), t);
|
||||
const questsRoot = tree.find((n) => n.id === 'quests-root')!;
|
||||
const questNode = questsRoot.children!.find((c) => c.id === 'quest-q1')!;
|
||||
expect(questNode.children!.some((c) => c.id === 'scene-s1')).toBe(true);
|
||||
expect(questNode.dropParentId).toBe('cf'); // le conteneur reçoit les drops de scènes
|
||||
// « + scène » pointe vers la création de scène DANS le conteneur (arc SYSTEM).
|
||||
expect(questNode.createActions![0].route).toBe('/campaigns/c/arcs/sys/chapters/cf/scenes/create');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user