feat(m1): PR3 T3.0 — shell UI, action kinds, story tab #16

Merged
ginnoir merged 26 commits from feat/m1-progression into main 2026-06-11 22:52:29 -05:00
44 changed files with 2431 additions and 400 deletions
+39 -2
View File
@@ -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
+12 -5
View File
@@ -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);
+78
View File
@@ -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/);
});
});
@@ -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 }],
+34 -1
View File
@@ -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: [],
},
];
+1 -1
View File
@@ -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';
+51 -8
View File
@@ -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<typeof resourceDefSchema>;
export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
export type UnlockDef = z.infer<typeof unlockDefSchema>;
export type ActionKind = z.infer<typeof actionKindSchema>;
export type ActionGroup = z.infer<typeof actionGroupSchema>;
export type ActionDef = z.infer<typeof actionDefSchema>;
export interface Content {
+1
View File
@@ -10,6 +10,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 300,
yields: [{ resourceId: 'gold', amount: 1 }],
},
+209 -3
View File
@@ -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/);
});
});
+36 -3
View File
@@ -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<string[]> {
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);
}
}
});
+60
View File
@@ -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();
+7
View File
@@ -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 }],
+22
View File
@@ -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();
});
});
+112
View File
@@ -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<string, boolean>;
}
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;
+2
View File
@@ -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 },
},
};
}
+12
View File
@@ -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,
+16
View File
@@ -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);
}
+18 -2
View File
@@ -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,
),
+55 -2
View File
@@ -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);
});
});
});
+266
View File
@@ -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<string, string> = {};
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));
});
});
+55
View File
@@ -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<string, string> = {};
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);
});
});
+146 -3
View File
@@ -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']),
);
});
});
+1
View File
@@ -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 {
+19 -2
View File
@@ -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<string, boolean>;
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 };
+119 -65
View File
@@ -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<void> {
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();
});
}
+23 -10
View File
@@ -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<GamePrefs>) => void;
setSettingsOpen: (open: boolean) => void;
setActivePanel: (panel: ActivePanel) => void;
setSelectedStoryNodeId: (id: string | null) => void;
toggleActionGroupCollapsed: (groupKey: string) => void;
}
export const useGameStore = create<GameStoreState>((set) => ({
export const useGameStore = create<GameStoreState>((set, get) => ({
resources: [],
activeActionId: null,
actionName: null,
@@ -40,21 +43,31 @@ export const useGameStore = create<GameStoreState>((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 });
},
}));
+11 -7
View File
@@ -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 };
}
+137 -12
View File
@@ -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<ActionColumnKind, string> = {
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<string>();
const childrenOf = new Map<string, string[]>();
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<string>): 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<string>()));
}
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<string, ActionView>();
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<string, { id: string; label: string; actions: ActionView[] }>();
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,
};
}
+53
View File
@@ -0,0 +1,53 @@
export function AboutPanel() {
return (
<div className="max-w-2xl space-y-6">
<div>
<h2 className="font-bold text-slate-100 text-xl tracking-tight">About Idlegame</h2>
<p className="text-slate-400 text-sm">A text-fantasy RPG incremental experience.</p>
</div>
<div className="space-y-6 text-slate-300">
<section className="space-y-2">
<h3 className="font-semibold text-slate-200 text-sm tracking-wide uppercase">
How to Play
</h3>
<p className="text-sm leading-relaxed text-slate-400">
Idlegame is driven by actions and choices. Select actions from the{' '}
<strong className="text-amber-400">Play</strong> 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.
</p>
</section>
<section className="space-y-2">
<h3 className="font-semibold text-slate-200 text-sm tracking-wide uppercase">
The Chronicle
</h3>
<p className="text-sm leading-relaxed text-slate-400">
As you perform actions, you will unlock narrative points of interest. Head to the{' '}
<strong className="text-amber-400">Story</strong> tab to make crucial choices and read
the history of your journey.
</p>
</section>
<section className="space-y-2">
<h3 className="font-semibold text-slate-200 text-sm tracking-wide uppercase">
Technical Specs
</h3>
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<dt className="text-slate-500">Version</dt>
<dd className="font-mono text-slate-300">0.1.0 (M1 Milestone)</dd>
<dt className="text-slate-500">Engine</dt>
<dd className="text-slate-300">Pure TypeScript State Machine</dd>
<dt className="text-slate-500">Framework</dt>
<dd className="text-slate-300">React + Zustand + Tailwind CSS</dd>
<dt className="text-slate-500">Target Platform</dt>
<dd className="text-slate-300">Responsive Web & PWA</dd>
</dl>
</div>
</section>
</div>
</div>
);
}
+153
View File
@@ -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<string | null>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative flex gap-1 w-full">
<button
type="button"
disabled={isDisabled}
aria-pressed={action.kind === 'loop' ? action.loopEnabled : undefined}
title={prefs.actionDetailMode === 'hover' ? action.storyTooltip : undefined}
onClick={() => gameRuntime.performAction(action.id)}
className={`relative flex-1 overflow-hidden rounded-lg border px-4 py-3 text-left transition-all duration-200 ${borderBgClass}`}
>
{isActive ? (
<div
className="absolute inset-y-0 left-0 bg-amber-500/15 transition-all duration-100 ease-linear pointer-events-none"
style={{ width: `${Math.round(actionProgress * 100)}%` }}
aria-hidden="true"
/>
) : null}
<div className="relative flex flex-col gap-1 w-full">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-slate-100 flex items-center gap-2">
{action.kind === 'loop' && (
<span
className={`h-4 w-4 rounded border flex items-center justify-center transition-colors shrink-0 ${
action.loopEnabled
? 'border-amber-500 bg-amber-500 text-slate-950'
: 'border-slate-600 bg-slate-900/50'
}`}
aria-hidden="true"
>
{action.loopEnabled && (
<svg
className="h-2.5 w-2.5 stroke-slate-950 stroke-[3] fill-none"
viewBox="0 0 24 24"
>
<title>Loop enabled</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span>
)}
{action.name}
</span>
<div className="flex items-center gap-1.5">
{action.kind === 'story' && (
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-amber-300 uppercase tracking-wider">
Story
</span>
)}
<span className="text-slate-400 text-xs shrink-0">
{isActive
? 'running…'
: isDisabled
? (action.disabledReason ?? 'unavailable')
: 'start'}
</span>
</div>
</div>
{summaryParts.length > 0 ? (
<p className="text-slate-500 text-xs">{summaryParts.join(' · ')}</p>
) : null}
{prefs.actionDetailMode === 'inline' && action.storyHint ? (
<p className="text-slate-400 text-xs mt-0.5">{action.storyHint}</p>
) : null}
</div>
</button>
{prefs.actionDetailMode === 'info-button' && action.storyTooltip ? (
<div className="relative shrink-0 flex">
<button
type="button"
aria-label={`Details for ${action.name}`}
aria-expanded={openInfoId === action.id}
aria-describedby={openInfoId === action.id ? tooltipId : undefined}
onClick={() => setOpenInfoId(openInfoId === action.id ? null : action.id)}
className="flex items-center rounded-lg border border-slate-700 bg-slate-800/70 px-2 text-slate-400 transition-colors hover:border-amber-500/60 hover:text-slate-200"
>
</button>
{openInfoId === action.id ? (
<div
id={tooltipId}
role="tooltip"
className="absolute top-full right-0 z-10 mt-1 w-64 rounded-lg border border-slate-600 bg-slate-900 p-3 text-slate-300 text-xs shadow-lg"
>
{action.storyTooltip}
</div>
) : null}
</div>
) : null}
</div>
);
}
+23
View File
@@ -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 (
<div className="w-80 min-w-80 shrink-0 flex flex-col gap-4 bg-slate-900/40 rounded-xl p-4 border border-slate-800/60">
<h2 className="font-bold text-slate-200 text-sm uppercase tracking-widest border-b border-slate-800 pb-2 select-none">
{label}
</h2>
<div className="flex flex-col gap-3">
{groups.map((group) => (
<ActionGroup key={group.id} group={group} actionKind={actionKind} />
))}
</div>
</div>
);
}
+36
View File
@@ -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 (
<div className="flex flex-col gap-1 w-full">
<button
type="button"
aria-expanded={!collapsed}
onClick={() => gameRuntime.toggleActionGroupCollapsed(`${actionKind}:${group.id}`)}
className="flex w-full items-center justify-between py-1.5 text-left text-slate-400 hover:text-slate-200 transition-colors focus:outline-none"
>
<span className="font-semibold text-xs uppercase tracking-wider">{group.label}</span>
<span className="text-slate-500 text-xs shrink-0 select-none">{collapsed ? '▶' : '▼'}</span>
</button>
{!collapsed && (
<div className="flex flex-col gap-2 pl-1">
{group.actions.map((action) => (
<ActionCard key={action.id} action={action} />
))}
</div>
)}
</div>
);
}
-112
View File
@@ -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<string | null>(null);
return (
<section aria-label="Actions" className="flex flex-col gap-2">
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Actions</h2>
{actions.map((action) => {
const isActive = action.id === activeActionId;
const isDisabled = !action.available && !isActive;
const summaryParts = [
action.costsSummary ? `Cost: ${action.costsSummary}` : null,
action.yieldsSummary ? `Yield: ${action.yieldsSummary}` : null,
].filter(Boolean);
return (
<div key={action.id} className="relative flex gap-1">
<button
type="button"
disabled={isDisabled}
title={prefs.actionDetailMode === 'hover' ? action.storyTooltip : undefined}
onClick={() => gameRuntime.enqueueAction(action.id)}
className={`relative w-full overflow-hidden rounded-lg border px-4 py-3 text-left transition-colors ${
isDisabled
? 'cursor-not-allowed border-slate-800 bg-slate-900/40 opacity-50'
: 'border-slate-700 bg-slate-800/70 hover:border-amber-500/60 hover:bg-slate-800'
}`}
>
{isActive ? (
<div
className="absolute inset-y-0 left-0 bg-amber-500/15"
style={{ width: `${Math.round(actionProgress * 100)}%` }}
aria-hidden="true"
/>
) : null}
<div className="relative flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="font-medium text-slate-100">{action.name}</span>
<span className="text-slate-400 text-xs">
{isActive
? 'running…'
: isDisabled
? (action.disabledReason ?? 'unavailable')
: 'start'}
</span>
</div>
{summaryParts.length > 0 ? (
<p className="text-slate-500 text-xs">{summaryParts.join(' · ')}</p>
) : null}
{prefs.actionDetailMode === 'inline' && action.storyHint ? (
<p className="text-slate-400 text-xs">{action.storyHint}</p>
) : null}
</div>
</button>
{prefs.actionDetailMode === 'info-button' && action.storyTooltip ? (
<div className="relative shrink-0">
<button
type="button"
aria-label={`Details for ${action.name}`}
aria-expanded={openInfoId === action.id}
onClick={() => setOpenInfoId(openInfoId === action.id ? null : action.id)}
className="flex h-full items-center rounded-lg border border-slate-700 bg-slate-800/70 px-2 text-slate-400 transition-colors hover:border-amber-500/60 hover:text-slate-200"
title={action.storyTooltip}
>
</button>
{openInfoId === action.id ? (
<div
role="tooltip"
className="absolute top-full right-0 z-10 mt-1 max-w-xs rounded-lg border border-slate-600 bg-slate-900 p-3 text-slate-300 text-xs shadow-lg"
>
{action.storyTooltip}
</div>
) : null}
</div>
) : null}
</div>
);
})}
{queuedActionNames.length > 0 ? (
<ol aria-label="Action queue" className="mt-2 flex flex-col gap-1">
{queuedActionNames.map((name, index) => (
<li
key={queuedActionIds[index]}
className="flex items-center justify-between rounded border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
>
<span className="text-slate-300">{name}</span>
<button
type="button"
aria-label={`Cancel ${name}`}
onClick={() => gameRuntime.cancelQueuedAction(index)}
className="rounded px-2 py-0.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-amber-400"
>
</button>
</li>
))}
</ol>
) : null}
</section>
);
}
+28 -48
View File
@@ -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 <PlayPanel />;
case 'story':
return <StoryView />;
case 'settings':
return <SettingsPanel />;
case 'about':
return <AboutPanel />;
default:
return <PlayPanel />;
}
};
return (
<>
<StoryPanel />
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100">
<header className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<h1 className="font-bold text-2xl tracking-tight">Idlegame</h1>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={storyHasUnread ? 'Story, unread' : 'Story'}
onClick={() => gameRuntime.openStoryPanel()}
className="relative rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
>
Story
{storyHasUnread ? (
<span
className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-amber-500"
aria-hidden
/>
) : null}
</button>
<button
type="button"
aria-label="Settings"
aria-expanded={settingsOpen}
onClick={() => setSettingsOpen(!settingsOpen)}
className="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
>
</button>
</div>
</div>
<p className="text-slate-500 text-sm">M1 playable loop</p>
<SettingsDrawer />
</header>
<ResourceBar />
<ActionPanel />
<EventLog />
</main>
</>
<AppShell
nav={<NavRail />}
center={renderActivePanel()}
right={activePanel === 'play' ? <RightRail /> : undefined}
/>
);
}
+27
View File
@@ -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 (
<div
className={`grid min-h-dvh text-slate-100 ${
right
? 'grid-cols-[auto_1fr] md:grid-cols-[14rem_1fr_auto]'
: 'grid-cols-[auto_1fr] md:grid-cols-[14rem_1fr]'
}`}
>
<div className="border-slate-800 border-r bg-slate-950">{nav}</div>
<main className="overflow-y-auto p-6">{center}</main>
{right ? (
<aside className="hidden w-72 border-slate-800 border-l bg-slate-950 p-6 md:block">
{right}
</aside>
) : null}
</div>
);
}
+143
View File
@@ -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: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"
/>
</svg>
),
},
{
id: 'story',
label: 'Story',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
),
},
{
id: 'settings',
label: 'Settings',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
{
id: 'about',
label: 'About',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
},
];
return (
<div className="flex h-full flex-col p-2 md:p-4">
<div className="mb-8 hidden items-center justify-between px-2 md:flex">
<span className="bg-gradient-to-r from-amber-400 via-amber-200 to-amber-500 bg-clip-text font-extrabold text-lg text-transparent tracking-wider">
IDLEGAME
</span>
<span className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] font-semibold text-slate-400 tracking-wide">
M1
</span>
</div>
<nav className="flex flex-1 flex-col gap-1.5">
{navItems.map((item) => {
const isActive = activePanel === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => gameRuntime.setActivePanel(item.id)}
className={`relative flex cursor-pointer items-center justify-center md:justify-start gap-3 rounded-lg px-2.5 py-2.5 md:px-3 text-sm font-medium transition-all duration-200 ${
isActive
? 'border border-amber-500/30 bg-amber-500/10 text-amber-400'
: 'border border-transparent text-slate-400 hover:bg-slate-900/60 hover:text-slate-200'
}`}
>
{item.icon}
<span className="hidden md:inline">{item.label}</span>
{item.id === 'story' && storyHasUnread ? (
<>
<span
className="absolute top-1.5 right-1.5 md:top-1/2 md:right-3 h-2 w-2 md:-translate-y-1/2 animate-pulse rounded-full bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.6)]"
aria-hidden="true"
/>
<span className="sr-only">New story</span>
</>
) : null}
</button>
);
})}
</nav>
<div className="mt-auto hidden border-slate-800/85 border-t pt-4 text-center md:block">
<span className="text-[11px] text-slate-600">senpai edition</span>
</div>
</div>
);
}
+66
View File
@@ -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 (
<div className="space-y-6">
<div className="block md:hidden">
<ResourceBar />
</div>
<section aria-label="Actions" className="flex gap-4 overflow-x-auto pb-4">
{columns.map((col) => (
<ActionColumn
key={col.kind}
label={col.label}
groups={col.groups}
actionKind={col.kind}
/>
))}
</section>
{queuedActionNames.length > 0 ? (
<div className="border border-slate-800 bg-slate-900/30 rounded-xl p-4">
<h3 className="font-semibold text-slate-400 text-xs uppercase tracking-wider mb-2">
Action Queue
</h3>
<ol aria-label="Action queue" className="flex flex-col gap-1.5">
{queuedActionNames.map((name, index) => (
<li
// biome-ignore lint/suspicious/noArrayIndexKey: indices are stable and identify individual queue items
key={`${queuedActionIds[index]}-${index}`}
className="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
>
<span className="text-slate-300">{name}</span>
<button
type="button"
aria-label={`Cancel ${name}`}
onClick={() => gameRuntime.cancelQueuedAction(index)}
className="rounded px-2 py-0.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-amber-400"
>
</button>
</li>
))}
</ol>
</div>
) : null}
{showEventLog && (
<div className="block md:hidden">
<EventLog />
</div>
)}
</div>
);
}
+48
View File
@@ -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 (
<div className="flex h-full flex-col gap-6">
{/* Resources */}
<div>
<h2 className="mb-3 font-semibold text-slate-400 text-xs tracking-wider uppercase">
Resources
</h2>
<ResourceBar />
</div>
{/* Inventory placeholder */}
<div className="border-slate-900 border-t pt-4">
<h2 className="mb-3 font-semibold text-slate-400 text-xs tracking-wider uppercase">
Inventory
</h2>
<p className="text-slate-600 text-xs">Items coming soon.</p>
</div>
{/* Event log — gated by pref, collapsible via local state */}
{showEventLog && (
<div className="flex-1 border-slate-900 border-t pt-4">
<button
type="button"
aria-expanded={logOpen}
aria-controls="right-rail-event-log"
onClick={() => setLogOpen((prev) => !prev)}
className="mb-3 flex w-full items-center justify-between font-semibold text-slate-400 text-xs tracking-wider uppercase hover:text-slate-300"
>
<span>Event Log</span>
<span aria-hidden="true" className="text-slate-500">
{logOpen ? '▾' : '▸'}
</span>
</button>
<div id="right-rail-event-log">{logOpen && <EventLog />}</div>
</div>
)}
</div>
);
}
-40
View File
@@ -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 (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-4">
<label className="flex flex-col gap-1 text-sm">
Story opens
<select
value={prefs.storyOpenMode}
onChange={(e) => setPrefs({ storyOpenMode: e.target.value as StoryOpenMode })}
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100"
>
<option value="auto">Automatically</option>
<option value="choices-only">Choices only</option>
<option value="manual">Manually</option>
</select>
</label>
<label className="mt-3 flex flex-col gap-1 text-sm">
Action details
<select
value={prefs.actionDetailMode}
onChange={(e) => setPrefs({ actionDetailMode: e.target.value as ActionDetailMode })}
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100"
>
<option value="inline">Inline subtitles</option>
<option value="hover">Hover tooltips</option>
<option value="info-button">Info button</option>
</select>
</label>
</div>
);
}
+103
View File
@@ -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 (
<div className="max-w-xl space-y-6">
<div>
<h2 className="font-bold text-slate-100 text-xl tracking-tight">Game Settings</h2>
<p className="text-slate-400 text-sm">Configure your gameplay and UI preferences.</p>
</div>
<div className="space-y-4">
{/* Story Open Mode */}
<div className="flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900/40 p-5">
<div className="flex flex-col">
<span className="font-semibold text-slate-200 text-sm">Story Navigation</span>
<span className="text-slate-400 text-xs">
How should the game navigate when story events are unlocked?
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
{(['auto', 'choices-only', 'manual'] as StoryOpenMode[]).map((mode) => {
const labels: Record<StoryOpenMode, string> = {
auto: 'Automatically',
'choices-only': 'Choices Only',
manual: 'Manually',
};
const isActive = prefs.storyOpenMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => setPrefs({ storyOpenMode: mode })}
className={`cursor-pointer rounded-lg border px-3 py-2 text-xs font-medium transition-all duration-200 ${
isActive
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-slate-800 bg-slate-950/20 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`}
>
{labels[mode]}
</button>
);
})}
</div>
</div>
{/* Action Detail Mode */}
<div className="flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900/40 p-5">
<div className="flex flex-col">
<span className="font-semibold text-slate-200 text-sm">Action Details</span>
<span className="text-slate-400 text-xs">
Where should action requirements and details be shown?
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
{(['inline', 'hover', 'info-button'] as ActionDetailMode[]).map((mode) => {
const labels: Record<ActionDetailMode, string> = {
inline: 'Inline',
hover: 'Hover Tooltips',
'info-button': 'Info Button',
};
const isActive = prefs.actionDetailMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => setPrefs({ actionDetailMode: mode })}
className={`cursor-pointer rounded-lg border px-3 py-2 text-xs font-medium transition-all duration-200 ${
isActive
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-slate-800 bg-slate-950/20 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`}
>
{labels[mode]}
</button>
);
})}
</div>
</div>
{/* Show event log */}
<div className="flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900/40 p-5">
<label className="flex cursor-pointer items-center justify-between gap-4">
<div className="flex flex-col">
<span className="font-semibold text-slate-200 text-sm">Show Event Log</span>
<span className="text-slate-400 text-xs">
Display the scrollable event log in the right rail and on mobile.
</span>
</div>
<input
type="checkbox"
checked={prefs.showEventLog}
onChange={(e) => setPrefs({ showEventLog: e.target.checked })}
className="h-4 w-4 cursor-pointer accent-amber-500"
/>
</label>
</div>
</div>
</div>
);
}
-74
View File
@@ -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 (
<div
className="fixed inset-0 z-50 flex bg-black/80"
role="dialog"
aria-modal="true"
aria-label="Story"
>
<div className="flex flex-1 flex-col md:flex-row">
<aside className="hidden max-h-full w-full overflow-y-auto border-slate-700 border-r p-4 md:block md:w-1/3">
<h3 className="mb-2 font-medium text-slate-400 text-xs uppercase tracking-wide">
Story log
</h3>
<ul className="flex flex-col gap-2 text-slate-300 text-sm">
{storyLog.map((entry) => (
<li key={`${entry.nodeId}:${entry.choiceLabel ?? ''}:${entry.prose}`}>
{entry.choiceLabel ? (
<span className="text-amber-400/80">[{entry.choiceLabel}] </span>
) : null}
{entry.prose}
</li>
))}
</ul>
</aside>
<main className="flex flex-1 flex-col gap-4 p-6">
<p className="flex-1 text-lg text-slate-100 leading-relaxed">{story.currentProse}</p>
{hasChoices ? (
<div className="flex flex-col gap-2">
{story.choices.map((choice) => (
<button
key={choice.id}
type="button"
disabled={choice.disabled}
title={choice.disabled ? (choice.disabledReason ?? undefined) : undefined}
onClick={() => gameRuntime.applyStoryChoice(choice.id)}
className="rounded-lg border border-slate-600 bg-slate-900/60 px-4 py-3 text-left transition-colors hover:border-amber-500/60 disabled:cursor-not-allowed disabled:opacity-40"
>
{choice.label}
</button>
))}
</div>
) : (
<button
type="button"
onClick={() => gameRuntime.continueStory()}
className="self-start rounded-lg bg-amber-600 px-4 py-2 transition-colors hover:bg-amber-500"
>
Continue
</button>
)}
<button
type="button"
onClick={() => gameRuntime.closeStoryPanel()}
className="self-end text-slate-400 text-sm transition-colors hover:text-slate-200"
>
Close
</button>
</main>
</div>
</div>
);
}
+73
View File
@@ -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<HTMLDivElement>(null);
const selectedEntryRef = useRef<HTMLLIElement>(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 (
<div className="flex h-full items-center justify-center rounded-xl border border-slate-800/60 bg-slate-900/40 shadow-inner">
<p className="text-sm text-slate-500 italic">The chronicle is empty</p>
</div>
);
}
return (
<div
ref={scrollRef}
className="overflow-y-auto rounded-xl border border-slate-800/60 bg-slate-900/40 p-4 shadow-inner"
>
<h2 className="mb-3 text-xs font-semibold tracking-wider text-slate-400 uppercase">
Prose Log
</h2>
<ul className="space-y-3">
{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 (
<li
key={`${entry.nodeId}:${String(i)}`}
ref={isFirstMatch ? selectedEntryRef : undefined}
className={`rounded-lg border px-3 py-2 text-sm leading-relaxed transition-colors duration-200 ${
isHighlighted
? 'border-amber-500/30 bg-amber-500/10 text-slate-100'
: 'border-transparent text-slate-300'
}`}
>
{entry.choiceLabel ? (
<span className="mb-1 mr-2 inline-block rounded bg-amber-500/20 px-1.5 py-0.5 text-xs font-medium text-amber-300">
{entry.choiceLabel}
</span>
) : null}
<span className="whitespace-pre-wrap">{entry.prose}</span>
</li>
);
})}
</ul>
</div>
);
}
+92
View File
@@ -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 (
<li>
<button
type="button"
className={labelClasses}
style={{ marginLeft: `${depth * 1}rem` }}
onClick={() => onSelect(node.id)}
aria-current={isSelected ? 'true' : undefined}
>
<span className="flex items-center gap-1.5">
{node.active && (
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
)}
{node.label}
</span>
</button>
{node.children.length > 0 && (
<ul className="mt-0.5 space-y-0.5">
{node.children.map((child) => (
<StoryTreeNode
key={child.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</ul>
)}
</li>
);
}
export function StoryTree({ nodes, selectedId, onSelect }: StoryTreeProps) {
if (nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-slate-500 italic">No story branches yet</p>
</div>
);
}
return (
<div className="overflow-y-auto rounded-xl border border-slate-800/60 bg-slate-900/40 p-4 shadow-inner">
<h2 className="mb-3 text-xs font-semibold tracking-wider text-slate-400 uppercase">
Story Tree
</h2>
<ul className="space-y-0.5">
{nodes.map((node) => (
<StoryTreeNode
key={node.id}
node={node}
depth={0}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</ul>
</div>
);
}
+42
View File
@@ -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 (
<div className={`flex ${SHELL_HEIGHT} flex-col items-center justify-center gap-6`}>
<p className="max-w-lg text-center text-lg leading-relaxed text-slate-100 whitespace-pre-wrap">
{currentProse}
</p>
<button
type="button"
onClick={() => gameRuntime.continueStory()}
className="cursor-pointer rounded-lg bg-amber-600 px-6 py-2.5 font-medium text-slate-100 transition-colors hover:bg-amber-500"
>
Continue
</button>
</div>
);
}
return (
<div className={`grid ${SHELL_HEIGHT} grid-cols-[3fr_2fr] gap-4`}>
<StoryTree
nodes={tree}
selectedId={selectedId}
onSelect={(id) => gameRuntime.selectStoryNode(id)}
/>
<StoryProseLog log={log} selectedId={selectedId} />
</div>
);
}