feat(content): add action kind and group schema
This commit is contained in:
@@ -6,6 +6,7 @@ const validActions = [
|
|||||||
{
|
{
|
||||||
id: 'forage',
|
id: 'forage',
|
||||||
name: 'Forage',
|
name: 'Forage',
|
||||||
|
group: { id: 'camp', label: 'Camp' },
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||||
},
|
},
|
||||||
@@ -28,6 +29,7 @@ describe('buildContent()', () => {
|
|||||||
{
|
{
|
||||||
id: 'forage',
|
id: 'forage',
|
||||||
name: 'Forage',
|
name: 'Forage',
|
||||||
|
group: { id: 'camp', label: 'Camp' },
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
yields: [{ resourceId: 'ghost', amount: 1 }],
|
yields: [{ resourceId: 'ghost', amount: 1 }],
|
||||||
},
|
},
|
||||||
@@ -48,6 +50,7 @@ describe('buildContent()', () => {
|
|||||||
{
|
{
|
||||||
id: 'forage',
|
id: 'forage',
|
||||||
name: 'Forage',
|
name: 'Forage',
|
||||||
|
group: { id: 'camp', label: 'Camp' },
|
||||||
durationMs: -1,
|
durationMs: -1,
|
||||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||||
},
|
},
|
||||||
@@ -66,6 +69,7 @@ describe('costs and multi-yield', () => {
|
|||||||
{
|
{
|
||||||
id: 'craft',
|
id: 'craft',
|
||||||
name: 'Craft',
|
name: 'Craft',
|
||||||
|
group: { id: 'camp', label: 'Camp' },
|
||||||
durationMs: 1000,
|
durationMs: 1000,
|
||||||
costs: [{ resourceId: 'wood', amount: 2 }],
|
costs: [{ resourceId: 'wood', amount: 2 }],
|
||||||
yields: [
|
yields: [
|
||||||
@@ -84,6 +88,7 @@ describe('costs and multi-yield', () => {
|
|||||||
{
|
{
|
||||||
id: 'craft',
|
id: 'craft',
|
||||||
name: 'Craft',
|
name: 'Craft',
|
||||||
|
group: { id: 'camp', label: 'Camp' },
|
||||||
durationMs: 1000,
|
durationMs: 1000,
|
||||||
costs: [{ resourceId: 'ghost', amount: 1 }],
|
costs: [{ resourceId: 'ghost', amount: 1 }],
|
||||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||||
@@ -104,6 +109,7 @@ describe('unlock conditions', () => {
|
|||||||
{
|
{
|
||||||
id: 'forage',
|
id: 'forage',
|
||||||
name: 'Forage',
|
name: 'Forage',
|
||||||
|
group: { id: 'camp', label: 'Camp' },
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||||
unlock: {
|
unlock: {
|
||||||
@@ -131,6 +137,7 @@ describe('action narrative fields', () => {
|
|||||||
{
|
{
|
||||||
id: 'forage',
|
id: 'forage',
|
||||||
name: 'Forage',
|
name: 'Forage',
|
||||||
|
group: { id: 'camp', label: 'Camp' },
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||||
storyHint: 'Gather what the forest offers.',
|
storyHint: 'Gather what the forest offers.',
|
||||||
@@ -148,3 +155,57 @@ describe('action narrative fields', () => {
|
|||||||
expect(content.actionsById.forage.storyTooltip).toBeUndefined();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesB
|
|||||||
export type GameContent = Content & StoryContent;
|
export type GameContent = Content & StoryContent;
|
||||||
export const content: GameContent = { ...base, ...story };
|
export const content: GameContent = { ...base, ...story };
|
||||||
|
|
||||||
export type { ActionDef, Content, ResourceDef } from './schema';
|
export type { ActionDef, ActionGroup, ActionKind, Content, ResourceDef } from './schema';
|
||||||
|
|||||||
+51
-8
@@ -25,20 +25,63 @@ export const unlockDefSchema = z.object({
|
|||||||
requireStoryFlags: z.array(z.string().min(1)).optional(),
|
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),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1),
|
label: 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(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 ResourceDef = z.infer<typeof resourceDefSchema>;
|
||||||
export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
|
export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
|
||||||
export type UnlockDef = z.infer<typeof unlockDefSchema>;
|
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 type ActionDef = z.infer<typeof actionDefSchema>;
|
||||||
|
|
||||||
export interface Content {
|
export interface Content {
|
||||||
|
|||||||
Reference in New Issue
Block a user