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 }) }) })