diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index 0ab42ba..28869a6 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -77,7 +77,21 @@ invariants(): Invariant[] { // Контентная валидация живёт здесь (не в сцене), чтобы работать и в меню. - return [...this.engineAgent.invariants(), ...validateContent(this.game.mapFiles)]; + const list: Invariant[] = [...this.engineAgent.invariants(), ...validateContent(this.game.mapFiles)]; + // used-флаги и снапшот идут разными путями — сверяем их на месте. + 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) { + list.push({ + id: 'interact-used-consistent', + severity: 'error', + message: `объект ${o.id}: флаг used=${flags.has(`used:${o.id}`)}, снапшот used=${o.used}`, + where: 'agent/GameAgent' + }); + } + } + return list; } step(n = 1, opts?: { render?: boolean }): { tick: number } { @@ -135,19 +149,30 @@ return this.engineAgent.command(name, args) as JsonValue; } - /** Идти в тайл: маршрут просит сцену (scene:route), кликает по узлам по очереди. */ + /** + * Идти в тайл: маршрут просит сцену (scene:route), кликает по узлам по очереди. + * Узел, клик в который сработал как взаимодействие (мот/цветок/NPC на пути), + * пропускается: герой останавливается рядом, идём дальше со следующего. + */ async walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise { const route = this.command('scene:route', { x: tx, y: ty }); if (!Array.isArray(route) || route.length === 0) return false; + let misses = 0; for (const node of route as { x: number; y: number }[]) { this.tapTile(node.x, node.y); const wait = await this.waitFor( `s.hero && s.hero.tile && s.hero.tile.x === ${node.x} && s.hero.tile.y === ${node.y}`, { timeoutTicks: opts?.timeoutTicks ?? 240 } ); - if (!wait.ok) return false; + if (!wait.ok) { + const hero = this.snapshot().hero as { tile?: { x: number; y: number }; moving?: boolean } | undefined; + // Герой ещё идёт или что-то пошло не так — не угадываем. + if (!hero?.tile || hero.moving || ++misses > 4) return false; + continue; + } } - return true; + const hero = this.snapshot().hero as { tile?: { x: number; y: number } } | undefined; + return hero?.tile?.x === tx && hero?.tile?.y === ty; } /** Долистать активный диалог: жать advance, пока он открыт. */ diff --git a/apps/game/src/data/__tests__/dialogues.test.ts b/apps/game/src/data/__tests__/dialogues.test.ts new file mode 100644 index 0000000..a3d7737 --- /dev/null +++ b/apps/game/src/data/__tests__/dialogues.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { DialogueRunner, type DialogueView } from '@rpg/engine'; +import { GameState } from '@rpg/engine'; +import { DIALOGUES } from '../dialogues'; + +/** Заглушка view: запоминает показанные реплики. */ +function stubView(): DialogueView & { shown: string[] } { + const shown: string[] = []; + return { + shown, + show: (n: { text: string }) => void shown.push(n.text), + hide: () => undefined + }; +} + +describe('trader_repeat — ветка по вару motes', () => { + it('без мотов — только базовая реплика', () => { + const view = stubView(); + const runner = new DialogueRunner(new GameState(), view); + runner.start(DIALOGUES.trader_repeat); + runner.advance(); + expect(view.shown).toHaveLength(1); + expect(view.shown[0]).toContain('Звони тихо'); + }); + + it('с мотами (motes >= 1) — добавляется реплика о мота́х', () => { + const view = stubView(); + const state = new GameState(); + state.setVar('motes', 2); + const runner = new DialogueRunner(state, view); + runner.start(DIALOGUES.trader_repeat); + runner.advance(); + expect(view.shown).toHaveLength(2); + expect(view.shown[1]).toContain('Моты'); + }); +}); \ No newline at end of file diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts index d4381c1..db106fb 100644 --- a/apps/game/src/data/dialogues.ts +++ b/apps/game/src/data/dialogues.ts @@ -80,14 +80,19 @@ } }, - // Повторная Мила: до сдачи квеста. + // Повторная Мила: до сдачи квеста. Ветка по вару motes — видит моты в сумке. trader_repeat: { start: 'quiet', nodes: { quiet: { speaker: 'Торговка Мила', text: '*колокольчик один раз* ...Звони тихо. Пепел просыпается от гулкого.', - end: true + next: 'motes_note' + }, + motes_note: { + speaker: 'Торговка Мила', + text: '*замечает искру в сумке* ...Моты? Серая земля с искрой — редкость. Принеси три — поменяю на соль.', + whenVar: { key: 'motes', op: 'ge', value: 1 } } } }, diff --git a/apps/game/src/data/interactables.ts b/apps/game/src/data/interactables.ts index 127535b..a3b4248 100644 --- a/apps/game/src/data/interactables.ts +++ b/apps/game/src/data/interactables.ts @@ -1,9 +1,10 @@ import type { ItemId } from './items'; +import type { AreaId } from './locations'; /** - * Интерактивные объекты области: сундуки, очаги, записки, прилавки. - * Первая подходящая реакция (по `when`) срабатывает; эффекты применяет - * рантайм systems/Interactables.ts через sink сцены. + * Интерактивные объекты области: сундуки, очаги, записки, прилавки, столбы + * у троп, подборные сгустки, рычаги. Первая подходящая реакция (по `when`) + * срабатывает; эффекты применяет рантайм systems/Interactables.ts через sink. */ /** Условие реакции: все указанные поля должны выполняться. */ @@ -24,6 +25,8 @@ setFlags?: string[]; clearFlags?: string[]; setVar?: { id: string; value: number }; + /** Прибавить к вару (подбор мотов: +1 за каждый нарост). */ + addVar?: { id: string; by: number }; /** Выдать предмет (в сумку). */ gives?: ItemId; /** Сколько штук gives (по умолчанию 1). */ @@ -85,8 +88,126 @@ return { ok: true, response }; } -/** Контент интерьеров (см. docs/world.md: дом Ирвина, лавка Милы). */ -export const INTERACTABLES: Record<'house_elder' | 'shop', InteractableDef[]> = { +/** Контент по областям (см. docs/world.md): интерьеры и улицы. */ +export const INTERACTABLES: Partial> = { + // --- луга: столбы у троп и подборные пепельные моты (зачаток экономики) --- + meadows: [ + { + id: 'sign_meadows_east', + tile: { x: 25, y: 13 }, + kind: 'signpost', + label: 'Столб у тропы', + responses: [ + { + text: '«Звенец» — стёрто, но различимо. Дальше на восток: иди на звон.', + sound: 'sfx/ui_click' + } + ] + }, + { + id: 'sign_meadows_west', + tile: { x: 2, y: 3 }, + kind: 'signpost', + label: 'Столб у тропы', + responses: [ + { + text: '«Серые пруды» — стрелка на северо-запад. Ниже приписка: «туман лжёт».', + sound: 'sfx/ui_click' + } + ] + }, + { + id: 'mote_1', + tile: { x: 11, y: 14 }, + kind: 'pickup', + label: 'Пепельный мот', + once: true, + responses: [ + { + text: 'Сгусток серой земли с тёплой искрой. Мот — то, ради чего сажают колокольчики.', + gives: 'mote', + addVar: { id: 'motes', by: 1 }, + sound: 'sfx/ui_click' + } + ] + }, + { + id: 'mote_2', + tile: { x: 14, y: 20 }, + kind: 'pickup', + label: 'Пепельный мот', + once: true, + responses: [ + { + text: 'Ещё один мот. Земля здесь отдаёт их неохотно.', + gives: 'mote', + addVar: { id: 'motes', by: 1 }, + sound: 'sfx/ui_click' + } + ] + }, + { + id: 'mote_3', + tile: { x: 14, y: 6 }, + kind: 'pickup', + label: 'Пепельный мот', + once: true, + responses: [ + { + text: 'Третий мот за день. Сумка тихо позвякивает.', + gives: 'mote', + addVar: { id: 'motes', by: 1 }, + sound: 'sfx/ui_click' + } + ] + } + ], + ponds: [ + { + id: 'sign_ponds', + tile: { x: 3, y: 2 }, + kind: 'signpost', + label: 'Столб у берега', + responses: [ + { + text: '«Не дышать накатом» — буквы вросли в ржавчину. Кто-то дописал: «голос дороже соли».', + sound: 'sfx/ui_click' + } + ] + } + ], + zvenets: [ + { + id: 'sign_zvenets', + tile: { x: 3, y: 9 }, + kind: 'signpost', + label: 'Столб у входа', + responses: [ + { + text: '«Звенец. Мастерская мира». Ниже чужим почерком: «кузня чинит резонаторы, лавка меняет соль».', + sound: 'sfx/ui_click' + } + ] + }, + { + id: 'bell_rope', + tile: { x: 13, y: 7 }, + kind: 'lever', + label: 'Верёвка колокола', + responses: [ + { + when: { notFlag: 'rang_tower' }, + text: 'Верёвка в ладони. Колокол качнулся — низкий тон ушёл в землю. Кто-то услышит.', + setFlags: ['rang_tower'], + sound: 'sfx/bell_low' + }, + { + text: 'Колокол качнулся снова. Звон тот же: пол-тона ниже, чем в звонной книге.', + sound: 'sfx/bell_low' + } + ] + } + ], house_elder: [ { id: 'chest_elder', diff --git a/apps/game/src/data/items.ts b/apps/game/src/data/items.ts index 3e7b298..2dc6297 100644 --- a/apps/game/src/data/items.ts +++ b/apps/game/src/data/items.ts @@ -8,7 +8,7 @@ desc: string; } -export type ItemId = 'cloth' | 'bellflower' | 'salt' | 'water_flask'; +export type ItemId = 'cloth' | 'bellflower' | 'salt' | 'water_flask' | 'mote'; export const ITEMS: Record = { cloth: { @@ -30,6 +30,11 @@ id: 'water_flask', name: 'Вода в медном бутле', desc: 'Мутная, но чистая. Пруды отдают воду неохотно.' + }, + mote: { + id: 'mote', + name: 'Пепельный мот', + desc: 'Сгусток серой земли с тёплой искрой — то, что колокольчик вытянул из пепла. Мила меняет моты на товар.' } }; diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 20fbd42..d9ab410 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -94,6 +94,7 @@ } ], ambience: 'ash', + interactables: INTERACTABLES.meadows, // Безгласные олени у берегов прудов — подходят к звону. fauna: [ { x: 9, y: 18 }, @@ -142,6 +143,7 @@ ] } ], + interactables: INTERACTABLES.ponds, // Один олень на северо-западном берегу. fauna: [{ x: 4, y: 6 }] }, @@ -179,6 +181,7 @@ duration: 0.2 } ], + interactables: INTERACTABLES.zvenets, ambience: 'ash' }, // --- интерьеры: рамка WALL, пол FLOOR, дверной проём снизу --- diff --git a/apps/game/src/data/npcs.ts b/apps/game/src/data/npcs.ts index 70e5bea..c3d553f 100644 --- a/apps/game/src/data/npcs.ts +++ b/apps/game/src/data/npcs.ts @@ -29,7 +29,7 @@ tile: { x: 17, y: 11 }, sprite: 'trader_mila', dialogueFirst: 'trader_first', - dialogueRepeat: 'trader_first', + dialogueRepeat: 'trader_repeat', flagKey: 'metTrader' } ]; \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 4fbe3a5..7a2b4f1 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -1086,8 +1086,26 @@ const p = this.tileCenter(def.tile.x, def.tile.y); view.position.set(p.x, p.y); const g = new Graphics(); - g.rect(-5, -12, 10, 12).fill(0x4a3b2a); - g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо + switch (def.kind) { + case 'signpost': // столбик с доской + g.rect(-1, -10, 2, 10).fill(0x4a3b2a); + g.rect(-6, -14, 12, 5).fill(0x5c4a33); + g.rect(-6, -14, 12, 1).fill(0x8a6d3a); + break; + case 'lever': // рычаг: основание и наклонная рукоять + g.rect(-3, -2, 6, 2).fill(0x4a3b2a); + g.rect(0, -8, 2, 7).fill(0x8a6d3a); + g.rect(-2, -10, 4, 2).fill(0x8a6d3a); + break; + case 'pickup': // кучка пепла с тёплой искрой + g.rect(-4, -3, 8, 3).fill(0x3a3a3a); + g.rect(-2, -4, 4, 2).fill(0x4a4a4a); + g.rect(0, -4, 1, 1).fill(0xf0d878); + break; + default: // контейнер/предмет осмотра: ящик с бронзовой кромкой + g.rect(-5, -12, 10, 12).fill(0x4a3b2a); + g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо + } view.addChild(g); const label = new PixelText({ text: def.label ?? def.id, size: 9, color: 0xd8c79a }); label.anchor.set(0.5, 1); diff --git a/apps/game/src/systems/Interactables.ts b/apps/game/src/systems/Interactables.ts index 5f453d8..80df40d 100644 --- a/apps/game/src/systems/Interactables.ts +++ b/apps/game/src/systems/Interactables.ts @@ -60,6 +60,7 @@ for (const f of r.setFlags ?? []) this.state.setFlag(f); for (const f of r.clearFlags ?? []) this.state.clearFlag(f); 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 (r.sound) this.sink.playSound(r.sound); diff --git a/apps/game/src/systems/__tests__/Interactables.test.ts b/apps/game/src/systems/__tests__/Interactables.test.ts index 01032cf..df9ee67 100644 --- a/apps/game/src/systems/__tests__/Interactables.test.ts +++ b/apps/game/src/systems/__tests__/Interactables.test.ts @@ -97,6 +97,32 @@ expect(calls.sound).toEqual(['sfx/bell_hit']); }); + it('addVar: подбор мотов копит вар, а не затирает', () => { + const { world, st, calls } = make([ + def({ + id: 'mote', + kind: 'pickup', + once: true, + responses: [{ text: 'мот', gives: 'mote', addVar: { id: 'motes', by: 1 } }] + }) + ]); + world.tryInteract(world.defAt(3, 3)!); + expect(st.getNumber('motes')).toBe(1); + // Второй такой же объект (другой тайл): вар растёт от прежнего значения. + const { world: w2, st: st2 } = make([ + def({ + id: 'mote_2', + kind: 'pickup', + once: true, + responses: [{ text: 'мот', gives: 'mote', addVar: { id: 'motes', by: 1 } }] + }) + ]); + st2.setVar('motes', 2); + w2.tryInteract(w2.defAt(3, 3)!); + expect(st2.getNumber('motes')).toBe(3); + expect(calls.toast).toEqual(['мот']); + }); + it('when от GameState: реакция меняется с флагом', () => { const { world, calls, st } = make([ def({ diff --git a/docs/demo.md b/docs/demo.md index 8b74da7..19f4801 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -116,7 +116,12 @@ `kind: 'return'`, `InteractableDef`/`Interactables` (сундук, очаг, записка, прилавок; реакции по `when`, `once` → флаг `used:`, `gives`), снапшот `interactables`, сценарий `tools/checks/interact.mjs`. -- **Батч 3**: взаимодействие с миром шире (знаки/подбор/рычаг, мот-песок). +- ✅ **Батч 3 — взаимодействие с миром шире** (готово): столбы у троп (луга, + пруды, Звенец), подбор пепельных мотов (`gives` + `addVar`, вар `motes` — + зачаток экономики), верёвка колокола башни (флаг `rang_tower`), ветка + повторного диалога Милы по вару (`whenVar motes`), инвариант + `interact-used-consistent` в мосте, `walkTo` пропускает узлы-объекты, + сценарий `tools/checks/interact-world.mjs`. - **Батч 4**: ИИ врагов (LOS/шум/патруль/wary/flee/return). - Принцип среза: каждая геймплейная система имеет сценарий в `tools/checks/`. diff --git a/docs/engine/agent.md b/docs/engine/agent.md index 1048c0b..300e73c 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -83,7 +83,11 @@ Отсюда **главное правило**: инъекция ввода и шаг, который её видит, должны быть атомарны (один JS-стек). `tapVirtual/press/key` внутри делают инъекцию + один `stepTick` — между `evaluate`-вызовами rAF успел бы очистить «just pressed». -`walkTo` тоже атомарен по узлам: клик + ожидание тайла. +`walkTo` тоже атомарен по узлам: клик + ожидание тайла. Если клик по узлу +маршрута сработал как взаимодействие (мот/цветок/NPC на пути) — герой +останавливается рядом, и такой узел **пропускается**: идём дальше со +следующего. Поэтому `walkTo` возвращает true, только если герой стоит на +целевом тайле; узлы-объекты на пути не ломают маршрут. Семантика «just pressed» не меняется: инъекция между тиками видна сцене ровно один тик (очистка в `endTick`). diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 13bb9bc..5303174 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -77,6 +77,10 @@ (`tapTile` в юнитах), но в старых скриптах — целься в промежуточный тайл. 3. **walkTo в занятый тайл** (NPC, высокий объект): тайл непроходим, маршрут не найден. Кликни в сам NPC-тайл (`tapTile`) — сцена подведёт героя сама. + **Интерактив на ПУТИ**: `walkTo` кликает по узлам маршрута, и клик в тайл + мота/цветка срабатывает как взаимодействие — герой останавливается рядом. + Начиная с батча 3 такие узлы пропускаются автоматически; в старых сценариях + веди героя через промежуточные точки в стороне от объектов. 4. **Гонка rAF и инъекции**: инъекция и шаг должны быть в одном JS-стеке — высокоуровневые методы моста (`tapTile/press/key/walkTo`) уже атомарны; сырые `inject*` из страницы + отдельный `step` — гонка с rAF. diff --git a/docs/world.md b/docs/world.md index c8fdffa..793c5a1 100644 --- a/docs/world.md +++ b/docs/world.md @@ -102,6 +102,9 @@ стоят у колокольчиков. Пейзажные «друзья», не добыча. - **Накат** (опасность) — свежий пласт пепла: дышать поверх опасно. В игре — зоны, требующие маски/обхода. +- **Пепельные моты** (экономика) — сгустки серой земли с тёплой искрой: то, что + корни колокольчика вытянули из пепла. Их подбирают на лугах и меняют у Милы на + товар. Серая земля — единственная почва, что снова принимает посев (см. раздел 3). - **Старая Машина** (финал акта) — не босс в привычном смысле: механизм, который надо не убить, а **уложить спать правильным звоном**. diff --git a/tools/agent.mjs b/tools/agent.mjs index e47d924..18145de 100644 --- a/tools/agent.mjs +++ b/tools/agent.mjs @@ -93,7 +93,8 @@ { name: 'maps:fresh', run: mapsFresh }, { name: 'agent:invariants', run: agentInvariants }, { name: 'transitions', run: () => runScenario('transitions', 'tools/checks/transitions.mjs') }, - { name: 'interact', run: () => runScenario('interact', 'tools/checks/interact.mjs') } + { name: 'interact', run: () => runScenario('interact', 'tools/checks/interact.mjs') }, + { name: 'interact-world', run: () => runScenario('interact-world', 'tools/checks/interact-world.mjs') } ]; const only = args.only ?? all.map((p) => p.name); const skip = args.skip ?? []; diff --git a/tools/checks/interact-world.mjs b/tools/checks/interact-world.mjs new file mode 100644 index 0000000..76d3b91 --- /dev/null +++ b/tools/checks/interact-world.mjs @@ -0,0 +1,128 @@ +/** + * Сценарий interact-world — взаимодействие с миром шире (батч 3). + * 1) столб у тропы: тост с текстом знака; + * 2) подбор мота: предмет в сумке, вар motes растёт, used:; + * 3) второй и третий моты: вар копится (addVar, порядок не важен); + * 4) знак наката у прудов; + * 5) верёвка колокола: флаг rang_tower, повтор — другой текст; + * 6) Мила: знакомство -> повторный диалог с веткой по мотам; + * 7) инвариант interact-used-consistent чист. + * Запуск: node tools/agent.mjs run tools/checks/interact-world.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +export default async function ({ pretty }) { + const c = new Checks('interact-world'); + const server = await startDevServer(); + let ctx; + try { + await c.run('столб у тропы: тост с текстом знака', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.command('scene:sleepAll'); + // Столб (25,13) у тропы в Звенец — клик издалека: герой подойдёт сам. + await ctx.agent.tapTile(25, 13); + const w = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { + timeoutTicks: 1500 + }); + c.expect(w.ok, 'знак не показал текст', w.snapshot.lastToast); + return null; + }); + await c.run('мот 1: предмет + вар + used', async () => { + await ctx.agent.tapTile(11, 14); + const w = await ctx.agent.waitFor( + '(s.inventory.find((i) => i.id === "mote")?.count ?? 0) === 1 && s.vars.motes === 1', + { timeoutTicks: 1500 } + ); + c.expect(w.ok, 'подбор мота не дал предмет и вар', { + inventory: w.snapshot.inventory, + vars: w.snapshot.vars + }); + const s = await ctx.agent.snapshot(); + const mote = (s.interactables ?? []).find((o) => o.id === 'mote_1'); + c.expect(mote?.used === true, 'мот не помечен used', mote); + return null; + }); + await c.run('моты 2 и 3: вар копится (addVar)', async () => { + await ctx.agent.tapTile(14, 20); + const w2 = await ctx.agent.waitFor('s.vars.motes === 2', { timeoutTicks: 1500 }); + c.expect(w2.ok, 'второй мот не поднял вар до 2', w2.snapshot.vars); + await ctx.agent.tapTile(14, 6); + const w3 = await ctx.agent.waitFor('s.vars.motes === 3', { timeoutTicks: 1500 }); + c.expect(w3.ok, 'третий мот не поднял вар до 3', w3.snapshot.vars); + return null; + }); + await c.run('знак наката у прудов', async () => { + // Тропа на пруды (2,2): step-переход; у берега — столб (3,2). + const w = await ctx.agent.walkTo(2, 2, { timeoutTicks: 3000 }); + c.expect(w, 'не дошёл до тропы на пруды'); + await ctx.agent.waitFor('s.area === "ponds"', { timeoutTicks: 600 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await ctx.agent.tapTile(3, 2); + const ws = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("накатом")', { + timeoutTicks: 900 + }); + c.expect(ws.ok, 'знак наката не показал текст', ws.snapshot.lastToast); + // Обратно на луга (2,2) прудов. + const wb = await ctx.agent.walkTo(2, 2, { timeoutTicks: 3000 }); + c.expect(wb, 'не вернулся к тропе с прудов'); + await ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 600 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return null; + }); + await c.run('верёвка колокола: флаг + повтор с другим текстом', async () => { + // Звенец через тропу (26,14); верёвка у башни (13,7) — клик издалека. + const w = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + c.expect(w, 'не дошёл до тропы в Звенец'); + await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await ctx.agent.tapTile(13, 7); + const wr = await ctx.agent.waitFor('s.flags.includes("rang_tower")', { + timeoutTicks: 1500 + }); + c.expect(wr.ok, 'звон не поднял rang_tower', wr.snapshot.flags); + const s1 = await ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('ушёл в землю'), 'первый звон — не тот текст', text1); + await ctx.agent.tapTile(13, 7); + await ctx.agent.step(150); + const s2 = await ctx.agent.snapshot(); + const text2 = s2.lastToast?.text ?? ''; + c.expect( + text2.includes('снова') && text2 !== text1, + 'повторный звон не сменил текст', + text2 + ); + return null; + }); + await c.run('Мила: знакомство -> повтор с веткой по мотам', async () => { + // Первое знакомство (trader_first): полотно. + await ctx.agent.tapTile(17, 11); + const d1 = await ctx.agent.runDialogue(900); + c.expect(d1, 'первый диалог с Милой не завершился'); + // Повтор (trader_repeat): при моты >= 1 после первой реплики идёт + // ветка о мота́х — листаем вручную и ловим её текст. + await ctx.agent.tapTile(17, 11); + const o = await ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 900 }); + c.expect(o.ok, 'повторный диалог не открылся'); + await ctx.agent.press('advance'); + const w = await ctx.agent.waitFor( + 's.dialogue != null && (s.dialogue.text ?? "").includes("Моты")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'в повторном диалоге нет ветки о мота́х', w.snapshot.dialogue); + const d2 = await ctx.agent.runDialogue(600); + c.expect(d2, 'повторный диалог не завершился'); + return null; + }); + await c.run('инвариант: used-флаги и снапшот согласованы', async () => { + const inv = await ctx.agent.invariants(); + const bad = inv.filter((i) => i.severity === 'error'); + c.expect(bad.length === 0, 'ошибки инвариантов', bad); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file