diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index e77c475..71681d7 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -16,6 +16,7 @@ } from '@rpg/engine'; import { AUDIO_KEYS } from './data/audio'; import { THEMES } from './data/music'; +import { registerStateMigrations } from './data/migrations'; // Шрифт идёт с движком (OFL): Vite кладёт файл в сборку и даёт готовый URL. import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'; @@ -68,6 +69,8 @@ constructor(readonly engine: Engine) { this.saves = new SaveManager(window.localStorage); + // Миграции формата state (старые сейвы поднимаются при load). + registerStateMigrations(this.state); // Ключи без расширения -> .png; ключи .json (атласы) резолвятся как есть. this.assets = new AssetLoader((key) => key.endsWith('.json') ? `${import.meta.env.BASE_URL}${key}` : `${import.meta.env.BASE_URL}${key}.png` diff --git a/apps/game/src/data/__tests__/migrations.test.ts b/apps/game/src/data/__tests__/migrations.test.ts new file mode 100644 index 0000000..95c260f --- /dev/null +++ b/apps/game/src/data/__tests__/migrations.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { GameState } from '@rpg/engine'; +import { migrateV1toV2, registerStateMigrations, STATE_DATA_VERSION } from '../migrations'; + +describe('миграции GameStateData', () => { + it('v1→v2: знакомства переименованы, остальное перенесено, version=2', () => { + const out = migrateV1toV2({ + version: 1, + flags: ['metElder', 'quest_bells_taken', 'metTrader'], + vars: { flowers: 3 } + }); + expect(out.version).toBe(2); + expect(out.flags).toEqual(['met_elder', 'quest_bells_taken', 'met_mila']); + expect(out.vars).toEqual({ flowers: 3 }); + }); + + it('старый сейв v1 читается state.load с миграцией', () => { + const s = new GameState(); + registerStateMigrations(s); + expect(s.dataVersion).toBe(STATE_DATA_VERSION); + s.load({ version: 1, flags: ['metElder', 'metTrader'], vars: {} }); + expect(s.hasFlag('met_elder')).toBe(true); + expect(s.hasFlag('met_mila')).toBe(true); + expect(s.hasFlag('metElder')).toBe(false); + expect(s.hasFlag('metTrader')).toBe(false); + }); + + it('сейв текущей версии миграций не получает', () => { + const s = new GameState(); + registerStateMigrations(s); + s.load({ version: STATE_DATA_VERSION, flags: ['quest_bells_done'], vars: {} }); + expect(s.hasFlag('quest_bells_done')).toBe(true); + expect(s.allFlags).toEqual(['quest_bells_done']); + // сериализация — в текущей версии + expect(s.serialize().version).toBe(STATE_DATA_VERSION); + }); +}); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/validate.test.ts b/apps/game/src/data/__tests__/validate.test.ts index 79d27e0..7d57443 100644 --- a/apps/game/src/data/__tests__/validate.test.ts +++ b/apps/game/src/data/__tests__/validate.test.ts @@ -66,7 +66,6 @@ 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; } diff --git a/apps/game/src/data/ids.ts b/apps/game/src/data/ids.ts index 9099c97..72f22f0 100644 --- a/apps/game/src/data/ids.ts +++ b/apps/game/src/data/ids.ts @@ -8,8 +8,8 @@ /** * Флаги сюжета: ключ = значение = id (строка в GameState/сейве). - * Знакомства — camelCase (легаси-стиль, не переименовывать: сейвы хранят - * флаги как есть, миграция не планируется). + * Знакомства ставятся самими графами диалогов (dialogueFirst), а не кодом + * сцены; старые ключи metElder/metTrader мигрируют (data/migrations.ts). */ export const FLAGS = { /** Познакомились со старейшиной (диалог elder_first). */ @@ -27,11 +27,7 @@ /** Прочитана записка в доме Ирвина (интерактив note_elder). */ read_note: 'read_note', /** Подсказка «как собирать колокольчики» уже показана (вход в пруды). */ - hint_bells: 'hint_bells', - /** Говорили с Ирвином (flagKey NPC elder — движок поднимает сам). */ - metElder: 'metElder', - /** Говорили с Милой (flagKey NPC trader — движок поднимает сам). */ - metTrader: 'metTrader' + hint_bells: 'hint_bells' } as const; export type FlagId = keyof typeof FLAGS; diff --git a/apps/game/src/data/migrations.ts b/apps/game/src/data/migrations.ts new file mode 100644 index 0000000..3388a1d --- /dev/null +++ b/apps/game/src/data/migrations.ts @@ -0,0 +1,24 @@ +import { GameState, type GameStateData } from '@rpg/engine'; +import { FLAGS } from './ids'; + +/** + * Миграции GameStateData (ось state-версии, отдельная от SAVE_VERSION): + * регистрируются в Game один раз, применяются в state.load при чтении сейва. + * Контракт движка: миграция сама повышает version; от старых версий к новым. + */ + +/** Текущая версия формата данных состояния. */ +export const STATE_DATA_VERSION = 2; + +/** v1→v2: знакомства metElder/metTrader → единый snake_case реестра FLAGS. */ +export function migrateV1toV2(data: GameStateData): GameStateData { + const rename = (f: string): string => + f === 'metElder' ? FLAGS.met_elder : f === 'metTrader' ? FLAGS.met_mila : f; + return { version: 2, flags: data.flags.map(rename), vars: { ...data.vars } }; +} + +/** Зарегистрировать все миграции (зовёт Game при создании состояния). */ +export function registerStateMigrations(state: GameState): void { + state.dataVersion = STATE_DATA_VERSION; + state.addMigration(migrateV1toV2); +} \ No newline at end of file diff --git a/apps/game/src/data/npcs.ts b/apps/game/src/data/npcs.ts index ce17454..5bbd626 100644 --- a/apps/game/src/data/npcs.ts +++ b/apps/game/src/data/npcs.ts @@ -1,5 +1,5 @@ import type { Vec2 } from '@rpg/engine'; -import type { FlagId } from './ids'; +import { FLAGS, type FlagId } from './ids'; /** * NPC локации: позиция в юнитах, спрайт/атлас, диалоги и флаг сюжета @@ -33,7 +33,7 @@ sprite: 'elder_irwin', dialogueFirst: 'elder_first', dialogueRepeat: 'elder_repeat', - flagKey: 'metElder' + flagKey: FLAGS.met_elder }, { id: 'trader', @@ -42,7 +42,7 @@ sprite: 'trader_mila', dialogueFirst: 'trader_first', dialogueRepeat: 'trader_repeat', - flagKey: 'metTrader' + flagKey: FLAGS.met_mila } ]; diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts index 0f111bc..003e813 100644 --- a/apps/game/src/data/quests.ts +++ b/apps/game/src/data/quests.ts @@ -1,4 +1,4 @@ -import type { GameState, Inventory } from '@rpg/engine'; +import { evalConditions, type DialogueConditions, type DialogueWorld, type GameState, type Inventory } from '@rpg/engine'; import { itemName } from './items'; import { FLAGS, VARS, type FlagId, type VarId } from './ids'; @@ -6,7 +6,9 @@ * Квестовая система (контент по docs/world.md). * Квест — декларативный набор стадий; состояние живёт во флагах/варах * GameState (ставятся графами диалогов), статус выводится из состояния. - * Сцена спрашивает только «какой диалог у NPC» и «что делать после». + * Готовность стадии — те же условия, что и в графах диалогов (данные, не + * замыкания — валидатор и dry-run их видят). Сцена спрашивает только + * «какой диалог у NPC». */ /** Квест «Три цветка» (docs/world.md, акт 1): собрать и посадить колокольчики. */ @@ -23,8 +25,8 @@ goal?: number; /** Диалог стадии: взятие (первая) или сдача (промежуточная). */ dialogue?: string; - /** Готовность диалога (по умолчанию — сразу, как стадия активна). */ - ready?: (state: GameState) => boolean; + /** Готовность диалога — условия как в графах (по умолчанию сразу). */ + ready?: DialogueConditions; /** Флаг, которым граф диалога завершает стадию. Без него стадия — «эпилог». */ doneFlag?: FlagId; } @@ -55,7 +57,7 @@ progressKey: VARS.flowers, goal: QUEST_FLOWERS, dialogue: 'elder_hand_in', - ready: (state) => state.getNumber(VARS.flowers) >= QUEST_FLOWERS, + ready: { whenVars: [{ key: VARS.flowers, op: 'ge', value: QUEST_FLOWERS }] }, doneFlag: FLAGS.quest_bells_done }, // Эпилог: крючок акта 1 — разговор с Милой после посадки. @@ -90,13 +92,13 @@ /** * Диалог NPC по квест-стадиям: активная стадия этого NPC с готовым диалогом. - * null — обычный repeat-диалог. + * world — предикаты мира (сумка) для hasItem-условий. null — repeat-диалог. */ -export function questDialogueFor(state: GameState, npcId: string): string | null { +export function questDialogueFor(state: GameState, npcId: string, world?: DialogueWorld): string | null { for (const quest of QUESTS) { const stage = activeStage(state, quest); if (!stage || stage.npc !== npcId || !stage.dialogue) continue; - if (stage.ready && !stage.ready(state)) continue; + if (stage.ready && !evalConditions(stage.ready, state, world)) continue; return stage.dialogue; } return null; diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index e0c42ca..1b72181 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -28,7 +28,8 @@ type JsonValue, type Scene, type SnapshotLayer, - type Vec2 + type Vec2, + type DialogueWorld } from '@rpg/engine'; import { Game } from '../Game'; import { MenuScene } from './MenuScene'; @@ -52,6 +53,7 @@ import { DIALOGUES } from '../data/dialogues'; import { QUEST_FLOWERS, questDialogueFor } from '../data/quests'; import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects'; +import type { ItemId } from '../data/items'; import { TENSION_STEM } from '../data/music'; import { FLAGS, VARS } from '../data/ids'; import type { EnemyKindId } from '../data/enemies'; @@ -936,13 +938,18 @@ this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`); } + /** Предикаты мира для условий (сумка героя). */ + private dialogueWorld(): DialogueWorld { + return { hasItem: (id) => this.game.inventory.count(id as ItemId) > 0 }; + } + private talkTo(def: NpcDef): void { + // Флаг знакомства поднимает сам граф dialogueFirst; сцена только читает. const met = this.game.state.hasFlag(def.flagKey); - if (!met) this.game.state.setFlag(def.flagKey); // Сюжетная ветка — из квест-стадии; иначе обычный диалог. const id = !met ? def.dialogueFirst - : (questDialogueFor(this.game.state, def.id) ?? def.dialogueRepeat); + : (questDialogueFor(this.game.state, def.id, this.dialogueWorld()) ?? def.dialogueRepeat); this.dialogue.start(DIALOGUES[id], id); } diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index bb8110e..d9fd331 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -286,6 +286,7 @@ ``` Версионирование контента — через `GameState.dataVersion` + `addMigration`: -при изменении формата флагов старые сейвы чинятся автоматически при загрузке. -Версия самого сейва (`version` в SaveData и в мете) — отдельная ось от -`GameStateData.version`. \ No newline at end of file +при изменении формата флагов старые сейвы чинятся автоматически при загрузке +(миграции игры регистрируются один раз при старте — `registerStateMigrations` +в `Game`; контракт — в core.md). Версия самого сейва (`version` в SaveData +и в мете) — отдельная ось от `GameStateData.version`. \ No newline at end of file diff --git a/docs/engine/core.md b/docs/engine/core.md index d6be692..d4f4f83 100644 --- a/docs/engine/core.md +++ b/docs/engine/core.md @@ -100,6 +100,12 @@ state.load(data); // миграции применятся автоматически ``` +Контракт миграций: **миграция сама повышает `version`** в возвращаемых +данных; регистрируются от старых версий к новым, применяются по порядку +при `load`. Цикл загрузки останавливается, как только данные догнали +`dataVersion`, — миграция, забывшая повысить version, не даст следующей +миграции примениться к уже повышенным данным дважды. + ## StateMachine Конечный автомат для состояний сущностей (idle/walk/attack), AI и т.п.: diff --git a/packages/engine/src/core/GameState.ts b/packages/engine/src/core/GameState.ts index 86b186b..b87f693 100644 --- a/packages/engine/src/core/GameState.ts +++ b/packages/engine/src/core/GameState.ts @@ -85,10 +85,12 @@ /** Загрузить данные (с применением миграций). Восстанавливает состояние поверх текущего. */ load(data: GameStateData): void { let d = data; + // Контракт: миграция сама повышает version. Если она забыла — + // выходим, как только данные догнали текущую версию, чтобы не + // применить следующую миграцию к уже повышенным данным дважды. for (const m of this.migrations) { - if (d.version < this.dataVersion) { - d = m(d); - } + if (d.version >= this.dataVersion) break; + d = m(d); } this.dataVersion = d.version; this.flags = new Set(d.flags); diff --git a/packages/engine/src/core/__tests__/GameState.test.ts b/packages/engine/src/core/__tests__/GameState.test.ts index 5287983..5f02f4f 100644 --- a/packages/engine/src/core/__tests__/GameState.test.ts +++ b/packages/engine/src/core/__tests__/GameState.test.ts @@ -50,6 +50,29 @@ expect(s.hasFlag('x')).toBe(true); }); + it('две миграции применяются по порядку, по одной на версию', () => { + const s = new GameState(); + s.dataVersion = 3; + s.addMigration((d) => ({ ...d, version: 2, flags: [...d.flags, 'step1'] })); + s.addMigration((d) => ({ ...d, version: 3, flags: [...d.flags, 'step2'] })); + + s.load({ version: 1, flags: [], vars: {} }); + expect(s.allFlags).toEqual(['step1', 'step2']); + }); + + it('миграция без повышения version не применяется дважды', () => { + const s = new GameState(); + s.dataVersion = 2; + // Миграция нарушает контракт (забыла version) — цикл всё равно + // не даёт следующей миграции испортить данные. + s.addMigration((d) => ({ ...d, vars: { ...d.vars, n: Number(d.vars.n ?? 0) + 1 } })); + s.addMigration((d) => ({ ...d, version: 2, flags: ['done'] })); + + s.load({ version: 1, flags: [], vars: {} }); + expect(s.getNumber('n')).toBe(1); + expect(s.hasFlag('done')).toBe(true); + }); + it('reset очищает всё', () => { const s = new GameState(); s.setFlag('a');