Files
Dungeons-Ground/packages/game-engine/src/index.test.ts
pavel444-byte 6c39188027
Some checks failed
CI / validate (push) Failing after 3m36s
CI / validate (pull_request) Failing after 11s
feat(gameplay): add versioned SRD rules migration
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 08:45:50 +05:00

180 lines
7.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, proficiencyBonus, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
import type { Character } from '@dng/shared'
const fixed: RandomSource = { integer: () => 12 }
const hero: Character = {
id: 'hero', name: 'Rook', concept: 'Relic runner', controller: 'human', userId: 'user',
abilities: { str: 14, dex: 16, con: 12, int: 10, wis: 8, cha: 13 },
hp: 10, maxHp: 12, defense: 14, proficiency: 2, inventory: [], statuses: [],
}
describe('game engine', () => {
it('calculates ability modifiers', () => {
expect(abilityModifier(8)).toBe(-1)
expect(abilityModifier(16)).toBe(3)
})
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(15)
expect(roll.success).toBe(true)
expect(roll.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
})
it('labels disadvantage rolls correctly', () => {
const values = [18, 4]
const roll = resolveCheck(
{ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 10, mode: 'disadvantage', reason: 'Sneak' },
hero,
{ integer: () => values.shift()! },
)
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' },
hero,
{ integer: () => 4 },
)
const events = bindMechanicalEventsToRolls([
{ type: 'damage', actorId: 'hero', targetId: 'target', value: 999, item: null, damageType: 'slashing', description: 'Strike' },
], [damage])
expect(events[0]?.value).toBe(6)
})
it('clamps mechanical state', () => {
const result = applyMechanicalEvents([hero], [{ type: 'damage', actorId: null, targetId: 'hero', value: 99, item: null, damageType: 'force', description: 'Catastrophic hit' }])
expect(result.characters[0]?.hp).toBe(0)
expect(result.audit).toHaveLength(1)
})
it('closes only complete or forced rounds', () => {
expect(shouldCloseRound(['a', 'b'], ['a'])).toBe(false)
expect(shouldCloseRound(['a', 'b'], ['a', 'b'])).toBe(true)
expect(shouldCloseRound(['a', 'b'], [], true)).toBe(true)
})
it('derives proficiency and expertise from the server-owned rules profile', () => {
const trained: Character = {
...hero,
rules: {
version: 2, level: 9, skillProficiencies: ['perception'], skillExpertise: ['perception'],
savingThrowProficiencies: ['con'], temporaryHp: 0, deathSaves: { successes: 0, failures: 0 },
exhaustion: 1, damageResistances: [], damageVulnerabilities: [], damageImmunities: [],
resources: {}, legacyProficiencyFallback: false,
},
}
const roll = resolveCheck({
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: false,
difficulty: 10, mode: 'normal', reason: 'Notice the rune',
}, trained, fixed)
expect(proficiencyBonus(9)).toBe(4)
// WIS -1 + expertise 8 - exhaustion 2.
expect(roll.modifier).toBe(5)
})
it('applies resistance, temporary HP, zero-HP state and healing recovery', () => {
const guarded: Character = {
...hero,
hp: 4,
rules: {
version: 2, level: 1, skillProficiencies: [], skillExpertise: [], savingThrowProficiencies: [],
temporaryHp: 2, deathSaves: { successes: 0, failures: 0 }, exhaustion: 0,
damageResistances: ['fire'], damageVulnerabilities: [], damageImmunities: [],
resources: {}, legacyProficiencyFallback: false,
},
}
const damaged = applyMechanicalEvents([guarded], [{
type: 'damage', actorId: null, targetId: 'hero', value: 12, item: null,
damageType: 'fire', description: 'Flames engulf the hero',
}]).characters[0]!
expect(damaged.rules?.temporaryHp).toBe(0)
expect(damaged.hp).toBe(0)
expect(damaged.statuses).toContain('unconscious')
const healed = applyMechanicalEvents([damaged], [{
type: 'healing', actorId: null, targetId: 'hero', value: 4, item: null,
damageType: null, description: 'An ally restores the hero',
}]).characters[0]!
expect(healed.hp).toBe(4)
expect(healed.statuses).not.toContain('unconscious')
})
it('tracks natural death-save outcomes without model-authored state changes', () => {
const dying: Character = {
...hero,
hp: 0,
statuses: ['unconscious'],
rules: {
version: 2, level: 1, skillProficiencies: [], skillExpertise: [], savingThrowProficiencies: [],
temporaryHp: 0, deathSaves: { successes: 0, failures: 0 }, exhaustion: 0,
damageResistances: [], damageVulnerabilities: [], damageImmunities: [],
resources: {}, legacyProficiencyFallback: false,
},
}
const deathRoll = resolveCheck({
actorId: 'hero', kind: 'deathSavingThrow', mode: 'normal', reason: 'Cling to life',
}, dying, { integer: () => 20 })
const recovered = applyMechanicalEvents([dying], [], [deathRoll]).characters[0]!
expect(recovered.hp).toBe(1)
expect(recovered.statuses).not.toContain('unconscious')
expect(recovered.rules?.deathSaves).toEqual({ successes: 0, failures: 0 })
})
})