diff --git a/docs/architecture.md b/docs/architecture.md index 960808c..11f0083 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,12 @@ storage, requestAnimationFrame, or Date scheduling. resource and one timed action. M1 expands this into real resources, actions, story nodes, automation unlocks, and prestige definitions. +Each action carries a behavior `kind` (`instant`, `loop`, `timed`, `story`, +`context`) and a `group` (`{ id, label }`) used for UI layout. The schema +enforces kind-specific invariants: `timed`/`loop` require `durationMs`, `timed` +requires at least one yield, and `story` requires a `storyChoiceId` linking the +action to a choice on the current story node. + ## State `src/state/` owns environment coupling: @@ -42,8 +48,39 @@ story nodes, automation unlocks, and prestige definitions. ## UI `src/ui/` renders the view model and calls runtime commands. Components should -not implement gameplay rules. The M0 shell includes a resource readout, action -panel, progress bar, and event log. +not implement gameplay rules. + +### Shell layout + +PR3 replaces the M0/PR2 single-column overlay with a three-region shell +(`AppShell.tsx`): a left **nav rail** (Play / Story / Settings / About), a +**center** panel for the active tab, and a **right rail** shown on Play +(resources, an inventory placeholder, and an optional event log gated by the +`showEventLog` pref). `store.activePanel` selects the center panel; there is no +modal overlay. New story beats raise a nav badge (`storyHasUnread`), and +`storyOpenMode: auto` switches to the Story tab instead of opening an overlay. + +### Action kinds + +Play organizes actions into **columns by behavior kind**, ordered +`instant → loop → timed → story → context`, with collapsible theme **groups** +inside each column (collapse state persists in `prefs.collapsedActionGroups`). +The pure engine dispatches each kind through `performAction` (`game.ts`): +`instant` applies costs/yields immediately, `timed` enqueues, `loop` toggles an +entry in `enabledLoopActionIds` (an idle runner starts the highest-priority +affordable loop only when the queue is empty and only during live ticks, never +offline), and `story` applies the linked choice. Story forks are taken **only** +through Story-kind actions; the Story tab itself is read-only. + +### Story tab + +`StoryView.tsx` is a read-only 60/40 split: a branching `StoryTree` built from +the story graph's choice/trigger edges (seen paths emphasized, unseen dimmed) and +a scrollable `StoryProseLog` with no choice buttons. The boot intro shows a +single Continue affordance that advances `boot_intro → fork_choice`; thereafter +progression is driven by Story-kind actions. + +The full design lives in `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md`. ## Verification diff --git a/src/content/__tests__/definitions.test.ts b/src/content/__tests__/definitions.test.ts index bfa1315..88d838a 100644 --- a/src/content/__tests__/definitions.test.ts +++ b/src/content/__tests__/definitions.test.ts @@ -3,21 +3,28 @@ import { createGameState, enqueueAction, tickGame } from '../../engine/game'; import { content } from '../index'; describe('M1 stub content pack', () => { - it('defines two resources and four to five actions with costs and unlocks', () => { + it('defines two resources and multiple actions with costs, unlocks, and varied kinds', () => { expect(content.resources).toHaveLength(2); expect(content.actions.length).toBeGreaterThanOrEqual(4); - 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); expect(withUnlocks.length).toBeGreaterThanOrEqual(1); + // verify the new kinds are present + const kinds = new Set(content.actions.map((a) => a.kind)); + expect(kinds.has('timed')).toBe(true); + expect(kinds.has('loop')).toBe(true); + expect(kinds.has('story')).toBe(true); }); - it('can simulate a costed action without throwing', () => { + it('can simulate a costed timed action without throwing', () => { const state = createGameState(content); - const trade = content.actions.find((a) => a.costs.length > 0); + const trade = content.actions.find((a) => a.costs.length > 0 && a.kind === 'timed'); if (!trade) { - throw new Error('expected at least one costed action'); + throw new Error('expected at least one costed timed action'); + } + if (trade.durationMs === undefined) { + throw new Error('expected costed timed action to have durationMs'); } enqueueAction(state, content, trade.id); tickGame(state, content, trade.durationMs); diff --git a/src/content/__tests__/schema.test.ts b/src/content/__tests__/schema.test.ts index 960a724..b15c946 100644 --- a/src/content/__tests__/schema.test.ts +++ b/src/content/__tests__/schema.test.ts @@ -6,6 +6,7 @@ const validActions = [ { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], }, @@ -28,6 +29,7 @@ describe('buildContent()', () => { { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'ghost', amount: 1 }], }, @@ -48,6 +50,7 @@ describe('buildContent()', () => { { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: -1, yields: [{ resourceId: 'gold', amount: 1 }], }, @@ -66,6 +69,7 @@ describe('costs and multi-yield', () => { { id: 'craft', name: 'Craft', + group: { id: 'camp', label: 'Camp' }, durationMs: 1000, costs: [{ resourceId: 'wood', amount: 2 }], yields: [ @@ -84,6 +88,7 @@ describe('costs and multi-yield', () => { { id: 'craft', name: 'Craft', + group: { id: 'camp', label: 'Camp' }, durationMs: 1000, costs: [{ resourceId: 'ghost', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }], @@ -104,6 +109,7 @@ describe('unlock conditions', () => { { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], unlock: { @@ -131,6 +137,7 @@ describe('action narrative fields', () => { { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], storyHint: 'Gather what the forest offers.', @@ -148,3 +155,74 @@ describe('action narrative fields', () => { expect(content.actionsById.forage.storyTooltip).toBeUndefined(); }); }); + +describe('action kind schema', () => { + it('accepts kind, group, and storyChoiceId', () => { + const content = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'pick_high_road', + name: 'Take the high road', + kind: 'story', + group: { id: 'fork', label: 'Crossroads' }, + storyChoiceId: 'pick_a', + yields: [], + }, + ], + }); + expect(content.actionsById.pick_high_road.kind).toBe('story'); + expect(content.actionsById.pick_high_road.group.label).toBe('Crossroads'); + }); + + it('defaults kind to timed and requires durationMs for timed actions', () => { + expect(() => + buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'broken', + name: 'Broken', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }), + ).toThrow(); + }); + + it('requires durationMs for loop actions', () => { + const content = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'rest', + name: 'Rest', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp activities' }, + durationMs: 2000, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + expect(content.actionsById.rest.kind).toBe('loop'); + }); + + it('rejects loop action without durationMs', () => { + expect(() => + buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'broken_loop', + name: 'Broken loop', + kind: 'loop', + group: { id: 'camp', label: 'Camp' }, + yields: [], + }, + ], + }), + ).toThrow(/durationMs/); + }); +}); diff --git a/src/content/__tests__/storySchema.test.ts b/src/content/__tests__/storySchema.test.ts index 046e897..6f0be59 100644 --- a/src/content/__tests__/storySchema.test.ts +++ b/src/content/__tests__/storySchema.test.ts @@ -9,6 +9,8 @@ const actionsById = { scout_path: { id: 'scout_path', name: 'Scout', + kind: 'timed' as const, + group: { id: 'travel', label: 'Travel' }, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], diff --git a/src/content/definitions.ts b/src/content/definitions.ts index ad9157e..27a432d 100644 --- a/src/content/definitions.ts +++ b/src/content/definitions.ts @@ -7,6 +7,8 @@ export const actionDefs = [ { id: 'gather_supplies', name: 'Gather supplies', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'supplies', amount: 2 }], storyHint: 'Basic camp labor.', @@ -15,6 +17,8 @@ export const actionDefs = [ { id: 'scout_path', name: 'Scout the path', + kind: 'timed', + group: { id: 'travel', label: 'Travel' }, durationMs: 5000, costs: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'coin', amount: 1 }], @@ -24,6 +28,8 @@ export const actionDefs = [ { id: 'trade_supplies', name: 'Trade at camp', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, durationMs: 4000, costs: [{ resourceId: 'supplies', amount: 3 }], yields: [{ resourceId: 'coin', amount: 2 }], @@ -34,6 +40,8 @@ export const actionDefs = [ { id: 'fortify_camp', name: 'Fortify camp', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, durationMs: 8000, costs: [ { resourceId: 'supplies', amount: 5 }, @@ -47,6 +55,8 @@ export const actionDefs = [ { id: 'push_onward', name: 'Push onward', + kind: 'timed', + group: { id: 'travel', label: 'Travel' }, durationMs: 6000, costs: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'coin', amount: 3 }], @@ -57,9 +67,32 @@ export const actionDefs = [ { id: 'rest', name: 'Rest briefly', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp activities' }, + loopPriority: 0, durationMs: 2000, yields: [{ resourceId: 'supplies', amount: 1 }], storyHint: 'Catch your breath.', - storyTooltip: 'Yields 1 Supply. Quick recovery.', + storyTooltip: 'Idle upkeep — runs when nothing else is queued.', + }, + { + id: 'pick_high_road', + name: 'Take the high road', + kind: 'story', + group: { id: 'fork', label: 'Crossroads' }, + storyChoiceId: 'pick_a', + storyHint: 'Route A — high ground and supplies.', + storyTooltip: 'Story fork: grants route A flag and resources. Hides river path.', + yields: [], + }, + { + id: 'follow_river', + name: 'Follow the river', + kind: 'story', + group: { id: 'fork', label: 'Crossroads' }, + storyChoiceId: 'pick_b', + storyHint: 'Route B — river trade and coin.', + storyTooltip: 'Story fork: grants route B flag. Hides high road path.', + yields: [], }, ]; diff --git a/src/content/index.ts b/src/content/index.ts index 59ad305..1222a88 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -9,4 +9,4 @@ const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesB export type GameContent = Content & StoryContent; export const content: GameContent = { ...base, ...story }; -export type { ActionDef, Content, ResourceDef } from './schema'; +export type { ActionDef, ActionGroup, ActionKind, Content, ResourceDef } from './schema'; diff --git a/src/content/schema.ts b/src/content/schema.ts index 41771a4..527d274 100644 --- a/src/content/schema.ts +++ b/src/content/schema.ts @@ -25,20 +25,63 @@ export const unlockDefSchema = z.object({ requireStoryFlags: z.array(z.string().min(1)).optional(), }); -export const actionDefSchema = z.object({ +export const actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context']); + +export const actionGroupSchema = z.object({ id: z.string().min(1), - name: z.string().min(1), - durationMs: z.number().positive(), - 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(), + label: z.string().min(1), }); +export const actionDefSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + kind: actionKindSchema.default('timed'), + group: actionGroupSchema, + durationMs: z.number().positive().optional(), + loopPriority: z.number().int().nonnegative().optional(), + costs: z.array(resourceAmountSchema).default([]), + yields: z.array(resourceAmountSchema).default([]), + unlock: unlockDefSchema.optional(), + storyHint: z.string().min(1).optional(), + storyTooltip: z.string().min(1).optional(), + storyChoiceId: z.string().min(1).optional(), + contextId: z.string().min(1).optional(), + automation: z + .object({ + unlockAfterManualCompletions: z.number().int().positive().default(1), + }) + .optional(), + }) + .superRefine((action, ctx) => { + if ((action.kind === 'timed' || action.kind === 'loop') && action.durationMs === undefined) { + ctx.addIssue({ + code: 'custom', + message: `${action.kind} actions require durationMs`, + path: ['durationMs'], + }); + } + if (action.kind === 'story' && action.storyChoiceId === undefined) { + ctx.addIssue({ + code: 'custom', + message: 'story actions require storyChoiceId', + path: ['storyChoiceId'], + }); + } + if (action.kind === 'timed' && action.yields.length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'timed actions require at least one yield', + path: ['yields'], + }); + } + }); + export type ResourceDef = z.infer; export type ResourceAmount = z.infer; export type UnlockDef = z.infer; +export type ActionKind = z.infer; +export type ActionGroup = z.infer; export type ActionDef = z.infer; export interface Content { diff --git a/src/engine/__tests__/determinism.test.ts b/src/engine/__tests__/determinism.test.ts index 6e6ef1f..799010b 100644 --- a/src/engine/__tests__/determinism.test.ts +++ b/src/engine/__tests__/determinism.test.ts @@ -10,6 +10,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: { id: 'test', label: 'Test' }, durationMs: 300, yields: [{ resourceId: 'gold', amount: 1 }], }, diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index b353fd1..e4f7e77 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -1,14 +1,22 @@ import { describe, expect, it } from 'vitest'; +import { content as gameContent } from '../../content/index'; import { buildContent } from '../../content/schema'; +import { buildStoryContent } from '../../content/storySchema'; import { cancelQueuedAction, canUnlockAction, clearQueue, createGameState, enqueueAction, + executeInstant, + maybeStartLoopAction, + performAction, startAction, tickGame, } from '../game'; +import { enterStoryNode } from '../story'; + +const DEFAULT_GROUP = { id: 'test', label: 'Test' }; function testContent() { return buildContent({ @@ -17,6 +25,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: DEFAULT_GROUP, durationMs: 300, yields: [{ resourceId: 'gold', amount: 2 }], }, @@ -28,9 +37,27 @@ function queueContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, - { id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, - { id: 'c', name: 'C', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { + id: 'a', + name: 'A', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, + { + id: 'b', + name: 'B', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, + { + id: 'c', + name: 'C', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, ], }); } @@ -45,12 +72,14 @@ function costContent() { { id: 'gather', name: 'Gather', + group: DEFAULT_GROUP, durationMs: 300, yields: [{ resourceId: 'supplies', amount: 2 }], }, { id: 'trade', name: 'Trade', + group: DEFAULT_GROUP, durationMs: 300, costs: [{ resourceId: 'supplies', amount: 5 }], yields: [{ resourceId: 'coin', amount: 3 }], @@ -58,6 +87,7 @@ function costContent() { { id: 'scout', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 300, yields: [{ resourceId: 'coin', amount: 1 }], unlock: { minResources: { coin: 1 } }, @@ -204,6 +234,7 @@ describe('unlock conditions', () => { { id: 'secret', name: 'Secret', + group: DEFAULT_GROUP, durationMs: 100, yields: [{ resourceId: 'gold', amount: 1 }], unlock: { requireStoryFlags: ['path_scouted'] }, @@ -245,12 +276,14 @@ describe('completion advances queue', () => { { id: 'cheap', name: 'Cheap', + group: DEFAULT_GROUP, durationMs: 100, yields: [{ resourceId: 'supplies', amount: 1 }], }, { id: 'dear', name: 'Dear', + group: DEFAULT_GROUP, durationMs: 100, costs: [{ resourceId: 'supplies', amount: 6 }], yields: [{ resourceId: 'supplies', amount: 1 }], @@ -258,6 +291,7 @@ describe('completion advances queue', () => { { id: 'free', name: 'Free', + group: DEFAULT_GROUP, durationMs: 100, yields: [{ resourceId: 'supplies', amount: 1 }], }, @@ -283,6 +317,7 @@ describe('completion advances queue', () => { { id: 'combo', name: 'Combo', + group: DEFAULT_GROUP, durationMs: 100, yields: [ { resourceId: 'a', amount: 2 }, @@ -356,3 +391,174 @@ describe('tickGame()', () => { expect(state.activeActionId).toBeNull(); }); }); + +describe('executeInstant()', () => { + const instantContent = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 0 }, + { id: 'coin', name: 'Coin', startAmount: 5 }, + ], + actions: [ + { + id: 'buy_supply', + name: 'Buy supply', + kind: 'instant', + group: { id: 'buy', label: 'Buy' }, + costs: [{ resourceId: 'coin', amount: 2 }], + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + + it('applies costs and yields immediately without queueing', () => { + const state = createGameState(instantContent); + executeInstant(state, instantContent, 'buy_supply'); + expect(state.resources.coin).toBe(3); + expect(state.resources.supplies).toBe(1); + expect(state.activeActionId).toBeNull(); + expect(state.actionQueue).toEqual([]); + }); + + it('throws when unaffordable', () => { + const state = createGameState(instantContent); + state.resources.coin = 0; + expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/); + }); +}); + +describe('loop idle runner', () => { + const loopContent = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }], + actions: [ + { + id: 'rest', + name: 'Rest', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp' }, + durationMs: 1000, + loopPriority: 0, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + + it('defaults enabledLoopActionIds to empty', () => { + const state = createGameState(loopContent); + expect(state.enabledLoopActionIds).toEqual({}); + }); + + it('starts enabled loop action when idle', () => { + const state = createGameState(loopContent); + state.enabledLoopActionIds = { rest: true }; + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBe('rest'); + }); + + it('does not start loop when queue has items', () => { + const state = createGameState(loopContent); + state.enabledLoopActionIds = { rest: true }; + state.actionQueue.push('rest'); + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBeNull(); + }); + + it('does not start a disabled loop action', () => { + const state = createGameState(loopContent); + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBeNull(); + }); +}); + +describe('performAction()', () => { + it('dispatches timed actions through enqueueAction and returns empty array', () => { + const state = createGameState(gameContent); + const events = performAction(state, gameContent, 'gather_supplies'); + expect(state.activeActionId).toBe('gather_supplies'); + expect(events).toEqual([]); + }); + + it('toggles loop actions, starts them when idle, and returns empty array', () => { + const state = createGameState(gameContent); + const events1 = performAction(state, gameContent, 'rest'); // enable + expect(state.enabledLoopActionIds.rest).toBe(true); + expect(state.activeActionId).toBe('rest'); // started because idle + available + expect(events1).toEqual([]); + const events2 = performAction(state, gameContent, 'rest'); // disable + expect(state.enabledLoopActionIds.rest).toBe(false); + expect(events2).toEqual([]); + }); + + it('dispatches story actions through executeStoryAction and returns events', () => { + const state = createGameState(gameContent); + enterStoryNode(state, gameContent, 'fork_choice'); + const events = performAction(state, gameContent, 'pick_high_road'); + expect(state.storyFlags.route_a).toBe(true); + expect(events.length).toBeGreaterThan(0); + expect(events[0].kind).toBe('enter'); + }); + + it('executes instant actions immediately', () => { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 0 }, + { id: 'coin', name: 'Coin', startAmount: 5 }, + ], + actions: [ + { + id: 'buy_supply', + name: 'Buy supply', + kind: 'instant', + group: { id: 'buy', label: 'Buy' }, + costs: [{ resourceId: 'coin', amount: 2 }], + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + { + id: 'enter_cave', + name: 'Enter cave', + kind: 'context', + group: { id: 'travel', label: 'Travel' }, + contextId: 'cave', + }, + ], + }); + const story = buildStoryContent( + [{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }], + base.actionsById, + base.resourcesById, + ); + const fixture = { ...base, ...story }; + const state = createGameState(fixture); + performAction(state, fixture, 'buy_supply'); + expect(state.resources.coin).toBe(3); + expect(state.resources.supplies).toBe(1); + expect(state.activeActionId).toBeNull(); + }); + + it('throws for context actions (not implemented in M1)', () => { + const base = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }], + actions: [ + { + id: 'enter_cave', + name: 'Enter cave', + kind: 'context', + group: { id: 'travel', label: 'Travel' }, + contextId: 'cave', + }, + ], + }); + const story = buildStoryContent( + [{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }], + base.actionsById, + base.resourcesById, + ); + const fixture = { ...base, ...story }; + const state = createGameState(fixture); + expect(() => performAction(state, fixture, 'enter_cave')).toThrow(/not implemented/i); + }); + + it('throws for unknown actions', () => { + const state = createGameState(gameContent); + expect(() => performAction(state, gameContent, 'nope')).toThrow(/[Uu]nknown/); + }); +}); diff --git a/src/engine/__tests__/purity.test.ts b/src/engine/__tests__/purity.test.ts index 5881642..2d08e93 100644 --- a/src/engine/__tests__/purity.test.ts +++ b/src/engine/__tests__/purity.test.ts @@ -3,7 +3,40 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; const ENGINE_DIR = join(import.meta.dirname, '..'); -const FORBIDDEN = [/from\s+['"]react/, /from\s+['"]react-dom/, /from\s+['"]zustand/]; + +/** + * Patterns that must NOT appear in engine source files. + * + * React / state-manager imports — match the import statement so plain + * string occurrences in comments are not flagged. + * + * Environment / browser / wall-clock APIs — matched as usage tokens + * (property-access or call-site forms) to avoid false-positives on + * prose comments that name these APIs without using them. In + * particular: + * - `Date\.now\(` catches the call site; a comment saying "Date + * scheduling" does not contain "Date.now(" so it passes. + * - `localStorage\.` / `indexedDB\.` catch member-access, not the + * bare words that appear in save.ts's module-doc comment. + * - `from\s+['"]idb-keyval` catches the package import. + * - `\bdocument\.` / `\bwindow\.` catch DOM member-access. + * - `requestAnimationFrame\(` catches the call site. + * + * lz-string is a pure compression library used by save.ts — it is + * intentionally NOT in this list. + */ +const FORBIDDEN: { pattern: RegExp; label: string }[] = [ + { pattern: /from\s+['"]react['"]/, label: 'react import' }, + { pattern: /from\s+['"]react-dom['"]/, label: 'react-dom import' }, + { pattern: /from\s+['"]zustand['"]/, label: 'zustand import' }, + { pattern: /Date\.now\(/, label: 'Date.now() call (wall-clock)' }, + { pattern: /localStorage\./, label: 'localStorage access (storage API)' }, + { pattern: /indexedDB\./, label: 'indexedDB access (storage API)' }, + { pattern: /from\s+['"]idb-keyval['"]/, label: 'idb-keyval import (storage API)' }, + { pattern: /\bdocument\./, label: 'document access (DOM API)' }, + { pattern: /\bwindow\./, label: 'window access (browser global)' }, + { pattern: /requestAnimationFrame\(/, label: 'requestAnimationFrame call (scheduling API)' }, +]; async function engineSourceFiles(): Promise { const entries = await readdir(ENGINE_DIR, { withFileTypes: true }); @@ -18,8 +51,8 @@ describe('engine purity', () => { expect(files.length).toBeGreaterThan(0); for (const file of files) { const source = await readFile(file, 'utf8'); - for (const pattern of FORBIDDEN) { - expect(source, `${file} must stay free of ${pattern}`).not.toMatch(pattern); + for (const { pattern, label } of FORBIDDEN) { + expect(source, `${file} must not use ${label}`).not.toMatch(pattern); } } }); diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index b0eaf37..25e9401 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -18,6 +18,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: { id: 'test', label: 'Test' }, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], }, @@ -69,6 +70,15 @@ describe('createSave()', () => { expect(save.state.currentStoryNodeId).toBe('route_a_beat'); expect(save.state.seenStoryNodeIds).toHaveLength(2); }); + + it('snapshots enabledLoopActionIds in the save payload and isolates from mutation', () => { + const state = sampleState(); + state.enabledLoopActionIds = { rest: true }; + const save = createSave(state, 1700); + expect(save.state.enabledLoopActionIds).toEqual({ rest: true }); + state.enabledLoopActionIds.rest = false; + expect(save.state.enabledLoopActionIds).toEqual({ rest: true }); + }); }); describe('serialize / deserialize round-trip', () => { @@ -83,6 +93,27 @@ describe('serialize / deserialize round-trip', () => { const restored = fromExportString(toExportString(save)); expect(restored).toEqual(save); }); + + it('preserves enabledLoopActionIds through a round-trip', () => { + const state = sampleState(); + state.enabledLoopActionIds = { rest: true, patrol: false }; + const restored = deserializeSave(serializeSave(createSave(state, 1700))); + expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false }); + }); + + it('defaults enabledLoopActionIds to {} when absent from save JSON', () => { + const json = JSON.stringify({ + version: 1, + savedAt: 1700, + state: { + resources: { gold: 0 }, + activeActionId: null, + actionElapsedMs: 0, + }, + }); + const restored = deserializeSave(json); + expect(restored.state.enabledLoopActionIds).toEqual({}); + }); }); describe('invalid / tampered saves', () => { @@ -104,6 +135,35 @@ describe('invalid / tampered saves', () => { }); }); +describe('applyOfflineProgress() — loop invariant', () => { + it('does NOT start a loop action during offline catch-up even when it is enabled', () => { + // Hardest invariant: maybeStartLoopAction is called by the runtime AFTER each live + // tick, never from tickGame itself. Offline catch-up replays tickGame directly, so + // loop actions must never start (and therefore never yield) during catch-up. + const content = buildContent({ + resources: [{ id: 'wood', name: 'Wood', startAmount: 0 }], + actions: [ + { + id: 'chop', + name: 'Chop Wood', + kind: 'loop', + group: { id: 'test', label: 'Test' }, + durationMs: 1000, + yields: [{ resourceId: 'wood', amount: 1 }], + }, + ], + }); + const state = createGameState(content); + // Enable the loop — player has toggled it on — but do NOT make it active. + state.enabledLoopActionIds.chop = true; + // Simulate coming back online after 10 seconds (10 full loop durations). + applyOfflineProgress(state, content, 0, 10_000); + // The loop must NOT have started or yielded during offline catch-up. + expect(state.activeActionId).toBeNull(); + expect(state.resources.wood).toBe(0); + }); +}); + describe('applyOfflineProgress()', () => { it('credits whole ticks of elapsed time to the active action', () => { const content = testContent(); diff --git a/src/engine/__tests__/story.test.ts b/src/engine/__tests__/story.test.ts index 006ff25..4897240 100644 --- a/src/engine/__tests__/story.test.ts +++ b/src/engine/__tests__/story.test.ts @@ -13,6 +13,8 @@ import { type StoryEvent, } from '../story'; +const DEFAULT_GROUP = { id: 'test', label: 'Test' }; + function gameContent() { const base = buildContent({ resources: [ @@ -23,6 +25,7 @@ function gameContent() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], @@ -54,6 +57,7 @@ function gameContentWithActionTrigger() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], @@ -91,6 +95,7 @@ function gameContentWithThreshold() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], @@ -132,6 +137,7 @@ function gameContentWithFork() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], @@ -189,6 +195,7 @@ function gameContentWithGatedChoice() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], diff --git a/src/engine/__tests__/storyActions.test.ts b/src/engine/__tests__/storyActions.test.ts new file mode 100644 index 0000000..03b5ad2 --- /dev/null +++ b/src/engine/__tests__/storyActions.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { content } from '../../content/index'; +import { createGameState, executeStoryAction } from '../game'; +import { enterStoryNode } from '../story'; + +describe('executeStoryAction()', () => { + it('applies the linked story choice', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + executeStoryAction(state, content, 'pick_high_road'); + expect(state.storyFlags.route_a).toBe(true); + expect(state.currentStoryNodeId).toBe('route_a_beat'); + }); + + it('throws when the choice is not available', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + executeStoryAction(state, content, 'pick_high_road'); + // after taking route A, the current node has no choices, so follow_river (pick_b) is unavailable + expect(() => executeStoryAction(state, content, 'follow_river')).toThrow(); + }); +}); diff --git a/src/engine/game.ts b/src/engine/game.ts index b83b52e..eeb3da4 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -1,4 +1,8 @@ import type { Content } from '../content/schema'; +import type { StoryContent } from '../content/storySchema'; +import { applyChoice, isStoryChoiceAvailable, type StoryEvent } from './story'; + +type GameContent = Content & StoryContent; /** * Core game state and per-tick simulation. @@ -22,6 +26,8 @@ export interface GameState { currentStoryNodeId: string; /** Story node ids the player has already seen. */ seenStoryNodeIds: string[]; + /** loop-kind action id -> whether the player has enabled it for idle running. */ + enabledLoopActionIds: Record; } export function createGameState(content: Content): GameState { @@ -37,6 +43,7 @@ export function createGameState(content: Content): GameState { storyFlags: {}, currentStoryNodeId: '', seenStoryNodeIds: [], + enabledLoopActionIds: {}, }; } @@ -156,6 +163,110 @@ export function clearQueue(state: GameState): void { state.actionQueue.length = 0; } +/** + * Execute an instant action immediately, deducting costs and granting yields + * without occupying a queue slot or requiring a duration. + * Throws if the action is not of kind 'instant' or is unavailable. + */ +export function executeInstant(state: GameState, content: Content, actionId: string): void { + const action = content.actionsById[actionId]; + if (action?.kind !== 'instant') { + throw new Error(`Action "${actionId}" is not instant`); + } + if (!isActionAvailable(state, content, actionId)) { + throw new Error(`Cannot perform instant action "${actionId}"`); + } + deductCosts(state, content, actionId); + grantYields(state, content, actionId); +} + +/** + * Execute a story action by resolving its storyChoiceId and applying it. + * Throws if the action is not of kind 'story', has no storyChoiceId, or the + * choice is not currently available. + */ +export function executeStoryAction( + state: GameState, + content: GameContent, + actionId: string, +): StoryEvent[] { + const action = content.actionsById[actionId]; + if (action?.kind !== 'story' || !action.storyChoiceId) { + throw new Error(`Action "${actionId}" is not a story action`); + } + if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { + throw new Error(`Story choice "${action.storyChoiceId}" is not available`); + } + return applyChoice(state, content, action.storyChoiceId); +} + +/** + * Start the highest-priority enabled, available loop action when the game is idle. + * Invoked by the runtime AFTER each live tick — never from `tickGame`, so loop + * actions do not run during offline catch-up (which replays `tickGame` directly). + */ +export function maybeStartLoopAction(state: GameState, content: Content): void { + if (state.activeActionId !== null || state.actionQueue.length > 0) return; + + const candidates = content.actions + .filter((a) => a.kind === 'loop' && state.enabledLoopActionIds[a.id]) + .sort((a, b) => (a.loopPriority ?? 0) - (b.loopPriority ?? 0)); + + for (const action of candidates) { + if (isActionAvailable(state, content, action.id)) { + beginAction(state, content, action.id); + return; + } + } +} + +/** + * Single entry point for all player-initiated action dispatch. + * + * Dispatches by action.kind to the appropriate per-kind function: + * - instant: execute immediately (costs/yields, no queue slot) + * - timed: enqueue (start if idle, queue otherwise) + * - loop: toggle player enable preference; start runner if just enabled + * - story: resolve storyChoiceId and apply the choice + * - context: not implemented in M1 — throws + * + * Note on loop toggle: disabling is always allowed, even when the action is + * currently unaffordable. Affordability is the runner's concern (maybeStartLoopAction + * re-checks each tick). Throwing on unaffordable before toggling would wrongly + * block the player from DISABLING an active but now-unaffordable loop. + */ +export function performAction( + state: GameState, + content: GameContent, + actionId: string, +): StoryEvent[] { + const action = content.actionsById[actionId]; + if (!action) throw new Error(`Unknown action "${actionId}"`); + + switch (action.kind) { + case 'instant': + executeInstant(state, content, actionId); + return []; + case 'timed': + enqueueAction(state, content, actionId); + return []; + case 'loop': { + const willEnable = !state.enabledLoopActionIds[actionId]; + state.enabledLoopActionIds[actionId] = willEnable; + if (willEnable) { + maybeStartLoopAction(state, content); + } + return []; + } + case 'story': + return executeStoryAction(state, content, actionId); + case 'context': + throw new Error(`Context action "${actionId}" is not implemented`); + default: + throw new Error(`Unknown action kind "${(action as { kind: string }).kind}"`); + } +} + /** * Advance the active action by `tickMs`. On completion, grants yields and * advances the queue — actions do not auto-repeat when the queue is empty. @@ -169,6 +280,7 @@ export function tickGame(state: GameState, content: Content, tickMs: number): Ti const actionId = state.activeActionId; const action = content.actionsById[actionId]; if (!action) return { completedActionIds }; + if (action.durationMs === undefined) return { completedActionIds }; if (state.actionElapsedMs < action.durationMs) return { completedActionIds }; state.actionElapsedMs -= action.durationMs; diff --git a/src/engine/save.ts b/src/engine/save.ts index f4c4cf5..6d125d0 100644 --- a/src/engine/save.ts +++ b/src/engine/save.ts @@ -32,6 +32,7 @@ export const gameStateSchema = z.object({ storyFlags: z.record(z.string(), z.boolean()).default({}), currentStoryNodeId: z.string().default(''), seenStoryNodeIds: z.array(z.string()).default([]), + enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}), }); export const saveSchema = z.object({ @@ -55,6 +56,7 @@ export function createSave(state: GameState, now: number): SaveData { storyFlags: { ...state.storyFlags }, currentStoryNodeId: state.currentStoryNodeId, seenStoryNodeIds: [...state.seenStoryNodeIds], + enabledLoopActionIds: { ...state.enabledLoopActionIds }, }, }; } diff --git a/src/engine/story.ts b/src/engine/story.ts index 2d6714c..ad5899f 100644 --- a/src/engine/story.ts +++ b/src/engine/story.ts @@ -165,6 +165,18 @@ export function getAvailableChoices(state: GameState, content: GameContent): Sto return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements)); } +export function isStoryChoiceAvailable( + state: GameState, + content: GameContent, + storyChoiceId: string, +): boolean { + const node = getCurrentNode(state, content); + if (!node?.choices) return false; + const choice = node.choices.find((c) => c.id === storyChoiceId); + if (!choice) return false; + return meetsChoiceRequirements(state, choice.requirements); +} + export function applyChoice( state: GameState, content: GameContent, diff --git a/src/index.css b/src/index.css index 33a8804..c1fbf4c 100644 --- a/src/index.css +++ b/src/index.css @@ -10,3 +10,19 @@ body { min-height: 100dvh; background-color: #020617; /* slate-950 */ } + +/* Custom Scrollbar for action columns */ +.overflow-x-auto::-webkit-scrollbar { + height: 6px; +} +.overflow-x-auto::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.3); + border-radius: 9999px; +} +.overflow-x-auto::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.4); + border-radius: 9999px; +} +.overflow-x-auto::-webkit-scrollbar-thumb:hover { + background: rgba(245, 158, 11, 0.5); +} diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index 22f4629..2103567 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -4,6 +4,8 @@ import { createGameState, enqueueAction, startAction } from '../../engine/game'; import { createSave, serializeSave } from '../../engine/save'; import { createMemoryBackend, loadGame, saveGame } from '../persistence'; +const DEFAULT_GROUP = { id: 'test', label: 'Test' }; + function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], @@ -11,6 +13,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], }, @@ -22,8 +25,20 @@ function queueTestContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { id: 'a', name: 'A', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, - { id: 'b', name: 'B', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { + id: 'a', + name: 'A', + group: DEFAULT_GROUP, + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, + { + id: 'b', + name: 'B', + group: DEFAULT_GROUP, + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, ], }); } @@ -95,6 +110,7 @@ describe('loadGame()', () => { storyFlags: {}, currentStoryNodeId: '', seenStoryNodeIds: [], + enabledLoopActionIds: {}, }, 1000, ), diff --git a/src/state/__tests__/prefs.test.ts b/src/state/__tests__/prefs.test.ts index 56d2a17..7771d94 100644 --- a/src/state/__tests__/prefs.test.ts +++ b/src/state/__tests__/prefs.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getPrefs, setPrefs } from '../prefs'; describe('prefs', () => { @@ -14,12 +14,65 @@ describe('prefs', () => { }); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('returns defaults when localStorage empty', () => { - expect(getPrefs()).toEqual({ storyOpenMode: 'auto', actionDetailMode: 'inline' }); + expect(getPrefs()).toEqual({ + storyOpenMode: 'auto', + actionDetailMode: 'inline', + collapsedActionGroups: {}, + showEventLog: true, + }); }); it('round-trips updated prefs', () => { setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' }); expect(getPrefs().storyOpenMode).toBe('manual'); }); + + it('migrates v1 prefs to v2 if v2 does not exist', () => { + const v1Prefs = { + storyOpenMode: 'manual', + actionDetailMode: 'hover', + collapsedActionGroups: { 'some-group': true }, + }; + localStorage.setItem('idlegame:prefs:v1', JSON.stringify(v1Prefs)); + + const migratedPrefs = getPrefs(); + + expect(migratedPrefs).toEqual({ + storyOpenMode: 'manual', + actionDetailMode: 'hover', + collapsedActionGroups: { 'some-group': true }, + showEventLog: true, + }); + + const rawV2 = localStorage.getItem('idlegame:prefs:v2'); + expect(rawV2).not.toBeNull(); + expect(JSON.parse(rawV2 ?? 'null')).toEqual(migratedPrefs); + }); + + it('returns defaults and does not migrate if v1 prefs is invalid JSON', () => { + localStorage.setItem('idlegame:prefs:v1', '{invalid-json}'); + + const prefs = getPrefs(); + expect(prefs).toEqual({ + storyOpenMode: 'auto', + actionDetailMode: 'inline', + collapsedActionGroups: {}, + showEventLog: true, + }); + + expect(localStorage.getItem('idlegame:prefs:v2')).toBeNull(); + }); + + describe('expanded prefs', () => { + it('defaults collapsedActionGroups and showEventLog', () => { + const prefs = getPrefs(); + expect(prefs.collapsedActionGroups).toEqual({}); + expect(prefs.showEventLog).toBe(true); + }); + }); }); diff --git a/src/state/__tests__/runtime.test.ts b/src/state/__tests__/runtime.test.ts new file mode 100644 index 0000000..008c3cb --- /dev/null +++ b/src/state/__tests__/runtime.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { content } from '../../content'; +import { getPrefs } from '../prefs'; +import { GameRuntime } from '../runtime'; +import { useGameStore } from '../store'; + +describe('GameRuntime', () => { + let runtime: GameRuntime; + + beforeEach(async () => { + // Stub localStorage + const storage: Record = {}; + vi.stubGlobal('localStorage', { + getItem(key: string) { + return storage[key] ?? null; + }, + setItem(key: string, value: string) { + storage[key] = value; + }, + removeItem(key: string) { + delete storage[key]; + }, + clear() { + for (const k of Object.keys(storage)) { + delete storage[k]; + } + }, + }); + + // Stub requestAnimationFrame + vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + vi.stubGlobal('indexedDB', undefined); + + // Stub visibilityState and document.addEventListener + vi.stubGlobal('document', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + visibilityState: 'visible', + }); + + // Stub window.addEventListener + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + + // Setup clean store + useGameStore.setState({ + log: [], + storyHasUnread: false, + storyLog: [], + prefs: getPrefs(), + activePanel: 'play', + }); + + runtime = new GameRuntime(); + // Boot the runtime to initialize state + await runtime.boot(); + }); + + afterEach(() => { + runtime.stop(); + vi.unstubAllGlobals(); + }); + + it('performs timed action', () => { + runtime.performAction('gather_supplies'); + const store = useGameStore.getState(); + expect(store.log).toContain('Started: Gather supplies.'); + }); + + it('performs loop action', () => { + // Loop actions toggle enable state + runtime.performAction('rest'); + const view = useGameStore.getState(); + // Verify it is enabled + expect(view.actions.find((a) => a.id === 'rest')?.loopEnabled).toBe(true); + }); + + it('performs story action and appends story log', () => { + // Let's first move state to fork_choice node where story choices are available + runtime.continueStory(); + + // Pick the high road + runtime.performAction('pick_high_road'); + + const store = useGameStore.getState(); + // The story log should have the node we entered: route_a_beat + expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true); + // Should append choice label to normal log + expect(store.log).toContain('Story: Take the high road'); + }); + + it('performs story action and appends custom log outcomes', () => { + // Save original fork_choice node + const originalNode = content.storyNodesById.fork_choice; + if (!originalNode) { + throw new Error('fork_choice node not found in content'); + } + const originalNodes = [...content.storyNodes]; + + // Create a modified fork_choice node with a log outcome on pick_a + const choices = originalNode.choices ?? []; + const firstChoice = choices[0]; + if (!firstChoice) { + throw new Error('first choice not found on fork_choice'); + } + + const modifiedChoice = { + ...firstChoice, + outcomes: [ + ...(firstChoice.outcomes ?? []), + { type: 'log' as const, text: 'Custom log from story action!' }, + ], + }; + + const modifiedNode = { + ...originalNode, + choices: [modifiedChoice, ...choices.slice(1)], + }; + + // Mutate content + content.storyNodesById.fork_choice = modifiedNode; + content.storyNodes = content.storyNodes.map((n) => (n.id === 'fork_choice' ? modifiedNode : n)); + + try { + // Move to fork_choice + runtime.continueStory(); + + // Clear logs to check cleanly + useGameStore.setState({ log: [], storyLog: [] }); + + // Perform the story action + runtime.performAction('pick_high_road'); + + const store = useGameStore.getState(); + expect(store.log).toContain('Custom log from story action!'); + } finally { + // Restore content + content.storyNodesById.fork_choice = originalNode; + content.storyNodes = originalNodes; + } + }); + + it('setActivePanel does not auto-advance boot_intro to fork_choice', () => { + // Initially we boot into boot_intro. + // If we call setActivePanel('story'), it should NOT trigger the auto-advance logic + // from 'boot_intro' to 'fork_choice'. + runtime.setActivePanel('story'); + + const store = useGameStore.getState(); + expect(store.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(false); + }); + + it('continueStory() advances boot_intro to fork_choice and opens panel when storyOpenMode is auto', () => { + const store = useGameStore.getState(); + store.setPrefs({ ...store.prefs, storyOpenMode: 'auto' }); + + // Force play panel and closed/no unread story + store.setActivePanel('play'); + store.setStoryHasUnread(false); + + runtime.continueStory(); + + const updated = useGameStore.getState(); + // Verifies it advances to fork_choice + expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true); + // Verifies it opens panel and does not set unread (since it's open) + expect(updated.activePanel).toBe('story'); + expect(updated.storyHasUnread).toBe(false); + }); + + it('continueStory() advances boot_intro to fork_choice and sets unread when storyOpenMode is manual', () => { + const store = useGameStore.getState(); + store.setPrefs({ ...store.prefs, storyOpenMode: 'manual' }); + + // Force play panel and closed/no unread story + store.setActivePanel('play'); + store.setStoryHasUnread(false); + + runtime.continueStory(); + + const updated = useGameStore.getState(); + // Verifies it advances to fork_choice + expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true); + // Verifies it does NOT open panel and sets unread flag to true + expect(updated.activePanel).toBe('play'); + expect(updated.storyHasUnread).toBe(true); + }); + + it('applies story choice with an action mapping and processes log outcomes', () => { + // Move to fork_choice + runtime.continueStory(); + + // Apply choice 'pick_a' (which maps to 'pick_high_road' action) + runtime.applyStoryChoice('pick_a'); + + const store = useGameStore.getState(); + expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true); + expect(store.log).toContain('Story: Take the high road'); + }); + + it('applies story choice without an action mapping and appends custom log outcomes', () => { + // Save original fork_choice node + const originalNode = content.storyNodesById.fork_choice; + if (!originalNode) { + throw new Error('fork_choice node not found in content'); + } + const originalNodes = [...content.storyNodes]; + + // Create a modified fork_choice node with a custom choice that has a 'log' outcome + const customChoice = { + id: 'custom_choice_no_action', + label: 'Perform custom choice', + outcomes: [{ type: 'log' as const, text: 'This is a custom log outcome!' }], + targetNodeId: 'route_a_beat', + }; + + const modifiedNode = { + ...originalNode, + choices: [...(originalNode.choices ?? []), customChoice], + }; + + // Mutate content + content.storyNodesById.fork_choice = modifiedNode; + content.storyNodes = content.storyNodes.map((n) => (n.id === 'fork_choice' ? modifiedNode : n)); + + try { + // Move to fork_choice + runtime.continueStory(); + + // Clear logs to check cleanly + useGameStore.setState({ log: [], storyLog: [] }); + + // Apply choice + runtime.applyStoryChoice('custom_choice_no_action'); + + const store = useGameStore.getState(); + expect(store.log).toContain('This is a custom log outcome!'); + expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true); + } finally { + // Restore content + content.storyNodesById.fork_choice = originalNode; + content.storyNodes = originalNodes; + } + }); + + it('registers lifecycle listeners on boot and removes them on stop', () => { + const addSpyDoc = vi.spyOn(document, 'addEventListener'); + const removeSpyDoc = vi.spyOn(document, 'removeEventListener'); + const addSpyWin = vi.spyOn(window, 'addEventListener'); + const removeSpyWin = vi.spyOn(window, 'removeEventListener'); + + const testRuntime = new GameRuntime(); + testRuntime.boot(); + + expect(addSpyDoc).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + expect(addSpyWin).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + + testRuntime.stop(); + + expect(removeSpyDoc).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + expect(removeSpyWin).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + }); +}); diff --git a/src/state/__tests__/store.test.ts b/src/state/__tests__/store.test.ts new file mode 100644 index 0000000..cca2040 --- /dev/null +++ b/src/state/__tests__/store.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getPrefs } from '../prefs'; +import { useGameStore } from '../store'; + +describe('game store nav and prefs', () => { + beforeEach(() => { + const store: Record = {}; + vi.stubGlobal('localStorage', { + getItem(key: string) { + return store[key] ?? null; + }, + setItem(key: string, value: string) { + store[key] = value; + }, + }); + useGameStore.setState({ + activePanel: 'play', + selectedStoryNodeId: null, + prefs: getPrefs(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('has default activePanel and selectedStoryNodeId', () => { + const state = useGameStore.getState(); + expect(state.activePanel).toBe('play'); + expect(state.selectedStoryNodeId).toBeNull(); + }); + + it('updates activePanel via setActivePanel', () => { + const state = useGameStore.getState(); + state.setActivePanel('story'); + expect(useGameStore.getState().activePanel).toBe('story'); + }); + + it('updates selectedStoryNodeId via setSelectedStoryNodeId', () => { + const state = useGameStore.getState(); + state.setSelectedStoryNodeId('node-1'); + expect(useGameStore.getState().selectedStoryNodeId).toBe('node-1'); + }); + + it('toggles collapsed action groups in prefs', () => { + const state = useGameStore.getState(); + expect(state.prefs.collapsedActionGroups['skills:gather']).toBeUndefined(); + + state.toggleActionGroupCollapsed('skills:gather'); + expect(useGameStore.getState().prefs.collapsedActionGroups['skills:gather']).toBe(true); + + useGameStore.getState().toggleActionGroupCollapsed('skills:gather'); + expect(useGameStore.getState().prefs.collapsedActionGroups['skills:gather']).toBe(false); + }); +}); diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index ff8816b..157d2a5 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; +import { content } from '../../content/index'; import { buildContent } from '../../content/schema'; import { buildStoryContent } from '../../content/storySchema'; -import { createGameState, enqueueAction, startAction } from '../../engine/game'; +import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game'; import { enterStoryNode } from '../../engine/story'; import { formatOfflineDuration, toView } from '../viewModel'; +const DEFAULT_GROUP = { id: 'test', label: 'Test' }; + function testContent() { const base = buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], @@ -12,6 +15,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: DEFAULT_GROUP, durationMs: 200, yields: [{ resourceId: 'gold', amount: 1 }], }, @@ -87,8 +91,20 @@ describe('toView()', () => { 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 }] }, + { + id: 'a', + name: 'Alpha', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, + { + id: 'b', + name: 'Bravo', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, ], ); const state = createGameState(content); @@ -108,6 +124,7 @@ describe('toView() action availability', () => { { id: 'locked', name: 'Locked', + group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'coin', amount: 1 }], unlock: { requireStoryFlags: ['route_a'] }, @@ -127,6 +144,7 @@ describe('toView() action availability', () => { { id: 'forage', name: 'Forage', + group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'coin', amount: 1 }], }, @@ -188,3 +206,128 @@ describe('formatOfflineDuration()', () => { expect(formatOfflineDuration(7_200_000)).toBe('2h'); }); }); + +describe('action columns projection', () => { + it('produces all five kind columns in fixed order', () => { + const state = createGameState(content); + const view = toView(state, content); + expect(view.actionColumns.map((c) => c.kind)).toEqual([ + 'instant', + 'loop', + 'timed', + 'story', + 'context', + ]); + }); + + it('groups timed actions by their content group', () => { + const state = createGameState(content); + const view = toView(state, content); + const timed = view.actionColumns.find((c) => c.kind === 'timed'); + expect(timed?.groups.some((g) => g.id === 'camp')).toBe(true); + expect(timed?.groups.some((g) => g.id === 'travel')).toBe(true); + }); + + it('marks loopEnabled from enabledLoopActionIds', () => { + const state = createGameState(content); + state.enabledLoopActionIds = { rest: true }; + const view = toView(state, content); + const rest = view.actionColumns + .flatMap((c) => c.groups) + .flatMap((g) => g.actions) + .find((a) => a.id === 'rest'); + expect(rest?.loopEnabled).toBe(true); + }); + + it('shows story actions only when their choice is available, hiding siblings after a fork is taken', () => { + const state = createGameState(content); + // before reaching the fork, story actions are hidden + let storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story'); + expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0); + // at the fork, both story actions appear + enterStoryNode(state, content, 'fork_choice'); + storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story'); + const idsAtFork = storyCol?.groups.flatMap((g) => g.actions).map((a) => a.id) ?? []; + expect(idsAtFork).toEqual(expect.arrayContaining(['pick_high_road', 'follow_river'])); + // after taking route A, the sibling hides + performAction(state, content, 'pick_high_road'); + storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story'); + expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0); + }); +}); + +describe('story.atBootIntro', () => { + it('is true at the boot-entry node and false after entering a different node', () => { + const base = buildContent({ + resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }], + actions: [ + { + id: 'forage', + name: 'Forage', + group: DEFAULT_GROUP, + 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: 'route_a_beat', prose: 'The high road.' }, + ], + base.actionsById, + base.resourcesById, + ); + const c = { ...base, ...story }; + const state = createGameState(c); + // Simulate boot: enter the boot-entry node + enterStoryNode(state, c, 'boot_intro'); + expect(toView(state, c).story.atBootIntro).toBe(true); + // After moving past boot into fork_choice, atBootIntro must be false + enterStoryNode(state, c, 'fork_choice'); + expect(toView(state, c).story.atBootIntro).toBe(false); + }); +}); + +describe('story tree projection', () => { + it('builds a tree marking seen and active nodes', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + const view = toView(state, content); + // fork_choice should be a node in the tree, marked active+seen, with route children + const findNode = ( + nodes: typeof view.story.tree, + id: string, + ): (typeof nodes)[number] | undefined => { + for (const n of nodes) { + if (n.id === id) return n; + const deeper = findNode(n.children, id); + if (deeper) return deeper; + } + return undefined; + }; + const fork = findNode(view.story.tree, 'fork_choice'); + expect(fork).toBeDefined(); + expect(fork?.active).toBe(true); + expect(fork?.seen).toBe(true); + expect(fork?.children.map((c) => c.id)).toEqual( + expect.arrayContaining(['route_a_beat', 'route_b_beat']), + ); + }); +}); diff --git a/src/state/persistence.ts b/src/state/persistence.ts index 8455258..f036a32 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -106,6 +106,7 @@ export async function loadGame( storyFlags: { ...save.state.storyFlags }, currentStoryNodeId: save.state.currentStoryNodeId ?? '', seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], + enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) }, }; savedAt = save.savedAt; } catch { diff --git a/src/state/prefs.ts b/src/state/prefs.ts index 1055b71..a46fd63 100644 --- a/src/state/prefs.ts +++ b/src/state/prefs.ts @@ -1,4 +1,4 @@ -const PREFS_KEY = 'idlegame:prefs:v1'; +const PREFS_KEY = 'idlegame:prefs:v2'; export type StoryOpenMode = 'auto' | 'choices-only' | 'manual'; export type ActionDetailMode = 'inline' | 'hover' | 'info-button'; @@ -6,18 +6,35 @@ export type ActionDetailMode = 'inline' | 'hover' | 'info-button'; export interface GamePrefs { storyOpenMode: StoryOpenMode; actionDetailMode: ActionDetailMode; + collapsedActionGroups: Record; + showEventLog: boolean; } const DEFAULTS: GamePrefs = { storyOpenMode: 'auto', actionDetailMode: 'inline', + collapsedActionGroups: {}, + showEventLog: true, }; export function getPrefs(): GamePrefs { if (typeof localStorage === 'undefined') return { ...DEFAULTS }; try { const raw = localStorage.getItem(PREFS_KEY); - if (!raw) return { ...DEFAULTS }; + if (!raw) { + const rawV1 = localStorage.getItem('idlegame:prefs:v1'); + if (rawV1) { + try { + const parsedV1 = JSON.parse(rawV1); + const migrated = { ...DEFAULTS, ...parsedV1 }; + localStorage.setItem(PREFS_KEY, JSON.stringify(migrated)); + return migrated; + } catch { + return { ...DEFAULTS }; + } + } + return { ...DEFAULTS }; + } return { ...DEFAULTS, ...JSON.parse(raw) }; } catch { return { ...DEFAULTS }; diff --git a/src/state/runtime.ts b/src/state/runtime.ts index 6ccd13a..ead630f 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -1,20 +1,20 @@ import { content } from '../content'; import { cancelQueuedAction as engineCancelQueuedAction, - enqueueAction as engineEnqueueAction, type GameState, - isActionAvailable, + maybeStartLoopAction, + performAction as performActionEngine, 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 { type ActivePanel, useGameStore } from './store'; import { processStoryTriggers, type StoryUiEffect, - shouldAutoOpenPanel, + shouldAutoNavigateToStory, storyEventsToLogEntries, } from './storyOrchestration'; import { formatOfflineDuration, toView } from './viewModel'; @@ -32,7 +32,7 @@ import { formatOfflineDuration, toView } from './viewModel'; const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh const AUTOSAVE_INTERVAL_MS = 10_000; -class GameRuntime { +export class GameRuntime { private state: GameState | null = null; private readonly loop: TickLoop = createTickLoop(); private readonly backend: SaveBackend = createDefaultBackend(); @@ -41,6 +41,16 @@ class GameRuntime { private lastSaveAt = 0; private booted = false; + private readonly visibilityChangeListener = (): void => { + if (document.visibilityState === 'hidden') { + void this.save(); + } + }; + + private readonly beforeUnloadListener = (): void => { + void this.save(); + }; + async boot(): Promise { if (this.booted) { return; @@ -75,47 +85,93 @@ class GameRuntime { cancelAnimationFrame(this.rafId); this.rafId = null; } + document.removeEventListener('visibilitychange', this.visibilityChangeListener); + window.removeEventListener('beforeunload', this.beforeUnloadListener); + this.booted = false; + } + + performAction(actionId: string): void { + const state = this.state; + if (!state) return; + const action = content.actionsById[actionId]; + if (!action) return; + + try { + const isStory = action.kind === 'story'; + const events = performActionEngine(state, content, actionId); + const store = useGameStore.getState(); + + // Append any custom log outcomes + for (const event of events.filter((e) => e.kind === 'log')) { + store.appendLog(event.prose); + } + + if (isStory) { + const entries = storyEventsToLogEntries(events); + for (const entry of entries) { + store.appendStoryLog(entry); + } + const choiceLabel = entries.at(-1)?.choiceLabel; + if (choiceLabel) { + store.appendLog(`Story: ${choiceLabel}`); + } + this.runPublishTriggers(); + } else if (action.kind === 'timed') { + const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; + store.appendLog(`${verb}: ${action.name}.`); + } + + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed'); + } + } + + setActivePanel(panel: ActivePanel): void { + const store = useGameStore.getState(); + store.setActivePanel(panel); + if (panel === 'story') { + store.setStoryHasUnread(false); + } + } + + selectStoryNode(id: string): void { + useGameStore.getState().setSelectedStoryNodeId(id); + } + + toggleActionGroupCollapsed(groupKey: string): void { + useGameStore.getState().toggleActionGroupCollapsed(groupKey); } enqueueAction(actionId: string): void { - const state = this.state; - 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]; - if (action) { - const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; - useGameStore.getState().appendLog(`${verb}: ${action.name}.`); - } - } catch (err) { - const msg = err instanceof Error ? err.message : 'Cannot start action'; - useGameStore.getState().appendLog(msg); - } - this.publish(); + this.performAction(actionId); } 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'); + const action = content.actions.find((a) => a.storyChoiceId === choiceId); + if (action) { + this.performAction(action.id); + } else { + const state = this.state; + if (!state) return; + try { + const events = engineApplyChoice(state, content, choiceId); + const entries = storyEventsToLogEntries(events); + const store = useGameStore.getState(); + + // Append any custom log outcomes + for (const event of events.filter((e) => e.kind === 'log')) { + store.appendLog(event.prose); + } + + for (const entry of entries) store.appendStoryLog(entry); + const choiceLabel = entries.at(-1)?.choiceLabel; + if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); + this.runPublishTriggers(); + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed'); + } } } @@ -131,15 +187,6 @@ class GameRuntime { } } - 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; @@ -149,30 +196,36 @@ class GameRuntime { 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)}…`); + 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); + if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) { + this.setActivePanel('story'); } else { store.setStoryHasUnread(true); - store.setStoryPanelOpen(false); } this.publish(); return; } - this.closeStoryPanel(); + this.setActivePanel('play'); this.publish(); } private applyStoryUiEffect(effect: StoryUiEffect): void { const store = useGameStore.getState(); + const prefs = store.prefs; 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); + if (prefs.storyOpenMode === 'auto') { + store.setActivePanel('story'); + store.setStoryHasUnread(false); + } else { + store.setStoryHasUnread(true); + } } else if (effect.enteredNodeIds.length > 0) { store.setStoryHasUnread(true); } @@ -201,6 +254,7 @@ class GameRuntime { ); this.applyStoryUiEffect(effect); } + maybeStartLoopAction(state, content); }); if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { @@ -234,15 +288,15 @@ class GameRuntime { } private installLifecycleHooks(): void { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - void this.save(); - } - }); - window.addEventListener('beforeunload', () => { - void this.save(); - }); + document.addEventListener('visibilitychange', this.visibilityChangeListener); + window.addEventListener('beforeunload', this.beforeUnloadListener); } } export const gameRuntime = new GameRuntime(); + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + gameRuntime.stop(); + }); +} diff --git a/src/state/store.ts b/src/state/store.ts index b498971..90e010e 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -16,23 +16,26 @@ export interface StoryLogEntry { choiceLabel?: string; } +export type ActivePanel = 'play' | 'story' | 'settings' | 'about'; + export interface GameStoreState extends GameView { log: string[]; - storyPanelOpen: boolean; storyHasUnread: boolean; storyLog: StoryLogEntry[]; prefs: GamePrefs; - settingsOpen: boolean; + activePanel: ActivePanel; + selectedStoryNodeId: string | null; 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; + setActivePanel: (panel: ActivePanel) => void; + setSelectedStoryNodeId: (id: string | null) => void; + toggleActionGroupCollapsed: (groupKey: string) => void; } -export const useGameStore = create((set) => ({ +export const useGameStore = create((set, get) => ({ resources: [], activeActionId: null, actionName: null, @@ -40,21 +43,31 @@ export const useGameStore = create((set) => ({ queuedActionIds: [], queuedActionNames: [], actions: [], - story: { currentProse: null, choices: [] }, + story: { currentProse: null, atBootIntro: false, choices: [], tree: [] }, + actionColumns: [], log: [], - storyPanelOpen: false, storyHasUnread: false, storyLog: [], prefs: getPrefs(), - settingsOpen: false, + activePanel: 'play', + selectedStoryNodeId: null, 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 }), + setActivePanel: (panel) => set({ activePanel: panel }), + setSelectedStoryNodeId: (id) => set({ selectedStoryNodeId: id }), + toggleActionGroupCollapsed: (groupKey) => { + const currentPrefs = get().prefs; + const nextCollapsed = { + ...currentPrefs.collapsedActionGroups, + [groupKey]: !currentPrefs.collapsedActionGroups[groupKey], + }; + const prefs = persistPrefs({ collapsedActionGroups: nextCollapsed }); + set({ prefs }); + }, })); diff --git a/src/state/storyOrchestration.ts b/src/state/storyOrchestration.ts index 6f253e3..6ce4537 100644 --- a/src/state/storyOrchestration.ts +++ b/src/state/storyOrchestration.ts @@ -17,7 +17,7 @@ export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] { .map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel })); } -export function shouldAutoOpenPanel( +export function shouldAutoNavigateToStory( prefs: GamePrefs, nodeId: string, content: GameContent, @@ -39,11 +39,15 @@ export function processStoryTriggers( 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)}…`, - ); + enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content)); + const customLogs = events.filter((e) => e.kind === 'log').map((e) => e.prose); + const eventLogLines = [ + ...customLogs, + ...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 64b9957..c49e35a 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -5,7 +5,7 @@ import { type GameState, isActionAvailable, } from '../engine/game'; -import { getAvailableChoices, getCurrentNode } from '../engine/story'; +import { getAvailableChoices, getCurrentNode, isStoryChoiceAvailable } from '../engine/story'; /** * Pure mapping from engine state to the view model the React shell renders. @@ -18,6 +18,17 @@ export interface ResourceView { amount: number; } +export const ACTION_COLUMN_ORDER = ['instant', 'loop', 'timed', 'story', 'context'] as const; +export type ActionColumnKind = (typeof ACTION_COLUMN_ORDER)[number]; + +const COLUMN_LABELS: Record = { + instant: 'Instant', + loop: 'Loop', + timed: 'Timed', + story: 'Story', + context: 'Context', +}; + export interface ActionView { id: string; name: string; @@ -27,6 +38,20 @@ export interface ActionView { storyTooltip?: string; costsSummary: string | null; yieldsSummary: string | null; + kind: ActionColumnKind; + loopEnabled: boolean; +} + +export interface ActionGroupView { + id: string; + label: string; + actions: ActionView[]; +} + +export interface ActionColumnView { + kind: ActionColumnKind; + label: string; + groups: ActionGroupView[]; } export interface StoryChoiceView { @@ -36,9 +61,19 @@ export interface StoryChoiceView { disabledReason: string | null; } +export interface StoryTreeNodeView { + id: string; + label: string; + seen: boolean; + active: boolean; + children: StoryTreeNodeView[]; +} + export interface StoryView { currentProse: string | null; + atBootIntro: boolean; choices: StoryChoiceView[]; + tree: StoryTreeNodeView[]; } export interface GameView { @@ -51,6 +86,7 @@ export interface GameView { queuedActionNames: string[]; actions: ActionView[]; story: StoryView; + actionColumns: ActionColumnView[]; } function actionDisabledReason( @@ -72,6 +108,38 @@ function formatResourceList( .join(', '); } +function buildStoryTree(state: GameState, content: GameContent): StoryTreeNodeView[] { + // Edges come from choice targets and trigger targets, skipping self-edges. + const childIds = new Set(); + const childrenOf = new Map(); + for (const node of content.storyNodes) { + const targets: string[] = []; + for (const choice of node.choices ?? []) { + if (choice.targetNodeId !== node.id) targets.push(choice.targetNodeId); + } + for (const trigger of node.triggers ?? []) { + if (trigger.targetNodeId !== node.id) targets.push(trigger.targetNodeId); + } + childrenOf.set(node.id, targets); + for (const t of targets) childIds.add(t); + } + const build = (id: string, seenOnPath: Set): StoryTreeNodeView => { + const children = seenOnPath.has(id) + ? [] + : (childrenOf.get(id) ?? []).map((c) => build(c, new Set(seenOnPath).add(id))); + return { + id, + label: id, // minimal label per spec (T3.0 ships a minimal tree) + seen: state.seenStoryNodeIds.includes(id), + active: state.currentStoryNodeId === id, + children, + }; + }; + return content.storyNodes + .filter((n) => !childIds.has(n.id)) + .map((n) => build(n.id, new Set())); +} + export function toView(state: GameState, content: GameContent): GameView { const resources: ResourceView[] = content.resources.map((resource) => ({ id: resource.id, @@ -80,27 +148,82 @@ export function toView(state: GameState, content: GameContent): GameView { })); const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined; - const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0; + const actionProgress = action?.durationMs + ? Math.min(1, state.actionElapsedMs / action.durationMs) + : 0; 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), - })); + // Build a map of ActionView by id for column assembly + const actionViewMap = new Map(); + const actions: ActionView[] = content.actions.map((a) => { + const isStory = a.kind === 'story'; + const available = isStory + ? isStoryChoiceAvailable(state, content, a.storyChoiceId ?? '') + : isActionAvailable(state, content, a.id); + const view: ActionView = { + id: a.id, + name: a.name, + available, + disabledReason: isStory ? null : 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), + kind: a.kind, + loopEnabled: !!state.enabledLoopActionIds[a.id], + }; + actionViewMap.set(a.id, view); + return view; + }); + + // Build action columns: one per kind in fixed order + const actionColumns: ActionColumnView[] = ACTION_COLUMN_ORDER.map((kind) => { + // Collect actions of this kind + const kindActions = content.actions.filter((a) => a.kind === kind); + + // For story kind: only include available actions (hides siblings after fork) + const includedActions = + kind === 'story' + ? kindActions.filter((a) => actionViewMap.get(a.id)?.available === true) + : kindActions; + + // Group by action.group, preserving first-seen order + const groupOrder: string[] = []; + const groupMap = new Map(); + for (const a of includedActions) { + const view = actionViewMap.get(a.id); + if (!view) continue; + if (!groupMap.has(a.group.id)) { + groupOrder.push(a.group.id); + groupMap.set(a.group.id, { id: a.group.id, label: a.group.label, actions: [] }); + } + groupMap.get(a.group.id)?.actions.push(view); + } + + const groups: ActionGroupView[] = groupOrder + .map((gid) => groupMap.get(gid)) + .filter((g): g is ActionGroupView => g !== undefined); + + return { + kind, + label: COLUMN_LABELS[kind], + groups, + }; + }); const node = getCurrentNode(state, content); const availableChoices = getAvailableChoices(state, content); const allChoices = node?.choices ?? []; + const bootEntryNodeId = content.storyNodes.find((n) => + n.triggers?.some((t) => t.type === 'boot'), + )?.id; + const atBootIntro = node != null && node.id === bootEntryNodeId; + const story: StoryView = { currentProse: node?.prose ?? null, + atBootIntro, choices: allChoices.map((choice) => { const available = availableChoices.some((c) => c.id === choice.id); return { @@ -110,6 +233,7 @@ export function toView(state: GameState, content: GameContent): GameView { disabledReason: available ? null : 'Requirements not met', }; }), + tree: buildStoryTree(state, content), }; return { @@ -121,6 +245,7 @@ export function toView(state: GameState, content: GameContent): GameView { queuedActionNames, actions, story, + actionColumns, }; } diff --git a/src/ui/AboutPanel.tsx b/src/ui/AboutPanel.tsx new file mode 100644 index 0000000..7280517 --- /dev/null +++ b/src/ui/AboutPanel.tsx @@ -0,0 +1,53 @@ +export function AboutPanel() { + return ( +
+
+

