feat(gameplay): enforce core SRD d20 rules
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, shouldCloseRound, type RandomSource } from './index'
|
||||
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
|
||||
import type { Character } from '@dng/shared'
|
||||
|
||||
const fixed: RandomSource = { integer: () => 12 }
|
||||
@@ -18,7 +18,7 @@ describe('game engine', () => {
|
||||
it('keeps dice server-owned and auditable', () => {
|
||||
const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed)
|
||||
expect(roll.rolls).toEqual([12])
|
||||
expect(roll.total).toBe(17)
|
||||
expect(roll.total).toBe(15)
|
||||
expect(roll.success).toBe(true)
|
||||
expect(roll.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
|
||||
})
|
||||
@@ -30,10 +30,60 @@ describe('game engine', () => {
|
||||
hero,
|
||||
{ integer: () => values.shift()! },
|
||||
)
|
||||
expect(roll.formula).toBe('2d20kl1+5')
|
||||
expect(roll.formula).toBe('2d20kl1+3')
|
||||
expect(roll.kept).toEqual([4])
|
||||
})
|
||||
|
||||
it('applies proficiency only to proficient skills and saving throws', () => {
|
||||
const perception = resolveCheck({
|
||||
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: true,
|
||||
difficulty: 12, mode: 'normal', reason: 'Spot an ambush',
|
||||
}, hero, fixed)
|
||||
const constitutionSave = resolveCheck({
|
||||
actorId: 'hero', kind: 'savingThrow', ability: 'con', proficient: false,
|
||||
difficulty: 12, mode: 'normal', reason: 'Endure poison',
|
||||
}, hero, fixed)
|
||||
|
||||
expect(perception.modifier).toBe(1)
|
||||
expect(constitutionSave.modifier).toBe(1)
|
||||
})
|
||||
|
||||
it('uses server-owned Armor Class and natural attack outcomes', () => {
|
||||
const target: Character = { ...hero, id: 'target', defense: 30 }
|
||||
const naturalTwenty = resolveCheck({
|
||||
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
|
||||
difficulty: 1, mode: 'normal', reason: 'Sword strike',
|
||||
}, hero, { integer: () => 20 }, target)
|
||||
const naturalOne = resolveCheck({
|
||||
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
|
||||
difficulty: 1, mode: 'normal', reason: 'Sword strike',
|
||||
}, hero, { integer: () => 1 }, { ...target, defense: 1 })
|
||||
|
||||
expect(naturalTwenty.difficulty).toBe(30)
|
||||
expect(naturalTwenty.success).toBe(true)
|
||||
expect(naturalOne.difficulty).toBe(1)
|
||||
expect(naturalOne.success).toBe(false)
|
||||
})
|
||||
|
||||
it('skips damage on a miss and doubles only critical damage dice', () => {
|
||||
const target: Character = { ...hero, id: 'target', defense: 14 }
|
||||
const checks = [
|
||||
{ actorId: 'hero', targetId: 'target', kind: 'attack' as const, ability: 'str' as const, mode: 'normal' as const, reason: 'Sword strike' },
|
||||
{ actorId: 'hero', targetId: 'target', kind: 'damage' as const, dice: '1d6+2', mode: 'normal' as const, reason: 'Sword damage' },
|
||||
]
|
||||
|
||||
const missed = resolveChecks(checks, [hero, target], { integer: () => 1 })
|
||||
expect(missed).toHaveLength(1)
|
||||
expect(missed[0]?.checkKind).toBe('attack')
|
||||
|
||||
const values = [20, 4, 5]
|
||||
const critical = resolveChecks(checks, [hero, target], { integer: () => values.shift()! })
|
||||
expect(critical).toHaveLength(2)
|
||||
expect(critical[1]?.formula).toBe('2d6+2')
|
||||
expect(critical[1]?.rolls).toEqual([4, 5])
|
||||
expect(critical[1]?.total).toBe(11)
|
||||
})
|
||||
|
||||
it('replaces model-proposed damage with the authoritative roll total', () => {
|
||||
const damage = resolveCheck(
|
||||
{ actorId: 'hero', targetId: 'target', kind: 'damage', dice: '1d6+2', mode: 'normal', reason: 'Strike' },
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
Reference in New Issue
Block a user