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 { buildContent } from '../../content/schema';
|
||||
import { buildStoryContent } from '../../content/storySchema';
|
||||
import { createGameState, enqueueAction, startAction } from '../../engine/game';
|
||||
import { enterStoryNode } from '../../engine/story';
|
||||
import { formatOfflineDuration, toView } from '../viewModel';
|
||||
|
||||
function testContent() {
|
||||
return buildContent({
|
||||
const base = buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
|
||||
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()', () => {
|
||||
@@ -51,13 +84,13 @@ describe('toView()', () => {
|
||||
});
|
||||
|
||||
it('includes queued action ids in order', () => {
|
||||
const content = buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold' }],
|
||||
actions: [
|
||||
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 }] },
|
||||
],
|
||||
});
|
||||
);
|
||||
const state = createGameState(content);
|
||||
enqueueAction(state, content, 'a');
|
||||
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()', () => {
|
||||
it('formats sub-minute durations in seconds', () => {
|
||||
expect(formatOfflineDuration(0)).toBe('0s');
|
||||
|
||||
Reference in New Issue
Block a user