Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m53s
Build & Push Images / build (core) (push) Successful in 3m40s
Build & Push Images / build-switcher (push) Successful in 16s

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:
2026-07-03 15:29:23 +02:00
parent 268f4721ce
commit b2c3800bf8
278 changed files with 15570 additions and 1451 deletions

View File

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