feat(m1): PR3 T3.0 — shell UI, action kinds, story tab #16

Merged
ginnoir merged 26 commits from feat/m1-progression into main 2026-06-11 22:52:29 -05:00
9 changed files with 83 additions and 13 deletions
Showing only changes of commit b54805637c - Show all commits
+12 -5
View File
@@ -3,21 +3,28 @@ import { createGameState, enqueueAction, tickGame } from '../../engine/game';
import { content } from '../index';
describe('M1 stub content pack', () => {
it('defines two resources and four to five actions with costs and unlocks', () => {
it('defines two resources and multiple actions with costs, unlocks, and varied kinds', () => {
expect(content.resources).toHaveLength(2);
expect(content.actions.length).toBeGreaterThanOrEqual(4);
expect(content.actions.length).toBeLessThanOrEqual(6);
const withCosts = content.actions.filter((a) => a.costs.length > 0);
const withUnlocks = content.actions.filter((a) => a.unlock !== undefined);
expect(withCosts.length).toBeGreaterThanOrEqual(2);
expect(withUnlocks.length).toBeGreaterThanOrEqual(1);
// verify the new kinds are present
const kinds = new Set(content.actions.map((a) => a.kind));
expect(kinds.has('timed')).toBe(true);
expect(kinds.has('loop')).toBe(true);
expect(kinds.has('story')).toBe(true);
});
it('can simulate a costed action without throwing', () => {
it('can simulate a costed timed action without throwing', () => {
const state = createGameState(content);
const trade = content.actions.find((a) => a.costs.length > 0);
const trade = content.actions.find((a) => a.costs.length > 0 && a.kind === 'timed');
if (!trade) {
throw new Error('expected at least one costed action');
throw new Error('expected at least one costed timed action');
}
if (trade.durationMs === undefined) {
throw new Error('expected costed timed action to have durationMs');
}
enqueueAction(state, content, trade.id);
tickGame(state, content, trade.durationMs);
@@ -9,6 +9,8 @@ const actionsById = {
scout_path: {
id: 'scout_path',
name: 'Scout',
kind: 'timed' as const,
group: { id: 'travel', label: 'Travel' },
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
+34 -1
View File
@@ -7,6 +7,8 @@ export const actionDefs = [
{
id: 'gather_supplies',
name: 'Gather supplies',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000,
yields: [{ resourceId: 'supplies', amount: 2 }],
storyHint: 'Basic camp labor.',
@@ -15,6 +17,8 @@ export const actionDefs = [
{
id: 'scout_path',
name: 'Scout the path',
kind: 'timed',
group: { id: 'travel', label: 'Travel' },
durationMs: 5000,
costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 1 }],
@@ -24,6 +28,8 @@ export const actionDefs = [
{
id: 'trade_supplies',
name: 'Trade at camp',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 4000,
costs: [{ resourceId: 'supplies', amount: 3 }],
yields: [{ resourceId: 'coin', amount: 2 }],
@@ -34,6 +40,8 @@ export const actionDefs = [
{
id: 'fortify_camp',
name: 'Fortify camp',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 8000,
costs: [
{ resourceId: 'supplies', amount: 5 },
@@ -47,6 +55,8 @@ export const actionDefs = [
{
id: 'push_onward',
name: 'Push onward',
kind: 'timed',
group: { id: 'travel', label: 'Travel' },
durationMs: 6000,
costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 3 }],
@@ -57,9 +67,32 @@ export const actionDefs = [
{
id: 'rest',
name: 'Rest briefly',
kind: 'loop',
group: { id: 'camp_loop', label: 'Camp activities' },
loopPriority: 0,
durationMs: 2000,
yields: [{ resourceId: 'supplies', amount: 1 }],
storyHint: 'Catch your breath.',
storyTooltip: 'Yields 1 Supply. Quick recovery.',
storyTooltip: 'Idle upkeep — runs when nothing else is queued.',
},
{
id: 'pick_high_road',
name: 'Take the high road',
kind: 'story',
group: { id: 'fork', label: 'Crossroads' },
storyChoiceId: 'pick_a',
storyHint: 'Route A — high ground and supplies.',
storyTooltip: 'Story fork: grants route A flag and resources. Hides river path.',
yields: [],
},
{
id: 'follow_river',
name: 'Follow the river',
kind: 'story',
group: { id: 'fork', label: 'Crossroads' },
storyChoiceId: 'pick_b',
storyHint: 'Route B — river trade and coin.',
storyTooltip: 'Story fork: grants route B flag. Hides high road path.',
yields: [],
},
];
+1
View File
@@ -10,6 +10,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 300,
yields: [{ resourceId: 'gold', amount: 1 }],
},
+14 -3
View File
@@ -10,6 +10,8 @@ import {
tickGame,
} from '../game';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }],
@@ -17,6 +19,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 300,
yields: [{ resourceId: 'gold', amount: 2 }],
},
@@ -28,9 +31,9 @@ function queueContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [
{ id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'c', name: 'C', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'a', name: 'A', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'b', name: 'B', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'c', name: 'C', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
],
});
}
@@ -45,12 +48,14 @@ function costContent() {
{
id: 'gather',
name: 'Gather',
group: DEFAULT_GROUP,
durationMs: 300,
yields: [{ resourceId: 'supplies', amount: 2 }],
},
{
id: 'trade',
name: 'Trade',
group: DEFAULT_GROUP,
durationMs: 300,
costs: [{ resourceId: 'supplies', amount: 5 }],
yields: [{ resourceId: 'coin', amount: 3 }],
@@ -58,6 +63,7 @@ function costContent() {
{
id: 'scout',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 300,
yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { minResources: { coin: 1 } },
@@ -204,6 +210,7 @@ describe('unlock conditions', () => {
{
id: 'secret',
name: 'Secret',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'gold', amount: 1 }],
unlock: { requireStoryFlags: ['path_scouted'] },
@@ -245,12 +252,14 @@ describe('completion advances queue', () => {
{
id: 'cheap',
name: 'Cheap',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'dear',
name: 'Dear',
group: DEFAULT_GROUP,
durationMs: 100,
costs: [{ resourceId: 'supplies', amount: 6 }],
yields: [{ resourceId: 'supplies', amount: 1 }],
@@ -258,6 +267,7 @@ describe('completion advances queue', () => {
{
id: 'free',
name: 'Free',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
@@ -283,6 +293,7 @@ describe('completion advances queue', () => {
{
id: 'combo',
name: 'Combo',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [
{ resourceId: 'a', amount: 2 },
+1
View File
@@ -18,6 +18,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
+7
View File
@@ -13,6 +13,8 @@ import {
type StoryEvent,
} from '../story';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function gameContent() {
const base = buildContent({
resources: [
@@ -23,6 +25,7 @@ function gameContent() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
@@ -54,6 +57,7 @@ function gameContentWithActionTrigger() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
@@ -91,6 +95,7 @@ function gameContentWithThreshold() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
@@ -132,6 +137,7 @@ function gameContentWithFork() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
@@ -189,6 +195,7 @@ function gameContentWithGatedChoice() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
+5 -2
View File
@@ -4,6 +4,8 @@ import { createGameState, enqueueAction, startAction } from '../../engine/game';
import { createSave, serializeSave } from '../../engine/save';
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
@@ -11,6 +13,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
@@ -22,8 +25,8 @@ function queueTestContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [
{ id: 'a', name: 'A', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'b', name: 'B', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'a', name: 'A', group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'b', name: 'B', group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
],
});
}
+7 -2
View File
@@ -5,6 +5,8 @@ import { createGameState, enqueueAction, 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 }],
@@ -12,6 +14,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 200,
yields: [{ resourceId: 'gold', amount: 1 }],
},
@@ -87,8 +90,8 @@ describe('toView()', () => {
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 }] },
{ 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);
@@ -108,6 +111,7 @@ describe('toView() action availability', () => {
{
id: 'locked',
name: 'Locked',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { requireStoryFlags: ['route_a'] },
@@ -127,6 +131,7 @@ describe('toView() action availability', () => {
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }],
},