feat(m1): playable loop — story graph, story panel, queue UI #14
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildContent } from '../../content/schema';
|
||||||
|
import { buildStoryContent } from '../../content/storySchema';
|
||||||
|
import { createGameState } from '../game';
|
||||||
|
import { enterStoryNode, initStory } from '../story';
|
||||||
|
|
||||||
|
function gameContent() {
|
||||||
|
const base = buildContent({
|
||||||
|
resources: [
|
||||||
|
{ id: 'supplies', name: 'Supplies', startAmount: 10 },
|
||||||
|
{ id: 'coin', name: 'Coin', startAmount: 0 },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'scout_path',
|
||||||
|
name: 'Scout',
|
||||||
|
durationMs: 1000,
|
||||||
|
costs: [],
|
||||||
|
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const story = buildStoryContent(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'boot_intro',
|
||||||
|
prose: 'Boot.',
|
||||||
|
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
|
||||||
|
enterOutcomes: [{ type: 'grantResource', resourceId: 'coin', amount: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
base.actionsById,
|
||||||
|
base.resourcesById,
|
||||||
|
);
|
||||||
|
return { ...base, ...story };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('enterStoryNode()', () => {
|
||||||
|
it('sets current node, marks seen, applies enterOutcomes', () => {
|
||||||
|
const content = gameContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
const events = enterStoryNode(state, content, 'boot_intro');
|
||||||
|
expect(state.currentStoryNodeId).toBe('boot_intro');
|
||||||
|
expect(state.seenStoryNodeIds).toContain('boot_intro');
|
||||||
|
expect(state.resources.coin).toBe(1);
|
||||||
|
expect(events.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initStory()', () => {
|
||||||
|
it('leaves currentStoryNodeId empty until triggers run', () => {
|
||||||
|
const content = gameContent();
|
||||||
|
const state = createGameState(content);
|
||||||
|
initStory(state, content);
|
||||||
|
expect(state.currentStoryNodeId).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Content } from '../content/schema';
|
||||||
|
import type { StoryContent, StoryOutcome } from '../content/storySchema';
|
||||||
|
import type { GameState } from './game';
|
||||||
|
|
||||||
|
type GameContent = Content & StoryContent;
|
||||||
|
|
||||||
|
export interface StoryEvent {
|
||||||
|
kind: 'enter' | 'log';
|
||||||
|
nodeId: string;
|
||||||
|
prose: string;
|
||||||
|
choiceLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOutcomes(
|
||||||
|
state: GameState,
|
||||||
|
_content: GameContent,
|
||||||
|
outcomes: StoryOutcome[],
|
||||||
|
events: StoryEvent[],
|
||||||
|
): void {
|
||||||
|
for (const outcome of outcomes) {
|
||||||
|
switch (outcome.type) {
|
||||||
|
case 'setFlag':
|
||||||
|
state.storyFlags[outcome.flag] = true;
|
||||||
|
break;
|
||||||
|
case 'clearFlag':
|
||||||
|
state.storyFlags[outcome.flag] = false;
|
||||||
|
break;
|
||||||
|
case 'grantResource':
|
||||||
|
state.resources[outcome.resourceId] =
|
||||||
|
(state.resources[outcome.resourceId] ?? 0) + outcome.amount;
|
||||||
|
break;
|
||||||
|
case 'consumeResource': {
|
||||||
|
const current = state.resources[outcome.resourceId] ?? 0;
|
||||||
|
if (current < outcome.amount) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot consume ${outcome.amount} ${outcome.resourceId} (have ${current})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.resources[outcome.resourceId] = current - outcome.amount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'log':
|
||||||
|
events.push({ kind: 'log', nodeId: state.currentStoryNodeId, prose: outcome.text });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enterStoryNode(
|
||||||
|
state: GameState,
|
||||||
|
content: GameContent,
|
||||||
|
nodeId: string,
|
||||||
|
): StoryEvent[] {
|
||||||
|
const node = content.storyNodesById[nodeId];
|
||||||
|
if (!node) throw new Error(`Unknown story node "${nodeId}"`);
|
||||||
|
const events: StoryEvent[] = [];
|
||||||
|
if (!state.seenStoryNodeIds.includes(nodeId)) {
|
||||||
|
state.seenStoryNodeIds.push(nodeId);
|
||||||
|
}
|
||||||
|
state.currentStoryNodeId = nodeId;
|
||||||
|
applyOutcomes(state, content, node.enterOutcomes ?? [], events);
|
||||||
|
events.unshift({ kind: 'enter', nodeId, prose: node.prose });
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initStory(state: GameState, _content: GameContent): void {
|
||||||
|
state.storyFlags = state.storyFlags ?? {};
|
||||||
|
state.seenStoryNodeIds = state.seenStoryNodeIds ?? [];
|
||||||
|
if (!state.currentStoryNodeId) {
|
||||||
|
state.currentStoryNodeId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user