About Idlegame

+

A text-fantasy RPG incremental experience.

+
+ +
+
+

+ How to Play +

+

+ Idlegame is driven by actions and choices. Select actions from the{' '} + Play screen to execute them. Some actions + are timed and can be queued. Once completed, they reward you with resources, unlock new + deeds, or advance the chronicle. +

+
+ +
+

+ The Chronicle +

+

+ As you perform actions, you will unlock narrative points of interest. Head to the{' '} + Story tab to make crucial choices and read + the history of your journey. +

+
+ +
+

+ Technical Specs +

+
+
+
Version
+
0.1.0 (M1 Milestone)
+
Engine
+
Pure TypeScript State Machine
+
Framework
+
React + Zustand + Tailwind CSS
+
Target Platform
+
Responsive Web & PWA
+
+
+
+
+
+ ); +} diff --git a/src/ui/ActionCard.tsx b/src/ui/ActionCard.tsx new file mode 100644 index 0000000..cf8f2e4 --- /dev/null +++ b/src/ui/ActionCard.tsx @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState } from 'react'; +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; +import type { ActionView } from '../state/viewModel'; + +interface ActionCardProps { + action: ActionView; +} + +export function ActionCard({ action }: ActionCardProps) { + const activeActionId = useGameStore((s) => s.activeActionId); + const actionProgress = useGameStore((s) => s.actionProgress); + const prefs = useGameStore((s) => s.prefs); + const [openInfoId, setOpenInfoId] = useState(null); + const containerRef = useRef(null); + + const isOpen = openInfoId === action.id; + + useEffect(() => { + if (!isOpen) return; + + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpenInfoId(null); + } + } + + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, [isOpen]); + + 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); + + let borderBgClass = ''; + if (isDisabled) { + borderBgClass = 'border-slate-800 bg-slate-900/40 opacity-50 cursor-not-allowed'; + } else { + borderBgClass = + 'border-slate-700 bg-slate-800/70 hover:border-amber-500/60 hover:bg-slate-800 cursor-pointer'; + if (action.kind === 'story') { + borderBgClass = + 'border-amber-500/50 bg-slate-800/70 hover:border-amber-500/80 hover:bg-slate-800 cursor-pointer'; + } else if (action.kind === 'loop' && action.loopEnabled) { + borderBgClass = + 'border-amber-500 bg-amber-500/10 shadow-[0_0_8px_rgba(245,158,11,0.15)] hover:border-amber-400 hover:bg-amber-500/15 cursor-pointer'; + } + } + + const tooltipId = `tooltip-${action.id}`; + + return ( +
+ + {prefs.actionDetailMode === 'info-button' && action.storyTooltip ? ( +
+ + {openInfoId === action.id ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/ui/ActionColumn.tsx b/src/ui/ActionColumn.tsx new file mode 100644 index 0000000..ee7229c --- /dev/null +++ b/src/ui/ActionColumn.tsx @@ -0,0 +1,23 @@ +import type { ActionGroupView } from '../state/viewModel'; +import { ActionGroup } from './ActionGroup'; + +interface ActionColumnProps { + label: string; + groups: ActionGroupView[]; + actionKind: string; +} + +export function ActionColumn({ label, groups, actionKind }: ActionColumnProps) { + return ( +
+

+ {label} +

+
+ {groups.map((group) => ( + + ))} +
+
+ ); +} diff --git a/src/ui/ActionGroup.tsx b/src/ui/ActionGroup.tsx new file mode 100644 index 0000000..58ca8ae --- /dev/null +++ b/src/ui/ActionGroup.tsx @@ -0,0 +1,36 @@ +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; +import type { ActionGroupView } from '../state/viewModel'; +import { ActionCard } from './ActionCard'; + +interface ActionGroupProps { + group: ActionGroupView; + actionKind: string; +} + +export function ActionGroup({ group, actionKind }: ActionGroupProps) { + const collapsed = useGameStore( + (s) => !!s.prefs.collapsedActionGroups[`${actionKind}:${group.id}`], + ); + + return ( +
+ + {!collapsed && ( +
+ {group.actions.map((action) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/ui/ActionPanel.tsx b/src/ui/ActionPanel.tsx deleted file mode 100644 index 5dd2f37..0000000 --- a/src/ui/ActionPanel.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState } from 'react'; -import { gameRuntime } from '../state/runtime'; -import { useGameStore } from '../state/store'; - -/** Action list with queue, cancel, disabled states, and story hints/tooltips. */ -export function ActionPanel() { - 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

- {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} -
- ); - })} - {queuedActionNames.length > 0 ? ( -
    - {queuedActionNames.map((name, index) => ( -
  1. - {name} - -
  2. - ))} -
- ) : null} -
- ); -} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index bdc9ee0..fe4bfa9 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,65 +1,45 @@ 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'; +import { AboutPanel } from './AboutPanel'; +import { AppShell } from './AppShell'; +import { NavRail } from './NavRail'; +import { PlayPanel } from './PlayPanel'; +import { RightRail } from './RightRail'; +import { SettingsPanel } from './SettingsPanel'; +import { StoryView } from './StoryView'; /** * 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); + const activePanel = useGameStore((s) => s.activePanel); useEffect(() => { void gameRuntime.boot(); }, []); + const renderActivePanel = () => { + switch (activePanel) { + case 'play': + return ; + case 'story': + return ; + case 'settings': + return ; + case 'about': + return ; + default: + return ; + } + }; + return ( - <> - -
-
-
-

Idlegame

-
- - -
-
-

M1 playable loop

- -
- - - -
- + } + center={renderActivePanel()} + right={activePanel === 'play' ? : undefined} + /> ); } diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx new file mode 100644 index 0000000..b120c1a --- /dev/null +++ b/src/ui/AppShell.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; + +interface AppShellProps { + nav: ReactNode; + center: ReactNode; + right?: ReactNode; +} + +export function AppShell({ nav, center, right }: AppShellProps) { + return ( +
+
{nav}
+
{center}
+ {right ? ( + + ) : null} +
+ ); +} diff --git a/src/ui/NavRail.tsx b/src/ui/NavRail.tsx new file mode 100644 index 0000000..7f316b2 --- /dev/null +++ b/src/ui/NavRail.tsx @@ -0,0 +1,143 @@ +import { gameRuntime } from '../state/runtime'; +import { type ActivePanel, useGameStore } from '../state/store'; + +export function NavRail() { + const activePanel = useGameStore((s) => s.activePanel); + const storyHasUnread = useGameStore((s) => s.storyHasUnread); + + const navItems: { id: ActivePanel; label: string; icon: React.ReactNode }[] = [ + { + id: 'play', + label: 'Play', + icon: ( + + ), + }, + { + id: 'story', + label: 'Story', + icon: ( + + ), + }, + { + id: 'settings', + label: 'Settings', + icon: ( + + ), + }, + { + id: 'about', + label: 'About', + icon: ( + + ), + }, + ]; + + return ( +
+
+ + IDLEGAME + + + M1 + +
+ + + +
+ senpai edition +
+
+ ); +} diff --git a/src/ui/PlayPanel.tsx b/src/ui/PlayPanel.tsx new file mode 100644 index 0000000..bff01aa --- /dev/null +++ b/src/ui/PlayPanel.tsx @@ -0,0 +1,66 @@ +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; +import { ActionColumn } from './ActionColumn'; +import { EventLog } from './EventLog'; +import { ResourceBar } from './ResourceBar'; + +export function PlayPanel() { + const showEventLog = useGameStore((s) => s.prefs.showEventLog); + const columns = useGameStore((s) => s.actionColumns).filter( + (col) => col.groups.length > 0 && col.groups.some((g) => g.actions.length > 0), + ); + const queuedActionIds = useGameStore((s) => s.queuedActionIds); + const queuedActionNames = useGameStore((s) => s.queuedActionNames); + + return ( +
+
+ +
+ +
+ {columns.map((col) => ( + + ))} +
+ + {queuedActionNames.length > 0 ? ( +
+

+ Action Queue +

+
    + {queuedActionNames.map((name, index) => ( +
  1. + {name} + +
  2. + ))} +
+
+ ) : null} + + {showEventLog && ( +
+ +
+ )} +
+ ); +} diff --git a/src/ui/RightRail.tsx b/src/ui/RightRail.tsx new file mode 100644 index 0000000..ee4fb0c --- /dev/null +++ b/src/ui/RightRail.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { useGameStore } from '../state/store'; +import { EventLog } from './EventLog'; +import { ResourceBar } from './ResourceBar'; + +export function RightRail() { + const showEventLog = useGameStore((s) => s.prefs.showEventLog); + const [logOpen, setLogOpen] = useState(true); + + return ( +
+ {/* Resources */} +
+

+ Resources +

+ +
+ + {/* Inventory placeholder */} +
+

+ Inventory +

+

Items coming soon.

+
+ + {/* Event log — gated by pref, collapsible via local state */} + {showEventLog && ( +
+ +
{logOpen && }
+
+ )} +
+ ); +} diff --git a/src/ui/SettingsDrawer.tsx b/src/ui/SettingsDrawer.tsx deleted file mode 100644 index 0d467ff..0000000 --- a/src/ui/SettingsDrawer.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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/SettingsPanel.tsx b/src/ui/SettingsPanel.tsx new file mode 100644 index 0000000..33acc67 --- /dev/null +++ b/src/ui/SettingsPanel.tsx @@ -0,0 +1,103 @@ +import type { ActionDetailMode, StoryOpenMode } from '../state/prefs'; +import { useGameStore } from '../state/store'; + +export function SettingsPanel() { + const prefs = useGameStore((s) => s.prefs); + const setPrefs = useGameStore((s) => s.setPrefs); + + return ( +
+
+

Game Settings

+

Configure your gameplay and UI preferences.

+
+ +
+ {/* Story Open Mode */} +
+
+ Story Navigation + + How should the game navigate when story events are unlocked? + +
+
+ {(['auto', 'choices-only', 'manual'] as StoryOpenMode[]).map((mode) => { + const labels: Record = { + auto: 'Automatically', + 'choices-only': 'Choices Only', + manual: 'Manually', + }; + const isActive = prefs.storyOpenMode === mode; + return ( + + ); + })} +
+
+ + {/* Action Detail Mode */} +
+
+ Action Details + + Where should action requirements and details be shown? + +
+
+ {(['inline', 'hover', 'info-button'] as ActionDetailMode[]).map((mode) => { + const labels: Record = { + inline: 'Inline', + hover: 'Hover Tooltips', + 'info-button': 'Info Button', + }; + const isActive = prefs.actionDetailMode === mode; + return ( + + ); + })} +
+
+ {/* Show event log */} +
+ +
+
+
+ ); +} diff --git a/src/ui/StoryPanel.tsx b/src/ui/StoryPanel.tsx deleted file mode 100644 index 97481f2..0000000 --- a/src/ui/StoryPanel.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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) => ( - - ))} -
- ) : ( - - )} - -
-
-
- ); -} diff --git a/src/ui/StoryProseLog.tsx b/src/ui/StoryProseLog.tsx new file mode 100644 index 0000000..7cf8fd9 --- /dev/null +++ b/src/ui/StoryProseLog.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react'; +import type { StoryLogEntry } from '../state/store'; + +interface StoryProseLogProps { + log: StoryLogEntry[]; + selectedId: string | null; +} + +export function StoryProseLog({ log, selectedId }: StoryProseLogProps) { + const scrollRef = useRef(null); + const selectedEntryRef = useRef(null); + const prevLogCountRef = useRef(log.length); + + // Auto-scroll to bottom when new entries arrive + useEffect(() => { + if (log.length > prevLogCountRef.current && !selectedId && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + prevLogCountRef.current = log.length; + }, [log.length, selectedId]); + + // Scroll to selected entry when selectedId changes + useEffect(() => { + if (selectedId && selectedEntryRef.current) { + selectedEntryRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [selectedId]); + + if (log.length === 0) { + return ( +
+

