feat(gameplay): add versioned SRD rules migration
Some checks failed
CI / validate (push) Failing after 3m36s
CI / validate (pull_request) Failing after 11s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-26 08:45:50 +05:00
parent 89fd1fd984
commit 6c39188027
18 changed files with 1143 additions and 42 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, proficiencyBonus, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
import type { Character } from '@dng/shared'
const fixed: RandomSource = { integer: () => 12 }
@@ -91,13 +91,13 @@ describe('game engine', () => {
{ integer: () => 4 },
)
const events = bindMechanicalEventsToRolls([
{ type: 'damage', actorId: 'hero', targetId: 'target', value: 999, item: null, description: 'Strike' },
{ type: 'damage', actorId: 'hero', targetId: 'target', value: 999, item: null, damageType: 'slashing', description: 'Strike' },
], [damage])
expect(events[0]?.value).toBe(6)
})
it('clamps mechanical state', () => {
const result = applyMechanicalEvents([hero], [{ type: 'damage', actorId: null, targetId: 'hero', value: 99, item: null, description: 'Catastrophic hit' }])
const result = applyMechanicalEvents([hero], [{ type: 'damage', actorId: null, targetId: 'hero', value: 99, item: null, damageType: 'force', description: 'Catastrophic hit' }])
expect(result.characters[0]?.hp).toBe(0)
expect(result.audit).toHaveLength(1)
})
@@ -107,4 +107,73 @@ describe('game engine', () => {
expect(shouldCloseRound(['a', 'b'], ['a', 'b'])).toBe(true)
expect(shouldCloseRound(['a', 'b'], [], true)).toBe(true)
})
it('derives proficiency and expertise from the server-owned rules profile', () => {
const trained: Character = {
...hero,
rules: {
version: 2, level: 9, skillProficiencies: ['perception'], skillExpertise: ['perception'],
savingThrowProficiencies: ['con'], temporaryHp: 0, deathSaves: { successes: 0, failures: 0 },
exhaustion: 1, damageResistances: [], damageVulnerabilities: [], damageImmunities: [],
resources: {}, legacyProficiencyFallback: false,
},
}
const roll = resolveCheck({
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: false,
difficulty: 10, mode: 'normal', reason: 'Notice the rune',
}, trained, fixed)
expect(proficiencyBonus(9)).toBe(4)
// WIS -1 + expertise 8 - exhaustion 2.
expect(roll.modifier).toBe(5)
})
it('applies resistance, temporary HP, zero-HP state and healing recovery', () => {
const guarded: Character = {
...hero,
hp: 4,
rules: {
version: 2, level: 1, skillProficiencies: [], skillExpertise: [], savingThrowProficiencies: [],
temporaryHp: 2, deathSaves: { successes: 0, failures: 0 }, exhaustion: 0,
damageResistances: ['fire'], damageVulnerabilities: [], damageImmunities: [],
resources: {}, legacyProficiencyFallback: false,
},
}
const damaged = applyMechanicalEvents([guarded], [{
type: 'damage', actorId: null, targetId: 'hero', value: 12, item: null,
damageType: 'fire', description: 'Flames engulf the hero',
}]).characters[0]!
expect(damaged.rules?.temporaryHp).toBe(0)
expect(damaged.hp).toBe(0)
expect(damaged.statuses).toContain('unconscious')
const healed = applyMechanicalEvents([damaged], [{
type: 'healing', actorId: null, targetId: 'hero', value: 4, item: null,
damageType: null, description: 'An ally restores the hero',
}]).characters[0]!
expect(healed.hp).toBe(4)
expect(healed.statuses).not.toContain('unconscious')
})
it('tracks natural death-save outcomes without model-authored state changes', () => {
const dying: Character = {
...hero,
hp: 0,
statuses: ['unconscious'],
rules: {
version: 2, level: 1, skillProficiencies: [], skillExpertise: [], savingThrowProficiencies: [],
temporaryHp: 0, deathSaves: { successes: 0, failures: 0 }, exhaustion: 0,
damageResistances: [], damageVulnerabilities: [], damageImmunities: [],
resources: {}, legacyProficiencyFallback: false,
},
}
const deathRoll = resolveCheck({
actorId: 'hero', kind: 'deathSavingThrow', mode: 'normal', reason: 'Cling to life',
}, dying, { integer: () => 20 })
const recovered = applyMechanicalEvents([dying], [], [deathRoll]).characters[0]!
expect(recovered.hp).toBe(1)
expect(recovered.statuses).not.toContain('unconscious')
expect(recovered.rules?.deathSaves).toEqual({ successes: 0, failures: 0 })
})
})

