367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { content } from '../../content/index';
|
|
import { buildContent } from '../../content/schema';
|
|
import { buildStoryContent } from '../../content/storySchema';
|
|
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 }],
|
|
actions: [
|
|
{
|
|
id: 'forage',
|
|
name: 'Forage',
|
|
group: DEFAULT_GROUP,
|
|
durationMs: 200,
|
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
|
},
|
|
],
|
|
});
|
|
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()', () => {
|
|
it('maps resources with their names and amounts', () => {
|
|
const content = testContent();
|
|
const view = toView(createGameState(content), content);
|
|
expect(view.resources).toEqual([{ id: 'gold', name: 'Gold', amount: 4 }]);
|
|
});
|
|
|
|
it('reports no progress and no action name when idle', () => {
|
|
const content = testContent();
|
|
const view = toView(createGameState(content), content);
|
|
expect(view.activeActionId).toBeNull();
|
|
expect(view.actionName).toBeNull();
|
|
expect(view.actionProgress).toBe(0);
|
|
});
|
|
|
|
it('reports the active action name and fractional progress', () => {
|
|
const content = testContent();
|
|
const state = createGameState(content);
|
|
startAction(state, content, 'forage');
|
|
state.actionElapsedMs = 50; // of 200ms
|
|
const view = toView(state, content);
|
|
expect(view.actionName).toBe('Forage');
|
|
expect(view.actionProgress).toBeCloseTo(0.25);
|
|
});
|
|
|
|
it('clamps progress to at most 1', () => {
|
|
const content = testContent();
|
|
const state = createGameState(content);
|
|
startAction(state, content, 'forage');
|
|
state.actionElapsedMs = 999;
|
|
expect(toView(state, content).actionProgress).toBe(1);
|
|
});
|
|
|
|
it('includes queued action ids in order', () => {
|
|
const content = contentWithActions(
|
|
[{ id: 'gold', name: 'Gold' }],
|
|
[
|
|
{
|
|
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);
|
|
enqueueAction(state, content, 'a');
|
|
enqueueAction(state, content, 'b');
|
|
const view = toView(state, content);
|
|
expect(view.queuedActionIds).toEqual(['b']);
|
|
expect(view.queuedActionNames).toEqual(['Bravo']);
|
|
});
|
|
});
|
|
|
|
describe('toView() action availability', () => {
|
|
it('marks locked actions unavailable with reason', () => {
|
|
const content = contentWithActions(
|
|
[{ id: 'coin', name: 'Coin', startAmount: 0 }],
|
|
[
|
|
{
|
|
id: 'locked',
|
|
name: 'Locked',
|
|
group: DEFAULT_GROUP,
|
|
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',
|
|
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: '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');
|
|
expect(formatOfflineDuration(45_000)).toBe('45s');
|
|
});
|
|
|
|
it('formats minutes with seconds', () => {
|
|
expect(formatOfflineDuration(90_000)).toBe('1m 30s');
|
|
expect(formatOfflineDuration(120_000)).toBe('2m');
|
|
});
|
|
|
|
it('formats hours with minutes', () => {
|
|
expect(formatOfflineDuration(3_660_000)).toBe('1h 1m');
|
|
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('marks automationUnlocked on actions after manual completion', () => {
|
|
const state = createGameState(content);
|
|
state.manualCompletionCounts.gather_supplies = 1;
|
|
const view = toView(state, content);
|
|
const gather = view.actionColumns
|
|
.flatMap((c) => c.groups)
|
|
.flatMap((g) => g.actions)
|
|
.find((a) => a.id === 'gather_supplies');
|
|
|
|
expect(gather?.automationUnlocked).toBe(true);
|
|
});
|
|
|
|
it('marks actions already in the automation queue', () => {
|
|
const state = createGameState(content);
|
|
state.automationQueue = ['gather_supplies'];
|
|
const view = toView(state, content);
|
|
const gather = view.actionColumns
|
|
.flatMap((c) => c.groups)
|
|
.flatMap((g) => g.actions)
|
|
.find((a) => a.id === 'gather_supplies');
|
|
|
|
expect(gather?.inAutomationQueue).toBe(true);
|
|
});
|
|
|
|
it('projects automation queue ids and display names', () => {
|
|
const state = createGameState(content);
|
|
state.automationQueue = ['gather_supplies', 'missing_action'];
|
|
const view = toView(state, content);
|
|
|
|
expect(view.automationQueueIds).toEqual(['gather_supplies', 'missing_action']);
|
|
expect(view.automationQueueNames).toEqual(['Gather supplies', 'missing_action']);
|
|
});
|
|
|
|
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']),
|
|
);
|
|
});
|
|
});
|