feat(state): view model action availability and story passage
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user