diff --git a/apps/game/src/data/__tests__/quests.test.ts b/apps/game/src/data/__tests__/quests.test.ts new file mode 100644 index 0000000..9588602 --- /dev/null +++ b/apps/game/src/data/__tests__/quests.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { GameState, Inventory } from '@rpg/engine'; +import { QUESTS, QUEST_FLOWERS, activeStage, inventoryItems, questDialogueFor, questDone, questEffectFor, questLog, questTaken } from '../quests'; + +const bells = QUESTS[0]!; + +describe('квест «Три цветка» — стадии', () => { + it('до знакомства квест не взят, стадий нет', () => { + const s = new GameState(); + expect(questTaken(s, bells)).toBe(false); + expect(questLog(s)).toEqual([]); + }); + + it('после взятия активна стадия сбора; диалог сдачи не готов', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + expect(questTaken(s, bells)).toBe(true); + expect(activeStage(s, bells)?.dialogue).toBe('elder_hand_in'); + expect(questDialogueFor(s, 'elder')).toBeNull(); // цветов мало + expect(questLog(s)).toEqual([ + { done: false, text: `Собрать лунные колокольчики у Серых прудов (0/${QUEST_FLOWERS})` } + ]); + }); + + it('собрано достаточно — сдача готова', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setVar('flowers', QUEST_FLOWERS); + expect(questDialogueFor(s, 'elder')).toBe('elder_hand_in'); + expect(questLog(s)).toEqual([ + { done: false, text: `Собрать лунные колокольчики у Серых прудов (3/3)` } + ]); + expect(questDone(s, bells)).toBe(false); + }); + + it('после сдачи — эпилог с Милой и финальная строка', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setVar('flowers', QUEST_FLOWERS); + s.setFlag('quest_bells_done'); + expect(questDone(s, bells)).toBe(true); + expect(questDialogueFor(s, 'trader')).toBe('trader_after'); + expect(questDialogueFor(s, 'elder')).toBeNull(); // Ирвин — обычный repeat + expect(questLog(s)).toEqual([ + { done: true, text: `Собрать лунные колокольчики у Серых прудов (3/3)` }, + { done: true, text: 'Цветы посажены у тропы. Поляна гудит.' } + ]); + }); +}); + +describe('эффекты диалогов', () => { + it('эффекты берутся из стадий реестра и знакомств', () => { + expect(questEffectFor('elder_hand_in')).toBe('plant_flowers'); + expect(questEffectFor('trader_first')).toBe('give_cloth'); + expect(questEffectFor('elder_first')).toBeNull(); + }); + + it('квест завершён, но эпилог (без doneFlag) остаётся активной стадией', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setFlag('quest_bells_done'); + expect(questDone(s, bells)).toBe(true); + expect(activeStage(s, bells)?.npc).toBe('trader'); + }); +}); + +describe('inventoryItems из сумки', () => { + it('имя + множитель, пусто — заглушка', () => { + const inv = new Inventory(); + expect(inventoryItems(inv)).toEqual(['— пусто —']); + inv.add('cloth'); + inv.add('bellflower', 2); + expect(inventoryItems(inv)).toEqual(['Вощёное полотно', `Лунный колокольчик ×2`]); + }); +}); \ No newline at end of file diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts index c32feae..3ce90c7 100644 --- a/apps/game/src/data/quests.ts +++ b/apps/game/src/data/quests.ts @@ -2,31 +2,147 @@ import { itemName } from './items'; /** - * Квест «Три цветка» (docs/world.md, акт 1): Ирвин просит собрать - * лунные колокольчики у Серых прудов и посадить их на лугу. - * Прогресс — во флагах/варах GameState, сумка — в Inventory. + * Квестовая система (контент по docs/world.md). + * Квест — декларативный набор стадий; состояние живёт во флагах/варах + * GameState (ставятся графами диалогов), статус выводится из состояния. + * Сцена спрашивает только «какой диалог у NPC» и «что делать после». */ + +/** Квест «Три цветка» (docs/world.md, акт 1): собрать и посадить колокольчики. */ export const QUEST_FLOWERS = 3; +/** Одна стадия квеста. */ +export interface QuestStage { + /** Чей диалог ведёт стадию (id из NpcDef). */ + npc: string; + /** Строка журнала, пока стадия активна. */ + text: string; + /** Прогресс N/M по var-ключу (если стадия считается в штуках). */ + progressKey?: string; + goal?: number; + /** Диалог стадии: взятие (первая) или сдача (промежуточная). */ + dialogue?: string; + /** Готовность диалога (по умолчанию — сразу, как стадия активна). */ + ready?: (state: GameState) => boolean; + /** Флаг, которым граф диалога завершает стадию. Без него стадия — «эпилог». */ + doneFlag?: string; + /** Побочный эффект сцены после диалога стадии (см. applyQuestEffect). */ + effect?: string; +} + +export interface QuestDef { + id: string; + title: string; + /** Стадии по порядку; активная — первая незавершённая. */ + stages: QuestStage[]; +} + +export const QUESTS: QuestDef[] = [ + { + id: 'bells', + title: 'Три цветка', + stages: [ + // Взятие: Ирвин рассказывает про поляны у прудов. + { + npc: 'elder', + text: 'Слышать гул на закате — пепел дышит у прудов.', + dialogue: 'elder_first', + doneFlag: 'quest_bells_taken' + }, + // Сбор: активная строка журнала с прогрессом; сдача — Ирвину. + { + npc: 'elder', + text: 'Собрать лунные колокольчики у Серых прудов', + progressKey: 'flowers', + goal: QUEST_FLOWERS, + dialogue: 'elder_hand_in', + ready: (state) => state.getNumber('flowers') >= QUEST_FLOWERS, + doneFlag: 'quest_bells_done', + effect: 'plant_flowers' + }, + // Эпилог: крючок акта 1 — разговор с Милой после посадки. + { + npc: 'trader', + text: 'Цветы посажены у тропы. Поляна гудит.', + dialogue: 'trader_after' + } + ] + } +]; + +/** Квест взят: стоит doneFlag первой стадии. */ +export function questTaken(state: GameState, quest: QuestDef): boolean { + const first = quest.stages[0]!; + return first.doneFlag !== undefined && state.hasFlag(first.doneFlag); +} + +/** Квест завершён: все завершающие флаги стоят. */ +export function questDone(state: GameState, quest: QuestDef): boolean { + return quest.stages.every((s) => s.doneFlag === undefined || state.hasFlag(s.doneFlag)); +} + +/** Активная стадия: первая незавершённая (эпилог без doneFlag — тоже стадия). */ +export function activeStage(state: GameState, quest: QuestDef): QuestStage | null { + for (const stage of quest.stages) { + if (stage.doneFlag !== undefined && state.hasFlag(stage.doneFlag)) continue; + return stage; + } + return null; +} + +/** + * Диалог NPC по квест-стадиям: активная стадия этого NPC с готовым диалогом. + * null — обычный repeat-диалог. + */ +export function questDialogueFor(state: GameState, npcId: string): 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; + return stage.dialogue; + } + return null; +} + +/** Побочные эффекты внеквестовых диалогов (знакомства). */ +const EXTRA_EFFECTS: Record = { + trader_first: 'give_cloth' // Мила дарит полотно при знакомстве +}; + +/** Побочный эффект по завершённому диалогу (из стадий реестра и знакомств). */ +export function questEffectFor(dialogueId: string): string | null { + for (const quest of QUESTS) { + for (const stage of quest.stages) { + if (stage.dialogue === dialogueId && stage.effect) return stage.effect; + } + } + return EXTRA_EFFECTS[dialogueId] ?? null; +} + /** Одна строка журнала квестов. */ export interface QuestEntry { done: boolean; text: string; } -/** Журнал квестов по флагам GameState. */ +/** Журнал: стадии квестов с момента взятия (первая служебная стадия не показывается). */ export function questLog(state: GameState): QuestEntry[] { const entries: QuestEntry[] = []; - if (state.hasFlag('quest_bells_taken')) { - const n = Math.min(state.getNumber('flowers'), QUEST_FLOWERS); - entries.push({ - done: state.hasFlag('quest_bells_done'), - text: `Собрать лунные колокольчики у Серых прудов (${n}/${QUEST_FLOWERS})` + for (const quest of QUESTS) { + let reached = true; + quest.stages.forEach((stage, i) => { + // Эпилог (без doneFlag) достигнут — значит, завершён. + const done = stage.doneFlag === undefined || state.hasFlag(stage.doneFlag); + if (i > 0 && reached) { + const progress = + stage.progressKey !== undefined && stage.goal !== undefined + ? ` (${Math.min(state.getNumber(stage.progressKey), stage.goal)}/${stage.goal})` + : ''; + entries.push({ done, text: `${stage.text}${progress}` }); + } + if (!done) reached = false; }); } - if (state.hasFlag('quest_bells_done')) { - entries.push({ done: true, text: 'Цветы посажены у тропы. Поляна гудит.' }); - } return entries; } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 317340d..3543b5b 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -30,7 +30,7 @@ import { locationOf, type LocationDef } from '../data/locations'; import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; -import { QUEST_FLOWERS } from '../data/quests'; +import { QUEST_FLOWERS, questDialogueFor, questEffectFor } from '../data/quests'; import type { EnemyKindId } from '../data/enemies'; import { PlayerController, type HeroTextures } from '../systems/PlayerController'; import { DialogueSystem } from '../systems/DialogueSystem'; @@ -574,25 +574,18 @@ private talkTo(def: NpcDef): void { const met = this.game.state.hasFlag(def.flagKey); if (!met) this.game.state.setFlag(def.flagKey); - let id = met ? def.dialogueRepeat : def.dialogueFirst; - // Сюжетные ветки по прогрессу (квесты — по флагам GameState). - if (met && def.id === 'elder') { - const flowers = this.game.state.getNumber('flowers'); - if (!this.game.state.hasFlag('quest_bells_done') && flowers >= QUEST_FLOWERS) { - id = 'elder_hand_in'; - } - } - if (met && def.id === 'trader' && this.game.state.hasFlag('quest_bells_done')) { - id = 'trader_after'; - } + // Сюжетная ветка — из квест-стадии; иначе обычный диалог. + const id = !met + ? def.dialogueFirst + : (questDialogueFor(this.game.state, def.id) ?? def.dialogueRepeat); this.dialogue.start(DIALOGUES[id], id); } private onDialogueFinished(id: string): void { - if (id === 'elder_hand_in') this.plantFlowers(); - // Полотно — предмет сумки (флаг got_cloth остаётся как метка знакомства). - // TODO(батч 2): выдача предметов уходит в квест-модель. - if (id === 'trader_first') this.game.inventory.add('cloth'); + // Побочные эффекты — из квест-стадий реестра. + const effect = questEffectFor(id); + if (effect === 'plant_flowers') this.plantFlowers(); + if (effect === 'give_cloth') this.game.inventory.add('cloth'); } /** Посадка цветов у тропы: тайлы гудят колокольчиками (крючок акта 1). */