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 { 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' },