diff --git a/docs/architecture.md b/docs/architecture.md index 90639b1..960808c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,8 @@ Idlegame is split into four layers. queue advancement (actions do not auto-repeat when the queue is empty). - `save.ts`: versioned save schema, serialized export/import strings, and offline elapsed calculation. +- `story.ts`: story graph traversal, triggers (boot, actionComplete, + minResources), choices, outcomes. - `num.ts`: branded numeric boundary and human-readable formatting. The engine must stay pure. It does not import React, Zustand, browser APIs, @@ -33,6 +35,9 @@ story nodes, automation unlocks, and prestige definitions. - requestAnimationFrame loop and lifecycle hooks - mapping engine state to view models - Zustand store updates for React +- `storyOrchestration.ts`: evaluates triggers after boot/publish/action + completion; maps prefs to panel auto-open. +- Player prefs (`prefs.ts`) in localStorage — not in save v1. ## UI diff --git a/src/content/__tests__/definitions.test.ts b/src/content/__tests__/definitions.test.ts index 52f2584..bfa1315 100644 --- a/src/content/__tests__/definitions.test.ts +++ b/src/content/__tests__/definitions.test.ts @@ -6,7 +6,7 @@ describe('M1 stub content pack', () => { it('defines two resources and four to five actions with costs and unlocks', () => { expect(content.resources).toHaveLength(2); expect(content.actions.length).toBeGreaterThanOrEqual(4); - expect(content.actions.length).toBeLessThanOrEqual(5); + expect(content.actions.length).toBeLessThanOrEqual(6); const withCosts = content.actions.filter((a) => a.costs.length > 0); const withUnlocks = content.actions.filter((a) => a.unlock !== undefined); expect(withCosts.length).toBeGreaterThanOrEqual(2); diff --git a/src/content/__tests__/schema.test.ts b/src/content/__tests__/schema.test.ts index 1d2ad19..960a724 100644 --- a/src/content/__tests__/schema.test.ts +++ b/src/content/__tests__/schema.test.ts @@ -124,3 +124,27 @@ describe('unlock conditions', () => { expect(content.actionsById.forage.unlock).toBeUndefined(); }); }); + +describe('action narrative fields', () => { + it('accepts optional storyHint and storyTooltip', () => { + const actions = [ + { + id: 'forage', + name: 'Forage', + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + storyHint: 'Gather what the forest offers.', + storyTooltip: 'Your first step into the wild.', + }, + ]; + const content = buildContent({ resources: validResources, actions }); + expect(content.actionsById.forage.storyHint).toBe('Gather what the forest offers.'); + expect(content.actionsById.forage.storyTooltip).toBe('Your first step into the wild.'); + }); + + it('defaults storyHint and storyTooltip to undefined when omitted', () => { + const content = buildContent({ resources: validResources, actions: validActions }); + expect(content.actionsById.forage.storyHint).toBeUndefined(); + expect(content.actionsById.forage.storyTooltip).toBeUndefined(); + }); +}); diff --git a/src/content/__tests__/story.test.ts b/src/content/__tests__/story.test.ts new file mode 100644 index 0000000..79e5397 --- /dev/null +++ b/src/content/__tests__/story.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { createGameState, isActionAvailable } from '../../engine/game'; +import { applyChoice, enterStoryNode, evaluateTriggers, initStory } from '../../engine/story'; +import { content } from '../index'; + +describe('stub story graph', () => { + it('route A unlocks fortify_camp but not push_onward', () => { + const state = createGameState(content); + initStory(state, content); + evaluateTriggers(state, content, { reason: 'boot' }); + enterStoryNode(state, content, 'fork_choice'); + applyChoice(state, content, 'pick_a'); + expect(state.storyFlags.route_a).toBe(true); + expect(isActionAvailable(state, content, 'fortify_camp')).toBe(true); + expect(isActionAvailable(state, content, 'push_onward')).toBe(false); + }); + + it('route B unlocks push_onward but not route-A fortify flag gate', () => { + const state = createGameState(content); + initStory(state, content); + evaluateTriggers(state, content, { reason: 'boot' }); + enterStoryNode(state, content, 'fork_choice'); + applyChoice(state, content, 'pick_b'); + expect(isActionAvailable(state, content, 'push_onward')).toBe(true); + expect(isActionAvailable(state, content, 'fortify_camp')).toBe(false); + }); + + it('continueStory chain: boot_intro advances to fork_choice', () => { + const state = createGameState(content); + initStory(state, content); + evaluateTriggers(state, content, { reason: 'boot' }); + expect(state.currentStoryNodeId).toBe('boot_intro'); + enterStoryNode(state, content, 'fork_choice'); + expect(state.currentStoryNodeId).toBe('fork_choice'); + expect(content.storyNodesById.fork_choice.choices).toHaveLength(2); + }); +}); diff --git a/src/content/__tests__/storySchema.test.ts b/src/content/__tests__/storySchema.test.ts new file mode 100644 index 0000000..046e897 --- /dev/null +++ b/src/content/__tests__/storySchema.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { buildStoryContent } from '../storySchema'; + +const resourcesById = { + supplies: { id: 'supplies', name: 'Supplies', startAmount: 0 }, + coin: { id: 'coin', name: 'Coin', startAmount: 0 }, +}; +const actionsById = { + scout_path: { + id: 'scout_path', + name: 'Scout', + durationMs: 1000, + costs: [], + yields: [{ resourceId: 'coin', amount: 1 }], + }, +}; + +const validNodes = [ + { + id: 'boot_intro', + prose: 'You wake at the crossroads.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: 'Which way?', + choices: [ + { + id: 'pick_a', + label: 'High road', + outcomes: [{ type: 'setFlag', flag: 'route_a' }], + targetNodeId: 'route_a_beat', + }, + { + id: 'pick_b', + label: 'Low road', + outcomes: [{ type: 'setFlag', flag: 'route_b' }], + targetNodeId: 'route_b_beat', + }, + ], + }, + { id: 'route_a_beat', prose: 'The high road.' }, + { id: 'route_b_beat', prose: 'The low road.' }, +]; + +describe('buildStoryContent()', () => { + it('indexes nodes and finds boot trigger', () => { + const story = buildStoryContent(validNodes, actionsById, resourcesById); + expect(story.storyNodesById.fork_choice.prose).toContain('Which way'); + expect(story.bootTargetNodeId).toBe('boot_intro'); + }); + + it('rejects dangling targetNodeId on choices', () => { + expect(() => + buildStoryContent( + [ + { + id: 'n', + prose: 'x', + choices: [{ id: 'c', label: 'y', outcomes: [], targetNodeId: 'missing' }], + }, + ], + actionsById, + resourcesById, + ), + ).toThrow(/unknown story node/i); + }); + + it('rejects unknown actionId in actionComplete trigger', () => { + expect(() => + buildStoryContent( + [ + { + id: 't', + prose: 'x', + triggers: [{ type: 'actionComplete', actionId: 'ghost', targetNodeId: 'boot_intro' }], + }, + { id: 'boot_intro', prose: 'hi' }, + ], + actionsById, + resourcesById, + ), + ).toThrow(/unknown action/i); + }); + + it('requires exactly one boot trigger', () => { + expect(() => + buildStoryContent([{ id: 'n', prose: 'no boot' }], actionsById, resourcesById), + ).toThrow(/boot trigger/i); + }); +}); diff --git a/src/content/definitions.ts b/src/content/definitions.ts index 39c6fb5..ad9157e 100644 --- a/src/content/definitions.ts +++ b/src/content/definitions.ts @@ -9,6 +9,8 @@ export const actionDefs = [ name: 'Gather supplies', durationMs: 3000, yields: [{ resourceId: 'supplies', amount: 2 }], + storyHint: 'Basic camp labor.', + storyTooltip: 'Yields 2 Supplies. No cost.', }, { id: 'scout_path', @@ -16,6 +18,8 @@ export const actionDefs = [ durationMs: 5000, costs: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'coin', amount: 1 }], + storyHint: 'Map the crossing.', + storyTooltip: 'Costs 2 Supplies. Yields 1 Coin. Triggers scout aftermath story.', }, { id: 'trade_supplies', @@ -24,6 +28,8 @@ export const actionDefs = [ costs: [{ resourceId: 'supplies', amount: 3 }], yields: [{ resourceId: 'coin', amount: 2 }], unlock: { minResources: { coin: 1 } }, + storyHint: 'Barter with travelers.', + storyTooltip: 'Costs 3 Supplies. Yields 2 Coin. Unlocks at 1 Coin.', }, { id: 'fortify_camp', @@ -34,12 +40,26 @@ export const actionDefs = [ { resourceId: 'coin', amount: 2 }, ], yields: [{ resourceId: 'supplies', amount: 4 }], - unlock: { minResources: { supplies: 8 } }, + unlock: { minResources: { supplies: 8 }, requireStoryFlags: ['route_a'] }, + storyHint: 'Walls for the high road camp.', + storyTooltip: 'Route A only. Costs supplies and coin.', + }, + { + id: 'push_onward', + name: 'Push onward', + durationMs: 6000, + costs: [{ resourceId: 'supplies', amount: 2 }], + yields: [{ resourceId: 'coin', amount: 3 }], + unlock: { requireStoryFlags: ['route_b'] }, + storyHint: 'Follow the river route.', + storyTooltip: 'Costs 2 Supplies. Yields 3 Coin. Route B only.', }, { id: 'rest', name: 'Rest briefly', durationMs: 2000, yields: [{ resourceId: 'supplies', amount: 1 }], + storyHint: 'Catch your breath.', + storyTooltip: 'Yields 1 Supply. Quick recovery.', }, ]; diff --git a/src/content/index.ts b/src/content/index.ts index 3d77f73..59ad305 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,7 +1,12 @@ import { actionDefs, resourceDefs } from './definitions'; -import { buildContent } from './schema'; +import { buildContent, type Content } from './schema'; +import { storyNodeDefs } from './story'; +import { buildStoryContent, type StoryContent } from './storySchema'; -/** The validated, indexed content the engine and view consume. */ -export const content = buildContent({ resources: resourceDefs, actions: actionDefs }); +const base = buildContent({ resources: resourceDefs, actions: actionDefs }); +const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesById); + +export type GameContent = Content & StoryContent; +export const content: GameContent = { ...base, ...story }; export type { ActionDef, Content, ResourceDef } from './schema'; diff --git a/src/content/schema.ts b/src/content/schema.ts index 976bfcf..41771a4 100644 --- a/src/content/schema.ts +++ b/src/content/schema.ts @@ -32,6 +32,8 @@ export const actionDefSchema = z.object({ costs: z.array(resourceAmountSchema).default([]), yields: z.array(resourceAmountSchema).min(1), unlock: unlockDefSchema.optional(), + storyHint: z.string().min(1).optional(), + storyTooltip: z.string().min(1).optional(), }); export type ResourceDef = z.infer; diff --git a/src/content/story.ts b/src/content/story.ts new file mode 100644 index 0000000..78e2274 --- /dev/null +++ b/src/content/story.ts @@ -0,0 +1,48 @@ +export const storyNodeDefs = [ + { + id: 'boot_intro', + prose: '[Stub] You wake at a crossroads camp. Smoke rises from a cold fire pit.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: '[Stub] Tracks split. The high road climbs; the low road bends toward the river.', + choices: [ + { + id: 'pick_a', + label: 'Take the high road', + outcomes: [ + { type: 'setFlag', flag: 'route_a' }, + { type: 'grantResource', resourceId: 'supplies', amount: 3 }, + { type: 'grantResource', resourceId: 'coin', amount: 2 }, + ], + targetNodeId: 'route_a_beat', + }, + { + id: 'pick_b', + label: 'Follow the river', + outcomes: [ + { type: 'setFlag', flag: 'route_b' }, + { type: 'grantResource', resourceId: 'coin', amount: 2 }, + ], + targetNodeId: 'route_b_beat', + }, + ], + }, + { id: 'route_a_beat', prose: '[Stub] Route A: high ground, extra supplies.' }, + { id: 'route_b_beat', prose: '[Stub] Route B: river trade, extra coin.' }, + { + id: 'threshold_listener', + prose: ' ', + triggers: [ + { type: 'minResources', minResources: { coin: 3 }, targetNodeId: 'merchant_flavor' }, + ], + }, + { id: 'merchant_flavor', prose: '[Stub] A merchant remembers your face.' }, + { + id: 'scout_listener', + prose: ' ', + triggers: [{ type: 'actionComplete', actionId: 'scout_path', targetNodeId: 'scout_aftermath' }], + }, + { id: 'scout_aftermath', prose: '[Stub] The path is mapped.' }, +]; diff --git a/src/content/storySchema.ts b/src/content/storySchema.ts new file mode 100644 index 0000000..fb5b440 --- /dev/null +++ b/src/content/storySchema.ts @@ -0,0 +1,127 @@ +import { z } from 'zod'; +import type { ActionDef } from './schema'; + +export const storyOutcomeSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('setFlag'), flag: z.string().min(1) }), + z.object({ type: z.literal('clearFlag'), flag: z.string().min(1) }), + z.object({ + type: z.literal('grantResource'), + resourceId: z.string().min(1), + amount: z.number().positive(), + }), + z.object({ + type: z.literal('consumeResource'), + resourceId: z.string().min(1), + amount: z.number().positive(), + }), + z.object({ type: z.literal('log'), text: z.string().min(1) }), +]); + +export const choiceRequirementsSchema = z.object({ + minResources: z.record(z.string(), z.number().nonnegative()).optional(), + requireStoryFlags: z.array(z.string().min(1)).optional(), + excludeStoryFlags: z.array(z.string().min(1)).optional(), +}); + +export const storyChoiceSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + requirements: choiceRequirementsSchema.optional(), + outcomes: z.array(storyOutcomeSchema).default([]), + targetNodeId: z.string().min(1), +}); + +export const storyTriggerSchema = z.object({ + type: z.enum(['boot', 'actionComplete', 'minResources']), + actionId: z.string().min(1).optional(), + minResources: z.record(z.string(), z.number().nonnegative()).optional(), + targetNodeId: z.string().min(1), + once: z.boolean().default(true), +}); + +export const storyNodeSchema = z.object({ + id: z.string().min(1), + prose: z.string().min(1), + choices: z.array(storyChoiceSchema).optional(), + triggers: z.array(storyTriggerSchema).optional(), + enterOutcomes: z.array(storyOutcomeSchema).optional(), +}); + +export type StoryOutcome = z.infer; +export type StoryChoice = z.infer; +export type StoryTrigger = z.infer; +export type StoryNode = z.infer; + +export interface StoryContent { + storyNodes: StoryNode[]; + storyNodesById: Record; + bootTargetNodeId: string; +} + +function indexStoryNodes(nodes: StoryNode[]): Record { + const byId: Record = {}; + for (const node of nodes) { + if (byId[node.id]) throw new Error(`Duplicate story node id "${node.id}"`); + byId[node.id] = node; + } + return byId; +} + +export function buildStoryContent( + rawNodes: unknown[], + actionsById: Record, + resourcesById: Record, +): StoryContent { + const storyNodes = rawNodes.map((n) => storyNodeSchema.parse(n)); + const storyNodesById = indexStoryNodes(storyNodes); + + let bootTargetNodeId: string | null = null; + for (const node of storyNodes) { + for (const trigger of node.triggers ?? []) { + if (trigger.type === 'boot') { + if (bootTargetNodeId !== null) { + throw new Error('Story graph must have exactly one boot trigger'); + } + bootTargetNodeId = trigger.targetNodeId; + } + if (trigger.type === 'actionComplete' && !actionsById[trigger.actionId ?? '']) { + throw new Error(`Story trigger references unknown action "${trigger.actionId}"`); + } + if (trigger.minResources) { + for (const resourceId of Object.keys(trigger.minResources)) { + if (!resourcesById[resourceId]) { + throw new Error(`Story trigger references unknown resource "${resourceId}"`); + } + } + } + if (!storyNodesById[trigger.targetNodeId]) { + throw new Error(`Story trigger references unknown story node "${trigger.targetNodeId}"`); + } + } + for (const choice of node.choices ?? []) { + if (!storyNodesById[choice.targetNodeId]) { + throw new Error(`Story choice references unknown story node "${choice.targetNodeId}"`); + } + for (const outcome of choice.outcomes) { + if (outcome.type === 'grantResource' || outcome.type === 'consumeResource') { + if (!resourcesById[outcome.resourceId]) { + throw new Error(`Story outcome references unknown resource "${outcome.resourceId}"`); + } + } + } + } + for (const outcome of node.enterOutcomes ?? []) { + if (outcome.type === 'grantResource' || outcome.type === 'consumeResource') { + if (!resourcesById[outcome.resourceId]) { + throw new Error(`Story enterOutcome references unknown resource "${outcome.resourceId}"`); + } + } + } + } + + if (bootTargetNodeId === null) { + throw new Error('Story graph must have exactly one boot trigger'); + } + + return { storyNodes, storyNodesById, bootTargetNodeId }; +} diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 58791da..b353fd1 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -76,6 +76,16 @@ describe('createGameState()', () => { }); }); +describe('createGameState() story fields', () => { + it('initializes empty story state', () => { + const content = testContent(); + const state = createGameState(content); + expect(state.storyFlags).toEqual({}); + expect(state.currentStoryNodeId).toBe(''); + expect(state.seenStoryNodeIds).toEqual([]); + }); +}); + describe('startAction()', () => { it('activates the action and resets its progress', () => { const content = testContent(); @@ -202,7 +212,8 @@ describe('unlock conditions', () => { }); const state = createGameState(content); expect(() => enqueueAction(state, content, 'secret')).toThrow(/cannot enqueue/i); - expect(canUnlockAction(state, content, 'secret', { path_scouted: true })).toBe(true); + state.storyFlags.path_scouted = true; + expect(canUnlockAction(state, content, 'secret')).toBe(true); }); }); @@ -288,6 +299,25 @@ describe('completion advances queue', () => { }); }); +describe('tickGame() completion result', () => { + it('returns the id of each action that completed this tick', () => { + const content = testContent(); + const state = createGameState(content); + enqueueAction(state, content, 'forage'); + const result = tickGame(state, content, 300); + expect(result.completedActionIds).toEqual(['forage']); + expect(state.activeActionId).toBeNull(); + }); + + it('returns an empty array when nothing completes', () => { + const content = testContent(); + const state = createGameState(content); + enqueueAction(state, content, 'forage'); + const result = tickGame(state, content, 100); + expect(result.completedActionIds).toEqual([]); + }); +}); + describe('tickGame()', () => { it('does nothing when no action is active', () => { const content = testContent(); diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index e65f78b..b0eaf37 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -58,6 +58,17 @@ describe('createSave()', () => { state.actionQueue.push('forage'); expect(save.state.actionQueue).toEqual(['forage', 'forage']); }); + + it('snapshots story fields in the save payload', () => { + const state = sampleState(); + state.storyFlags = { route_a: true }; + state.currentStoryNodeId = 'route_a_beat'; + state.seenStoryNodeIds = ['boot_intro', 'route_a_beat']; + const save = createSave(state, 1700); + expect(save.state.storyFlags).toEqual({ route_a: true }); + expect(save.state.currentStoryNodeId).toBe('route_a_beat'); + expect(save.state.seenStoryNodeIds).toHaveLength(2); + }); }); describe('serialize / deserialize round-trip', () => { diff --git a/src/engine/__tests__/story.test.ts b/src/engine/__tests__/story.test.ts new file mode 100644 index 0000000..006ff25 --- /dev/null +++ b/src/engine/__tests__/story.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from 'vitest'; +import { buildContent } from '../../content/schema'; +import { buildStoryContent } from '../../content/storySchema'; +import { createGameState } from '../game'; +import { + applyChoice, + applyOutcomes, + enterStoryNode, + evaluateTriggers, + getAvailableChoices, + getCurrentNode, + initStory, + type StoryEvent, +} from '../story'; + +function gameContent() { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'scout_path', + name: 'Scout', + durationMs: 1000, + costs: [], + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + enterOutcomes: [{ type: 'grantResource', resourceId: 'coin', amount: 1 }], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +function gameContentWithActionTrigger() { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'scout_path', + name: 'Scout', + durationMs: 1000, + costs: [], + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'scout_aftermath', + prose: 'After scouting.', + triggers: [ + { type: 'actionComplete', actionId: 'scout_path', targetNodeId: 'scout_aftermath' }, + ], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +function gameContentWithThreshold() { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'scout_path', + name: 'Scout', + durationMs: 1000, + costs: [], + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'merchant_flavor', + prose: 'Merchant.', + triggers: [ + { + type: 'minResources', + minResources: { coin: 3 }, + targetNodeId: 'merchant_flavor', + }, + ], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +function gameContentWithFork() { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'scout_path', + name: 'Scout', + durationMs: 1000, + costs: [], + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'You wake at the crossroads.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: 'Which way?', + choices: [ + { + id: 'pick_a', + label: 'High road', + outcomes: [{ type: 'setFlag', flag: 'route_a' }], + targetNodeId: 'route_a_beat', + }, + { + id: 'pick_b', + label: 'Low road', + outcomes: [{ type: 'setFlag', flag: 'route_b' }], + targetNodeId: 'route_b_beat', + }, + { + id: 'pick_b_if_excluded', + label: 'Low road (excluded after A)', + requirements: { excludeStoryFlags: ['route_a'] }, + outcomes: [{ type: 'setFlag', flag: 'route_b' }], + targetNodeId: 'route_b_beat', + }, + ], + }, + { id: 'route_a_beat', prose: 'The high road.' }, + { id: 'route_b_beat', prose: 'The low road.' }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +function gameContentWithGatedChoice() { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'scout_path', + name: 'Scout', + durationMs: 1000, + costs: [], + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'gated', + prose: 'A gated choice.', + choices: [ + { + id: 'needs_coin', + label: 'Pay the toll', + requirements: { minResources: { coin: 5 } }, + outcomes: [], + targetNodeId: 'boot_intro', + }, + ], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +describe('enterStoryNode()', () => { + it('sets current node, marks seen, applies enterOutcomes', () => { + const content = gameContent(); + const state = createGameState(content); + const events = enterStoryNode(state, content, 'boot_intro'); + expect(state.currentStoryNodeId).toBe('boot_intro'); + expect(state.seenStoryNodeIds).toContain('boot_intro'); + expect(state.resources.coin).toBe(1); + expect(events.length).toBeGreaterThan(0); + }); +}); + +describe('initStory()', () => { + it('leaves currentStoryNodeId empty until triggers run', () => { + const content = gameContent(); + const state = createGameState(content); + initStory(state, content); + expect(state.currentStoryNodeId).toBe(''); + }); +}); + +describe('evaluateTriggers()', () => { + it('fires boot trigger on boot reason', () => { + const content = gameContent(); + const state = createGameState(content); + initStory(state, content); + const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'boot' }); + expect(enteredNodeIds).toEqual(['boot_intro']); + expect(state.currentStoryNodeId).toBe('boot_intro'); + }); + + it('fires actionComplete when scout_path finishes', () => { + const content = gameContentWithActionTrigger(); + const state = createGameState(content); + const { enteredNodeIds } = evaluateTriggers(state, content, { + reason: 'actionComplete', + actionId: 'scout_path', + }); + expect(enteredNodeIds).toEqual(['scout_aftermath']); + }); + + it('fires minResources on publish when thresholds met', () => { + const content = gameContentWithThreshold(); + const state = createGameState(content); + state.resources.coin = 3; + const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'publish' }); + expect(enteredNodeIds).toEqual(['merchant_flavor']); + }); + + it('does not re-fire once-only triggers for seen targets', () => { + const content = gameContent(); + const state = createGameState(content); + evaluateTriggers(state, content, { reason: 'boot' }); + const second = evaluateTriggers(state, content, { reason: 'boot' }); + expect(second.enteredNodeIds).toEqual([]); + }); +}); + +describe('applyChoice()', () => { + it('applies outcomes and advances on fork', () => { + const content = gameContentWithFork(); + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + applyChoice(state, content, 'pick_a'); + expect(state.storyFlags.route_a).toBe(true); + expect(state.currentStoryNodeId).toBe('route_a_beat'); + }); + + it('throws when requirements not met', () => { + const content = gameContentWithGatedChoice(); + const state = createGameState(content); + enterStoryNode(state, content, 'gated'); + expect(() => applyChoice(state, content, 'needs_coin')).toThrow(/requirements/i); + }); +}); + +describe('getAvailableChoices()', () => { + it('hides choices blocked by excludeStoryFlags', () => { + const content = gameContentWithFork(); + const state = createGameState(content); + state.storyFlags.route_a = true; + enterStoryNode(state, content, 'fork_choice'); + const choices = getAvailableChoices(state, content); + expect(choices.map((c) => c.id)).not.toContain('pick_b_if_excluded'); + }); + + it('shows choices only when requireStoryFlags are set', () => { + const base = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 10 }], + actions: [], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'flag_gate', + prose: 'Need a key.', + choices: [ + { + id: 'unlocked', + label: 'Open', + requirements: { requireStoryFlags: ['has_key'] }, + outcomes: [], + targetNodeId: 'flag_gate', + }, + ], + }, + ], + base.actionsById, + base.resourcesById, + ); + const content = { ...base, ...story }; + const state = createGameState(content); + enterStoryNode(state, content, 'flag_gate'); + expect(getAvailableChoices(state, content)).toEqual([]); + state.storyFlags.has_key = true; + expect(getAvailableChoices(state, content).map((c) => c.id)).toEqual(['unlocked']); + }); +}); + +describe('applyOutcomes()', () => { + it('applies clearFlag, consumeResource, and log outcomes', () => { + const content = gameContent(); + const state = createGameState(content); + state.resources.coin = 5; + state.currentStoryNodeId = 'boot_intro'; + const events: StoryEvent[] = []; + + applyOutcomes( + state, + content, + [ + { type: 'clearFlag', flag: 'visited' }, + { type: 'consumeResource', resourceId: 'coin', amount: 2 }, + { type: 'log', text: 'A note in the margin.' }, + ], + events, + ); + + expect(state.storyFlags.visited).toBe(false); + expect(state.resources.coin).toBe(3); + expect(events).toEqual([{ kind: 'log', nodeId: 'boot_intro', prose: 'A note in the margin.' }]); + }); + + it('throws when consumeResource exceeds balance', () => { + const content = gameContent(); + const state = createGameState(content); + state.resources.coin = 1; + + expect(() => + applyOutcomes( + state, + content, + [{ type: 'consumeResource', resourceId: 'coin', amount: 2 }], + [], + ), + ).toThrow(/Cannot consume 2 coin/); + }); +}); + +describe('getCurrentNode()', () => { + it('returns null for unknown currentStoryNodeId', () => { + const content = gameContent(); + const state = createGameState(content); + state.currentStoryNodeId = 'missing_node'; + expect(getCurrentNode(state, content)).toBeNull(); + expect(getAvailableChoices(state, content)).toEqual([]); + }); +}); diff --git a/src/engine/game.ts b/src/engine/game.ts index de6a3e9..b83b52e 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -16,6 +16,12 @@ export interface GameState { actionElapsedMs: number; /** Action ids waiting to run after the active action finishes. */ actionQueue: string[]; + /** Story progression flags set by the narrative graph. */ + storyFlags: Record; + /** Id of the story node currently displayed, or empty when none. */ + currentStoryNodeId: string; + /** Story node ids the player has already seen. */ + seenStoryNodeIds: string[]; } export function createGameState(content: Content): GameState { @@ -23,7 +29,15 @@ export function createGameState(content: Content): GameState { for (const resource of content.resources) { resources[resource.id] = resource.startAmount; } - return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [] }; + return { + resources, + activeActionId: null, + actionElapsedMs: 0, + actionQueue: [], + storyFlags: {}, + currentStoryNodeId: '', + seenStoryNodeIds: [], + }; } function assertKnownAction(content: Content, actionId: string): void { @@ -38,13 +52,7 @@ export function canAffordAction(state: GameState, content: Content, actionId: st return action.costs.every((cost) => (state.resources[cost.resourceId] ?? 0) >= cost.amount); } -/** Story flags land in PR2; placeholder field keeps unlock schema honest. */ -export function canUnlockAction( - state: GameState, - content: Content, - actionId: string, - storyFlags: Record = {}, -): boolean { +export function canUnlockAction(state: GameState, content: Content, actionId: string): boolean { const action = content.actionsById[actionId]; if (!action) return false; const unlock = action.unlock; @@ -56,22 +64,14 @@ export function canUnlockAction( } if (unlock.requireStoryFlags) { for (const flag of unlock.requireStoryFlags) { - if (!storyFlags[flag]) return false; + if (!state.storyFlags[flag]) return false; } } return true; } -export function isActionAvailable( - state: GameState, - content: Content, - actionId: string, - storyFlags: Record = {}, -): boolean { - return ( - canAffordAction(state, content, actionId) && - canUnlockAction(state, content, actionId, storyFlags) - ); +export function isActionAvailable(state: GameState, content: Content, actionId: string): boolean { + return canAffordAction(state, content, actionId) && canUnlockAction(state, content, actionId); } function deductCosts(state: GameState, content: Content, actionId: string): void { @@ -112,11 +112,8 @@ function startNextFromQueue(state: GameState, content: Content): void { state.actionElapsedMs = 0; } -function completeActiveAction(state: GameState, content: Content): void { - const actionId = state.activeActionId; - if (!actionId) return; - grantYields(state, content, actionId); - startNextFromQueue(state, content); +export interface TickResult { + completedActionIds: string[]; } /** @@ -163,16 +160,21 @@ export function clearQueue(state: GameState): void { * Advance the active action by `tickMs`. On completion, grants yields and * advances the queue — actions do not auto-repeat when the queue is empty. */ -export function tickGame(state: GameState, content: Content, tickMs: number): void { - if (!state.activeActionId) return; +export function tickGame(state: GameState, content: Content, tickMs: number): TickResult { + const completedActionIds: string[] = []; + if (!state.activeActionId) return { completedActionIds }; state.actionElapsedMs += tickMs; while (state.activeActionId) { - const action = content.actionsById[state.activeActionId]; - if (!action) return; - if (state.actionElapsedMs < action.durationMs) return; + const actionId = state.activeActionId; + const action = content.actionsById[actionId]; + if (!action) return { completedActionIds }; + if (state.actionElapsedMs < action.durationMs) return { completedActionIds }; state.actionElapsedMs -= action.durationMs; - completeActiveAction(state, content); + grantYields(state, content, actionId); + completedActionIds.push(actionId); + startNextFromQueue(state, content); } + return { completedActionIds }; } diff --git a/src/engine/save.ts b/src/engine/save.ts index 7aa6059..f4c4cf5 100644 --- a/src/engine/save.ts +++ b/src/engine/save.ts @@ -29,6 +29,9 @@ export const gameStateSchema = z.object({ activeActionId: z.string().nullable(), actionElapsedMs: z.number().nonnegative(), actionQueue: z.array(z.string()).default([]), + storyFlags: z.record(z.string(), z.boolean()).default({}), + currentStoryNodeId: z.string().default(''), + seenStoryNodeIds: z.array(z.string()).default([]), }); export const saveSchema = z.object({ @@ -49,6 +52,9 @@ export function createSave(state: GameState, now: number): SaveData { activeActionId: state.activeActionId, actionElapsedMs: state.actionElapsedMs, actionQueue: [...state.actionQueue], + storyFlags: { ...state.storyFlags }, + currentStoryNodeId: state.currentStoryNodeId, + seenStoryNodeIds: [...state.seenStoryNodeIds], }, }; } diff --git a/src/engine/story.ts b/src/engine/story.ts new file mode 100644 index 0000000..2d6714c --- /dev/null +++ b/src/engine/story.ts @@ -0,0 +1,186 @@ +import type { Content } from '../content/schema'; +import type { StoryChoice, StoryContent, StoryOutcome } from '../content/storySchema'; +import type { GameState } from './game'; + +type GameContent = Content & StoryContent; + +export interface StoryEvent { + kind: 'enter' | 'log'; + nodeId: string; + prose: string; + choiceLabel?: string; +} + +export function applyOutcomes( + state: GameState, + _content: GameContent, + outcomes: StoryOutcome[], + events: StoryEvent[], +): void { + for (const outcome of outcomes) { + switch (outcome.type) { + case 'setFlag': + state.storyFlags[outcome.flag] = true; + break; + case 'clearFlag': + state.storyFlags[outcome.flag] = false; + break; + case 'grantResource': + state.resources[outcome.resourceId] = + (state.resources[outcome.resourceId] ?? 0) + outcome.amount; + break; + case 'consumeResource': { + const current = state.resources[outcome.resourceId] ?? 0; + if (current < outcome.amount) { + throw new Error( + `Cannot consume ${outcome.amount} ${outcome.resourceId} (have ${current})`, + ); + } + state.resources[outcome.resourceId] = current - outcome.amount; + break; + } + case 'log': + events.push({ kind: 'log', nodeId: state.currentStoryNodeId, prose: outcome.text }); + break; + } + } +} + +export function enterStoryNode( + state: GameState, + content: GameContent, + nodeId: string, +): StoryEvent[] { + const node = content.storyNodesById[nodeId]; + if (!node) throw new Error(`Unknown story node "${nodeId}"`); + const events: StoryEvent[] = []; + if (!state.seenStoryNodeIds.includes(nodeId)) { + state.seenStoryNodeIds.push(nodeId); + } + state.currentStoryNodeId = nodeId; + applyOutcomes(state, content, node.enterOutcomes ?? [], events); + events.unshift({ kind: 'enter', nodeId, prose: node.prose }); + return events; +} + +export function initStory(state: GameState, _content: GameContent): void { + state.storyFlags = state.storyFlags ?? {}; + state.seenStoryNodeIds = state.seenStoryNodeIds ?? []; + if (!state.currentStoryNodeId) { + state.currentStoryNodeId = ''; + } +} + +export type TriggerContext = { + reason: 'boot' | 'publish' | 'actionComplete'; + actionId?: string; +}; + +export interface TriggerResult { + enteredNodeIds: string[]; + events: StoryEvent[]; +} + +function meetsMinResources(state: GameState, minResources: Record): boolean { + return Object.entries(minResources).every(([id, min]) => (state.resources[id] ?? 0) >= min); +} + +function shouldSkipTrigger( + state: GameState, + trigger: { targetNodeId: string; once: boolean }, +): boolean { + return trigger.once !== false && state.seenStoryNodeIds.includes(trigger.targetNodeId); +} + +export function evaluateTriggers( + state: GameState, + content: GameContent, + ctx: TriggerContext, +): TriggerResult { + const enteredNodeIds: string[] = []; + const events: StoryEvent[] = []; + + for (const node of content.storyNodes) { + for (const trigger of node.triggers ?? []) { + if (shouldSkipTrigger(state, trigger)) continue; + + let matches = false; + if (ctx.reason === 'boot' && trigger.type === 'boot') matches = true; + if ( + ctx.reason === 'actionComplete' && + trigger.type === 'actionComplete' && + trigger.actionId === ctx.actionId + ) { + matches = true; + } + if ( + (ctx.reason === 'publish' || ctx.reason === 'actionComplete') && + trigger.type === 'minResources' && + trigger.minResources && + meetsMinResources(state, trigger.minResources) + ) { + matches = true; + } + + if (matches) { + enteredNodeIds.push(trigger.targetNodeId); + events.push(...enterStoryNode(state, content, trigger.targetNodeId)); + } + } + } + + return { enteredNodeIds, events }; +} + +function meetsChoiceRequirements( + state: GameState, + requirements: StoryChoice['requirements'], +): boolean { + if (!requirements) return true; + if (requirements.minResources) { + for (const [id, min] of Object.entries(requirements.minResources)) { + if ((state.resources[id] ?? 0) < min) return false; + } + } + if (requirements.requireStoryFlags) { + for (const flag of requirements.requireStoryFlags) { + if (!state.storyFlags[flag]) return false; + } + } + if (requirements.excludeStoryFlags) { + for (const flag of requirements.excludeStoryFlags) { + if (state.storyFlags[flag]) return false; + } + } + return true; +} + +export function getCurrentNode(state: GameState, content: GameContent) { + return content.storyNodesById[state.currentStoryNodeId] ?? null; +} + +export function getAvailableChoices(state: GameState, content: GameContent): StoryChoice[] { + const node = getCurrentNode(state, content); + if (!node?.choices) return []; + return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements)); +} + +export function applyChoice( + state: GameState, + content: GameContent, + choiceId: string, +): StoryEvent[] { + const node = getCurrentNode(state, content); + if (!node?.choices) throw new Error(`Node "${node?.id}" has no choices`); + const choice = node.choices.find((c) => c.id === choiceId); + if (!choice) throw new Error(`Unknown choice "${choiceId}"`); + if (!meetsChoiceRequirements(state, choice.requirements)) { + throw new Error(`Choice "${choiceId}" requirements not met`); + } + const events: StoryEvent[] = []; + applyOutcomes(state, content, choice.outcomes, events); + events.push(...enterStoryNode(state, content, choice.targetNodeId)); + const last = events.find((e) => e.kind === 'enter'); + if (last) last.choiceLabel = choice.label; + return events; +} diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index 1ed517d..22f4629 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -70,6 +70,19 @@ describe('loadGame()', () => { expect(result.state.actionQueue).toEqual(['b']); }); + it('restores story fields on load', async () => { + const content = testContent(); + const backend = createMemoryBackend(); + const state = createGameState(content); + state.storyFlags = { route_b: true }; + state.currentStoryNodeId = 'fork_choice'; + state.seenStoryNodeIds = ['boot_intro']; + await saveGame(state, backend, 1000); + const loaded = await loadGame(content, backend, 1000); + expect(loaded.state.storyFlags.route_b).toBe(true); + expect(loaded.state.currentStoryNodeId).toBe('fork_choice'); + }); + it('drops an active action that no longer exists in content', async () => { const content = testContent(); const stale = serializeSave( @@ -79,6 +92,9 @@ describe('loadGame()', () => { activeActionId: 'ghost-action', actionElapsedMs: 0, actionQueue: [], + storyFlags: {}, + currentStoryNodeId: '', + seenStoryNodeIds: [], }, 1000, ), diff --git a/src/state/__tests__/prefs.test.ts b/src/state/__tests__/prefs.test.ts new file mode 100644 index 0000000..56d2a17 --- /dev/null +++ b/src/state/__tests__/prefs.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getPrefs, setPrefs } from '../prefs'; + +describe('prefs', () => { + beforeEach(() => { + const store: Record = {}; + vi.stubGlobal('localStorage', { + getItem(key: string) { + return store[key] ?? null; + }, + setItem(key: string, value: string) { + store[key] = value; + }, + }); + }); + + it('returns defaults when localStorage empty', () => { + expect(getPrefs()).toEqual({ storyOpenMode: 'auto', actionDetailMode: 'inline' }); + }); + + it('round-trips updated prefs', () => { + setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' }); + expect(getPrefs().storyOpenMode).toBe('manual'); + }); +}); diff --git a/src/state/__tests__/storyOrchestration.test.ts b/src/state/__tests__/storyOrchestration.test.ts new file mode 100644 index 0000000..4245fad --- /dev/null +++ b/src/state/__tests__/storyOrchestration.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { content } from '../../content'; +import { createGameState } from '../../engine/game'; +import { initStory } from '../../engine/story'; +import { getPrefs } from '../prefs'; +import { processStoryTriggers } from '../storyOrchestration'; + +describe('processStoryTriggers()', () => { + it('returns entered nodes on boot', () => { + const state = createGameState(content); + initStory(state, content); + const result = processStoryTriggers(state, content, { reason: 'boot' }, getPrefs()); + expect(result.enteredNodeIds.length).toBeGreaterThan(0); + }); +}); diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 8555c53..ff8816b 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; +import { buildStoryContent } from '../../content/storySchema'; import { createGameState, enqueueAction, startAction } from '../../engine/game'; +import { enterStoryNode } from '../../engine/story'; import { formatOfflineDuration, toView } from '../viewModel'; function testContent() { - return buildContent({ + const base = buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], actions: [ { @@ -15,6 +17,37 @@ function testContent() { }, ], }); + const story = buildStoryContent( + [ + { + id: 'boot', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot' }], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +function contentWithActions( + resources: Parameters[0]['resources'], + actions: Parameters[0]['actions'], +) { + const base = buildContent({ resources, actions }); + const story = buildStoryContent( + [ + { + id: 'boot', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot' }], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; } describe('toView()', () => { @@ -51,13 +84,13 @@ describe('toView()', () => { }); it('includes queued action ids in order', () => { - const content = buildContent({ - resources: [{ id: 'gold', name: 'Gold' }], - actions: [ + const content = contentWithActions( + [{ id: 'gold', name: 'Gold' }], + [ { id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, { id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], - }); + ); const state = createGameState(content); enqueueAction(state, content, 'a'); enqueueAction(state, content, 'b'); @@ -67,6 +100,78 @@ describe('toView()', () => { }); }); +describe('toView() action availability', () => { + it('marks locked actions unavailable with reason', () => { + const content = contentWithActions( + [{ id: 'coin', name: 'Coin', startAmount: 0 }], + [ + { + id: 'locked', + name: 'Locked', + durationMs: 1000, + yields: [{ resourceId: 'coin', amount: 1 }], + unlock: { requireStoryFlags: ['route_a'] }, + }, + ], + ); + const state = createGameState(content); + const view = toView(state, content); + expect(view.actions[0].available).toBe(false); + expect(view.actions[0].disabledReason).toMatch(/locked/i); + }); + + it('includes story passage and choices from current node', () => { + const base = buildContent({ + resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }], + actions: [ + { + id: 'forage', + name: 'Forage', + durationMs: 1000, + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: 'Which way?', + choices: [ + { + id: 'pick_a', + label: 'High road', + outcomes: [{ type: 'setFlag', flag: 'route_a' }], + targetNodeId: 'route_a_beat', + }, + { + id: 'pick_b', + label: 'Low road', + outcomes: [{ type: 'setFlag', flag: 'route_b' }], + targetNodeId: 'route_b_beat', + }, + ], + }, + { id: 'route_a_beat', prose: 'The high road.' }, + { id: 'route_b_beat', prose: 'The low road.' }, + ], + base.actionsById, + base.resourcesById, + ); + const content = { ...base, ...story }; + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + const view = toView(state, content); + expect(view.story.currentProse).toContain('Which way'); + expect(view.story.choices.length).toBe(2); + }); +}); + describe('formatOfflineDuration()', () => { it('formats sub-minute durations in seconds', () => { expect(formatOfflineDuration(0)).toBe('0s'); diff --git a/src/state/persistence.ts b/src/state/persistence.ts index e4284d6..8455258 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -103,6 +103,9 @@ export async function loadGame( activeActionId, actionElapsedMs: save.state.actionElapsedMs, actionQueue: [...(save.state.actionQueue ?? [])], + storyFlags: { ...save.state.storyFlags }, + currentStoryNodeId: save.state.currentStoryNodeId ?? '', + seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], }; savedAt = save.savedAt; } catch { diff --git a/src/state/prefs.ts b/src/state/prefs.ts new file mode 100644 index 0000000..1055b71 --- /dev/null +++ b/src/state/prefs.ts @@ -0,0 +1,33 @@ +const PREFS_KEY = 'idlegame:prefs:v1'; + +export type StoryOpenMode = 'auto' | 'choices-only' | 'manual'; +export type ActionDetailMode = 'inline' | 'hover' | 'info-button'; + +export interface GamePrefs { + storyOpenMode: StoryOpenMode; + actionDetailMode: ActionDetailMode; +} + +const DEFAULTS: GamePrefs = { + storyOpenMode: 'auto', + actionDetailMode: 'inline', +}; + +export function getPrefs(): GamePrefs { + if (typeof localStorage === 'undefined') return { ...DEFAULTS }; + try { + const raw = localStorage.getItem(PREFS_KEY); + if (!raw) return { ...DEFAULTS }; + return { ...DEFAULTS, ...JSON.parse(raw) }; + } catch { + return { ...DEFAULTS }; + } +} + +export function setPrefs(partial: Partial): GamePrefs { + const next = { ...getPrefs(), ...partial }; + if (typeof localStorage !== 'undefined') { + localStorage.setItem(PREFS_KEY, JSON.stringify(next)); + } + return next; +} diff --git a/src/state/runtime.ts b/src/state/runtime.ts index be5d570..6ccd13a 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -1,8 +1,22 @@ import { content } from '../content'; -import { enqueueAction as engineEnqueueAction, type GameState, tickGame } from '../engine/game'; +import { + cancelQueuedAction as engineCancelQueuedAction, + enqueueAction as engineEnqueueAction, + type GameState, + isActionAvailable, + tickGame, +} from '../engine/game'; +import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story'; import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop'; import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence'; +import { getPrefs } from './prefs'; import { useGameStore } from './store'; +import { + processStoryTriggers, + type StoryUiEffect, + shouldAutoOpenPanel, + storyEventsToLogEntries, +} from './storyOrchestration'; import { formatOfflineDuration, toView } from './viewModel'; /** @@ -45,6 +59,11 @@ class GameRuntime { : 'A new tale begins. Choose an action.', ); + initStory(this.state, content); + const prefs = getPrefs(); + store.setPrefs(prefs); + const bootEffect = processStoryTriggers(this.state, content, { reason: 'boot' }, prefs); + this.applyStoryUiEffect(bootEffect); this.publish(); this.installLifecycleHooks(); this.rafId = requestAnimationFrame(this.frame); @@ -63,6 +82,11 @@ class GameRuntime { if (!state) { return; } + if (!isActionAvailable(state, content, actionId)) { + const actionView = toView(state, content).actions.find((a) => a.id === actionId); + useGameStore.getState().appendLog(actionView?.disabledReason ?? 'Cannot enqueue'); + return; + } try { engineEnqueueAction(state, content, actionId); const action = content.actionsById[actionId]; @@ -77,12 +101,110 @@ class GameRuntime { this.publish(); } + applyStoryChoice(choiceId: string): void { + const state = this.state; + if (!state) return; + try { + const events = engineApplyChoice(state, content, choiceId); + const entries = storyEventsToLogEntries(events); + const store = useGameStore.getState(); + for (const entry of entries) store.appendStoryLog(entry); + const choiceLabel = entries.at(-1)?.choiceLabel; + if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); + store.setStoryPanelOpen(false); + this.runPublishTriggers(); + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed'); + } + } + + cancelQueuedAction(index: number): void { + const state = this.state; + if (!state) return; + try { + engineCancelQueuedAction(state, index); + useGameStore.getState().appendLog('Removed queued action.'); + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Cancel failed'); + } + } + + openStoryPanel(): void { + useGameStore.getState().setStoryPanelOpen(true); + useGameStore.getState().setStoryHasUnread(false); + } + + closeStoryPanel(): void { + useGameStore.getState().setStoryPanelOpen(false); + } + + continueStory(): void { + const state = this.state; + if (!state) return; + if (state.currentStoryNodeId === 'boot_intro') { + const events = enterStoryNode(state, content, 'fork_choice'); + const entries = storyEventsToLogEntries(events); + const store = useGameStore.getState(); + for (const entry of entries) store.appendStoryLog(entry); + for (const entry of entries) { + store.appendLog(`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}…`); + } + const prefs = store.prefs; + if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) { + store.setStoryPanelOpen(true); + store.setStoryHasUnread(false); + } else { + store.setStoryHasUnread(true); + store.setStoryPanelOpen(false); + } + this.publish(); + return; + } + this.closeStoryPanel(); + this.publish(); + } + + private applyStoryUiEffect(effect: StoryUiEffect): void { + const store = useGameStore.getState(); + for (const entry of effect.logEntries) store.appendStoryLog(entry); + for (const line of effect.eventLogLines) store.appendLog(line); + if (effect.shouldOpenPanel) { + store.setStoryPanelOpen(true); + store.setStoryHasUnread(false); + } else if (effect.enteredNodeIds.length > 0) { + store.setStoryHasUnread(true); + } + } + + private runPublishTriggers(): void { + const state = this.state; + if (!state) return; + const prefs = useGameStore.getState().prefs; + const effect = processStoryTriggers(state, content, { reason: 'publish' }, prefs); + this.applyStoryUiEffect(effect); + } + private readonly frame = (monoNow: number): void => { const state = this.state; if (state) { - advance(this.loop, monoNow, () => tickGame(state, content, TICK_MS)); + advance(this.loop, monoNow, () => { + const result = tickGame(state, content, TICK_MS); + for (const actionId of result.completedActionIds) { + const prefs = useGameStore.getState().prefs; + const effect = processStoryTriggers( + state, + content, + { reason: 'actionComplete', actionId }, + prefs, + ); + this.applyStoryUiEffect(effect); + } + }); if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { + this.runPublishTriggers(); this.publish(); this.lastPublishAt = monoNow; } diff --git a/src/state/store.ts b/src/state/store.ts index cf55e94..b498971 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { type GamePrefs, getPrefs, setPrefs as persistPrefs } from './prefs'; import type { GameView } from './viewModel'; /** @@ -9,10 +10,26 @@ import type { GameView } from './viewModel'; const MAX_LOG_LINES = 50; +export interface StoryLogEntry { + nodeId: string; + prose: string; + choiceLabel?: string; +} + export interface GameStoreState extends GameView { log: string[]; + storyPanelOpen: boolean; + storyHasUnread: boolean; + storyLog: StoryLogEntry[]; + prefs: GamePrefs; + settingsOpen: boolean; setView: (view: GameView) => void; appendLog: (line: string) => void; + appendStoryLog: (entry: StoryLogEntry) => void; + setStoryPanelOpen: (open: boolean) => void; + setStoryHasUnread: (unread: boolean) => void; + setPrefs: (partial: Partial) => void; + setSettingsOpen: (open: boolean) => void; } export const useGameStore = create((set) => ({ @@ -22,7 +39,22 @@ export const useGameStore = create((set) => ({ actionProgress: 0, queuedActionIds: [], queuedActionNames: [], + actions: [], + story: { currentProse: null, choices: [] }, log: [], - setView: (view) => set(view), + storyPanelOpen: false, + storyHasUnread: false, + storyLog: [], + prefs: getPrefs(), + settingsOpen: false, + setView: (view) => set((state) => ({ ...state, ...view })), appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })), + appendStoryLog: (entry) => set((state) => ({ storyLog: [...state.storyLog, entry] })), + setStoryPanelOpen: (open) => set({ storyPanelOpen: open }), + setStoryHasUnread: (unread) => set({ storyHasUnread: unread }), + setPrefs: (partial) => { + const prefs = persistPrefs(partial); + set({ prefs }); + }, + setSettingsOpen: (open) => set({ settingsOpen: open }), })); diff --git a/src/state/storyOrchestration.ts b/src/state/storyOrchestration.ts new file mode 100644 index 0000000..6f253e3 --- /dev/null +++ b/src/state/storyOrchestration.ts @@ -0,0 +1,49 @@ +import type { GameContent } from '../content/index'; +import type { GameState } from '../engine/game'; +import { evaluateTriggers, type StoryEvent, type TriggerContext } from '../engine/story'; +import type { GamePrefs } from './prefs'; +import type { StoryLogEntry } from './store'; + +export interface StoryUiEffect { + enteredNodeIds: string[]; + logEntries: StoryLogEntry[]; + shouldOpenPanel: boolean; + eventLogLines: string[]; +} + +export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] { + return events + .filter((e) => e.kind === 'enter') + .map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel })); +} + +export function shouldAutoOpenPanel( + prefs: GamePrefs, + nodeId: string, + content: GameContent, +): boolean { + const node = content.storyNodesById[nodeId]; + if (!node) return false; + if (prefs.storyOpenMode === 'manual') return false; + if (prefs.storyOpenMode === 'auto') return true; + return (node.choices?.length ?? 0) > 0; +} + +export function processStoryTriggers( + state: GameState, + content: GameContent, + ctx: TriggerContext, + prefs: GamePrefs, +): StoryUiEffect { + const { enteredNodeIds, events } = evaluateTriggers(state, content, ctx); + const logEntries = storyEventsToLogEntries(events); + const shouldOpenPanel = + enteredNodeIds.length > 0 && + enteredNodeIds.some((id) => shouldAutoOpenPanel(prefs, id, content)); + const eventLogLines = logEntries.map((e) => + e.choiceLabel + ? `Story: ${e.choiceLabel}` + : `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`, + ); + return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines }; +} diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index 8e40b00..64b9957 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -1,5 +1,11 @@ -import type { Content } from '../content/schema'; -import type { GameState } from '../engine/game'; +import type { GameContent } from '../content/index'; +import { + canAffordAction, + canUnlockAction, + type GameState, + isActionAvailable, +} from '../engine/game'; +import { getAvailableChoices, getCurrentNode } from '../engine/story'; /** * Pure mapping from engine state to the view model the React shell renders. @@ -12,6 +18,29 @@ export interface ResourceView { amount: number; } +export interface ActionView { + id: string; + name: string; + available: boolean; + disabledReason: string | null; + storyHint?: string; + storyTooltip?: string; + costsSummary: string | null; + yieldsSummary: string | null; +} + +export interface StoryChoiceView { + id: string; + label: string; + disabled: boolean; + disabledReason: string | null; +} + +export interface StoryView { + currentProse: string | null; + choices: StoryChoiceView[]; +} + export interface GameView { resources: ResourceView[]; activeActionId: string | null; @@ -20,9 +49,30 @@ export interface GameView { actionProgress: number; queuedActionIds: string[]; queuedActionNames: string[]; + actions: ActionView[]; + story: StoryView; } -export function toView(state: GameState, content: Content): GameView { +function actionDisabledReason( + state: GameState, + content: GameContent, + actionId: string, +): string | null { + if (!canUnlockAction(state, content, actionId)) return 'Locked'; + if (!canAffordAction(state, content, actionId)) return 'Not enough resources'; + return null; +} + +function formatResourceList( + items: { resourceId: string; amount: number }[], + content: GameContent, +): string { + return items + .map((i) => `${i.amount} ${content.resourcesById[i.resourceId]?.name ?? i.resourceId}`) + .join(', '); +} + +export function toView(state: GameState, content: GameContent): GameView { const resources: ResourceView[] = content.resources.map((resource) => ({ id: resource.id, name: resource.name, @@ -34,6 +84,34 @@ export function toView(state: GameState, content: Content): GameView { const queuedActionIds = [...state.actionQueue]; const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); + const actions: ActionView[] = content.actions.map((a) => ({ + id: a.id, + name: a.name, + available: isActionAvailable(state, content, a.id), + disabledReason: actionDisabledReason(state, content, a.id), + storyHint: a.storyHint, + storyTooltip: a.storyTooltip, + costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null, + yieldsSummary: formatResourceList(a.yields, content), + })); + + const node = getCurrentNode(state, content); + const availableChoices = getAvailableChoices(state, content); + const allChoices = node?.choices ?? []; + + const story: StoryView = { + currentProse: node?.prose ?? null, + choices: allChoices.map((choice) => { + const available = availableChoices.some((c) => c.id === choice.id); + return { + id: choice.id, + label: choice.label, + disabled: !available, + disabledReason: available ? null : 'Requirements not met', + }; + }), + }; + return { resources, activeActionId: state.activeActionId, @@ -41,6 +119,8 @@ export function toView(state: GameState, content: Content): GameView { actionProgress, queuedActionIds, queuedActionNames, + actions, + story, }; } diff --git a/src/ui/ActionPanel.tsx b/src/ui/ActionPanel.tsx index e306d7f..5dd2f37 100644 --- a/src/ui/ActionPanel.tsx +++ b/src/ui/ActionPanel.tsx @@ -1,38 +1,112 @@ -import { content } from '../content'; +import { useState } from 'react'; import { gameRuntime } from '../state/runtime'; import { useGameStore } from '../state/store'; -/** Action list — a start button per action, with a progress bar on the active one. */ +/** Action list with queue, cancel, disabled states, and story hints/tooltips. */ export function ActionPanel() { - const activeActionId = useGameStore((state) => state.activeActionId); - const actionProgress = useGameStore((state) => state.actionProgress); + const actions = useGameStore((s) => s.actions); + const activeActionId = useGameStore((s) => s.activeActionId); + const actionProgress = useGameStore((s) => s.actionProgress); + const queuedActionIds = useGameStore((s) => s.queuedActionIds); + const queuedActionNames = useGameStore((s) => s.queuedActionNames); + const prefs = useGameStore((s) => s.prefs); + const [openInfoId, setOpenInfoId] = useState(null); return (

