SLOPSHOPPER

harnais

Harnais d'orchestration du dépôt : rend l'état que mesurent les scripts (suivi de vague, #2279 ; CI et train de publication en status line, #2280), sans porter…

newbandguardtoaststatusprompt
v0.1.0no licenseupdated 2026-10-09MyEdO/game/.claude/skills/harnais
A shopper browsing a rack in a slop shop
README

Warhammer Fantasy v4 — RPG tactique au tour par tour (web)

Jeu de rôle vidéoludique 100 % web, en français, type Neverwinter Nights, basé sur les règles de Warhammer Fantasy Roleplay 4ᵉ édition. On contrôle un groupe de 4 aventuriers (créés un par un ou pré-tirés) à travers la campagne impériale L'Ennemi Intérieur.

Toutes les règles et le contenu proviennent des fichiers sources (Source/). La base de jeu (src/data/*.json) est générée depuis ces sources — limitée au Livre de base et aux Archives de l'Empire I & II — et n'est jamais le fichier source brut.

Fonctionnalités (PR1 — fondations + tranche jouable)

  • Moteur de règles WFRP4 (src/engine) : Tests & Degrés de Réussite, Caractéristiques, Blessures, combat (touche/localisation/dégâts), états, création de personnage. Testé avec Vitest.
  • Créateur de personnage : aléatoire complet ou manuel, espèces et carrières du Livre de base.
  • Groupe de 4 : créés ou choisis parmi des pré-tirés.
  • Mode campagne : ouverture du Tome 1 (L'Ennemi dans l'Ombre) — l'auberge « La Diligence » et l'embuscade des mutants, en combat tactique sur grille.
  • Éditeur de niveau : peinture de tuiles, placement d'entités, dialogues/triggers/combats. La scène de campagne est un document au même format → entièrement ré-éditable dans l'éditeur.
  • Coop hotseat : les héros jouent tour à tour, le joueur actif est mis en avant.
  • Art sans asset binaire : tout est authoré en SVG dans le code. Les rigs de personnages sont composés part par part (src/gameIso/rig/), le décor est peint en billboards depuis src/gameIso/catalog/decor/ ; sol, murs, toits et les props volumiques sont bâtis en géométrie par src/gameIso/builders/.

Pile technique

  • three.js — rendu volumique du monde : la géométrie et les matières sont montées par src/gameIso/backends/webgl/, la scène elle-même (caméra, lumières, ambiance) par src/gameIso/stage/.
  • React + TypeScript — interface (menus, créateur, fiches, dialogues, HUD, éditeur) et surcouches SVG du plateau (grille, traits de mur, réticules, chrome des jetons).
  • Zustand — état partagé reliant React et le rendu du monde.
  • Vite — bundler · Vitest — tests, du moteur pur jusqu'à l'UI et au rendu.

Démarrage

npm install
npm run dev          # serveur de développement

src/data/*.json sont la source app-owned (commitée, éditée dans le Compendium), curée à la main.

Autres scripts : npm test (suite complète), npm run build (build de production), npm run typecheck.

Architecture

src/engine/             Règles WFRP4 (pur TS, testé)
src/data/               Base app-owned (JSON commité, éditable dans le Compendium)
src/state/              Schéma de Scène, store Zustand, flux de jet, IA, pathfinding
src/gameIso/            Rendu du monde : builders/ (géométrie pure) montée par stage/ sur le backend
                        three.js (backends/webgl/) ; rig/ et catalog/ portent l'art SVG ; plus
                        authoring/, detail/, fx/, pov/
src/geometry/           Grille, projection isométrique, déplacement
src/ui/                 Interface React (menus, créateur, HUD, éditeur, compendium)
src/scenes/             Documents de scène et de campagne
src/net/                Coop en ligne : client du relay WS (le Worker Cloudflare vit dans server/)
src/audio/              Musique et sons
src/i18n/               Textes français de l'interface

Le schéma de Scène (src/state/scene.ts) est l'unique contrat partagé par l'éditeur, le runtime et la campagne : aucune scène n'est codée « en dur ».

Périmètre & suite

PR1 pose les fondations et une tranche jouable. Les itérations suivantes ajouteront les Tomes 1-3 complets, la coop en ligne, la magie/les prières en combat, l'économie entre aventures et un bestiaire étendu — la structure data-driven est prévue pour les accueillir sans refonte.

Source 5 files
hooks/register.ts 10 lines
1// Mod `harnais` (#2278) : un fichier par fonction, chacune enregistrée ici.
2import type { Register } from 'claude-code'
3import { suivi } from './suivi'
4import { vigie } from './vigie'
5
6export const register: Register = (on) => {
7  suivi(on)
8  vigie(on)
9}
10
hooks/suivi.ts 171 lines
1// Fonction `suivi` du mod `harnais` (#2279) : le suivi de vague `.git/suivi/<N>.json` sous les yeux pendant
2// toute la session ; au démarrage, elle synchronise d'abord le principal (`synchroniser.mjs --json`, #2187).
3// Elle REND l'état de session que calcule le lecteur `scripts/ops/suivi.mjs --session
4// <id> --json [--depuis <cle>]` (`etatDeSession`), enregistre l'outil que décrit `suivi.mjs --outil --json`
5// (son schéma est dérivé de la donnée, #2460) et lui confie chaque lot par `suivi.mjs <N> --session <id>
6// --json --lot <json>` (`editer`). Le lecteur ne mesure jamais : pour chaque épique de son `aMesurer`, la
7// relecture lance `suivi.mjs <N> --mesurer --sans-fetch --json` ; le verrou de mesure de
8// `scripts/ops/suiviMesure.mjs`, pris sans attente, dédoublonne — le mod n'en décide rien (#2460, design §5).
9// Porteurs :
10// https://github.com/MyEdO/game/issues/2278#issuecomment-5983942497
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, On, RenderElement } from 'claude-code'
13import type { HarnaisEtatDeSession, HarnaisOutil, HarnaisSuivi, HarnaisSynchro, HarnaisSynchroEnAttente } from '../types'
14import { appel, lire } from './ops'
15import type { Lu } from './ops'
16
17const DEPART: HarnaisSuivi = { etat: null, cle: null, enAttente: null, generation: '' }
18const atome = atom({ plugin: 'harnais', key: 'suivi' } as const, DEPART)
19
20/**
21 * Période (ms) de relecture. Un lot par l'outil relit aussitôt ; un lot par le CLI (`ops:suivi -- N`) est vu
22 * en une minute au plus, pour un `node` de moins d'une seconde par minute
23 * (#2278, sonde P1). Valeur maison.
24 */
25const PERIODE_MS = 60 * 1000
26
27/** `scripts/ops/suivi.mjs` lancé avec `args` ; un échec va au journal de débogage. */
28async function suiviMjs<T>($: EngineInterface, args: readonly string[]): Promise<Lu<T>> {
29  let lu: Lu<T>
30  try {
31    lu = lire(await $.process.run(...appel($.plugin.root, 'suivi', args)))
32  } catch (erreur) {
33    lu = { ok: false, motif: `non lancé (${String(erreur)})` }
34  }
35  if (!lu.ok) $.ui.log(`harnais, suivi.mjs ${args.join(' ')} : ${lu.motif}`, { to: 'debug' })
36  return lu
37}
38
39/**
40 * Relit l'état de session depuis la clé retenue. Ignorée si une transition qui retient une clé a eu lieu
41 * depuis son lancement ; un échec garde le dernier état valide. Chaque épique de son `aMesurer` est mesurée
42 * (`mesurer`), sans attendre.
43 */
44async function relire($: EngineInterface) {
45  const lancee = await read($, atome)
46  const lu = await suiviMjs<HarnaisEtatDeSession>($, ['--session', await $.session.id(), '--json', ...(lancee.cle ? ['--depuis', lancee.cle] : [])])
47  if (!lu.ok) return
48  await update($, atome, (s) => (s.generation !== lancee.generation ? s : {
49    ...s,
50    etat: lu.valeur,
51    enAttente: lu.valeur.ajout ? { ajout: lu.valeur.ajout, cle: lu.valeur.cle } : s.enAttente,
52  }))
53  for (const epique of lu.valeur.aMesurer) void mesurer($, epique)
54}
55
56/** Borne (ms) d'une mesure : `gh` sur la portée, les branches et les worktrees. Valeur maison. */
57const BORNE_MESURE_MS = 300 * 1000
58
59/**
60 * La mesure de l'épique `epique` (`suivi.mjs <N> --mesurer --sans-fetch --json`) ; la relecture suivante en lit
61 * le résultat. Un échec va au journal de débogage.
62 */
63async function mesurer($: EngineInterface, epique: number) {
64  const args = [String(epique), '--mesurer', '--sans-fetch', '--json']
65  try {
66    const lu = lire(await $.process.run(...appel($.plugin.root, 'suivi', args, { borneMs: BORNE_MESURE_MS })))
67    if (!lu.ok) $.ui.log(`harnais, suivi.mjs ${args.join(' ')} : ${lu.motif}`, { to: 'debug' })
68  } catch (erreur) {
69    $.ui.log(`harnais, suivi.mjs ${args.join(' ')} : non lancé (${String(erreur)})`, { to: 'debug' })
70  }
71}
72
73const atomeSynchro = atom({ plugin: 'harnais', key: 'synchro' } as const, { texte: null } as HarnaisSynchroEnAttente)
74
75/**
76 * Borne (ms) de `synchroniser.mjs` : l'attente de ses verrous, le `fetch`, l'avance git ; le `post-merge`
77 * court en fond (`--consommer`, #2493) ; même valeur que `TIMEOUT_SYNCHRONISEUR`
78 * (`scripts/agents/compat-core.mjs`), côté Codex. Valeur maison.
79 */
80const BORNE_SYNCHRO_MS = 300 * 1000
81
82/** `synchroniser.mjs` lancé avec `args`, son état lu ; un lancement manqué est un échec nommé. */
83async function synchroniserMjs($: EngineInterface, args: readonly string[]): Promise<Lu<HarnaisSynchro>> {
84  try {
85    return lire(await $.process.run(...appel($.plugin.root, 'synchroniser', args, { borneMs: BORNE_SYNCHRO_MS })), { codes: [0, 1, 2] })
86  } catch (erreur) {
87    return { ok: false, motif: `non lancé (${String(erreur)})` }
88  }
89}
90
91/**
92 * Le principal synchronisé (`scripts/ops/synchroniser.mjs --json`, #2187) : son `texte` non vide est retenu
93 * pour UN bloc de contexte ; sans état lisible, le motif et la `ligne` de la re-mesure (`--mesurer --json`, #2493).
94 */
95async function synchroniser($: EngineInterface) {
96  const lu = await synchroniserMjs($, ['--json'])
97  let texte: string | null
98  if (lu.ok) texte = lu.valeur.texte || null
99  else {
100    const mesure = await synchroniserMjs($, ['--mesurer', '--json'])
101    texte = `[synchroniser] principal : synchroniseur sorti sans état (${lu.motif}) ; re-mesure : ${mesure.ok ? mesure.valeur.ligne : mesure.motif}`
102  }
103  await update($, atomeSynchro, () => ({ texte }))
104}
105
106/** La transition qui retient la clé `cle` : l'attente est vidée, une génération neuve est tirée. */
107const retenir = (s: HarnaisSuivi, cle: string, etat: HarnaisEtatDeSession | null = s.etat): HarnaisSuivi =>
108  ({ etat, cle, enAttente: null, generation: crypto.randomUUID() })
109
110export function suivi(on: On) {
111  on('session.start', async ($, e, next) => {
112    await synchroniser($)
113    await relire($)
114    $.clock.every(PERIODE_MS, () => {
115      void relire($)
116    })
117    const outil = await suiviMjs<HarnaisOutil>($, ['--outil', '--json'])
118    if (outil.ok) await $.tool.register(outil.valeur)
119    return next(e)
120  })
121
122  on('tool.call', { tool: 'mcp__harnais__suivi' }, async ($, e) => {
123    const lot = JSON.stringify({ epique: e.epique, mutations: e.mutations })
124    const lu = await suiviMjs<HarnaisEtatDeSession>($, [String(e.epique), '--session', await $.session.id(), '--json', '--lot', lot])
125    if (!lu.ok) return { deny: lu.motif }
126    await update($, atome, (s) => retenir(s, lu.valeur.cle, lu.valeur))
127    return { result: lu.valeur.ajout }
128  })
129
130  on('prompt.context', async ($, e, next) => {
131    const porte: { etat: HarnaisEtatDeSession | null } = { etat: null }
132    await update($, atome, (s) => {
133      porte.etat = s.etat?.contexte ? s.etat : null
134      return porte.etat ? retenir(s, porte.etat.cle) : s
135    })
136    const synchro: { texte: string | null } = { texte: null }
137    await update($, atomeSynchro, (s) => {
138      synchro.texte = s.texte
139      return s.texte ? { texte: null } : s
140    })
141    const { etat } = porte
142    const blocs = [
143      ...(synchro.texte ? [{ name: 'synchroniser', text: synchro.texte }] : []),
144      ...(etat ? [{ name: 'suivi', text: etat.contexte }] : []),
145    ]
146    return blocs.length ? next({ ...e, blocks: [...e.blocks, ...blocs] }) : next(e)
147  })
148
149  on('turn.start', async ($, e, next) => {
150    const lu = await read($, atome)
151    const { enAttente } = lu
152    if (!enAttente) return next(e)
153    try {
154      await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: enAttente.ajout }] } })
155    } catch (erreur) {
156      $.ui.log(`harnais, ajout du suivi non fait, reporté au tour suivant : ${String(erreur)}`, { to: 'debug' })
157      return next(e)
158    }
159    await update($, atome, (s) => (s.generation !== lu.generation ? s : retenir(s, enAttente.cle)))
160    return next(e)
161  })
162
163  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
164    const { etat } = await read($, atome)
165    if (e.props.hasSurvey || !etat?.suivis.length) return next(e)
166    const { Box, Text } = $.ui.resolve(e)
167    return h(Box, { flexDirection: 'column' }, ...etat.suivis.flatMap((lie) =>
168      lie.lignes.map((ligne, rang) => h(Text, { key: `${lie.epique}-${rang}`, dimColor: true }, ligne)))) as RenderElement
169  })
170}
171
hooks/vigie.ts 52 lines
1// Fonction `vigie` du mod `harnais` (#2280, V5) : l'état de la CI (`main`, branche de l'arbre) et du train
2// de publication sous les yeux de l'HUMAIN. Elle REND la mesure de `scripts/ops/vigie.mjs --json --arbre
3// <racine> [--depuis <etat>]` : la status line, un toast par transition. Le réveil du modèle n'est pas
4// ici (V1, `ops:ci -- --attendre`). Design : https://github.com/MyEdO/game/issues/2280#issuecomment-5995615185
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, On } from 'claude-code'
7import type { HarnaisMesureDeVigie, HarnaisVigie } from '../types'
8import { appel, lire } from './ops'
9import type { Lu } from './ops'
10
11const DEPART: HarnaisVigie = { etat: null }
12const atome = atom({ plugin: 'harnais', key: 'vigie' } as const, DEPART)
13
14/** Période (ms) d'une mesure (#2280, V5), valeur maison mesurée le 2026-10-05 : un tick coûte 2,4 s (chaud) à 3,5 s (froid), 8 sessions en dépensent au pire ~960 requêtes gh par heure (~19 % du quota de 5 000), et une minute pèse peu devant la médiane d'une course CI (7 min). */
15const PERIODE_MS = 60 * 1000
16
17/**
18 * Une mesure depuis l'état retenu : la ligne va à la status line, chaque transition à un toast, l'état
19 * rendu devient le `--depuis` suivant. Un échec va au journal de débogage et garde la dernière ligne.
20 */
21async function mesurer($: EngineInterface) {
22  const { etat } = await read($, atome)
23  const args = ['--json', '--arbre', await $.session.root(), ...(etat ? ['--depuis', etat] : [])]
24  let lu: Lu<HarnaisMesureDeVigie>
25  try {
26    lu = lire(await $.process.run(...appel($.plugin.root, 'vigie', args)))
27  } catch (erreur) {
28    lu = { ok: false, motif: `non lancé (${String(erreur)})` }
29  }
30  if (!lu.ok) {
31    $.ui.log(`harnais, vigie.mjs ${args.join(' ')} : ${lu.motif}`, { to: 'debug' })
32    return
33  }
34  const mesure = lu.valeur
35  await update($, atome, () => ({ etat: mesure.etat }))
36  $.ui.status(mesure.ligne)
37  for (const transition of mesure.transitions) $.ui.toast(transition)
38}
39
40export function vigie(on: On) {
41  // Matcher `isInteractive` : le moteur 2.1.289 refuse deux `session.start` sans matcher dans un plugin
42  // (`claude plugin validate --strict`, #2280), et la fonction rend pour l'humain ; une surface non
43  // interactive (`-p`, SDK, Desktop s'il passe par le SDK) ne lance pas la vigie.
44  on('session.start', { isInteractive: true }, async ($, e, next) => {
45    void mesurer($)
46    $.clock.every(PERIODE_MS, () => {
47      void mesurer($)
48    })
49    return next(e)
50  })
51}
52
hooks/ops.ts 36 lines
1// COUTURE du mod `harnais` (#2278), PURE : l'appel d'un script du dépôt et la lecture de sa sortie JSON
2// (mur `murs/mod-sans-regle`, `VERROU_MOD_COUTURE` d'oxlint.config.mjs). Le moteur ne suit `$` dans aucun import : le lancement
3// s'écrit au site d'appel, `lire(await $.process.run(...appel($.plugin.root, <script>, <args>)))`.
4import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
5
6/** Borne (ms) par défaut d'un script lancé ; le lecteur du suivi répond en moins d'une seconde (#2278, sonde P1). Valeur maison. */
7const BORNE_MS = 20 * 1000
8
9/** Ce que rend `lire` : la valeur lue, ou le motif de l'échec. */
10export type Lu<T> = { ok: true; valeur: T } | { ok: false; motif: string }
11
12/**
13 * L'argv et les options de `$.process.run` pour `node <dépôt>/scripts/ops/<script>.mjs ...args`, lancé depuis
14 * la racine du dépôt, trois niveaux au-dessus de `racinePlugin` (`.claude/skills/<mod>`), borné à `borneMs`.
15 */
16export function appel(racinePlugin: string, script: string, args: readonly string[], { borneMs = BORNE_MS }: { borneMs?: number } = {}): readonly [string[], ProcessRunInit] {
17  const racine = `${racinePlugin}/../../..`
18  return [['node', `${racine}/scripts/ops/${script}.mjs`, ...args], { cwd: racine, timeoutMs: borneMs }]
19}
20
21/**
22 * La sortie JSON d'un script : un objet, sinon le motif (code hors de `codes`, JSON illisible, forme fausse).
23 * `codes` : les codes de sortie dont le script écrit son JSON (0 par défaut).
24 */
25export function lire<T>(resultat: ProcessRunResult, { codes = [0] }: { codes?: readonly number[] } = {}): Lu<T> {
26  if (!codes.some((code) => code === resultat.exitCode)) return { ok: false, motif: resultat.stderr.trim() || `code de sortie ${resultat.exitCode}` }
27  let valeur: unknown
28  try {
29    valeur = JSON.parse(resultat.stdout)
30  } catch {
31    return { ok: false, motif: 'sortie JSON illisible' }
32  }
33  if (typeof valeur !== 'object' || valeur === null || Array.isArray(valeur)) return { ok: false, motif: 'sortie JSON qui n’est pas un objet' }
34  return { ok: true, valeur: valeur as T }
35}
36
types/index.d.ts 64 lines
1// Types du mod `harnais` : fonction `suivi` (#2279), l'état de session que rend `node scripts/ops/suivi.mjs
2// --session <id> --json` (`etatDeSession`, scripts/ops/suivi.mjs), l'outil que rend `--outil --json` et l'atome
3// `harnais.suivi` ; fonction `vigie`
4// (#2280), la mesure que rend `node scripts/ops/vigie.mjs --json --arbre <racine>` et l'atome `harnais.vigie` ;
5// l'état que rend `node scripts/ops/synchroniser.mjs --json` (#2187) et l'atome `harnais.synchro`.
6
7/** Un suivi lié à la session : son épique, son chemin et les lignes de sa situation (le bandeau). */
8export type HarnaisSuiviLie = { epique: number; chemin: string; lignes: string[] }
9
10/** L'état d'une session (`etatDeSession`), prêt à rendre. */
11export type HarnaisEtatDeSession = {
12  session: string
13  suivis: HarnaisSuiviLie[]
14  contexte: string
15  ajout: string
16  cle: string
17  /** Les épiques dont la confrontation demande une mesure (absente, illisible, périmée, portée changée). */
18  aMesurer: number[]
19}
20
21/** L'outil MCP que décrit `suivi.mjs --outil --json` (`OUTIL_SUIVI`, scripts/ops/suiviDonnee.mjs) : son schéma est dérivé de la donnée. */
22export type HarnaisOutil = { name: string; description: string; inputSchema: Record<string, unknown> }
23
24/** Une situation datée rendue par le lecteur, en attente du prochain tour, et la clé qu'elle porte. */
25export type HarnaisAjout = { ajout: string; cle: string }
26
27/**
28 * L'atome UNIQUE de la fonction `suivi`, modifié par une seule `update` à chaque transition :
29 * - `etat` : le dernier état de session VALIDE lu (`null` avant toute lecture réussie) ;
30 * - `cle` : la clé du dernier état porté en contexte, ajouté ou rendu par l'outil, le `--depuis` du lecteur ;
31 * - `enAttente` : la situation datée que le lecteur a rendue depuis `cle`, en attente du prochain tour ;
32 * - `generation` : le jeton, tiré neuf, de la dernière transition qui a retenu une clé ; une relecture
33 *   lancée sous une autre génération est ignorée.
34 */
35export type HarnaisSuivi = {
36  etat: HarnaisEtatDeSession | null
37  cle: string | null
38  enAttente: HarnaisAjout | null
39  generation: string
40}
41
42/** La mesure de `vigie.mjs --json` : la status line, une phrase de toast par transition, l'état opaque à repasser en `--depuis`. */
43export type HarnaisMesureDeVigie = { ligne: string; transitions: string[]; etat: string }
44
45/** L'atome de la fonction `vigie` : l'`etat` de la dernière mesure VALIDE (`null` avant la première), le `--depuis` suivant. */
46export type HarnaisVigie = { etat: string | null }
47
48/** L'état que rend `synchroniser.mjs --json` ou `--mesurer --json` (#2187, #2493) : son `etat` nommé, son `texte`
49 *  de session (vide pour un état muet) et ses champs, opaques au mod. */
50export type HarnaisSynchro = { etat: string, texte: string, ligne?: string } & Record<string, unknown>
51
52/** L'atome `harnais.synchro` : le texte de l'état de synchronisation à porter UNE fois en contexte, `null` sinon. */
53export type HarnaisSynchroEnAttente = { texte: string | null }
54
55declare module 'claude-code' {
56  interface PluginState {
57    harnais: {
58      suivi: HarnaisSuivi
59      vigie: HarnaisVigie
60      synchro: HarnaisSynchroEnAttente
61    }
62  }
63}
64