feat(gameplay): add versioned SRD rules migration
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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) })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user