feat(gameplay): add versioned SRD rules migration
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -10,6 +10,9 @@ const draft = {
|
||||
defense: 13,
|
||||
proficiency: 2,
|
||||
inventory: ['Signal lens', 'Field toolkit'],
|
||||
skillProficiencies: ['arcana', 'history', 'investigation', 'perception'],
|
||||
skillExpertise: ['investigation'],
|
||||
savingThrowProficiencies: ['int', 'wis'],
|
||||
persona: {
|
||||
voice: 'Precise, with dry humor.',
|
||||
motivation: 'Prove the relay is speaking from the future.',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CheckRequestSchema, PlayerIntentSchema } from './index'
|
||||
import { CharacterRulesSchema, CheckRequestSchema, PlayerIntentSchema } from './index'
|
||||
|
||||
describe('shared database contracts', () => {
|
||||
it('accepts PostgREST timestamptz values with an explicit UTC offset', () => {
|
||||
@@ -28,4 +28,21 @@ describe('shared database contracts', () => {
|
||||
difficulty: 13, mode: 'normal', reason: 'Resist the whisper',
|
||||
})).toMatchObject({ kind: 'savingThrow', ability: 'wis' })
|
||||
})
|
||||
|
||||
it('validates versioned SRD character mechanics', () => {
|
||||
const rules = CharacterRulesSchema.parse({
|
||||
version: 2,
|
||||
level: 5,
|
||||
skillProficiencies: ['perception'],
|
||||
skillExpertise: ['perception'],
|
||||
savingThrowProficiencies: ['wis'],
|
||||
resources: { focus: { current: 2, max: 3, recovery: 'shortRest' } },
|
||||
})
|
||||
expect(rules.temporaryHp).toBe(0)
|
||||
expect(rules.deathSaves).toEqual({ successes: 0, failures: 0 })
|
||||
expect(() => CharacterRulesSchema.parse({
|
||||
...rules,
|
||||
skillExpertise: ['stealth'],
|
||||
})).toThrow('expertise requires skill proficiency')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,62 @@ export const skillKeys = [
|
||||
export const SkillKeySchema = z.enum(skillKeys)
|
||||
export type SkillKey = z.infer<typeof SkillKeySchema>
|
||||
|
||||
export const damageTypeKeys = [
|
||||
'acid', 'bludgeoning', 'cold', 'fire', 'force', 'lightning', 'necrotic',
|
||||
'piercing', 'poison', 'psychic', 'radiant', 'slashing', 'thunder',
|
||||
] as const
|
||||
export const DamageTypeSchema = z.enum(damageTypeKeys)
|
||||
export type DamageType = z.infer<typeof DamageTypeSchema>
|
||||
|
||||
export const conditionKeys = [
|
||||
'blinded', 'charmed', 'deafened', 'frightened', 'grappled', 'incapacitated',
|
||||
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
|
||||
'stunned', 'unconscious', 'stable', 'dead',
|
||||
] as const
|
||||
export const ConditionKeySchema = z.enum(conditionKeys)
|
||||
export type ConditionKey = z.infer<typeof ConditionKeySchema>
|
||||
|
||||
export const CharacterResourceSchema = z.object({
|
||||
current: z.number().int().min(0),
|
||||
max: z.number().int().min(0),
|
||||
recovery: z.enum(['none', 'shortRest', 'longRest']).default('longRest'),
|
||||
}).strict().refine(value => value.current <= value.max, {
|
||||
message: 'resource current value must not exceed its maximum',
|
||||
path: ['current'],
|
||||
})
|
||||
|
||||
/**
|
||||
* Versioned SRD mechanics that older campaigns did not persist. The legacy
|
||||
* fallback is explicit so migrated games keep their previous proficiency
|
||||
* behavior without allowing it in newly-authored rules profiles.
|
||||
*/
|
||||
export const CharacterRulesSchema = z.object({
|
||||
version: z.literal(2),
|
||||
level: z.number().int().min(1).max(20).default(1),
|
||||
skillProficiencies: z.array(SkillKeySchema).max(18).default([]),
|
||||
skillExpertise: z.array(SkillKeySchema).max(18).default([]),
|
||||
savingThrowProficiencies: z.array(AbilityKeySchema).max(6).default([]),
|
||||
temporaryHp: z.number().int().min(0).max(999).default(0),
|
||||
deathSaves: z.object({
|
||||
successes: z.number().int().min(0).max(3),
|
||||
failures: z.number().int().min(0).max(3),
|
||||
}).strict().default({ successes: 0, failures: 0 }),
|
||||
exhaustion: z.number().int().min(0).max(6).default(0),
|
||||
damageResistances: z.array(DamageTypeSchema).max(13).default([]),
|
||||
damageVulnerabilities: z.array(DamageTypeSchema).max(13).default([]),
|
||||
damageImmunities: z.array(DamageTypeSchema).max(13).default([]),
|
||||
resources: z.record(z.string().trim().min(1).max(80), CharacterResourceSchema).default({}),
|
||||
legacyProficiencyFallback: z.boolean().default(false),
|
||||
}).strict().superRefine((value, context) => {
|
||||
const trained = new Set(value.skillProficiencies)
|
||||
for (const skill of value.skillExpertise) {
|
||||
if (!trained.has(skill)) {
|
||||
context.addIssue({ code: 'custom', message: 'expertise requires skill proficiency', path: ['skillExpertise'] })
|
||||
}
|
||||
}
|
||||
})
|
||||
export type CharacterRules = z.infer<typeof CharacterRulesSchema>
|
||||
|
||||
export const AbilityScoresSchema = z.object({
|
||||
str: z.number().int().min(1).max(30),
|
||||
dex: z.number().int().min(1).max(30),
|
||||
@@ -55,6 +111,9 @@ export const CharacterDraftSchema = z.object({
|
||||
defense: z.number().int().min(1).max(40),
|
||||
proficiency: z.number().int().min(1).max(10),
|
||||
inventory: z.array(z.string().trim().min(1).max(120)).max(12),
|
||||
skillProficiencies: z.array(SkillKeySchema).max(8).default([]),
|
||||
skillExpertise: z.array(SkillKeySchema).max(4).default([]),
|
||||
savingThrowProficiencies: z.array(AbilityKeySchema).max(3).default([]),
|
||||
persona: CharacterPersonaSchema,
|
||||
}).strict().refine(value => value.hp <= value.maxHp, {
|
||||
message: 'hp must not exceed maxHp',
|
||||
@@ -92,6 +151,7 @@ export const CharacterSchema = z.object({
|
||||
inventory: z.array(z.string().max(120)).max(50).default([]),
|
||||
statuses: z.array(z.string().max(80)).max(12).default([]),
|
||||
persona: z.record(z.string(), z.unknown()).optional(),
|
||||
rules: CharacterRulesSchema.optional(),
|
||||
})
|
||||
export type Character = z.infer<typeof CharacterSchema>
|
||||
|
||||
@@ -129,7 +189,7 @@ export type RoundContext = z.infer<typeof RoundContextSchema>
|
||||
|
||||
export const CheckRequestSchema = z.object({
|
||||
actorId: z.string().min(1),
|
||||
kind: z.enum(['ability', 'skill', 'savingThrow', 'attack', 'initiative', 'damage', 'healing']),
|
||||
kind: z.enum(['ability', 'skill', 'savingThrow', 'deathSavingThrow', 'attack', 'initiative', 'damage', 'healing']),
|
||||
ability: AbilityKeySchema.optional(),
|
||||
skill: SkillKeySchema.optional(),
|
||||
proficient: z.boolean().optional(),
|
||||
@@ -137,6 +197,7 @@ export const CheckRequestSchema = z.object({
|
||||
targetId: z.string().optional(),
|
||||
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
|
||||
dice: z.string().regex(/^\d+d\d+(?:[+-]\d+)?$/).optional(),
|
||||
damageType: DamageTypeSchema.optional(),
|
||||
reason: z.string().min(1).max(500),
|
||||
})
|
||||
export type CheckRequest = z.infer<typeof CheckRequestSchema>
|
||||
@@ -158,11 +219,12 @@ export const DiceRollSchema = z.object({
|
||||
export type DiceRoll = z.infer<typeof DiceRollSchema>
|
||||
|
||||
export const ProposedEventSchema = z.object({
|
||||
type: z.enum(['narrative', 'relationship', 'quest', 'inventory', 'status', 'damage', 'healing']),
|
||||
type: z.enum(['narrative', 'relationship', 'quest', 'inventory', 'status', 'damage', 'healing', 'temporaryHp', 'resource', 'rest']),
|
||||
actorId: z.string().nullable().default(null),
|
||||
targetId: z.string().nullable().default(null),
|
||||
value: z.number().int().nullable().default(null),
|
||||
item: z.string().max(120).nullable().default(null),
|
||||
damageType: DamageTypeSchema.nullable().default(null),
|
||||
description: z.string().min(1).max(1000),
|
||||
})
|
||||
export type ProposedEvent = z.infer<typeof ProposedEventSchema>
|
||||
|
||||
Reference in New Issue
Block a user