diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..5e35d68 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,11 @@ +#!/bin/sh +# Быстрая сетка перед коммитом: типы + граница движок/игра + юнит-тесты. +# Браузерные пробы (agent:check, смоуки) — отдельно, перед пушем. +# Пропустить разово: git commit --no-verify. +echo "[pre-commit] check:fast (typecheck + guard + тесты)..." +if ! npm run check:fast; then + echo "" + echo "[pre-commit] проверки провалены — коммит отменён." + echo " Пропустить разово: git commit --no-verify" + exit 1 +fi \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 01c3dab..bcca811 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ npx vitest run packages/engine/src/map/__tests__/pathfinding.test.ts # один файл тестов npm run typecheck # tsc --noEmit для обоих пакетов npm run guard # гвард границы движок/игра (импорты, JSON) +npm run check:fast # typecheck + guard + все тесты (~30 с, это же делает pre-commit хук) npm run art # перегенерация пиксель-арта из apps/game/tools/pixelart npm run maps # перегенерация карт-файлов (apps/game/tools/maps + encodeMap) npm run agent:check # полный прогон проверок через агентный мост (JSON) @@ -58,7 +59,7 @@ - `apps/game/tools/` — все тулзы игры (перенесены из корневого `tools/`): `agent.mjs`/`agent-lib.mjs` (агентный CLI), `checks/` (сценарии проверок), `pixelart/`, `maps/`, `audio/`, смоуки; `guards/boundary.mjs` — гвард границы движок/игра (`npm run guard`, входит в `agent:check`). - `apps/game/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). - `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox), интерактивные объекты (`Interactables.ts` — резолв реакций, used-флаги, `InteractSink`). -- Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). +- Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). **Флаги/вары — только через реестры `data/ids.ts`** (`FLAGS`/`VARS`, типы `FlagId`/`VarId`, `usedFlag(id)` для used:): строковое упоминание вне реестра ловится `validateReferences()` как error, незадействованный ключ — как warn. ### Пиксель-арт diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index 850cae9..568a79b 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -10,6 +10,7 @@ import type { Game } from '../Game'; import { gameLayer, type DialogueSnapshot, type GameSnapshot } from './snapshot'; import { validateContent } from '../data/validate'; +import { usedFlag } from '../data/ids'; /** * Агентный мост игры: поверх движкового каркаса (EngineAgent) добавляет @@ -82,11 +83,11 @@ const s = this.snapshot(); const flags = new Set(s.flags ?? []); for (const o of (s.interactables ?? []) as { id: string; used: boolean }[]) { - if (flags.has(`used:${o.id}`) !== o.used) { + if (flags.has(usedFlag(o.id)) !== o.used) { list.push({ id: 'interact-used-consistent', severity: 'error', - message: `объект ${o.id}: флаг used=${flags.has(`used:${o.id}`)}, снапшот used=${o.used}`, + message: `объект ${o.id}: флаг used=${flags.has(usedFlag(o.id))}, снапшот used=${o.used}`, where: 'agent/GameAgent' }); } diff --git a/apps/game/src/data/__tests__/validate.test.ts b/apps/game/src/data/__tests__/validate.test.ts index de3984c..cb3c3d3 100644 --- a/apps/game/src/data/__tests__/validate.test.ts +++ b/apps/game/src/data/__tests__/validate.test.ts @@ -2,7 +2,10 @@ import type { DialogueGraph, TileMapData } from '@rpg/engine'; import { TILES, buildMeadowsMap, buildPondsMap, buildZvenetsMap } from '../map'; import { AREAS } from '../locations'; -import { validateContent, validateDialogue, validateLocations, validateNpcs } from '../validate'; +import { NPCS } from '../npcs'; +import { DIALOGUES } from '../dialogues'; +import { FLAGS } from '../ids'; +import { validateContent, validateDialogue, validateLocations, validateNpcs, validateReferences } from '../validate'; /** Карта целиком из проходимой травы (или с одиночной стеной). */ function flatMap(w: number, h: number, wall?: { x: number; y: number }): TileMapData { @@ -27,6 +30,44 @@ }); }); +describe('validateReferences — реестры флагов/варов', () => { + it('реальный контент: только флаг знакомств вне контента (dead), ошибок нет', () => { + const inv = validateReferences(); + expect(inv.filter((i) => i.severity === 'error')).toEqual([]); + // got_cloth ставится диалогом и нигде не читается, но ставится — упомянут. + expect(inv.filter((i) => i.id === 'flag-dead').map((i) => i.message)).toEqual([]); + }); + + it('неизвестный флаг/вар в диалоге — error', () => { + const g: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', setFlags: ['quest_bels_done'], whenVar: { key: 'moats', op: 'ge', value: 1 } } } }; + const backup = DIALOGUES['test_ref']; + (DIALOGUES as Record)['test_ref'] = g; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('quest_bels_done'))).toBe(true); + expect(inv.some((i) => i.id === 'var-unknown' && i.message.includes('moats'))).toBe(true); + } finally { + if (backup === undefined) delete (DIALOGUES as Record)['test_ref']; + else (DIALOGUES as Record)['test_ref'] = backup; + } + }); + + it('битая ссылка квеста на NPC/диалог — error', () => { + // Симулируем опечатку: подменяем NPCS/QUESTS нельзя (const), поэтому + // проверяем негатив через фейковый NPC-деф на уровне флага flagKey. + const broken = { ...NPCS[0]!, flagKey: 'metEllder' as keyof typeof FLAGS }; + const backup = NPCS[0]!; + (NPCS as unknown as { [0]: typeof broken })[0] = broken; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('metEllder'))).toBe(true); + expect(inv.some((i) => i.id === 'flag-dead' && i.message.includes('metElder'))).toBe(true); + } finally { + (NPCS as unknown as { [0]: typeof backup })[0] = backup; + } + }); +}); + describe('validateDialogue', () => { it('битый start и битый next — ошибки', () => { const g: DialogueGraph = { diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts index db106fb..793cbf4 100644 --- a/apps/game/src/data/dialogues.ts +++ b/apps/game/src/data/dialogues.ts @@ -1,15 +1,10 @@ import type { DialogueGraph } from '@rpg/engine'; +import { FLAGS, VARS } from './ids'; /** * Диалоги NPC как графы для DialogueRunner (по docs/world.md, акт 1). * Мила — безгласная: говорит шёпотом, коротко. Ирвин — экономит дыхание. - * - * Флаги сюжета: - * met_elder — познакомились с Ирвином - * quest_bells_taken — Ирвин дал задание о колокольчиках - * quest_bells_done — колокольчики собраны и посажены (акт 1, дальше) - * met_mila — познакомились с Милой - * got_cloth — Мила дала вощёное полотно + * Флаги и вары — только через реестры data/ids.ts. */ export const DIALOGUES: Record = { elder_first: { @@ -31,7 +26,7 @@ next: 'give' }, // Узел-действие: выдача задания без реплики. - give: { setFlags: ['met_elder', 'quest_bells_taken'], next: 'ring' }, + give: { setFlags: [FLAGS.met_elder, FLAGS.quest_bells_taken], next: 'ring' }, ring: { speaker: 'Звонарь', text: 'Прозвоню дорогу до прудов и вернусь до темноты.' } } }, @@ -52,7 +47,7 @@ text: 'Три цветка. Живые. Сажай у тропы, звонарь. Серая земля примет.', next: 'accept' }, - accept: { setFlags: ['quest_bells_done'], next: 'ring' }, + accept: { setFlags: [FLAGS.quest_bells_done], next: 'ring' }, ring: { speaker: 'Звонарь', text: 'Пусть гудят. Это твой голос, Ирвин, — теперь в земле.' } } }, @@ -75,7 +70,7 @@ text: 'Держи вощёное полотно. На губы. И звони тихо — пепел не буди.', next: 'give_cloth' }, - give_cloth: { setFlags: ['met_mila', 'got_cloth'], next: 'reply' }, + give_cloth: { setFlags: [FLAGS.met_mila, FLAGS.got_cloth], next: 'reply' }, reply: { speaker: 'Звонарь', text: 'Спасибо, Мила. Верну и полотно, и голос — твой точно.' } } }, @@ -92,7 +87,7 @@ motes_note: { speaker: 'Торговка Мила', text: '*замечает искру в сумке* ...Моты? Серая земля с искрой — редкость. Принеси три — поменяю на соль.', - whenVar: { key: 'motes', op: 'ge', value: 1 } + whenVar: { key: VARS.motes, op: 'ge', value: 1 } } } }, diff --git a/apps/game/src/data/ids.ts b/apps/game/src/data/ids.ts new file mode 100644 index 0000000..90166b0 --- /dev/null +++ b/apps/game/src/data/ids.ts @@ -0,0 +1,58 @@ +/** + * Реестры строковых сущностей сюжета: флаги и вары GameState. + * Единственная точка правды: контент (диалоги, интерактивы, квесты, переходы) + * и код сцены ссылаются на них через константы, а data/validate.ts сверяет + * каждое строковое упоминание с реестром — опечатка падает в тестах и + * agent:invariants, а не молча создаёт новый флаг. + */ + +/** + * Флаги сюжета: ключ = значение = id (строка в GameState/сейве). + * Знакомства — camelCase (легаси-стиль, не переименовывать: сейвы хранят + * флаги как есть, миграция не планируется). + */ +export const FLAGS = { + /** Познакомились со старейшиной (диалог elder_first). */ + met_elder: 'met_elder', + /** Ирвин дал задание о колокольчиках (диалог elder_first; doneFlag стадии 1). */ + quest_bells_taken: 'quest_bells_taken', + /** Колокольчики собраны и посажены (диалог elder_hand_in; doneFlag стадии 2). */ + quest_bells_done: 'quest_bells_done', + /** Познакомились с Милой (диалог trader_first). */ + met_mila: 'met_mila', + /** Мила дала вощёное полотно (диалог trader_first). */ + got_cloth: 'got_cloth', + /** Колокол башни звенел (интерактив bell_rope). */ + rang_tower: 'rang_tower', + /** Прочитана записка в доме Ирвина (интерактив note_elder). */ + read_note: 'read_note', + /** Говорили с Ирвином (flagKey NPC elder — движок поднимает сам). */ + metElder: 'metElder', + /** Говорили с Милой (flagKey NPC trader — движок поднимает сам). */ + metTrader: 'metTrader' +} as const; + +export type FlagId = keyof typeof FLAGS; + +/** Вары прогресса (числовые; значение живёт в GameState.vars): ключ = id. */ +export const VARS = { + /** Пепельные моты в сумке (подбор наростов, победы над врагами). */ + motes: 'motes', + /** Собранные лунные колокольчики (квест «Три цветка»). */ + flowers: 'flowers', + /** Здоровье героя между входами в локацию. */ + hp: 'hp', + /** Побеждённые враги (отладка/статистика). */ + kills: 'kills', + /** Смерти героя (статистика). */ + deaths: 'deaths', + /** Резонансы башни (кат-сцена, статистика). */ + resonances: 'resonances' +} as const; + +export type VarId = keyof typeof VARS; + +/** Синтезируемый флаг одноразового интерактива (once): used:. */ +export function usedFlag(id: string): string { + return `used:${id}`; +} \ No newline at end of file diff --git a/apps/game/src/data/interactables.ts b/apps/game/src/data/interactables.ts index a3b4248..c00cb41 100644 --- a/apps/game/src/data/interactables.ts +++ b/apps/game/src/data/interactables.ts @@ -1,5 +1,6 @@ import type { ItemId } from './items'; import type { AreaId } from './locations'; +import { FLAGS, VARS } from './ids'; /** * Интерактивные объекты области: сундуки, очаги, записки, прилавки, столбы @@ -126,7 +127,7 @@ { text: 'Сгусток серой земли с тёплой искрой. Мот — то, ради чего сажают колокольчики.', gives: 'mote', - addVar: { id: 'motes', by: 1 }, + addVar: { id: VARS.motes, by: 1 }, sound: 'sfx/ui_click' } ] @@ -141,7 +142,7 @@ { text: 'Ещё один мот. Земля здесь отдаёт их неохотно.', gives: 'mote', - addVar: { id: 'motes', by: 1 }, + addVar: { id: VARS.motes, by: 1 }, sound: 'sfx/ui_click' } ] @@ -156,7 +157,7 @@ { text: 'Третий мот за день. Сумка тихо позвякивает.', gives: 'mote', - addVar: { id: 'motes', by: 1 }, + addVar: { id: VARS.motes, by: 1 }, sound: 'sfx/ui_click' } ] @@ -196,9 +197,9 @@ label: 'Верёвка колокола', responses: [ { - when: { notFlag: 'rang_tower' }, + when: { notFlag: FLAGS.rang_tower }, text: 'Верёвка в ладони. Колокол качнулся — низкий тон ушёл в землю. Кто-то услышит.', - setFlags: ['rang_tower'], + setFlags: [FLAGS.rang_tower], sound: 'sfx/bell_low' }, { @@ -230,7 +231,7 @@ label: 'Очаг', responses: [ { - when: { flag: 'quest_bells_done' }, + when: { flag: FLAGS.quest_bells_done }, text: 'Очаг горит ровнее. Пепел отступает — и тепло возвращается.', sound: 'sfx/bell_low' }, @@ -246,7 +247,7 @@ responses: [ { text: '«Тоны уходят вниз. Если колокол смолкнет — не буди Молчание».', - setFlags: ['read_note'], + setFlags: [FLAGS.read_note], sound: 'sfx/ui_click' } ] @@ -260,7 +261,7 @@ label: 'Прилавок', responses: [ { - when: { flag: 'quest_bells_done' }, + when: { flag: FLAGS.quest_bells_done }, text: 'На прилавке — соль и цветы в плошке. Поляна гудит — Мила звонит чаще.', sound: 'sfx/bell_hit' }, diff --git a/apps/game/src/data/npcs.ts b/apps/game/src/data/npcs.ts index c3d553f..ee97f52 100644 --- a/apps/game/src/data/npcs.ts +++ b/apps/game/src/data/npcs.ts @@ -1,3 +1,5 @@ +import type { FlagId } from './ids'; + /** NPC локации: тайл, спрайт, диалоги и флаг сюжета после первого разговора. */ export interface NpcDef { id: string; @@ -10,7 +12,7 @@ /** Повторный диалог. */ dialogueRepeat: string; /** Флаг, поднимаемый после первого диалога. */ - flagKey: string; + flagKey: FlagId; } export const NPCS: NpcDef[] = [ diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts index 3ce90c7..65b9ca0 100644 --- a/apps/game/src/data/quests.ts +++ b/apps/game/src/data/quests.ts @@ -1,5 +1,6 @@ import type { GameState, Inventory } from '@rpg/engine'; import { itemName } from './items'; +import { FLAGS, VARS, type FlagId, type VarId } from './ids'; /** * Квестовая система (контент по docs/world.md). @@ -11,6 +12,9 @@ /** Квест «Три цветка» (docs/world.md, акт 1): собрать и посадить колокольчики. */ export const QUEST_FLOWERS = 3; +/** Побочный эффект сцены после диалога стадии. */ +export type QuestEffect = 'plant_flowers' | 'give_cloth'; + /** Одна стадия квеста. */ export interface QuestStage { /** Чей диалог ведёт стадию (id из NpcDef). */ @@ -18,16 +22,16 @@ /** Строка журнала, пока стадия активна. */ text: string; /** Прогресс N/M по var-ключу (если стадия считается в штуках). */ - progressKey?: string; + progressKey?: VarId; goal?: number; /** Диалог стадии: взятие (первая) или сдача (промежуточная). */ dialogue?: string; /** Готовность диалога (по умолчанию — сразу, как стадия активна). */ ready?: (state: GameState) => boolean; /** Флаг, которым граф диалога завершает стадию. Без него стадия — «эпилог». */ - doneFlag?: string; + doneFlag?: FlagId; /** Побочный эффект сцены после диалога стадии (см. applyQuestEffect). */ - effect?: string; + effect?: QuestEffect; } export interface QuestDef { @@ -47,17 +51,17 @@ npc: 'elder', text: 'Слышать гул на закате — пепел дышит у прудов.', dialogue: 'elder_first', - doneFlag: 'quest_bells_taken' + doneFlag: FLAGS.quest_bells_taken }, // Сбор: активная строка журнала с прогрессом; сдача — Ирвину. { npc: 'elder', text: 'Собрать лунные колокольчики у Серых прудов', - progressKey: 'flowers', + progressKey: VARS.flowers, goal: QUEST_FLOWERS, dialogue: 'elder_hand_in', - ready: (state) => state.getNumber('flowers') >= QUEST_FLOWERS, - doneFlag: 'quest_bells_done', + ready: (state) => state.getNumber(VARS.flowers) >= QUEST_FLOWERS, + doneFlag: FLAGS.quest_bells_done, effect: 'plant_flowers' }, // Эпилог: крючок акта 1 — разговор с Милой после посадки. @@ -105,12 +109,12 @@ } /** Побочные эффекты внеквестовых диалогов (знакомства). */ -const EXTRA_EFFECTS: Record = { +const EXTRA_EFFECTS: Record = { trader_first: 'give_cloth' // Мила дарит полотно при знакомстве }; /** Побочный эффект по завершённому диалогу (из стадий реестра и знакомств). */ -export function questEffectFor(dialogueId: string): string | null { +export function questEffectFor(dialogueId: string): QuestEffect | null { for (const quest of QUESTS) { for (const stage of quest.stages) { if (stage.dialogue === dialogueId && stage.effect) return stage.effect; diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index 793c12c..95652f8 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -11,6 +11,8 @@ import { NPCS } from './npcs'; import { DIALOGUES } from './dialogues'; import { ENEMY_KINDS } from './enemies'; +import { QUESTS } from './quests'; +import { FLAGS, VARS } from './ids'; /** * Runtime-валидация контента → инварианты (замена JSON Schema: истина одна — @@ -133,12 +135,104 @@ return out; } +/** + * Референциальная целостность строковых сущностей: каждое упоминание флага/ + * вара в контенте существует в реестрах ids.ts (error), каждый ключ реестра + * где-то упомянут (warn — «мёртвый» флаг); ссылки квестов на NPC/диалоги — + * error. Движковые типы — строки, поэтому реестр сверяется только здесь. + */ +export function validateReferences(): Invariant[] { + const out: Invariant[] = []; + const flagsUsed = new Set(); + const varsUsed = new Set(); + + const checkFlag = (flag: string, where: string, what: string): void => { + flagsUsed.add(flag); + if (!(flag in FLAGS)) { + out.push({ id: 'flag-unknown', severity: 'error', message: `${what}: флаг «${flag}» вне реестра FLAGS`, where }); + } + }; + const checkVar = (id: string, where: string, what: string): void => { + varsUsed.add(id); + if (!(id in VARS)) { + out.push({ id: 'var-unknown', severity: 'error', message: `${what}: вар «${id}» вне реестра VARS`, where }); + } + }; + + for (const [id, graph] of Object.entries(DIALOGUES)) { + const where = `${WHERE}/dialogue/${id}`; + for (const node of Object.values(graph.nodes)) { + const nodes = [node, ...(node.choices ?? [])]; + for (const n of nodes) { + for (const f of n.setFlags ?? []) checkFlag(f, where, `setFlags`); + for (const f of n.clearFlags ?? []) checkFlag(f, where, `clearFlags`); + for (const f of n.when ?? []) checkFlag(f, where, `when`); + for (const f of n.whenNot ?? []) checkFlag(f, where, `whenNot`); + if (n.whenVar) checkVar(n.whenVar.key, where, 'whenVar.key'); + } + } + } + for (const area of Object.values(AREAS)) { + const where = `${WHERE}/area/${area.id}`; + for (const t of area.transitions) { + if (t.requiresFlag !== undefined) checkFlag(t.requiresFlag, where, `переход ${t.tile.x},${t.tile.y}`); + } + for (const obj of area.interactables ?? []) { + const w = `${where}/interactable/${obj.id}`; + for (const r of obj.responses) { + if (r.when?.flag !== undefined) checkFlag(r.when.flag, w, 'when.flag'); + if (r.when?.notFlag !== undefined) checkFlag(r.when.notFlag, w, 'when.notFlag'); + for (const f of r.setFlags ?? []) checkFlag(f, w, 'setFlags'); + for (const f of r.clearFlags ?? []) checkFlag(f, w, 'clearFlags'); + if (r.setVar) checkVar(r.setVar.id, w, 'setVar.id'); + if (r.addVar) checkVar(r.addVar.id, w, 'addVar.id'); + } + } + } + for (const quest of QUESTS) { + const where = `${WHERE}/quest/${quest.id}`; + for (const [i, stage] of quest.stages.entries()) { + if (stage.doneFlag !== undefined) checkFlag(stage.doneFlag, where, `стадия#${i}.doneFlag`); + if (stage.progressKey !== undefined) checkVar(stage.progressKey, where, `стадия#${i}.progressKey`); + if (!NPCS.some((n) => n.id === stage.npc)) { + out.push({ id: 'npc-ref', severity: 'error', message: `стадия#${i}: NPC «${stage.npc}» не существует`, where }); + } + if (stage.dialogue !== undefined && !(stage.dialogue in DIALOGUES)) { + out.push({ id: 'dialogue-ref', severity: 'error', message: `стадия#${i}: диалог «${stage.dialogue}» не существует`, where }); + } + } + } + for (const npc of NPCS) { + checkFlag(npc.flagKey, `${WHERE}/npc/${npc.id}`, 'flagKey'); + if (!(npc.dialogueFirst in DIALOGUES)) { + out.push({ id: 'dialogue-ref', severity: 'error', message: `NPC ${npc.id}: dialogueFirst «${npc.dialogueFirst}» не существует`, where: `${WHERE}/npc/${npc.id}` }); + } + if (!(npc.dialogueRepeat in DIALOGUES)) { + out.push({ id: 'dialogue-ref', severity: 'error', message: `npc ${npc.id}: dialogueRepeat «${npc.dialogueRepeat}» не существует`, where: `${WHERE}/npc/${npc.id}` }); + } + } + + // Мёртвые сущности реестра: ключ не упомянут нигде в контенте (warn). + for (const flag of Object.keys(FLAGS)) { + if (!flagsUsed.has(flag)) { + out.push({ id: 'flag-dead', severity: 'warn', message: `флаг «${flag}» из реестра не упоминается в контенте`, where: WHERE }); + } + } + for (const id of Object.keys(VARS)) { + if (!varsUsed.has(id)) { + out.push({ id: 'var-dead', severity: 'warn', message: `вар «${id}» из реестра не упоминается в контенте`, where: WHERE }); + } + } + return out; +} + /** Весь контент разом (для снапшота моста и тестов). */ export function validateContent(maps: Map): Invariant[] { return mergeInvariants( ...Object.entries(DIALOGUES).map(([id, g]) => validateDialogue(id, g)), validateNpcs(maps), validateLocations(maps), - validateEnemies() + validateEnemies(), + validateReferences() ); } \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 1a50e2c..1fab3df 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -46,6 +46,7 @@ import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; import { QUEST_FLOWERS, questDialogueFor, questEffectFor } from '../data/quests'; +import { VARS } from '../data/ids'; import type { EnemyKindId } from '../data/enemies'; import { PlayerController, type HeroTextures } from '../systems/PlayerController'; import { FaunaSystem } from '../systems/fauna/FaunaSystem'; @@ -183,8 +184,8 @@ }, (kind) => { // счётчики прогресса — в vars GameState - this.game.state.setVar('kills', this.game.state.getNumber('kills') + 1); - this.game.state.setVar('motes', this.game.state.getNumber('motes') + kind.motes); + this.game.state.setVar(VARS.kills, this.game.state.getNumber(VARS.kills) + 1); + this.game.state.setVar(VARS.motes, this.game.state.getNumber(VARS.motes) + kind.motes); } ); for (const s of area.enemies) { @@ -228,7 +229,7 @@ this.world.addChild(fx); }); - const savedHp = this.game.state.getNumber('hp') || PLAYER_COMBAT.maxHp; + const savedHp = this.game.state.getNumber(VARS.hp) || PLAYER_COMBAT.maxHp; this.playerCombat = new PlayerCombat(savedHp); this.healthBar = new HealthBar(); this.healthBar.setHp(this.playerCombat.hp); @@ -430,7 +431,7 @@ this.debug.setLines([ `tile ${tile.x},${tile.y}`, `pos ${Math.round(this.player.position.x)},${Math.round(this.player.position.y)}`, - `hp ${this.playerCombat.hp} kills ${this.game.state.getNumber('kills')}` + `hp ${this.playerCombat.hp} kills ${this.game.state.getNumber(VARS.kills)}` ]); this.debug.update(dt); if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture); @@ -538,7 +539,7 @@ const s = worldToScreen(from.x, from.y); this.combatViews.resonanceRing(s); this.game.camera.addShake(1.5, 0.25); - this.game.state.setVar('resonances', this.game.state.getNumber('resonances') + (slept > 0 ? 1 : 0)); + this.game.state.setVar(VARS.resonances, this.game.state.getNumber(VARS.resonances) + (slept > 0 ? 1 : 0)); // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел. if (this.inHazard !== null) { this.combatViews.hitBurst({ x: s.x - 14, y: s.y + 6 }); @@ -603,7 +604,7 @@ this.player.applyKnockback(worldNorm(dx, dy), PLAYER_COMBAT.knockback); if (this.playerCombat.dead) { - this.game.state.setVar('deaths', this.game.state.getNumber('deaths') + 1); + this.game.state.setVar(VARS.deaths, this.game.state.getNumber(VARS.deaths) + 1); this.playerCombat.revive(); // Респаун на стартовом тайле локации this.player.teleportTo(this.area.spawn); @@ -989,8 +990,8 @@ private collectFlower(x: number, y: number): void { this.map.setTile(x, y, TILES.GRASS); this.game.inventory.add('bellflower'); - const n = this.game.state.getNumber('flowers') + 1; - this.game.state.setVar('flowers', n); + const n = this.game.state.getNumber(VARS.flowers) + 1; + this.game.state.setVar(VARS.flowers, n); this.game.engine.events.emit('quest:flower', { n }); void this.game.audio.play('sfx/bell_hit', 0.6); const c = this.tileCenter(x, y); @@ -1017,8 +1018,8 @@ /** Посадка цветов у тропы: поляна гудит колокольчиками и разрастается. */ private plantFlowers(): void { - const left = this.game.state.getNumber('flowers') - QUEST_FLOWERS; - this.game.state.setVar('flowers', Math.max(0, left)); + const left = this.game.state.getNumber(VARS.flowers) - QUEST_FLOWERS; + this.game.state.setVar(VARS.flowers, Math.max(0, left)); const planted: [number, number][] = [ [15, 13], [16, 14], @@ -1154,7 +1155,7 @@ private saveAndExit(): void { const pos = this.player.currentTile(); - this.game.state.setVar('hp', this.playerCombat.hp); + this.game.state.setVar(VARS.hp, this.playerCombat.hp); this.game.saves.save('autosave', { version: SAVE_VERSION, area: this.area.id, diff --git a/apps/game/src/systems/Interactables.ts b/apps/game/src/systems/Interactables.ts index 80df40d..5d2b6d0 100644 --- a/apps/game/src/systems/Interactables.ts +++ b/apps/game/src/systems/Interactables.ts @@ -1,5 +1,6 @@ import type { GameState } from '@rpg/engine'; import type { ItemId } from '../data/items'; +import { usedFlag } from '../data/ids'; import { resolveInteractable, type InteractableDef, @@ -39,7 +40,7 @@ /** Одноразовый объект уже использован (флаг used: в GameState). */ isUsed(id: string): boolean { - return this.state.hasFlag(`used:${id}`); + return this.state.hasFlag(usedFlag(id)); } /** Взаимодействие: выбор реакции + применение эффектов. */ @@ -62,7 +63,7 @@ if (r.setVar) this.state.setVar(r.setVar.id, r.setVar.value); if (r.addVar) this.state.setVar(r.addVar.id, this.state.getNumber(r.addVar.id) + r.addVar.by); if (r.gives) this.sink.give(r.gives, r.count ?? 1); - if (def.once) this.state.setFlag(`used:${def.id}`); + if (def.once) this.state.setFlag(usedFlag(def.id)); if (r.sound) this.sink.playSound(r.sound); this.sink.showToast(r.text); } diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 9d593dc..961aef2 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -86,6 +86,34 @@ сырые `inject*` из страницы + отдельный `step` — гонка с rAF. 5. `s.error` в снапшоте — мост поймал исключение, читай текст. +## Ситуация: добавляю флаг / вар / предмет + +1. Флаг — ключ в `data/ids.ts` → `FLAGS` (вар — в `VARS`), в контенте и коде + только константа (`FLAGS.quest_bells_taken`), не литерал. Опечатка в + литерале ловится `validateReferences()` (error `flag-unknown`), ключ + реестра, нигде не упомянутый, — warn `flag-dead`. +2. Движковые поля (`DialogueEffects.setFlags`, `InteractWhen.flag`, + `TransitionDef.requiresFlag`) — строки: реестр живёт в игре, сверяется + только валидатором. Игровые типы (`NpcDef.flagKey`, `QuestStage.doneFlag`) + типизированы `FlagId`/`VarId` — там опечатка не скомпилируется. +3. used-флаг одноразового интерактива — только через `usedFlag(id)` + (`used:` в трёх местах конкатенировался руками — расхождение ловится + инвариантом `interact-used-consistent`). +4. Синтаксис знакомств — camelCase (`metElder`), сюжета — snake_case; это + легаси в сейвах, не унифицировать без миграции. + +## Ситуация: коммичу + +1. `pre-commit` хук (`.githooks/`, подключается `npm run prepare` → + `git config core.hooksPath .githooks`) гоняет `npm run check:fast` + (typecheck + guard + юнит-тесты, ~30 с). Провал = коммит отменён; + пропустить разово — `git commit --no-verify` (осознанно: хук не гоняет + браузерные пробы). +2. Полная сетка перед пушем — `node apps/game/tools/agent.mjs check` + + смоук акта (`node apps/game/tools/smoke-act1.mjs`). +3. Если коммит состоит из изменений движка — доки `docs/engine/` в том же + коммите (см. «меняю API движка»). + ## Ситуация: меняю API движка 1. Обнови соответствующий `docs/engine/*.md` и этот файл (если появился новый diff --git a/package.json b/package.json index fb6163b..82267dc 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/game", + "check:fast": "npm run typecheck && npm run guard && npm test", "agent": "node apps/game/tools/agent.mjs dev", "agent:check": "node apps/game/tools/agent.mjs check", "agent:snapshot": "node apps/game/tools/agent.mjs snapshot --new-game" }, + "prepare": "git config core.hooksPath .githooks", "devDependencies": { "typescript": "^5.6.0", "vitest": "^2.1.0"