feat(gameplay): enforce core SRD d20 rules
All checks were successful
CI / validate (push) Successful in 21m53s
CI / validate (pull_request) Successful in 15m15s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-23 18:50:55 +05:00
parent ace1e47a6d
commit 4e0c38c925
13 changed files with 230 additions and 27 deletions

View File

@@ -1,5 +1,5 @@
import { randomInt, randomUUID } from 'node:crypto'
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent } from '@dng/shared'
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
export interface RandomSource {
integer(min: number, max: number): number
@@ -51,13 +51,47 @@ export function bindMechanicalEventsToRolls(events: ProposedEvent[], rolls: Dice
})
}
const skillAbilities: Record<SkillKey, AbilityKey> = {
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'
}
export function resolveCheck(request: CheckRequest, actor: Character, random: RandomSource = secureRandom): DiceRoll {
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
}
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 {
@@ -82,9 +116,15 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
const diceCount = request.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]) + actor.proficiency
const modifier = abilityModifier(actor.abilities[ability]) + (usesProficiency(request) ? actor.proficiency : 0)
const total = keptValue + modifier
const difficulty = request.difficulty ?? null
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
return {
id: randomUUID(),
checkKind: request.kind,
@@ -96,13 +136,58 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
modifier,
total,
difficulty,
success: difficulty === null ? null : total >= difficulty,
success: difficulty === null ? null : automaticAttackOutcome ?? 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 }>