View File

@@ -1,5 +1,5 @@
import { randomInt, randomUUID } from 'node:crypto'
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
import type { AbilityKey, Character, CharacterRules, CheckRequest, DamageType, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
export interface RandomSource {
integer(min: number, max: number): number
@@ -16,6 +16,11 @@ export function abilityModifier(score: number): number {
return Math.floor((score - 10) / 2)
}
export function proficiencyBonus(level: number): number {
if (!Number.isInteger(level) || level < 1 || level > 20) throw new Error('Character level must be between 1 and 20')
return 2 + Math.floor((level - 1) / 4)
}
export function rollDice(formula: string, random: RandomSource = secureRandom) {
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
@@ -86,6 +91,54 @@ function usesProficiency(request: CheckRequest): boolean {
return request.proficient === true
}
function proficiencyMultiplier(request: CheckRequest, actor: Character): number {
const rules = actor.rules
if (!rules) return usesProficiency(request) ? 1 : 0
if (request.kind === 'skill' && request.skill) {
if (rules.skillExpertise.includes(request.skill)) return 2
if (rules.skillProficiencies.includes(request.skill)) return 1
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
}
if (request.kind === 'savingThrow' && request.ability) {
if (rules.savingThrowProficiencies.includes(request.ability)) return 1
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
}
if (request.kind === 'attack') return request.proficient === false ? 0 : 1
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
}
function normalizedStatuses(character: Character): Set<string> {
return new Set(character.statuses.map(status => status.trim().toLowerCase()))
}
function effectiveRollMode(request: CheckRequest, actor: Character, target?: Character): CheckRequest['mode'] {
let advantage = request.mode === 'advantage' ? 1 : 0
let disadvantage = request.mode === 'disadvantage' ? 1 : 0
const actorStatuses = normalizedStatuses(actor)
const targetStatuses = target ? normalizedStatuses(target) : new Set<string>()
if (['ability', 'skill', 'attack'].includes(request.kind) && actorStatuses.has('poisoned')) disadvantage += 1
if (request.kind === 'attack') {
if (actorStatuses.has('blinded') || actorStatuses.has('restrained') || actorStatuses.has('prone')) disadvantage += 1
if (actorStatuses.has('invisible')) advantage += 1
if (targetStatuses.has('blinded') || targetStatuses.has('restrained') || targetStatuses.has('paralyzed')
|| targetStatuses.has('petrified') || targetStatuses.has('stunned') || targetStatuses.has('unconscious')) advantage += 1
if (targetStatuses.has('invisible')) disadvantage += 1
}
if (request.kind === 'savingThrow' && request.ability === 'dex' && actorStatuses.has('restrained')) disadvantage += 1
if (advantage && disadvantage) return 'normal'
if (advantage) return 'advantage'
if (disadvantage) return 'disadvantage'
return 'normal'
}
function automaticallyFailedSave(request: CheckRequest, actor: Character): boolean {
if (request.kind !== 'savingThrow' || !request.ability || !['str', 'dex'].includes(request.ability)) return false
const statuses = normalizedStatuses(actor)
return ['paralyzed', 'petrified', 'stunned', 'unconscious'].some(status => statuses.has(status))
}
export function resolveCheck(
request: CheckRequest,
actor: Character,
@@ -112,11 +165,27 @@ export function resolveCheck(
}
}
if (request.kind === 'deathSavingThrow') {
const statuses = normalizedStatuses(actor)
if (actor.hp !== 0 || statuses.has('stable') || statuses.has('dead')) {
throw new Error('Death saving throws require an unstable character at 0 HP')
}
const result = random.integer(1, 20)
return {
id: randomUUID(), checkKind: request.kind, formula: '1d20', rolls: [result], kept: [result],
modifier: 0, total: result, difficulty: 10, success: result >= 10,
actorId: actor.id, targetId: null, createdAt: new Date().toISOString(),
}
}
const ability = abilityForCheck(request)
const diceCount = request.mode === 'normal' ? 1 : 2
const mode = effectiveRollMode(request, actor, target)
const diceCount = mode === 'normal' ? 1 : 2
const rolls = Array.from({ length: diceCount }, () => random.integer(1, 20))
const keptValue = request.mode === 'advantage' ? Math.max(...rolls) : request.mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
const modifier = abilityModifier(actor.abilities[ability]) + (usesProficiency(request) ? actor.proficiency : 0)
const keptValue = mode === 'advantage' ? Math.max(...rolls) : mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
const proficiency = actor.rules ? proficiencyBonus(actor.rules.level) : actor.proficiency
const exhaustionPenalty = actor.rules ? actor.rules.exhaustion * 2 : 0
const modifier = abilityModifier(actor.abilities[ability]) + proficiency * proficiencyMultiplier(request, actor) - exhaustionPenalty
const total = keptValue + modifier
if (request.kind === 'attack' && (!request.targetId || !target || target.id !== request.targetId)) {
throw new Error('Attack rolls require the active target character')
@@ -125,18 +194,19 @@ export function resolveCheck(
const automaticAttackOutcome = request.kind === 'attack'
? keptValue === 20 ? true : keptValue === 1 ? false : null
: null
const automaticSaveOutcome = automaticallyFailedSave(request, actor) ? false : null
return {
id: randomUUID(),
checkKind: request.kind,
formula: request.mode === 'normal'
formula: mode === 'normal'
? `1d20${modifier >= 0 ? '+' : ''}${modifier}`
: `2d20${request.mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
: `2d20${mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
rolls,
kept: [keptValue],
modifier,
total,
difficulty,
success: difficulty === null ? null : automaticAttackOutcome ?? total >= difficulty,
success: difficulty === null ? null : automaticAttackOutcome ?? automaticSaveOutcome ?? total >= difficulty,
actorId: actor.id,
targetId: request.targetId ?? null,
createdAt: new Date().toISOString(),
@@ -193,26 +263,145 @@ export interface AppliedState {
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>
}
export function applyMechanicalEvents(characters: Character[], events: ProposedEvent[]): AppliedState {
const next = characters.map(character => ({ ...character, inventory: [...character.inventory], statuses: [...character.statuses] }))
function cloneRules(rules: CharacterRules | undefined): CharacterRules | undefined {
return rules ? structuredClone(rules) : undefined
}
function addStatus(character: Character, status: string): void {
if (!character.statuses.some(current => current.toLowerCase() === status.toLowerCase())) character.statuses.push(status)
}
function removeStatus(character: Character, status: string): void {
character.statuses = character.statuses.filter(current => current.toLowerCase() !== status.toLowerCase())
}
function adjustedDamage(value: number, damageType: DamageType | null | undefined, rules: CharacterRules | undefined): number {
let damage = Math.max(0, value)
if (!damageType || !rules) return damage
if (rules.damageImmunities.includes(damageType)) return 0
if (rules.damageResistances.includes(damageType)) damage = Math.floor(damage / 2)
if (rules.damageVulnerabilities.includes(damageType)) damage *= 2
return damage
}
function applyDeathSavingThrows(characters: Character[], rolls: DiceRoll[]): void {
for (const roll of rolls.filter(candidate => candidate.checkKind === 'deathSavingThrow')) {
const character = characters.find(candidate => candidate.id === roll.actorId)
if (!character?.rules || character.hp !== 0 || normalizedStatuses(character).has('dead')) continue
const natural = roll.kept[0]
if (natural === 20) {
character.hp = 1
character.rules.deathSaves = { successes: 0, failures: 0 }
removeStatus(character, 'unconscious')
removeStatus(character, 'stable')
continue
}
if (roll.success) character.rules.deathSaves.successes = Math.min(3, character.rules.deathSaves.successes + 1)
else character.rules.deathSaves.failures = Math.min(3, character.rules.deathSaves.failures + (natural === 1 ? 2 : 1))
if (character.rules.deathSaves.successes >= 3) {
character.rules.deathSaves = { successes: 0, failures: 0 }
addStatus(character, 'stable')
}
if (character.rules.deathSaves.failures >= 3) {
removeStatus(character, 'unconscious')
removeStatus(character, 'stable')
addStatus(character, 'dead')
}
}
}
function applyRest(character: Character, rest: string): void {
if (!character.rules || normalizedStatuses(character).has('dead')) return
const longRest = rest.toLowerCase() === 'long'
for (const resource of Object.values(character.rules.resources)) {
if (longRest ? resource.recovery !== 'none' : resource.recovery === 'shortRest') resource.current = resource.max
}
if (!longRest || character.hp < 1) return
character.hp = character.maxHp
character.rules.temporaryHp = 0
character.rules.exhaustion = Math.max(0, character.rules.exhaustion - 1)
character.rules.deathSaves = { successes: 0, failures: 0 }
}
export function applyMechanicalEvents(characters: Character[], events: ProposedEvent[], rolls: DiceRoll[] = []): AppliedState {
const next = characters.map(character => ({
...character,
inventory: [...character.inventory],
statuses: [...character.statuses],
rules: cloneRules(character.rules),
}))
const audit: AppliedState['audit'] = []
applyDeathSavingThrows(next, rolls)
for (const event of events) {
if (!['damage', 'healing', 'inventory', 'status'].includes(event.type)) continue
if (!['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type)) continue
const target = next.find(character => character.id === event.targetId)
if (!target) throw new Error(`Unknown event target: ${event.targetId}`)
const before = structuredClone(target)
if (event.type === 'damage') target.hp = Math.max(0, target.hp - Math.max(0, event.value ?? 0))
if (event.type === 'healing') target.hp = Math.min(target.maxHp, target.hp + Math.max(0, event.value ?? 0))
if (event.type === 'damage') {
const damage = adjustedDamage(event.value ?? 0, event.damageType, target.rules)
const priorHp = target.hp
const absorbed = Math.min(target.rules?.temporaryHp ?? 0, damage)
if (target.rules) target.rules.temporaryHp -= absorbed
const hpDamage = damage - absorbed
const remainingDamageAtZero = Math.max(0, hpDamage - priorHp)
const criticalAtZero = priorHp === 0 && rolls.some(roll =>
roll.checkKind === 'attack'
&& roll.actorId === event.actorId
&& roll.targetId === event.targetId
&& roll.kept[0] === 20,
)
target.hp = Math.max(0, priorHp - hpDamage)
if (target.hp === 0 && !normalizedStatuses(target).has('dead')) {
if (remainingDamageAtZero >= target.maxHp) {
removeStatus(target, 'unconscious')
removeStatus(target, 'stable')
addStatus(target, 'dead')
} else {
addStatus(target, 'unconscious')
if (priorHp === 0 && target.rules) {
removeStatus(target, 'stable')
target.rules.deathSaves.failures = Math.min(3, target.rules.deathSaves.failures + (criticalAtZero ? 2 : 1))
if (target.rules.deathSaves.failures >= 3) {
removeStatus(target, 'unconscious')
addStatus(target, 'dead')
}
}
}
}
}
if (event.type === 'healing' && !normalizedStatuses(target).has('dead')) {
target.hp = Math.min(target.maxHp, target.hp + Math.max(0, event.value ?? 0))
if (target.hp > 0) {
removeStatus(target, 'unconscious')
removeStatus(target, 'stable')
if (target.rules) target.rules.deathSaves = { successes: 0, failures: 0 }
}
}
if (event.type === 'inventory' && event.item) {
if ((event.value ?? 1) >= 0 && !target.inventory.includes(event.item)) target.inventory.push(event.item)
if ((event.value ?? 1) < 0) target.inventory = target.inventory.filter(item => item !== event.item)
}
if (event.type === 'status' && event.item) {
if ((event.value ?? 1) >= 0 && !target.statuses.includes(event.item)) target.statuses.push(event.item)
if ((event.value ?? 1) < 0) target.statuses = target.statuses.filter(status => status !== event.item)
if (event.item.toLowerCase() === 'exhaustion' && target.rules) {
target.rules.exhaustion = Math.max(0, Math.min(6, target.rules.exhaustion + (event.value ?? 1)))
if (target.rules.exhaustion >= 6) addStatus(target, 'dead')
} else {
if ((event.value ?? 1) >= 0) addStatus(target, event.item)
if ((event.value ?? 1) < 0) removeStatus(target, event.item)
}
}
if (event.type === 'temporaryHp' && target.rules) {
target.rules.temporaryHp = Math.max(target.rules.temporaryHp, Math.max(0, event.value ?? 0))
}
if (event.type === 'resource' && event.item && target.rules?.resources[event.item]) {
const resource = target.rules.resources[event.item]!
resource.current = Math.max(0, Math.min(resource.max, resource.current + (event.value ?? -1)))
}
if (event.type === 'rest' && event.item) applyRest(target, event.item)
audit.push({ event, before, after: structuredClone(target) })
}

View File

@@ -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.',

View File

@@ -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')
})
})

View File

@@ -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>