Add Zod dependency and update API interfaces

- Added Zod as a dependency in package.json.
- Updated pnpm-lock.yaml to include Zod.
- Refactored API interfaces: exported new modules for perk, survivor, mission, and encounter.
- Removed obsolete api-interfaces.ts file.
- Enhanced tests for new schemas in api-interfaces.spec.ts, covering various validation scenarios.
This commit is contained in:
Maurycy
2026-05-07 00:46:03 +00:00
parent 308a1cf5c4
commit 65af268b86
17 changed files with 829 additions and 87 deletions

View File

@@ -1 +1,4 @@
export * from './lib/api-interfaces';
export * from './lib/perk';
export * from './lib/survivor';
export * from './lib/mission';
export * from './lib/encounter';

View File

@@ -1,7 +1,199 @@
import { apiInterfaces } from './api-interfaces';
import { describe, expect, it } from 'vitest';
import {
EncounterResultSchema,
MissionParticipantSchema,
MissionSchema,
PerkModifierSchema,
PerkSchema,
SurvivorSchema,
SurvivorStatsSchema,
SurvivorStateSchema,
} from '../index';
describe('apiInterfaces', () => {
it('should work', () => {
expect(apiInterfaces()).toEqual('api-interfaces');
const PERK_ID = 'a0000000-0000-4000-8000-000000000001';
const SURVIVOR_ID = 'a0000000-0000-4000-8000-000000000002';
const MISSION_ID = 'a0000000-0000-4000-8000-000000000003';
const validPerk = {
id: PERK_ID,
key: 'borrowed_time',
name: 'Borrowed Time',
description: 'Extends the window for decisive action.',
tags: ['rescue', 'altruism'],
modifiers: [
{ target: 'altruism', type: 'additive', amount: 2 },
{
target: 'successChance',
type: 'multiplicative',
amount: 1.1,
condition: { encounterTags: ['hook'] },
},
],
};
const validSurvivor = {
id: SURVIVOR_ID,
opaqueUserId: 'U123456',
channelId: 'ch_001',
name: 'Ada',
state: 'active',
stats: { objectives: 7, survival: 5, altruism: 8 },
perkSlots: [validPerk],
createdAt: '2026-01-01T00:00:00.000Z',
};
const validMission = {
id: MISSION_ID,
groupId: null,
participants: [
{
survivorId: SURVIVOR_ID,
state: 'active',
hookCount: 0,
},
],
difficulty: 2,
status: 'active',
encounterLibraryVersion: '1.0.0',
nextTickAt: '2026-01-01T00:01:00.000Z',
tickIndex: 0,
startedAt: '2026-01-01T00:00:00.000Z',
endedAt: null,
};
describe('SurvivorStateSchema', () => {
it('accepts all valid states', () => {
for (const s of ['idle', 'active', 'injured', 'downed', 'sacrificed']) {
expect(SurvivorStateSchema.parse(s)).toBe(s);
}
});
it('rejects unknown states', () => {
expect(() => SurvivorStateSchema.parse('dead')).toThrow();
});
});
describe('SurvivorStatsSchema', () => {
it('rejects stats outside 110', () => {
expect(() =>
SurvivorStatsSchema.parse({ objectives: 0, survival: 5, altruism: 5 })
).toThrow();
expect(() =>
SurvivorStatsSchema.parse({ objectives: 5, survival: 11, altruism: 5 })
).toThrow();
});
});
describe('PerkModifierSchema', () => {
it('accepts a conditional modifier', () => {
const result = PerkModifierSchema.parse(validPerk.modifiers[1]);
expect(result.condition?.encounterTags).toEqual(['hook']);
});
});
describe('PerkSchema', () => {
it('parses a valid perk', () => {
expect(PerkSchema.parse(validPerk).key).toBe('borrowed_time');
});
});
describe('SurvivorSchema', () => {
it('parses a valid survivor', () => {
expect(SurvivorSchema.parse(validSurvivor).name).toBe('Ada');
});
it('rejects anonymous opaque user IDs', () => {
expect(() =>
SurvivorSchema.parse({ ...validSurvivor, opaqueUserId: 'A999' })
).toThrow();
});
it('rejects more than 4 perk slots', () => {
expect(() =>
SurvivorSchema.parse({
...validSurvivor,
perkSlots: [validPerk, validPerk, validPerk, validPerk, validPerk],
})
).toThrow();
});
});
describe('MissionParticipantSchema', () => {
it('rejects hookCount > 2', () => {
expect(() =>
MissionParticipantSchema.parse({
survivorId: SURVIVOR_ID,
state: 'downed',
hookCount: 3,
})
).toThrow();
});
});
describe('MissionSchema', () => {
it('parses a valid solo mission', () => {
expect(MissionSchema.parse(validMission).difficulty).toBe(2);
});
it('rejects difficulty outside 13', () => {
expect(() =>
MissionSchema.parse({ ...validMission, difficulty: 4 })
).toThrow();
});
it('rejects groups larger than 4', () => {
const participant = validMission.participants[0];
expect(() =>
MissionSchema.parse({
...validMission,
participants: [
participant,
participant,
participant,
participant,
participant,
],
})
).toThrow();
});
});
describe('EncounterResultSchema', () => {
it('parses a successful encounter with no state change', () => {
const result = EncounterResultSchema.parse({
missionId: MISSION_ID,
survivorId: SURVIVOR_ID,
encounterKey: 'cleanse_hex',
tickIndex: 1,
seed: 'abc123',
success: true,
survivorStateChange: null,
modifiersApplied: [],
logText: 'Ada cleanses the hex totem. The fog thins briefly.',
});
expect(result.success).toBe(true);
expect(result.survivorStateChange).toBeNull();
});
it('parses an encounter that injures the survivor', () => {
const result = EncounterResultSchema.parse({
missionId: MISSION_ID,
survivorId: SURVIVOR_ID,
encounterKey: 'stalked',
tickIndex: 2,
seed: 'def456',
success: false,
survivorStateChange: { from: 'active', to: 'injured' },
modifiersApplied: [
{
perkKey: 'borrowed_time',
target: 'survival',
type: 'additive',
effectiveAmount: 2,
},
],
logText: 'Something moves in the trees. Ada takes a hit.',
});
expect(result.survivorStateChange?.to).toBe('injured');
});
});

View File

@@ -1,3 +0,0 @@
export function apiInterfaces(): string {
return 'api-interfaces';
}

View File

@@ -0,0 +1,29 @@
import { z } from 'zod';
import { PerkModifierTargetSchema, PerkModifierTypeSchema } from './perk';
import { SurvivorStateSchema } from './survivor';
export const ModifierApplicationSchema = z.object({
perkKey: z.string().min(1),
target: PerkModifierTargetSchema,
type: PerkModifierTypeSchema,
effectiveAmount: z.number(),
});
export type ModifierApplication = z.infer<typeof ModifierApplicationSchema>;
export const EncounterResultSchema = z.object({
missionId: z.uuid(),
survivorId: z.uuid(),
encounterKey: z.string().min(1),
tickIndex: z.number().int().min(0),
seed: z.string().min(1),
success: z.boolean(),
survivorStateChange: z
.object({
from: SurvivorStateSchema,
to: SurvivorStateSchema,
})
.nullable(),
modifiersApplied: z.array(ModifierApplicationSchema),
logText: z.string().min(1),
});
export type EncounterResult = z.infer<typeof EncounterResultSchema>;

View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
import { SurvivorStateSchema } from './survivor';
export const MissionStateSchema = z.enum([
'lobby',
'active',
'success',
'sacrifice',
'abandoned',
]);
export type MissionState = z.infer<typeof MissionStateSchema>;
export const MissionParticipantSchema = z.object({
survivorId: z.uuid(),
state: SurvivorStateSchema,
hookCount: z.number().int().min(0).max(2),
});
export type MissionParticipant = z.infer<typeof MissionParticipantSchema>;
export const MissionSchema = z.object({
id: z.uuid(),
groupId: z.uuid().nullable(),
participants: z.array(MissionParticipantSchema).min(1).max(4),
difficulty: z.number().int().min(1).max(3),
status: MissionStateSchema,
encounterLibraryVersion: z.string().min(1),
nextTickAt: z.iso.datetime(),
tickIndex: z.number().int().min(0),
startedAt: z.iso.datetime(),
endedAt: z.iso.datetime().nullable(),
});
export type Mission = z.infer<typeof MissionSchema>;

View File

@@ -0,0 +1,34 @@
import { z } from 'zod';
export const PerkModifierTargetSchema = z.enum([
'successChance',
'objectives',
'survival',
'altruism',
]);
export type PerkModifierTarget = z.infer<typeof PerkModifierTargetSchema>;
export const PerkModifierTypeSchema = z.enum(['additive', 'multiplicative']);
export type PerkModifierType = z.infer<typeof PerkModifierTypeSchema>;
export const PerkModifierSchema = z.object({
target: PerkModifierTargetSchema,
type: PerkModifierTypeSchema,
amount: z.number(),
condition: z
.object({
encounterTags: z.array(z.string()).min(1),
})
.optional(),
});
export type PerkModifier = z.infer<typeof PerkModifierSchema>;
export const PerkSchema = z.object({
id: z.uuid(),
key: z.string().min(1),
name: z.string().min(1),
description: z.string(),
tags: z.array(z.string()),
modifiers: z.array(PerkModifierSchema),
});
export type Perk = z.infer<typeof PerkSchema>;

View File

@@ -0,0 +1,30 @@
import { z } from 'zod';
import { PerkSchema } from './perk';
export const SurvivorStateSchema = z.enum([
'idle',
'active',
'injured',
'downed',
'sacrificed',
]);
export type SurvivorState = z.infer<typeof SurvivorStateSchema>;
export const SurvivorStatsSchema = z.object({
objectives: z.number().int().min(1).max(10),
survival: z.number().int().min(1).max(10),
altruism: z.number().int().min(1).max(10),
});
export type SurvivorStats = z.infer<typeof SurvivorStatsSchema>;
export const SurvivorSchema = z.object({
id: z.uuid(),
opaqueUserId: z.string().regex(/^U/, 'Must be a logged-in viewer opaque user ID'),
channelId: z.string().min(1),
name: z.string().min(1).max(32),
state: SurvivorStateSchema,
stats: SurvivorStatsSchema,
perkSlots: z.array(PerkSchema).max(4),
createdAt: z.iso.datetime(),
});
export type Survivor = z.infer<typeof SurvivorSchema>;