528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
import { GameEngine } from '../src/game/engine.ts'
|
|
import type { MonsterKind } from '../src/game/monsters.ts'
|
|
import { getMonsterTreasureClass } from '../src/game/monsters.ts'
|
|
import * as dropPipeline from '../src/game/drop-pipeline.ts'
|
|
import { executeDropPipeline } from '../src/game/drop-pipeline.ts'
|
|
import {
|
|
rollBaseUpgrade,
|
|
DifficultyLevelsTable,
|
|
parseDifficultyLevelsTable,
|
|
ITEM_UPGRADE_FLAG_EXCEPTIONAL,
|
|
ITEM_UPGRADE_FLAG_ELITE,
|
|
type UpgradableItemBase,
|
|
} from '../src/game/item-upgrade.ts'
|
|
import { D2Rng } from '../src/game/d2-rng.ts'
|
|
|
|
describe('Challenger M1 Adversarial Stress Test Suite', () => {
|
|
const dropTables = getEmbeddedDropTables()
|
|
|
|
const dummyTerrain = {
|
|
widthPx: 1000,
|
|
heightPx: 1000,
|
|
overlap: () => 0,
|
|
}
|
|
|
|
const idleInput = {
|
|
movement: { x: 0, y: 0 },
|
|
attacking: false,
|
|
pickingUp: false,
|
|
talking: false,
|
|
saving: false,
|
|
loading: false,
|
|
digits: [],
|
|
}
|
|
|
|
function createTestEngine(
|
|
difficulty: 'normal' | 'nightmare' | 'hell' = 'normal',
|
|
customMonsterKinds?: Map<string, MonsterKind>,
|
|
customDropTables?: any,
|
|
) {
|
|
return new GameEngine(dummyTerrain, {
|
|
spawn: { x: 0, y: 0 },
|
|
stats: [],
|
|
xpTable: [0, 100, 200],
|
|
dropTables: customDropTables ?? dropTables,
|
|
difficulty,
|
|
monsterKinds: customMonsterKinds,
|
|
skills: [],
|
|
npcDefs: [],
|
|
questDefs: [],
|
|
combatOptions: {} as any,
|
|
talkRadius: 50,
|
|
pickupRadius: 50,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
})
|
|
}
|
|
|
|
// =========================================================================
|
|
// Dimension 1: Fuzz and test invalid inputs to GameEngine drop processing
|
|
// =========================================================================
|
|
describe('Dimension 1: Invalid & Adversarial Inputs to GameEngine Drop Processing', () => {
|
|
it('rejects missing monsterLevel (undefined, null)', () => {
|
|
const engine = createTestEngine('normal')
|
|
for (const mlvl of [undefined, null]) {
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 10,
|
|
y: 10,
|
|
subjectId: 'fallen1',
|
|
monsterRank: 'normal',
|
|
monsterLevel: mlvl as any,
|
|
} as any,
|
|
]
|
|
expect(() => engine.tick(idleInput)).toThrow(
|
|
/GameEngine drop failure: missing or invalid monsterLevel for death event/,
|
|
)
|
|
}
|
|
})
|
|
|
|
it('rejects invalid numeric monsterLevel (0, negative, NaN, strings, objects)', () => {
|
|
const invalidLevels = [
|
|
0,
|
|
-1,
|
|
-100,
|
|
-Infinity,
|
|
Number.NaN,
|
|
'10',
|
|
'',
|
|
false,
|
|
true,
|
|
{},
|
|
[],
|
|
]
|
|
|
|
for (const mlvl of invalidLevels) {
|
|
const engine = createTestEngine('normal')
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 10,
|
|
y: 10,
|
|
subjectId: 'fallen1',
|
|
monsterRank: 'normal',
|
|
monsterLevel: mlvl as any,
|
|
} as any,
|
|
]
|
|
expect(() => engine.tick(idleInput)).toThrow(
|
|
/GameEngine drop failure: missing or invalid monsterLevel for death event/,
|
|
)
|
|
}
|
|
})
|
|
|
|
it('fuzzes unknown monsters and injection payloads in subjectId and superUniqueId', () => {
|
|
const adversarialIds = [
|
|
'__proto__',
|
|
'constructor',
|
|
'toString',
|
|
'valueOf',
|
|
'',
|
|
' ',
|
|
'DROP TABLE monsters;',
|
|
'<script>alert(1)</script>',
|
|
'null',
|
|
'undefined',
|
|
'non_existent_monster_666',
|
|
'!!!invalid!!!',
|
|
'1234567890',
|
|
]
|
|
|
|
for (const id of adversarialIds) {
|
|
const engine = createTestEngine('normal')
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 10,
|
|
y: 10,
|
|
subjectId: id,
|
|
monsterRank: 'normal',
|
|
monsterLevel: 10,
|
|
} as any,
|
|
]
|
|
expect(() => engine.tick(idleInput)).toThrow(
|
|
/GameEngine drop failure: unknown monster/,
|
|
)
|
|
}
|
|
})
|
|
|
|
it('rejects unknown superUnique with unknown subjectId', () => {
|
|
const engine = createTestEngine('normal')
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 10,
|
|
y: 10,
|
|
superUniqueId: 'NonExistentSuperUnique_XYZ',
|
|
monsterRank: 'unique',
|
|
monsterLevel: 50,
|
|
} as any,
|
|
]
|
|
expect(() => engine.tick(idleInput)).toThrow(
|
|
/GameEngine drop failure: unknown monster \(subjectId: "undefined", superUniqueId: "NonExistentSuperUnique_XYZ"\) not found in canonical drop tables/,
|
|
)
|
|
})
|
|
|
|
it('rejects corrupt combat monster with empty getTreasureClass() result', () => {
|
|
const customKinds = new Map<string, MonsterKind>()
|
|
customKinds.set('corrupt_combatant', {
|
|
id: 'corrupt_combatant',
|
|
baseId: 'corrupt_combatant',
|
|
name: 'Corrupt Combatant',
|
|
boss: false,
|
|
levels: { normal: 10, nightmare: 40, hell: 70 },
|
|
treasureClasses: ['Act 1 H2H A'],
|
|
getTreasureClass: () => '',
|
|
} as any)
|
|
|
|
const engine = createTestEngine('normal', customKinds)
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 10,
|
|
y: 10,
|
|
subjectId: 'corrupt_combatant',
|
|
monsterRank: 'normal',
|
|
monsterLevel: 10,
|
|
} as any,
|
|
]
|
|
|
|
expect(() => engine.tick(idleInput)).toThrow(
|
|
/GameEngine drop failure: corrupt or unresolvable treasure class for monster "corrupt_combatant" on difficulty "normal"/,
|
|
)
|
|
})
|
|
|
|
it('rejects combat monster resolving to unregistered TC', () => {
|
|
const customKinds = new Map<string, MonsterKind>()
|
|
customKinds.set('unregistered_tc_demon', {
|
|
id: 'unregistered_tc_demon',
|
|
baseId: 'unregistered_tc_demon',
|
|
name: 'Unregistered Demon',
|
|
levels: { normal: 10, nightmare: 40, hell: 70 },
|
|
treasureClasses: ['Totally_Fake_TC_Name_999'],
|
|
} as any)
|
|
|
|
const engine = createTestEngine('normal', customKinds)
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 10,
|
|
y: 10,
|
|
subjectId: 'unregistered_tc_demon',
|
|
monsterRank: 'normal',
|
|
monsterLevel: 10,
|
|
} as any,
|
|
]
|
|
|
|
expect(() => engine.tick(idleInput)).toThrow(
|
|
/GameEngine drop failure: treasure class "Totally_Fake_TC_Name_999" resolved for monster "unregistered_tc_demon" not found in drop tables/,
|
|
)
|
|
})
|
|
|
|
it('asserts executeDropPipeline is NEVER called when validation fails', () => {
|
|
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
|
|
const engine = createTestEngine('normal')
|
|
|
|
const invalidEvents = [
|
|
{ kind: 'kill', x: 0, y: 0, subjectId: 'fallen1', monsterLevel: 0 },
|
|
{ kind: 'kill', x: 0, y: 0, subjectId: 'fallen1', monsterLevel: -1 },
|
|
{ kind: 'kill', x: 0, y: 0, subjectId: 'unknown_mob', monsterLevel: 10 },
|
|
]
|
|
|
|
for (const ev of invalidEvents) {
|
|
engine.world.pendingKills = [ev as any]
|
|
expect(() => engine.tick(idleInput)).toThrow()
|
|
}
|
|
|
|
expect(dropSpy).not.toHaveBeenCalled()
|
|
dropSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 2: Verify all 251 authentic zero-drop critters and Ancient Barbarians
|
|
// =========================================================================
|
|
describe('Dimension 2: All 251 Authentic Zero-Drop Critters & Ancient Barbarians', () => {
|
|
it('verifies exactly 251 monsterKinds have authentic empty TCs on Normal', () => {
|
|
const normalEmptyKinds: string[] = []
|
|
for (const [id, kind] of dropTables.monsterKinds) {
|
|
const tc = typeof kind.getTreasureClass === 'function'
|
|
? kind.getTreasureClass('normal', 1)
|
|
: ''
|
|
if (!tc || tc.trim() === '') {
|
|
normalEmptyKinds.push(id)
|
|
}
|
|
}
|
|
expect(normalEmptyKinds.length).toBe(251)
|
|
})
|
|
|
|
it('slays all 251 authentic zero-drop critters on Normal without throwing, yielding 0 drops', () => {
|
|
const engine = createTestEngine('normal')
|
|
|
|
for (const [id, kind] of dropTables.monsterKinds) {
|
|
const tc = typeof kind.getTreasureClass === 'function'
|
|
? kind.getTreasureClass('normal', 1)
|
|
: ''
|
|
if (!tc || tc.trim() === '') {
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 10,
|
|
y: 10,
|
|
subjectId: id,
|
|
monsterRank: 'normal',
|
|
monsterLevel: 1,
|
|
} as any,
|
|
]
|
|
expect(() => engine.tick(idleInput), `Critter ${id} failed`).not.toThrow()
|
|
expect(engine.metrics.dropsRolled, `Critter ${id} rolled drops`).toBe(0)
|
|
expect(engine.ground.length, `Critter ${id} dropped ground items`).toBe(0)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('slays all 3 Arreat Summit Ancient Barbarians across Normal, Nightmare, and Hell with 0 drops', () => {
|
|
const ancientSuperUniques = [
|
|
'Ancient Barbarian 1',
|
|
'Ancient Barbarian 2',
|
|
'Ancient Barbarian 3',
|
|
]
|
|
|
|
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
|
|
const engine = createTestEngine(diff)
|
|
|
|
for (const ancientName of ancientSuperUniques) {
|
|
const suDef = dropTables.superUniques.get(ancientName)
|
|
expect(suDef, `SuperUnique ${ancientName} must exist`).toBeDefined()
|
|
expect(suDef?.treasureClass, `Ancient ${ancientName} TC must be empty`).toBe('')
|
|
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 10,
|
|
y: 10,
|
|
subjectId: suDef!.monsterId,
|
|
superUniqueId: ancientName,
|
|
monsterRank: 'unique',
|
|
monsterLevel: diff === 'hell' ? 90 : diff === 'nightmare' ? 68 : 37,
|
|
} as any,
|
|
]
|
|
|
|
expect(() => engine.tick(idleInput), `Ancient ${ancientName} on ${diff} threw error`).not.toThrow()
|
|
expect(engine.metrics.dropsRolled, `Ancient ${ancientName} on ${diff} rolled drops`).toBe(0)
|
|
expect(engine.ground.length, `Ancient ${ancientName} on ${diff} dropped ground items`).toBe(0)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('verifies ambient critters (chicken, rat, bird1, bird2) drop 0 items across all 3 difficulties', () => {
|
|
const critters = ['chicken', 'rat', 'bird1', 'bird2']
|
|
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
|
|
const engine = createTestEngine(diff)
|
|
for (const critter of critters) {
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 10,
|
|
y: 10,
|
|
subjectId: critter,
|
|
monsterRank: 'normal',
|
|
monsterLevel: 1,
|
|
} as any,
|
|
]
|
|
expect(() => engine.tick(idleInput)).not.toThrow()
|
|
expect(engine.metrics.dropsRolled).toBe(0)
|
|
expect(engine.ground.length).toBe(0)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 3: Verify base upgrade divisor calculations across all 3 difficulties
|
|
// =========================================================================
|
|
describe('Dimension 3: Base Upgrade Divisor Calculations & Difficulty Odds', () => {
|
|
it('verifies DifficultyLevelsTable parsing from authentic 1.13c table', () => {
|
|
const diffTable = dropTables.difficultyLevels
|
|
expect(diffTable).toBeDefined()
|
|
expect(diffTable.Normal.UberCodeOddsNormal).toBe(0)
|
|
expect(diffTable.Normal.UberCodeOddsGood).toBe(0)
|
|
expect(diffTable.Normal.UltraCodeOddsNormal).toBe(0)
|
|
expect(diffTable.Normal.UltraCodeOddsGood).toBe(0)
|
|
|
|
expect(diffTable.Nightmare.UberCodeOddsNormal).toBe(10)
|
|
expect(diffTable.Nightmare.UberCodeOddsGood).toBe(20)
|
|
expect(diffTable.Nightmare.UltraCodeOddsNormal).toBe(0)
|
|
expect(diffTable.Nightmare.UltraCodeOddsGood).toBe(0)
|
|
|
|
expect(diffTable.Hell.UberCodeOddsNormal).toBe(20)
|
|
expect(diffTable.Hell.UberCodeOddsGood).toBe(40)
|
|
expect(diffTable.Hell.UltraCodeOddsNormal).toBe(30)
|
|
expect(diffTable.Hell.UltraCodeOddsGood).toBe(40)
|
|
})
|
|
|
|
it('fails fast if DifficultyLevelsTable constructor receives incomplete difficulty entries', () => {
|
|
expect(() => new DifficultyLevelsTable([], new Map())).toThrow(
|
|
/Failed to initialize DifficultyLevelsTable: expected Normal, Nightmare, and Hell entries/,
|
|
)
|
|
})
|
|
|
|
it('fails fast if executeDropPipeline cannot resolve difficulty odds', () => {
|
|
const corruptedTables = {
|
|
...dropTables,
|
|
difficultyOdds: new Map(),
|
|
difficultyLevels: new Map(),
|
|
}
|
|
expect(() =>
|
|
executeDropPipeline(corruptedTables as any, {
|
|
tcName: 'Act 1 Equip A',
|
|
nLevel: 10,
|
|
monsterType: 1,
|
|
difficulty: 'normal',
|
|
}),
|
|
).toThrow(/Failed to resolve difficulty odds for difficulty: "normal"/)
|
|
})
|
|
|
|
it('verifies rollBaseUpgrade on Normal difficulty NEVER upgrades base items (10,000 rolls)', () => {
|
|
const mockBase: UpgradableItemBase = {
|
|
id: 'cap',
|
|
name: 'Cap',
|
|
kind: 'armor',
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
maxStack: 1,
|
|
value: 12,
|
|
damage: 0,
|
|
defense: 5,
|
|
tags: ['helm'],
|
|
level: 1,
|
|
normcode: 'cap',
|
|
ubercode: 'xap',
|
|
ultracode: 'uap',
|
|
}
|
|
const xapBase: UpgradableItemBase = { ...mockBase, id: 'xap', name: 'War Hat', level: 34 }
|
|
const uapBase: UpgradableItemBase = { ...mockBase, id: 'uap', name: 'Shako', level: 58 }
|
|
|
|
const lookup = (code: string) => (code === 'xap' ? xapBase : code === 'uap' ? uapBase : mockBase)
|
|
const rng = new D2Rng(1337)
|
|
|
|
// Normal difficulty: qf4 = 0, qf5 = 0
|
|
for (let i = 0; i < 10000; i++) {
|
|
const res = rollBaseUpgrade(mockBase, 0, 0, rng, lookup)
|
|
expect(res.isUber).toBe(false)
|
|
expect(res.base.id).toBe('cap')
|
|
}
|
|
})
|
|
|
|
it('verifies statistical distribution of base upgrades on Nightmare difficulty (50,000 rolls)', () => {
|
|
const mockBase: UpgradableItemBase = {
|
|
id: 'cap',
|
|
name: 'Cap',
|
|
kind: 'armor',
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
maxStack: 1,
|
|
value: 12,
|
|
damage: 0,
|
|
defense: 5,
|
|
tags: ['helm'],
|
|
level: 1,
|
|
normcode: 'cap',
|
|
ubercode: 'xap',
|
|
ultracode: 'uap',
|
|
}
|
|
const xapBase: UpgradableItemBase = { ...mockBase, id: 'xap', name: 'War Hat', level: 34 }
|
|
const uapBase: UpgradableItemBase = { ...mockBase, id: 'uap', name: 'Shako', level: 58 }
|
|
|
|
const lookup = (code: string) => (code === 'xap' ? xapBase : code === 'uap' ? uapBase : mockBase)
|
|
const rng = new D2Rng(4242)
|
|
|
|
// Nightmare Normal monster: qf4 = 10, qf5 = 0 (10 / 1024 ~= 0.009765625)
|
|
let upgrades = 0
|
|
const N = 50000
|
|
for (let i = 0; i < N; i++) {
|
|
const res = rollBaseUpgrade(mockBase, 10, 0, rng, lookup)
|
|
if (res.isUber) {
|
|
upgrades++
|
|
expect(res.base.id).toBe('xap')
|
|
}
|
|
}
|
|
const expectedRate = 10 / 1024
|
|
const actualRate = upgrades / N
|
|
// 3 sigma tolerance
|
|
const sigma = Math.sqrt((expectedRate * (1 - expectedRate)) / N)
|
|
expect(Math.abs(actualRate - expectedRate)).toBeLessThan(3.5 * sigma)
|
|
})
|
|
|
|
it('verifies statistical distribution of base upgrades on Hell difficulty (50,000 rolls)', () => {
|
|
const mockBase: UpgradableItemBase = {
|
|
id: 'cap',
|
|
name: 'Cap',
|
|
kind: 'armor',
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
maxStack: 1,
|
|
value: 12,
|
|
damage: 0,
|
|
defense: 5,
|
|
tags: ['helm'],
|
|
level: 1,
|
|
normcode: 'cap',
|
|
ubercode: 'xap',
|
|
ultracode: 'uap',
|
|
}
|
|
const xapBase: UpgradableItemBase = { ...mockBase, id: 'xap', name: 'War Hat', level: 34 }
|
|
const uapBase: UpgradableItemBase = { ...mockBase, id: 'uap', name: 'Shako', level: 58 }
|
|
|
|
const lookup = (code: string) => (code === 'xap' ? xapBase : code === 'uap' ? uapBase : mockBase)
|
|
const rng = new D2Rng(9999)
|
|
|
|
// Hell Good monster: qf4 = 40, qf5 = 40 (40/1024 ~= 0.0390625)
|
|
let eliteUpgrades = 0
|
|
let exceptionalUpgrades = 0
|
|
const N = 50000
|
|
for (let i = 0; i < N; i++) {
|
|
const res = rollBaseUpgrade(mockBase, 40, 40, rng, lookup)
|
|
if (res.isUber) {
|
|
if (res.base.id === 'uap') {
|
|
eliteUpgrades++
|
|
} else if (res.base.id === 'xap') {
|
|
exceptionalUpgrades++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Expected elite rate = 40/1024
|
|
const pElite = 40 / 1024
|
|
const actualElite = eliteUpgrades / N
|
|
const sigmaElite = Math.sqrt((pElite * (1 - pElite)) / N)
|
|
expect(Math.abs(actualElite - pElite)).toBeLessThan(3.5 * sigmaElite)
|
|
expect(exceptionalUpgrades).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('handles bases with xxx or missing upgrade codes without throwing', () => {
|
|
const unupgradableBase: UpgradableItemBase = {
|
|
id: 'amu',
|
|
name: 'Amulet',
|
|
kind: 'misc',
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
maxStack: 1,
|
|
value: 100,
|
|
damage: 0,
|
|
defense: 0,
|
|
tags: ['jewl'],
|
|
level: 1,
|
|
ubercode: 'xxx',
|
|
ultracode: 'xxx',
|
|
}
|
|
const rng = new D2Rng(100)
|
|
const res = rollBaseUpgrade(unupgradableBase, 1000, 1000, rng, () => undefined)
|
|
expect(res.isUber).toBe(false)
|
|
expect(res.base.id).toBe('amu')
|
|
})
|
|
})
|
|
})
|