180 lines
7.7 KiB
TypeScript
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 })
|
|
})
|
|
})
|