Merge pull request 'feat(m1): playable loop — story graph, story panel, queue UI' (#14) from feat/m1-playable-loop into main
This commit was merged in pull request #14.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof resourceDefSchema>;
|
||||
|
||||
@@ -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.' },
|
||||
];
|
||||
@@ -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<typeof storyOutcomeSchema>;
|
||||
export type StoryChoice = z.infer<typeof storyChoiceSchema>;
|
||||
export type StoryTrigger = z.infer<typeof storyTriggerSchema>;
|
||||
export type StoryNode = z.infer<typeof storyNodeSchema>;
|
||||
|
||||
export interface StoryContent {
|
||||
storyNodes: StoryNode[];
|
||||
storyNodesById: Record<string, StoryNode>;
|
||||
bootTargetNodeId: string;
|
||||
}
|
||||
|
||||
function indexStoryNodes(nodes: StoryNode[]): Record<string, StoryNode> {
|
||||
const byId: Record<string, StoryNode> = {};
|
||||
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<string, ActionDef>,
|
||||
resourcesById: Record<string, { id: string }>,
|
||||
): 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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+32
-30
@@ -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<string, boolean>;
|
||||
/** 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<string, boolean> = {},
|
||||
): 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<string, boolean> = {},
|
||||
): 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 };
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, number>): 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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getPrefs, setPrefs } from '../prefs';
|
||||
|
||||
describe('prefs', () => {
|
||||
beforeEach(() => {
|
||||
const store: Record<string, string> = {};
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof buildContent>[0]['resources'],
|
||||
actions: Parameters<typeof buildContent>[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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>): GamePrefs {
|
||||
const next = { ...getPrefs(), ...partial };
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(next));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
+124
-2
@@ -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;
|
||||
}
|
||||
|
||||
+33
-1
@@ -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<GamePrefs>) => void;
|
||||
setSettingsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameStoreState>((set) => ({
|
||||
@@ -22,7 +39,22 @@ export const useGameStore = create<GameStoreState>((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 }),
|
||||
}));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+83
-3
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+83
-9
@@ -1,23 +1,40 @@
|
||||
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<string | null>(null);
|
||||
|
||||
return (
|
||||
<section aria-label="Actions" className="flex flex-col gap-2">
|
||||
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Actions</h2>
|
||||
{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 (
|
||||
<div key={action.id} className="relative flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
key={action.id}
|
||||
disabled={isDisabled}
|
||||
title={prefs.actionDetailMode === 'hover' ? action.storyTooltip : undefined}
|
||||
onClick={() => gameRuntime.enqueueAction(action.id)}
|
||||
className="relative overflow-hidden rounded-lg border border-slate-700 bg-slate-800/70 px-4 py-3 text-left transition-colors hover:border-amber-500/60 hover:bg-slate-800"
|
||||
className={`relative w-full overflow-hidden rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
isDisabled
|
||||
? 'cursor-not-allowed border-slate-800 bg-slate-900/40 opacity-50'
|
||||
: 'border-slate-700 bg-slate-800/70 hover:border-amber-500/60 hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<div
|
||||
@@ -26,13 +43,70 @@ export function ActionPanel() {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-slate-100">{action.name}</span>
|
||||
<span className="text-slate-400 text-xs">{isActive ? 'running…' : 'start'}</span>
|
||||
<span className="text-slate-400 text-xs">
|
||||
{isActive
|
||||
? 'running…'
|
||||
: isDisabled
|
||||
? (action.disabledReason ?? 'unavailable')
|
||||
: 'start'}
|
||||
</span>
|
||||
</div>
|
||||
{summaryParts.length > 0 ? (
|
||||
<p className="text-slate-500 text-xs">{summaryParts.join(' · ')}</p>
|
||||
) : null}
|
||||
{prefs.actionDetailMode === 'inline' && action.storyHint ? (
|
||||
<p className="text-slate-400 text-xs">{action.storyHint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
{prefs.actionDetailMode === 'info-button' && action.storyTooltip ? (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Details for ${action.name}`}
|
||||
aria-expanded={openInfoId === action.id}
|
||||
onClick={() => setOpenInfoId(openInfoId === action.id ? null : action.id)}
|
||||
className="flex h-full items-center rounded-lg border border-slate-700 bg-slate-800/70 px-2 text-slate-400 transition-colors hover:border-amber-500/60 hover:text-slate-200"
|
||||
title={action.storyTooltip}
|
||||
>
|
||||
ⓘ
|
||||
</button>
|
||||
{openInfoId === action.id ? (
|
||||
<div
|
||||
role="tooltip"
|
||||
className="absolute top-full right-0 z-10 mt-1 max-w-xs rounded-lg border border-slate-600 bg-slate-900 p-3 text-slate-300 text-xs shadow-lg"
|
||||
>
|
||||
{action.storyTooltip}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{queuedActionNames.length > 0 ? (
|
||||
<ol aria-label="Action queue" className="mt-2 flex flex-col gap-1">
|
||||
{queuedActionNames.map((name, index) => (
|
||||
<li
|
||||
key={queuedActionIds[index]}
|
||||
className="flex items-center justify-between rounded border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-slate-300">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Cancel ${name}`}
|
||||
onClick={() => gameRuntime.cancelQueuedAction(index)}
|
||||
className="rounded px-2 py-0.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-amber-400"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+42
-4
@@ -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 (
|
||||
<>
|
||||
<StoryPanel />
|
||||
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100">
|
||||
<header>
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="font-bold text-2xl tracking-tight">Idlegame</h1>
|
||||
<p className="text-slate-500 text-sm">Walking skeleton — M0 scaffold.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={storyHasUnread ? 'Story, unread' : 'Story'}
|
||||
onClick={() => gameRuntime.openStoryPanel()}
|
||||
className="relative rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
|
||||
>
|
||||
Story
|
||||
{storyHasUnread ? (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-amber-500"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Settings"
|
||||
aria-expanded={settingsOpen}
|
||||
onClick={() => setSettingsOpen(!settingsOpen)}
|
||||
className="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">M1 playable loop</p>
|
||||
<SettingsDrawer />
|
||||
</header>
|
||||
<ResourceBar />
|
||||
<ActionPanel />
|
||||
<EventLog />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-900 p-4">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
Story opens
|
||||
<select
|
||||
value={prefs.storyOpenMode}
|
||||
onChange={(e) => setPrefs({ storyOpenMode: e.target.value as StoryOpenMode })}
|
||||
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100"
|
||||
>
|
||||
<option value="auto">Automatically</option>
|
||||
<option value="choices-only">Choices only</option>
|
||||
<option value="manual">Manually</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="mt-3 flex flex-col gap-1 text-sm">
|
||||
Action details
|
||||
<select
|
||||
value={prefs.actionDetailMode}
|
||||
onChange={(e) => setPrefs({ actionDetailMode: e.target.value as ActionDetailMode })}
|
||||
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100"
|
||||
>
|
||||
<option value="inline">Inline subtitles</option>
|
||||
<option value="hover">Hover tooltips</option>
|
||||
<option value="info-button">Info button</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex bg-black/80"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Story"
|
||||
>
|
||||
<div className="flex flex-1 flex-col md:flex-row">
|
||||
<aside className="hidden max-h-full w-full overflow-y-auto border-slate-700 border-r p-4 md:block md:w-1/3">
|
||||
<h3 className="mb-2 font-medium text-slate-400 text-xs uppercase tracking-wide">
|
||||
Story log
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2 text-slate-300 text-sm">
|
||||
{storyLog.map((entry) => (
|
||||
<li key={`${entry.nodeId}:${entry.choiceLabel ?? ''}:${entry.prose}`}>
|
||||
{entry.choiceLabel ? (
|
||||
<span className="text-amber-400/80">[{entry.choiceLabel}] </span>
|
||||
) : null}
|
||||
{entry.prose}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
<main className="flex flex-1 flex-col gap-4 p-6">
|
||||
<p className="flex-1 text-lg text-slate-100 leading-relaxed">{story.currentProse}</p>
|
||||
{hasChoices ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{story.choices.map((choice) => (
|
||||
<button
|
||||
key={choice.id}
|
||||
type="button"
|
||||
disabled={choice.disabled}
|
||||
title={choice.disabled ? (choice.disabledReason ?? undefined) : undefined}
|
||||
onClick={() => gameRuntime.applyStoryChoice(choice.id)}
|
||||
className="rounded-lg border border-slate-600 bg-slate-900/60 px-4 py-3 text-left transition-colors hover:border-amber-500/60 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => gameRuntime.continueStory()}
|
||||
className="self-start rounded-lg bg-amber-600 px-4 py-2 transition-colors hover:bg-amber-500"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => gameRuntime.closeStoryPanel()}
|
||||
className="self-end text-slate-400 text-sm transition-colors hover:text-slate-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user