import { randomInt, randomUUID } from 'node:crypto' import type { AbilityKey, Character, CharacterRules, CheckRequest, DamageType, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared' export interface RandomSource { integer(min: number, max: number): number } export const secureRandom: RandomSource = { integer(min, max) { if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new Error('Invalid random range') return randomInt(min, max + 1) }, } 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}`) const count = Number(match[1]) const sides = Number(match[2]) const modifier = match[3] ? Number(`${match[3]}${match[4]}`) : 0 if (count < 1 || count > 100 || sides < 2 || sides > 1000) throw new Error('Dice formula outside safe limits') const rolls = Array.from({ length: count }, () => random.integer(1, sides)) return { rolls, modifier, total: rolls.reduce((sum, value) => sum + value, modifier) } } /** * Converts model-proposed damage/healing events into server-authoritative events. * The model may decide that a roll is needed, but it cannot choose its result. */ export function bindMechanicalEventsToRolls(events: ProposedEvent[], rolls: DiceRoll[]): ProposedEvent[] { const unusedRollIds = new Set(rolls.map(roll => roll.id)) return events.flatMap(event => { if (event.type !== 'damage' && event.type !== 'healing') return [event] if (!event.targetId) return [] const roll = rolls.find(candidate => unusedRollIds.has(candidate.id) && candidate.checkKind === event.type && candidate.actorId === event.actorId && candidate.targetId === event.targetId, ) if (!roll) return [] unusedRollIds.delete(roll.id) return [{ ...event, value: Math.max(0, roll.total) }] }) } const skillAbilities: Record = { acrobatics: 'dex', animalHandling: 'wis', arcana: 'int', athletics: 'str', deception: 'cha', history: 'int', insight: 'wis', intimidation: 'cha', investigation: 'int', medicine: 'wis', nature: 'int', perception: 'wis', performance: 'cha', persuasion: 'cha', religion: 'int', sleightOfHand: 'dex', stealth: 'dex', survival: 'wis', } function abilityForCheck(request: CheckRequest): AbilityKey { if (request.kind === 'skill' && request.skill) return skillAbilities[request.skill] if (request.ability) return request.ability if (request.kind === 'initiative') return 'dex' return 'str' } function usesProficiency(request: CheckRequest): boolean { // The alpha treats ordinary weapon attacks as proficient unless the planner // explicitly marks an improvised or unfamiliar attack otherwise. if (request.kind === 'attack') return request.proficient !== false 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 { 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() 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, random: RandomSource = secureRandom, target?: Character, ): DiceRoll { if (request.kind === 'damage' || request.kind === 'healing') { const rolled = rollDice(request.dice ?? '1d6', random) return { // dice_rolls.id is a native PostgreSQL uuid. Keep persistence IDs free // of the human-readable prefixes used for logs and worker identities. id: randomUUID(), checkKind: request.kind, formula: request.dice ?? '1d6', rolls: rolled.rolls, kept: rolled.rolls, modifier: rolled.modifier, total: rolled.total, difficulty: null, success: null, actorId: actor.id, targetId: request.targetId ?? null, createdAt: new Date().toISOString(), } } 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 mode = effectiveRollMode(request, actor, target) const diceCount = mode === 'normal' ? 1 : 2 const rolls = Array.from({ length: diceCount }, () => random.integer(1, 20)) 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') } const difficulty = request.kind === 'attack' ? target!.defense : request.difficulty ?? null 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: mode === 'normal' ? `1d20${modifier >= 0 ? '+' : ''}${modifier}` : `2d20${mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`, rolls, kept: [keptValue], modifier, total, difficulty, success: difficulty === null ? null : automaticAttackOutcome ?? automaticSaveOutcome ?? total >= difficulty, actorId: actor.id, targetId: request.targetId ?? null, createdAt: new Date().toISOString(), } } function criticalDamageFormula(formula: string): string { const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula) if (!match) throw new Error(`Unsupported dice formula: ${formula}`) const modifier = match[3] ? `${match[3]}${match[4]}` : '' return `${Number(match[1]) * 2}d${match[2]}${modifier}` } /** * Resolves an ordered round plan while enforcing attack-to-damage rules. * A missed attack never rolls damage; a natural 20 doubles only the damage * dice and not the flat modifier. */ export function resolveChecks( requests: CheckRequest[], characters: Character[], random: RandomSource = secureRandom, ): DiceRoll[] { const rolls: DiceRoll[] = [] for (const originalRequest of requests) { const actor = characters.find(character => character.id === originalRequest.actorId) if (!actor) throw new Error(`Unknown check actor ${originalRequest.actorId}`) const target = originalRequest.targetId ? characters.find(character => character.id === originalRequest.targetId) : undefined let request = originalRequest if (request.kind === 'damage') { const attack = [...rolls].reverse().find(roll => roll.checkKind === 'attack' && roll.actorId === request.actorId && roll.targetId === request.targetId, ) if (attack?.success === false) continue if (attack?.kept[0] === 20) { request = { ...request, dice: criticalDamageFormula(request.dice ?? '1d6') } } } rolls.push(resolveCheck(request, actor, random, target)) } return rolls } export interface AppliedState { characters: Character[] audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }> } 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', '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') { 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.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) }) } return { characters: next, audit } } export function shouldCloseRound(activeHumanMemberIds: string[], readyMemberIds: string[], forcedByOwner = false): boolean { if (forcedByOwner) return true return activeHumanMemberIds.length > 0 && activeHumanMemberIds.every(id => readyMemberIds.includes(id)) }