feat(state): view model action availability and story passage

This commit is contained in:
ginnoir
2026-06-11 18:53:50 -05:00
parent b88399ac14
commit f29b05bd70
3 changed files with 195 additions and 8 deletions
+110 -5
View File
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema'; import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import { createGameState, enqueueAction, startAction } from '../../engine/game'; import { createGameState, enqueueAction, startAction } from '../../engine/game';
import { enterStoryNode } from '../../engine/story';
import { formatOfflineDuration, toView } from '../viewModel'; import { formatOfflineDuration, toView } from '../viewModel';
function testContent() { function testContent() {
return buildContent({ const base = buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
actions: [ actions: [
{ {
@@ -15,6 +17,37 @@ function testContent() {
}, },
], ],
}); });
const story = buildStoryContent(
[
{
id: 'boot',
prose: 'Boot.',
triggers: [{ type: 'boot', targetNodeId: 'boot' }],
},
],
base.actionsById,
base.resourcesById,
);
return { ...base, ...story };
}
function contentWithActions(
resources: Parameters<typeof buildContent>[0]['resources'],
actions: Parameters<typeof buildContent>[0]['actions'],
) {
const base = buildContent({ resources, actions });
const story = buildStoryContent(
[
{
id: 'boot',
prose: 'Boot.',
triggers: [{ type: 'boot', targetNodeId: 'boot' }],
},
],
base.actionsById,
base.resourcesById,
);
return { ...base, ...story };
} }
describe('toView()', () => { describe('toView()', () => {
@@ -51,13 +84,13 @@ describe('toView()', () => {
}); });
it('includes queued action ids in order', () => { it('includes queued action ids in order', () => {
const content = buildContent({ const content = contentWithActions(
resources: [{ id: 'gold', name: 'Gold' }], [{ id: 'gold', name: 'Gold' }],
actions: [ [
{ id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, { id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, { id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
], ],
}); );
const state = createGameState(content); const state = createGameState(content);
enqueueAction(state, content, 'a'); enqueueAction(state, content, 'a');
enqueueAction(state, content, 'b'); enqueueAction(state, content, 'b');
@@ -67,6 +100,78 @@ describe('toView()', () => {
}); });
}); });
describe('toView() action availability', () => {
it('marks locked actions unavailable with reason', () => {
const content = contentWithActions(
[{ id: 'coin', name: 'Coin', startAmount: 0 }],
[
{
id: 'locked',
name: 'Locked',
durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { requireStoryFlags: ['route_a'] },
},
],
);
const state = createGameState(content);
const view = toView(state, content);
expect(view.actions[0].available).toBe(false);
expect(view.actions[0].disabledReason).toMatch(/locked/i);
});
it('includes story passage and choices from current node', () => {
const base = buildContent({
resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }],
actions: [
{
id: 'forage',
name: 'Forage',
durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }],
},
],
});
const story = buildStoryContent(
[
{
id: 'boot_intro',
prose: 'Boot.',
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
},
{
id: 'fork_choice',
prose: 'Which way?',
choices: [
{
id: 'pick_a',
label: 'High road',
outcomes: [{ type: 'setFlag', flag: 'route_a' }],
targetNodeId: 'route_a_beat',
},
{
id: 'pick_b',
label: 'Low road',
outcomes: [{ type: 'setFlag', flag: 'route_b' }],
targetNodeId: 'route_b_beat',
},
],
},
{ id: 'route_a_beat', prose: 'The high road.' },
{ id: 'route_b_beat', prose: 'The low road.' },
],
base.actionsById,
base.resourcesById,
);
const content = { ...base, ...story };
const state = createGameState(content);
enterStoryNode(state, content, 'fork_choice');
const view = toView(state, content);
expect(view.story.currentProse).toContain('Which way');
expect(view.story.choices.length).toBe(2);
});
});
describe('formatOfflineDuration()', () => { describe('formatOfflineDuration()', () => {
it('formats sub-minute durations in seconds', () => { it('formats sub-minute durations in seconds', () => {
expect(formatOfflineDuration(0)).toBe('0s'); expect(formatOfflineDuration(0)).toBe('0s');
+2
View File
@@ -22,6 +22,8 @@ export const useGameStore = create<GameStoreState>((set) => ({
actionProgress: 0, actionProgress: 0,
queuedActionIds: [], queuedActionIds: [],
queuedActionNames: [], queuedActionNames: [],
actions: [],
story: { currentProse: null, choices: [] },
log: [], log: [],
setView: (view) => set(view), setView: (view) => set(view),
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })), appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
+83 -3
View File
@@ -1,5 +1,11 @@
import type { Content } from '../content/schema'; import type { GameContent } from '../content/index';
import type { GameState } from '../engine/game'; import {
canAffordAction,
canUnlockAction,
isActionAvailable,
type GameState,
} from '../engine/game';
import { getAvailableChoices, getCurrentNode } from '../engine/story';
/** /**
* Pure mapping from engine state to the view model the React shell renders. * Pure mapping from engine state to the view model the React shell renders.
@@ -12,6 +18,29 @@ export interface ResourceView {
amount: number; amount: number;
} }
export interface ActionView {
id: string;
name: string;
available: boolean;
disabledReason: string | null;
storyHint?: string;
storyTooltip?: string;
costsSummary: string | null;
yieldsSummary: string | null;
}
export interface StoryChoiceView {
id: string;
label: string;
disabled: boolean;
disabledReason: string | null;
}
export interface StoryView {
currentProse: string | null;
choices: StoryChoiceView[];
}
export interface GameView { export interface GameView {
resources: ResourceView[]; resources: ResourceView[];
activeActionId: string | null; activeActionId: string | null;
@@ -20,9 +49,30 @@ export interface GameView {
actionProgress: number; actionProgress: number;
queuedActionIds: string[]; queuedActionIds: string[];
queuedActionNames: string[]; queuedActionNames: string[];
actions: ActionView[];
story: StoryView;
} }
export function toView(state: GameState, content: Content): GameView { function actionDisabledReason(
state: GameState,
content: GameContent,
actionId: string,
): string | null {
if (!canUnlockAction(state, content, actionId)) return 'Locked';
if (!canAffordAction(state, content, actionId)) return 'Not enough resources';
return null;
}
function formatResourceList(
items: { resourceId: string; amount: number }[],
content: GameContent,
): string {
return items
.map((i) => `${i.amount} ${content.resourcesById[i.resourceId]?.name ?? i.resourceId}`)
.join(', ');
}
export function toView(state: GameState, content: GameContent): GameView {
const resources: ResourceView[] = content.resources.map((resource) => ({ const resources: ResourceView[] = content.resources.map((resource) => ({
id: resource.id, id: resource.id,
name: resource.name, name: resource.name,
@@ -34,6 +84,34 @@ export function toView(state: GameState, content: Content): GameView {
const queuedActionIds = [...state.actionQueue]; const queuedActionIds = [...state.actionQueue];
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id);
const actions: ActionView[] = content.actions.map((a) => ({
id: a.id,
name: a.name,
available: isActionAvailable(state, content, a.id),
disabledReason: actionDisabledReason(state, content, a.id),
storyHint: a.storyHint,
storyTooltip: a.storyTooltip,
costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null,
yieldsSummary: formatResourceList(a.yields, content),
}));
const node = getCurrentNode(state, content);
const availableChoices = getAvailableChoices(state, content);
const allChoices = node?.choices ?? [];
const story: StoryView = {
currentProse: node?.prose ?? null,
choices: allChoices.map((choice) => {
const available = availableChoices.some((c) => c.id === choice.id);
return {
id: choice.id,
label: choice.label,
disabled: !available,
disabledReason: available ? null : 'Requirements not met',
};
}),
};
return { return {
resources, resources,
activeActionId: state.activeActionId, activeActionId: state.activeActionId,
@@ -41,6 +119,8 @@ export function toView(state: GameState, content: Content): GameView {
actionProgress, actionProgress,
queuedActionIds, queuedActionIds,
queuedActionNames, queuedActionNames,
actions,
story,
}; };
} }