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
3 changed files with 113 additions and 9 deletions
Showing only changes of commit c48e53cc7b - Show all commits
+61
View File
@@ -6,6 +6,7 @@ const validActions = [
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
@@ -28,6 +29,7 @@ describe('buildContent()', () => {
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000,
yields: [{ resourceId: 'ghost', amount: 1 }],
},
@@ -48,6 +50,7 @@ describe('buildContent()', () => {
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: -1,
yields: [{ resourceId: 'gold', amount: 1 }],
},
@@ -66,6 +69,7 @@ describe('costs and multi-yield', () => {
{
id: 'craft',
name: 'Craft',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000,
costs: [{ resourceId: 'wood', amount: 2 }],
yields: [
@@ -84,6 +88,7 @@ describe('costs and multi-yield', () => {
{
id: 'craft',
name: 'Craft',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000,
costs: [{ resourceId: 'ghost', amount: 1 }],
yields: [{ resourceId: 'gold', amount: 1 }],
@@ -104,6 +109,7 @@ describe('unlock conditions', () => {
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
unlock: {
@@ -131,6 +137,7 @@ describe('action narrative fields', () => {
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
storyHint: 'Gather what the forest offers.',
@@ -148,3 +155,57 @@ describe('action narrative fields', () => {
expect(content.actionsById.forage.storyTooltip).toBeUndefined();
});
});
describe('action kind schema', () => {
it('accepts kind, group, and storyChoiceId', () => {
const content = buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'pick_high_road',
name: 'Take the high road',
kind: 'story',
group: { id: 'fork', label: 'Crossroads' },
storyChoiceId: 'pick_a',
yields: [],
},
],
});
expect(content.actionsById.pick_high_road.kind).toBe('story');
expect(content.actionsById.pick_high_road.group.label).toBe('Crossroads');
});
it('defaults kind to timed and requires durationMs for timed actions', () => {
expect(() =>
buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'broken',
name: 'Broken',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
yields: [{ resourceId: 'supplies', amount: 1 }],
},
],
}),
).toThrow();
});
it('requires durationMs for loop actions', () => {
const content = buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'rest',
name: 'Rest',
kind: 'loop',
group: { id: 'camp_loop', label: 'Camp activities' },
durationMs: 2000,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
],
});
expect(content.actionsById.rest.kind).toBe('loop');
});
});
+1 -1
View File
@@ -9,4 +9,4 @@ const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesB
export type GameContent = Content & StoryContent;
export const content: GameContent = { ...base, ...story };
export type { ActionDef, Content, ResourceDef } from './schema';
export type { ActionDef, ActionGroup, ActionKind, Content, ResourceDef } from './schema';
+51 -8
View File
@@ -25,20 +25,63 @@ export const unlockDefSchema = z.object({
requireStoryFlags: z.array(z.string().min(1)).optional(),
});
export const actionDefSchema = z.object({
export const actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context']);
export const actionGroupSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
durationMs: z.number().positive(),
costs: z.array(resourceAmountSchema).default([]),
yields: z.array(resourceAmountSchema).min(1),
unlock: unlockDefSchema.optional(),
storyHint: z.string().min(1).optional(),
storyTooltip: z.string().min(1).optional(),
label: z.string().min(1),
});
export const actionDefSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
kind: actionKindSchema.default('timed'),
group: actionGroupSchema,
durationMs: z.number().positive().optional(),
loopPriority: z.number().int().nonnegative().optional(),
costs: z.array(resourceAmountSchema).default([]),
yields: z.array(resourceAmountSchema).default([]),
unlock: unlockDefSchema.optional(),
storyHint: z.string().min(1).optional(),
storyTooltip: z.string().min(1).optional(),
storyChoiceId: z.string().min(1).optional(),
contextId: z.string().min(1).optional(),
automation: z
.object({
unlockAfterManualCompletions: z.number().int().positive().default(1),
})
.optional(),
})
.superRefine((action, ctx) => {
if ((action.kind === 'timed' || action.kind === 'loop') && action.durationMs === undefined) {
ctx.addIssue({
code: 'custom',
message: `${action.kind} actions require durationMs`,
path: ['durationMs'],
});
}
if (action.kind === 'story' && !action.storyChoiceId) {
ctx.addIssue({
code: 'custom',
message: 'story actions require storyChoiceId',
path: ['storyChoiceId'],
});
}
if (action.kind === 'timed' && action.yields.length === 0) {
ctx.addIssue({
code: 'custom',
message: 'timed actions require at least one yield',
path: ['yields'],
});
}
});
export type ResourceDef = z.infer<typeof resourceDefSchema>;
export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
export type UnlockDef = z.infer<typeof unlockDefSchema>;
export type ActionKind = z.infer<typeof actionKindSchema>;
export type ActionGroup = z.infer<typeof actionGroupSchema>;
export type ActionDef = z.infer<typeof actionDefSchema>;
export interface Content {