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