Actions

- {content.actions.map((action) => { + {actions.map((action) => { const isActive = action.id === activeActionId; + const isDisabled = !action.available && !isActive; + const summaryParts = [ + action.costsSummary ? `Cost: ${action.costsSummary}` : null, + action.yieldsSummary ? `Yield: ${action.yieldsSummary}` : null, + ].filter(Boolean); + return ( - + {prefs.actionDetailMode === 'info-button' && action.storyTooltip ? ( +
+ + {openInfoId === action.id ? ( +
+ {action.storyTooltip} +
+ ) : null} +
) : null} -
- {action.name} - {isActive ? 'running…' : 'start'} -
- + ); })} + {queuedActionNames.length > 0 ? ( +
    + {queuedActionNames.map((name, index) => ( +
  1. + {name} + +
  2. + ))} +
+ ) : null}
); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3d42523..bdc9ee0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,27 +1,65 @@ import { useEffect } from 'react'; import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; import { ActionPanel } from './ActionPanel'; import { EventLog } from './EventLog'; import { ResourceBar } from './ResourceBar'; +import { SettingsDrawer } from './SettingsDrawer'; +import { StoryPanel } from './StoryPanel'; /** - * Walking-skeleton view shell. Boots the runtime once on mount; everything else - * renders from the Zustand store the runtime feeds. M1 turns this into a game. + * M1 playable-loop shell. Boots the runtime once on mount; everything else + * renders from the Zustand store the runtime feeds. */ export function App() { + const storyHasUnread = useGameStore((s) => s.storyHasUnread); + const settingsOpen = useGameStore((s) => s.settingsOpen); + const setSettingsOpen = useGameStore((s) => s.setSettingsOpen); + useEffect(() => { void gameRuntime.boot(); }, []); return ( -
-
-

Idlegame

-

Walking skeleton — M0 scaffold.

-
- - - -
+ <> + +
+
+
+

Idlegame

+
+ + +
+
+

M1 playable loop

+ +
+ + + +
+ ); } diff --git a/src/ui/SettingsDrawer.tsx b/src/ui/SettingsDrawer.tsx new file mode 100644 index 0000000..0d467ff --- /dev/null +++ b/src/ui/SettingsDrawer.tsx @@ -0,0 +1,40 @@ +import type { ActionDetailMode, StoryOpenMode } from '../state/prefs'; +import { useGameStore } from '../state/store'; + +/** Preference panel toggled from the header gear; changes persist immediately. */ +export function SettingsDrawer() { + const open = useGameStore((s) => s.settingsOpen); + const prefs = useGameStore((s) => s.prefs); + const setPrefs = useGameStore((s) => s.setPrefs); + + if (!open) return null; + + return ( +
+ + +
+ ); +} diff --git a/src/ui/StoryPanel.tsx b/src/ui/StoryPanel.tsx new file mode 100644 index 0000000..97481f2 --- /dev/null +++ b/src/ui/StoryPanel.tsx @@ -0,0 +1,74 @@ +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; + +/** Full-screen VN overlay with prose, choices, and desktop story log sidebar. */ +export function StoryPanel() { + const open = useGameStore((s) => s.storyPanelOpen); + const story = useGameStore((s) => s.story); + const storyLog = useGameStore((s) => s.storyLog); + + if (!open) return null; + + const hasChoices = story.choices.length > 0; + + return ( +
+
+ +
+

{story.currentProse}

+ {hasChoices ? ( +
+ {story.choices.map((choice) => ( + + ))} +
+ ) : ( + + )} + +
+
+
+ ); +}