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, resource and one timed action. M1 expands this into real resources, actions,
story nodes, automation unlocks, and prestige definitions. 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 ## State
`src/state/` owns environment coupling: `src/state/` owns environment coupling:
@@ -42,8 +48,39 @@ story nodes, automation unlocks, and prestige definitions.
## UI ## UI
`src/ui/` renders the view model and calls runtime commands. Components should `src/ui/` renders the view model and calls runtime commands. Components should
not implement gameplay rules. The M0 shell includes a resource readout, action not implement gameplay rules.
panel, progress bar, and event log.
### 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 ## Verification
+12 -5
View File
@@ -3,21 +3,28 @@ import { createGameState, enqueueAction, tickGame } from '../../engine/game';
import { content } from '../index'; import { content } from '../index';
describe('M1 stub content pack', () => { 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.resources).toHaveLength(2);
expect(content.actions.length).toBeGreaterThanOrEqual(4); expect(content.actions.length).toBeGreaterThanOrEqual(4);
expect(content.actions.length).toBeLessThanOrEqual(6);
const withCosts = content.actions.filter((a) => a.costs.length > 0); const withCosts = content.actions.filter((a) => a.costs.length > 0);
const withUnlocks = content.actions.filter((a) => a.unlock !== undefined); const withUnlocks = content.actions.filter((a) => a.unlock !== undefined);
expect(withCosts.length).toBeGreaterThanOrEqual(2); expect(withCosts.length).toBeGreaterThanOrEqual(2);
expect(withUnlocks.length).toBeGreaterThanOrEqual(1); 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 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) { 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); enqueueAction(state, content, trade.id);
tickGame(state, content, trade.durationMs); tickGame(state, content, trade.durationMs);
+78
View File
@@ -6,6 +6,7 @@ const validActions = [
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -28,6 +29,7 @@ describe('buildContent()', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'ghost', amount: 1 }], yields: [{ resourceId: 'ghost', amount: 1 }],
}, },
@@ -48,6 +50,7 @@ describe('buildContent()', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: -1, durationMs: -1,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -66,6 +69,7 @@ describe('costs and multi-yield', () => {
{ {
id: 'craft', id: 'craft',
name: 'Craft', name: 'Craft',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000, durationMs: 1000,
costs: [{ resourceId: 'wood', amount: 2 }], costs: [{ resourceId: 'wood', amount: 2 }],
yields: [ yields: [
@@ -84,6 +88,7 @@ describe('costs and multi-yield', () => {
{ {
id: 'craft', id: 'craft',
name: 'Craft', name: 'Craft',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000, durationMs: 1000,
costs: [{ resourceId: 'ghost', amount: 1 }], costs: [{ resourceId: 'ghost', amount: 1 }],
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
@@ -104,6 +109,7 @@ describe('unlock conditions', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
unlock: { unlock: {
@@ -131,6 +137,7 @@ describe('action narrative fields', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
storyHint: 'Gather what the forest offers.', storyHint: 'Gather what the forest offers.',
@@ -148,3 +155,74 @@ describe('action narrative fields', () => {
expect(content.actionsById.forage.storyTooltip).toBeUndefined(); 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: { scout_path: {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
kind: 'timed' as const,
group: { id: 'travel', label: 'Travel' },
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
+34 -1
View File
@@ -7,6 +7,8 @@ export const actionDefs = [
{ {
id: 'gather_supplies', id: 'gather_supplies',
name: 'Gather supplies', name: 'Gather supplies',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'supplies', amount: 2 }],
storyHint: 'Basic camp labor.', storyHint: 'Basic camp labor.',
@@ -15,6 +17,8 @@ export const actionDefs = [
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout the path', name: 'Scout the path',
kind: 'timed',
group: { id: 'travel', label: 'Travel' },
durationMs: 5000, durationMs: 5000,
costs: [{ resourceId: 'supplies', amount: 2 }], costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -24,6 +28,8 @@ export const actionDefs = [
{ {
id: 'trade_supplies', id: 'trade_supplies',
name: 'Trade at camp', name: 'Trade at camp',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 4000, durationMs: 4000,
costs: [{ resourceId: 'supplies', amount: 3 }], costs: [{ resourceId: 'supplies', amount: 3 }],
yields: [{ resourceId: 'coin', amount: 2 }], yields: [{ resourceId: 'coin', amount: 2 }],
@@ -34,6 +40,8 @@ export const actionDefs = [
{ {
id: 'fortify_camp', id: 'fortify_camp',
name: 'Fortify camp', name: 'Fortify camp',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 8000, durationMs: 8000,
costs: [ costs: [
{ resourceId: 'supplies', amount: 5 }, { resourceId: 'supplies', amount: 5 },
@@ -47,6 +55,8 @@ export const actionDefs = [
{ {
id: 'push_onward', id: 'push_onward',
name: 'Push onward', name: 'Push onward',
kind: 'timed',
group: { id: 'travel', label: 'Travel' },
durationMs: 6000, durationMs: 6000,
costs: [{ resourceId: 'supplies', amount: 2 }], costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 3 }], yields: [{ resourceId: 'coin', amount: 3 }],
@@ -57,9 +67,32 @@ export const actionDefs = [
{ {
id: 'rest', id: 'rest',
name: 'Rest briefly', name: 'Rest briefly',
kind: 'loop',
group: { id: 'camp_loop', label: 'Camp activities' },
loopPriority: 0,
durationMs: 2000, durationMs: 2000,
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
storyHint: 'Catch your breath.', 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 type GameContent = Content & StoryContent;
export const content: GameContent = { ...base, ...story }; 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(), 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), id: z.string().min(1),
name: z.string().min(1), label: 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(),
}); });
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 ResourceDef = z.infer<typeof resourceDefSchema>;
export type ResourceAmount = z.infer<typeof resourceAmountSchema>; export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
export type UnlockDef = z.infer<typeof unlockDefSchema>; 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 type ActionDef = z.infer<typeof actionDefSchema>;
export interface Content { export interface Content {
+1
View File
@@ -10,6 +10,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
+209 -3
View File
@@ -1,14 +1,22 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { content as gameContent } from '../../content/index';
import { buildContent } from '../../content/schema'; import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import { import {
cancelQueuedAction, cancelQueuedAction,
canUnlockAction, canUnlockAction,
clearQueue, clearQueue,
createGameState, createGameState,
enqueueAction, enqueueAction,
executeInstant,
maybeStartLoopAction,
performAction,
startAction, startAction,
tickGame, tickGame,
} from '../game'; } from '../game';
import { enterStoryNode } from '../story';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() { function testContent() {
return buildContent({ return buildContent({
@@ -17,6 +25,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'gold', amount: 2 }], yields: [{ resourceId: 'gold', amount: 2 }],
}, },
@@ -28,9 +37,27 @@ function queueContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [ actions: [
{ id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, {
{ id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, id: 'a',
{ id: 'c', name: 'C', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, 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', id: 'gather',
name: 'Gather', name: 'Gather',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'supplies', amount: 2 }],
}, },
{ {
id: 'trade', id: 'trade',
name: 'Trade', name: 'Trade',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
costs: [{ resourceId: 'supplies', amount: 5 }], costs: [{ resourceId: 'supplies', amount: 5 }],
yields: [{ resourceId: 'coin', amount: 3 }], yields: [{ resourceId: 'coin', amount: 3 }],
@@ -58,6 +87,7 @@ function costContent() {
{ {
id: 'scout', id: 'scout',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { minResources: { coin: 1 } }, unlock: { minResources: { coin: 1 } },
@@ -204,6 +234,7 @@ describe('unlock conditions', () => {
{ {
id: 'secret', id: 'secret',
name: 'Secret', name: 'Secret',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
unlock: { requireStoryFlags: ['path_scouted'] }, unlock: { requireStoryFlags: ['path_scouted'] },
@@ -245,12 +276,14 @@ describe('completion advances queue', () => {
{ {
id: 'cheap', id: 'cheap',
name: 'Cheap', name: 'Cheap',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
}, },
{ {
id: 'dear', id: 'dear',
name: 'Dear', name: 'Dear',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
costs: [{ resourceId: 'supplies', amount: 6 }], costs: [{ resourceId: 'supplies', amount: 6 }],
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
@@ -258,6 +291,7 @@ describe('completion advances queue', () => {
{ {
id: 'free', id: 'free',
name: 'Free', name: 'Free',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
}, },
@@ -283,6 +317,7 @@ describe('completion advances queue', () => {
{ {
id: 'combo', id: 'combo',
name: 'Combo', name: 'Combo',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [ yields: [
{ resourceId: 'a', amount: 2 }, { resourceId: 'a', amount: 2 },
@@ -356,3 +391,174 @@ describe('tickGame()', () => {
expect(state.activeActionId).toBeNull(); 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'; import { describe, expect, it } from 'vitest';
const ENGINE_DIR = join(import.meta.dirname, '..'); 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[]> { async function engineSourceFiles(): Promise<string[]> {
const entries = await readdir(ENGINE_DIR, { withFileTypes: true }); const entries = await readdir(ENGINE_DIR, { withFileTypes: true });
@@ -18,8 +51,8 @@ describe('engine purity', () => {
expect(files.length).toBeGreaterThan(0); expect(files.length).toBeGreaterThan(0);
for (const file of files) { for (const file of files) {
const source = await readFile(file, 'utf8'); const source = await readFile(file, 'utf8');
for (const pattern of FORBIDDEN) { for (const { pattern, label } of FORBIDDEN) {
expect(source, `${file} must stay free of ${pattern}`).not.toMatch(pattern); expect(source, `${file} must not use ${label}`).not.toMatch(pattern);
} }
} }
}); });
+60
View File
@@ -18,6 +18,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -69,6 +70,15 @@ describe('createSave()', () => {
expect(save.state.currentStoryNodeId).toBe('route_a_beat'); expect(save.state.currentStoryNodeId).toBe('route_a_beat');
expect(save.state.seenStoryNodeIds).toHaveLength(2); 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', () => { describe('serialize / deserialize round-trip', () => {
@@ -83,6 +93,27 @@ describe('serialize / deserialize round-trip', () => {
const restored = fromExportString(toExportString(save)); const restored = fromExportString(toExportString(save));
expect(restored).toEqual(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', () => { 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()', () => { describe('applyOfflineProgress()', () => {
it('credits whole ticks of elapsed time to the active action', () => { it('credits whole ticks of elapsed time to the active action', () => {
const content = testContent(); const content = testContent();
+7
View File
@@ -13,6 +13,8 @@ import {
type StoryEvent, type StoryEvent,
} from '../story'; } from '../story';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function gameContent() { function gameContent() {
const base = buildContent({ const base = buildContent({
resources: [ resources: [
@@ -23,6 +25,7 @@ function gameContent() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -54,6 +57,7 @@ function gameContentWithActionTrigger() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -91,6 +95,7 @@ function gameContentWithThreshold() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -132,6 +137,7 @@ function gameContentWithFork() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -189,6 +195,7 @@ function gameContentWithGatedChoice() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], 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 { 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. * Core game state and per-tick simulation.
@@ -22,6 +26,8 @@ export interface GameState {
currentStoryNodeId: string; currentStoryNodeId: string;
/** Story node ids the player has already seen. */ /** Story node ids the player has already seen. */
seenStoryNodeIds: string[]; 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 { export function createGameState(content: Content): GameState {
@@ -37,6 +43,7 @@ export function createGameState(content: Content): GameState {
storyFlags: {}, storyFlags: {},
currentStoryNodeId: '', currentStoryNodeId: '',
seenStoryNodeIds: [], seenStoryNodeIds: [],
enabledLoopActionIds: {},
}; };
} }
@@ -156,6 +163,110 @@ export function clearQueue(state: GameState): void {
state.actionQueue.length = 0; 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 * Advance the active action by `tickMs`. On completion, grants yields and
* advances the queue — actions do not auto-repeat when the queue is empty. * 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 actionId = state.activeActionId;
const action = content.actionsById[actionId]; const action = content.actionsById[actionId];
if (!action) return { completedActionIds }; if (!action) return { completedActionIds };
if (action.durationMs === undefined) return { completedActionIds };
if (state.actionElapsedMs < action.durationMs) return { completedActionIds }; if (state.actionElapsedMs < action.durationMs) return { completedActionIds };
state.actionElapsedMs -= action.durationMs; state.actionElapsedMs -= action.durationMs;
+2
View File
@@ -32,6 +32,7 @@ export const gameStateSchema = z.object({
storyFlags: z.record(z.string(), z.boolean()).default({}), storyFlags: z.record(z.string(), z.boolean()).default({}),
currentStoryNodeId: z.string().default(''), currentStoryNodeId: z.string().default(''),
seenStoryNodeIds: z.array(z.string()).default([]), seenStoryNodeIds: z.array(z.string()).default([]),
enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}),
}); });
export const saveSchema = z.object({ export const saveSchema = z.object({
@@ -55,6 +56,7 @@ export function createSave(state: GameState, now: number): SaveData {
storyFlags: { ...state.storyFlags }, storyFlags: { ...state.storyFlags },
currentStoryNodeId: state.currentStoryNodeId, currentStoryNodeId: state.currentStoryNodeId,
seenStoryNodeIds: [...state.seenStoryNodeIds], 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)); 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( export function applyChoice(
state: GameState, state: GameState,
content: GameContent, content: GameContent,
+16
View File
@@ -10,3 +10,19 @@ body {
min-height: 100dvh; min-height: 100dvh;
background-color: #020617; /* slate-950 */ 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 { createSave, serializeSave } from '../../engine/save';
import { createMemoryBackend, loadGame, saveGame } from '../persistence'; import { createMemoryBackend, loadGame, saveGame } from '../persistence';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() { function testContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
@@ -11,6 +13,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -22,8 +25,20 @@ function queueTestContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [ 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: {}, storyFlags: {},
currentStoryNodeId: '', currentStoryNodeId: '',
seenStoryNodeIds: [], seenStoryNodeIds: [],
enabledLoopActionIds: {},
}, },
1000, 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'; import { getPrefs, setPrefs } from '../prefs';
describe('prefs', () => { describe('prefs', () => {
@@ -14,12 +14,65 @@ describe('prefs', () => {
}); });
}); });
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns defaults when localStorage empty', () => { 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', () => { it('round-trips updated prefs', () => {
setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' }); setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' });
expect(getPrefs().storyOpenMode).toBe('manual'); 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 { describe, expect, it } from 'vitest';
import { content } from '../../content/index';
import { buildContent } from '../../content/schema'; import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema'; 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 { enterStoryNode } from '../../engine/story';
import { formatOfflineDuration, toView } from '../viewModel'; import { formatOfflineDuration, toView } from '../viewModel';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() { function testContent() {
const base = buildContent({ const base = buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
@@ -12,6 +15,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 200, durationMs: 200,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -87,8 +91,20 @@ describe('toView()', () => {
const content = contentWithActions( const content = contentWithActions(
[{ id: 'gold', name: 'Gold' }], [{ 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); const state = createGameState(content);
@@ -108,6 +124,7 @@ describe('toView() action availability', () => {
{ {
id: 'locked', id: 'locked',
name: 'Locked', name: 'Locked',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { requireStoryFlags: ['route_a'] }, unlock: { requireStoryFlags: ['route_a'] },
@@ -127,6 +144,7 @@ describe('toView() action availability', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
}, },
@@ -188,3 +206,128 @@ describe('formatOfflineDuration()', () => {
expect(formatOfflineDuration(7_200_000)).toBe('2h'); 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 }, storyFlags: { ...save.state.storyFlags },
currentStoryNodeId: save.state.currentStoryNodeId ?? '', currentStoryNodeId: save.state.currentStoryNodeId ?? '',
seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])],
enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) },
}; };
savedAt = save.savedAt; savedAt = save.savedAt;
} catch { } 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 StoryOpenMode = 'auto' | 'choices-only' | 'manual';
export type ActionDetailMode = 'inline' | 'hover' | 'info-button'; export type ActionDetailMode = 'inline' | 'hover' | 'info-button';
@@ -6,18 +6,35 @@ export type ActionDetailMode = 'inline' | 'hover' | 'info-button';
export interface GamePrefs { export interface GamePrefs {
storyOpenMode: StoryOpenMode; storyOpenMode: StoryOpenMode;
actionDetailMode: ActionDetailMode; actionDetailMode: ActionDetailMode;
collapsedActionGroups: Record<string, boolean>;
showEventLog: boolean;
} }
const DEFAULTS: GamePrefs = { const DEFAULTS: GamePrefs = {
storyOpenMode: 'auto', storyOpenMode: 'auto',
actionDetailMode: 'inline', actionDetailMode: 'inline',
collapsedActionGroups: {},
showEventLog: true,
}; };
export function getPrefs(): GamePrefs { export function getPrefs(): GamePrefs {
if (typeof localStorage === 'undefined') return { ...DEFAULTS }; if (typeof localStorage === 'undefined') return { ...DEFAULTS };
try { try {
const raw = localStorage.getItem(PREFS_KEY); 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) }; return { ...DEFAULTS, ...JSON.parse(raw) };
} catch { } catch {
return { ...DEFAULTS }; return { ...DEFAULTS };
+119 -65
View File
@@ -1,20 +1,20 @@
import { content } from '../content'; import { content } from '../content';
import { import {
cancelQueuedAction as engineCancelQueuedAction, cancelQueuedAction as engineCancelQueuedAction,
enqueueAction as engineEnqueueAction,
type GameState, type GameState,
isActionAvailable, maybeStartLoopAction,
performAction as performActionEngine,
tickGame, tickGame,
} from '../engine/game'; } from '../engine/game';
import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story'; import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story';
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop'; import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence'; import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
import { getPrefs } from './prefs'; import { getPrefs } from './prefs';
import { useGameStore } from './store'; import { type ActivePanel, useGameStore } from './store';
import { import {
processStoryTriggers, processStoryTriggers,
type StoryUiEffect, type StoryUiEffect,
shouldAutoOpenPanel, shouldAutoNavigateToStory,
storyEventsToLogEntries, storyEventsToLogEntries,
} from './storyOrchestration'; } from './storyOrchestration';
import { formatOfflineDuration, toView } from './viewModel'; import { formatOfflineDuration, toView } from './viewModel';
@@ -32,7 +32,7 @@ import { formatOfflineDuration, toView } from './viewModel';
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
const AUTOSAVE_INTERVAL_MS = 10_000; const AUTOSAVE_INTERVAL_MS = 10_000;
class GameRuntime { export class GameRuntime {
private state: GameState | null = null; private state: GameState | null = null;
private readonly loop: TickLoop = createTickLoop(); private readonly loop: TickLoop = createTickLoop();
private readonly backend: SaveBackend = createDefaultBackend(); private readonly backend: SaveBackend = createDefaultBackend();
@@ -41,6 +41,16 @@ class GameRuntime {
private lastSaveAt = 0; private lastSaveAt = 0;
private booted = false; 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> { async boot(): Promise<void> {
if (this.booted) { if (this.booted) {
return; return;
@@ -75,47 +85,93 @@ class GameRuntime {
cancelAnimationFrame(this.rafId); cancelAnimationFrame(this.rafId);
this.rafId = null; 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 { enqueueAction(actionId: string): void {
const state = this.state; this.performAction(actionId);
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();
} }
applyStoryChoice(choiceId: string): void { applyStoryChoice(choiceId: string): void {
const state = this.state; const action = content.actions.find((a) => a.storyChoiceId === choiceId);
if (!state) return; if (action) {
try { this.performAction(action.id);
const events = engineApplyChoice(state, content, choiceId); } else {
const entries = storyEventsToLogEntries(events); const state = this.state;
const store = useGameStore.getState(); if (!state) return;
for (const entry of entries) store.appendStoryLog(entry); try {
const choiceLabel = entries.at(-1)?.choiceLabel; const events = engineApplyChoice(state, content, choiceId);
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); const entries = storyEventsToLogEntries(events);
store.setStoryPanelOpen(false); const store = useGameStore.getState();
this.runPublishTriggers();
this.publish(); // Append any custom log outcomes
} catch (err) { for (const event of events.filter((e) => e.kind === 'log')) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed'); 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 { continueStory(): void {
const state = this.state; const state = this.state;
if (!state) return; if (!state) return;
@@ -149,30 +196,36 @@ class GameRuntime {
const store = useGameStore.getState(); const store = useGameStore.getState();
for (const entry of entries) store.appendStoryLog(entry); for (const entry of entries) store.appendStoryLog(entry);
for (const entry of entries) { 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; const prefs = store.prefs;
if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) { if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) {
store.setStoryPanelOpen(true); this.setActivePanel('story');
store.setStoryHasUnread(false);
} else { } else {
store.setStoryHasUnread(true); store.setStoryHasUnread(true);
store.setStoryPanelOpen(false);
} }
this.publish(); this.publish();
return; return;
} }
this.closeStoryPanel(); this.setActivePanel('play');
this.publish(); this.publish();
} }
private applyStoryUiEffect(effect: StoryUiEffect): void { private applyStoryUiEffect(effect: StoryUiEffect): void {
const store = useGameStore.getState(); const store = useGameStore.getState();
const prefs = store.prefs;
for (const entry of effect.logEntries) store.appendStoryLog(entry); for (const entry of effect.logEntries) store.appendStoryLog(entry);
for (const line of effect.eventLogLines) store.appendLog(line); for (const line of effect.eventLogLines) store.appendLog(line);
if (effect.shouldOpenPanel) { if (effect.shouldOpenPanel) {
store.setStoryPanelOpen(true); if (prefs.storyOpenMode === 'auto') {
store.setStoryHasUnread(false); store.setActivePanel('story');
store.setStoryHasUnread(false);
} else {
store.setStoryHasUnread(true);
}
} else if (effect.enteredNodeIds.length > 0) { } else if (effect.enteredNodeIds.length > 0) {
store.setStoryHasUnread(true); store.setStoryHasUnread(true);
} }
@@ -201,6 +254,7 @@ class GameRuntime {
); );
this.applyStoryUiEffect(effect); this.applyStoryUiEffect(effect);
} }
maybeStartLoopAction(state, content);
}); });
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
@@ -234,15 +288,15 @@ class GameRuntime {
} }
private installLifecycleHooks(): void { private installLifecycleHooks(): void {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', this.visibilityChangeListener);
if (document.visibilityState === 'hidden') { window.addEventListener('beforeunload', this.beforeUnloadListener);
void this.save();
}
});
window.addEventListener('beforeunload', () => {
void this.save();
});
} }
} }
export const gameRuntime = new GameRuntime(); 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; choiceLabel?: string;
} }
export type ActivePanel = 'play' | 'story' | 'settings' | 'about';
export interface GameStoreState extends GameView { export interface GameStoreState extends GameView {
log: string[]; log: string[];
storyPanelOpen: boolean;
storyHasUnread: boolean; storyHasUnread: boolean;
storyLog: StoryLogEntry[]; storyLog: StoryLogEntry[];
prefs: GamePrefs; prefs: GamePrefs;
settingsOpen: boolean; activePanel: ActivePanel;
selectedStoryNodeId: string | null;
setView: (view: GameView) => void; setView: (view: GameView) => void;
appendLog: (line: string) => void; appendLog: (line: string) => void;
appendStoryLog: (entry: StoryLogEntry) => void; appendStoryLog: (entry: StoryLogEntry) => void;
setStoryPanelOpen: (open: boolean) => void;
setStoryHasUnread: (unread: boolean) => void; setStoryHasUnread: (unread: boolean) => void;
setPrefs: (partial: Partial<GamePrefs>) => 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: [], resources: [],
activeActionId: null, activeActionId: null,
actionName: null, actionName: null,
@@ -40,21 +43,31 @@ export const useGameStore = create<GameStoreState>((set) => ({
queuedActionIds: [], queuedActionIds: [],
queuedActionNames: [], queuedActionNames: [],
actions: [], actions: [],
story: { currentProse: null, choices: [] }, story: { currentProse: null, atBootIntro: false, choices: [], tree: [] },
actionColumns: [],
log: [], log: [],
storyPanelOpen: false,
storyHasUnread: false, storyHasUnread: false,
storyLog: [], storyLog: [],
prefs: getPrefs(), prefs: getPrefs(),
settingsOpen: false, activePanel: 'play',
selectedStoryNodeId: null,
setView: (view) => set((state) => ({ ...state, ...view })), setView: (view) => set((state) => ({ ...state, ...view })),
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })), appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
appendStoryLog: (entry) => set((state) => ({ storyLog: [...state.storyLog, entry] })), appendStoryLog: (entry) => set((state) => ({ storyLog: [...state.storyLog, entry] })),
setStoryPanelOpen: (open) => set({ storyPanelOpen: open }),
setStoryHasUnread: (unread) => set({ storyHasUnread: unread }), setStoryHasUnread: (unread) => set({ storyHasUnread: unread }),
setPrefs: (partial) => { setPrefs: (partial) => {
const prefs = persistPrefs(partial); const prefs = persistPrefs(partial);
set({ prefs }); 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 })); .map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel }));
} }
export function shouldAutoOpenPanel( export function shouldAutoNavigateToStory(
prefs: GamePrefs, prefs: GamePrefs,
nodeId: string, nodeId: string,
content: GameContent, content: GameContent,
@@ -39,11 +39,15 @@ export function processStoryTriggers(
const logEntries = storyEventsToLogEntries(events); const logEntries = storyEventsToLogEntries(events);
const shouldOpenPanel = const shouldOpenPanel =
enteredNodeIds.length > 0 && enteredNodeIds.length > 0 &&
enteredNodeIds.some((id) => shouldAutoOpenPanel(prefs, id, content)); enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content));
const eventLogLines = logEntries.map((e) => const customLogs = events.filter((e) => e.kind === 'log').map((e) => e.prose);
e.choiceLabel const eventLogLines = [
? `Story: ${e.choiceLabel}` ...customLogs,
: `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}`, ...logEntries.map((e) =>
); e.choiceLabel
? `Story: ${e.choiceLabel}`
: `Story: ${(content.storyNodesById[e.nodeId]?.prose ?? '').slice(0, 40)}`,
),
];
return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines }; return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines };
} }
+137 -12
View File
@@ -5,7 +5,7 @@ import {
type GameState, type GameState,
isActionAvailable, isActionAvailable,
} from '../engine/game'; } 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. * Pure mapping from engine state to the view model the React shell renders.
@@ -18,6 +18,17 @@ export interface ResourceView {
amount: number; 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 { export interface ActionView {
id: string; id: string;
name: string; name: string;
@@ -27,6 +38,20 @@ export interface ActionView {
storyTooltip?: string; storyTooltip?: string;
costsSummary: string | null; costsSummary: string | null;
yieldsSummary: 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 { export interface StoryChoiceView {
@@ -36,9 +61,19 @@ export interface StoryChoiceView {
disabledReason: string | null; disabledReason: string | null;
} }
export interface StoryTreeNodeView {
id: string;
label: string;
seen: boolean;
active: boolean;
children: StoryTreeNodeView[];
}
export interface StoryView { export interface StoryView {
currentProse: string | null; currentProse: string | null;
atBootIntro: boolean;
choices: StoryChoiceView[]; choices: StoryChoiceView[];
tree: StoryTreeNodeView[];
} }
export interface GameView { export interface GameView {
@@ -51,6 +86,7 @@ export interface GameView {
queuedActionNames: string[]; queuedActionNames: string[];
actions: ActionView[]; actions: ActionView[];
story: StoryView; story: StoryView;
actionColumns: ActionColumnView[];
} }
function actionDisabledReason( function actionDisabledReason(
@@ -72,6 +108,38 @@ function formatResourceList(
.join(', '); .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 { export function toView(state: GameState, content: GameContent): GameView {
const resources: ResourceView[] = content.resources.map((resource) => ({ const resources: ResourceView[] = content.resources.map((resource) => ({
id: resource.id, 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 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 queuedActionIds = [...state.actionQueue];
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id);
const actions: ActionView[] = content.actions.map((a) => ({ // Build a map of ActionView by id for column assembly
id: a.id, const actionViewMap = new Map<string, ActionView>();
name: a.name, const actions: ActionView[] = content.actions.map((a) => {
available: isActionAvailable(state, content, a.id), const isStory = a.kind === 'story';
disabledReason: actionDisabledReason(state, content, a.id), const available = isStory
storyHint: a.storyHint, ? isStoryChoiceAvailable(state, content, a.storyChoiceId ?? '')
storyTooltip: a.storyTooltip, : isActionAvailable(state, content, a.id);
costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null, const view: ActionView = {
yieldsSummary: formatResourceList(a.yields, content), 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 node = getCurrentNode(state, content);
const availableChoices = getAvailableChoices(state, content); const availableChoices = getAvailableChoices(state, content);
const allChoices = node?.choices ?? []; 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 = { const story: StoryView = {
currentProse: node?.prose ?? null, currentProse: node?.prose ?? null,
atBootIntro,
choices: allChoices.map((choice) => { choices: allChoices.map((choice) => {
const available = availableChoices.some((c) => c.id === choice.id); const available = availableChoices.some((c) => c.id === choice.id);
return { return {
@@ -110,6 +233,7 @@ export function toView(state: GameState, content: GameContent): GameView {
disabledReason: available ? null : 'Requirements not met', disabledReason: available ? null : 'Requirements not met',
}; };
}), }),
tree: buildStoryTree(state, content),
}; };
return { return {
@@ -121,6 +245,7 @@ export function toView(state: GameState, content: GameContent): GameView {
queuedActionNames, queuedActionNames,
actions, actions,
story, 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 { useEffect } from 'react';
import { gameRuntime } from '../state/runtime'; import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store'; import { useGameStore } from '../state/store';
import { ActionPanel } from './ActionPanel'; import { AboutPanel } from './AboutPanel';
import { EventLog } from './EventLog'; import { AppShell } from './AppShell';
import { ResourceBar } from './ResourceBar'; import { NavRail } from './NavRail';
import { SettingsDrawer } from './SettingsDrawer'; import { PlayPanel } from './PlayPanel';
import { StoryPanel } from './StoryPanel'; import { RightRail } from './RightRail';
import { SettingsPanel } from './SettingsPanel';
import { StoryView } from './StoryView';
/** /**
* M1 playable-loop shell. Boots the runtime once on mount; everything else * M1 playable-loop shell. Boots the runtime once on mount; everything else
* renders from the Zustand store the runtime feeds. * renders from the Zustand store the runtime feeds.
*/ */
export function App() { export function App() {
const storyHasUnread = useGameStore((s) => s.storyHasUnread); const activePanel = useGameStore((s) => s.activePanel);
const settingsOpen = useGameStore((s) => s.settingsOpen);
const setSettingsOpen = useGameStore((s) => s.setSettingsOpen);
useEffect(() => { useEffect(() => {
void gameRuntime.boot(); 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 ( return (
<> <AppShell
<StoryPanel /> nav={<NavRail />}
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100"> center={renderActivePanel()}
<header className="flex flex-col gap-2"> right={activePanel === 'play' ? <RightRail /> : undefined}
<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>
</>
); );
} }
+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>
);
}