Newer
Older
rpg / apps / game / src / data / quests.ts
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';

/**
 * Квестовая система (контент по docs/world.md).
 * Квест — декларативный набор стадий; состояние живёт во флагах/варах
 * GameState (ставятся графами диалогов), статус выводится из состояния.
 * Готовность стадии — те же условия, что и в графах диалогов (данные, не
 * замыкания — валидатор и dry-run их видят). Сцена спрашивает только
 * «какой диалог у NPC».
 */

/** Квест «Три цветка» (docs/world.md, акт 1): собрать и посадить колокольчики. */
export const QUEST_FLOWERS = 3;

/** Одна стадия квеста. */
export interface QuestStage {
    /** Чей диалог ведёт стадию (id из NpcDef). */
    npc: string;
    /** Строка журнала, пока стадия активна. */
    text: string;
    /** Прогресс N/M по var-ключу (если стадия считается в штуках). */
    progressKey?: VarId;
    goal?: number;
    /** Диалог стадии: взятие (первая) или сдача (промежуточная). */
    dialogue?: string;
    /** Готовность диалога — условия как в графах (по умолчанию сразу). */
    ready?: DialogueConditions;
    /** Флаг, которым граф диалога завершает стадию. Без него стадия — «эпилог». */
    doneFlag?: FlagId;
}

export interface QuestDef {
    id: string;
    title: string;
    /** Стадии по порядку; активная — первая незавершённая. */
    stages: QuestStage[];
}

export const QUESTS: QuestDef[] = [
    {
        id: 'bells',
        title: 'Три цветка',
        stages: [
            // Взятие: Ирвин рассказывает про поляны у прудов.
            {
                npc: 'elder',
                text: 'Слышать гул на закате — пепел дышит у прудов.',
                dialogue: 'elder_first',
                doneFlag: FLAGS.quest_bells_taken
            },
            // Сбор: активная строка журнала с прогрессом; сдача — Ирвину.
            {
                npc: 'elder',
                text: 'Собрать лунные колокольчики у Серых прудов',
                progressKey: VARS.flowers,
                goal: QUEST_FLOWERS,
                dialogue: 'elder_hand_in',
                ready: { whenVars: [{ key: VARS.flowers, op: 'ge', value: QUEST_FLOWERS }] },
                doneFlag: FLAGS.quest_bells_done
            },
            // Эпилог: крючок акта 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 с готовым диалогом.
 * world — предикаты мира (сумка) для hasItem-условий. null — repeat-диалог.
 */
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 && !evalConditions(stage.ready, state, world)) continue;
        return stage.dialogue;
    }
    return null;
}

/** Одна строка журнала квестов. */
export interface QuestEntry {
    done: boolean;
    text: string;
}

/** Журнал: стадии квестов с момента взятия (первая служебная стадия не показывается). */
export function questLog(state: GameState): QuestEntry[] {
    const entries: QuestEntry[] = [];
    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;
        });
    }
    return entries;
}

/** Строки сумки из инвентаря: имя + ×N (у одиночных — без множителя). */
export function inventoryItems(inventory: Inventory): string[] {
    const items = inventory.all.map((slot) =>
        slot.count > 1 ? `${itemName(slot.id)} ×${slot.count}` : itemName(slot.id)
    );
    if (items.length === 0) items.push('— пусто —');
    return items;
}