The chronicle is empty…

+
+ ); + } + + return ( +
+

+ Prose Log +

+
    + {log.map((entry, i) => { + const isHighlighted = selectedId != null && entry.nodeId === selectedId; + // Find first matching entry for the scroll-into-view ref + const isFirstMatch = isHighlighted && log.findIndex((e) => e.nodeId === selectedId) === i; + + return ( +
  • + {entry.choiceLabel ? ( + + {entry.choiceLabel} + + ) : null} + {entry.prose} +
  • + ); + })} +
+
+ ); +} diff --git a/src/ui/StoryTree.tsx b/src/ui/StoryTree.tsx new file mode 100644 index 0000000..26cda2f --- /dev/null +++ b/src/ui/StoryTree.tsx @@ -0,0 +1,92 @@ +import type { StoryTreeNodeView } from '../state/viewModel'; + +interface StoryTreeProps { + nodes: StoryTreeNodeView[]; + selectedId: string | null; + onSelect: (id: string) => void; +} + +interface StoryTreeNodeProps { + node: StoryTreeNodeView; + depth: number; + selectedId: string | null; + onSelect: (id: string) => void; +} + +function StoryTreeNode({ node, depth, selectedId, onSelect }: StoryTreeNodeProps) { + const isSelected = node.id === selectedId; + + let labelClasses = + 'w-full cursor-pointer rounded px-2 py-1 text-left text-sm transition-colors duration-150'; + + if (isSelected) { + labelClasses += ' bg-amber-500/15 border border-amber-500/40 text-amber-200'; + } else if (node.active) { + labelClasses += ' text-amber-400 hover:bg-slate-800/60 border border-transparent'; + } else if (!node.seen) { + labelClasses += ' text-slate-600 hover:bg-slate-800/40 border border-transparent'; + } else { + labelClasses += ' text-slate-300 hover:bg-slate-800/60 border border-transparent'; + } + + return ( +
  • + + {node.children.length > 0 && ( +
      + {node.children.map((child) => ( + + ))} +
    + )} +
  • + ); +} + +export function StoryTree({ nodes, selectedId, onSelect }: StoryTreeProps) { + if (nodes.length === 0) { + return ( +
    +

    No story branches yet…

    +
    + ); + } + + return ( +
    +

    + Story Tree +

    +
      + {nodes.map((node) => ( + + ))} +
    +
    + ); +} diff --git a/src/ui/StoryView.tsx b/src/ui/StoryView.tsx new file mode 100644 index 0000000..dd932ea --- /dev/null +++ b/src/ui/StoryView.tsx @@ -0,0 +1,42 @@ +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; +import { StoryProseLog } from './StoryProseLog'; +import { StoryTree } from './StoryTree'; + +const SHELL_HEIGHT = 'h-[calc(100dvh-6rem)]'; + +export function StoryView() { + const tree = useGameStore((s) => s.story.tree); + const log = useGameStore((s) => s.storyLog); + const selectedId = useGameStore((s) => s.selectedStoryNodeId); + const currentProse = useGameStore((s) => s.story.currentProse); + const isBootIntro = useGameStore((s) => s.story.atBootIntro); + + if (isBootIntro) { + return ( +
    +

    + {currentProse} +

    + +
    + ); + } + + return ( +
    + gameRuntime.selectStoryNode(id)} + /> + +
    + ); +}