206 lines
7.1 KiB
TypeScript
206 lines
7.1 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
import {
|
|
getMonsterTreasureClass,
|
|
} from '../src/game/monsters.ts'
|
|
import {
|
|
executeDropPipeline,
|
|
loadDropTables,
|
|
} from '../src/game/drop-pipeline.ts'
|
|
import * as dropPipeline from '../src/game/drop-pipeline.ts'
|
|
import * as treasureClass from '../src/game/treasure-class.ts'
|
|
import { GameEngine } from '../src/game/engine.ts'
|
|
import { auditDropParity, openDropDataArchives } from '../scripts/pack-canonical-drop-data.ts'
|
|
import * as fs from 'node:fs'
|
|
|
|
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
|
|
|
describe('Adversarial Reviewer Verification - Drop Parity & SuperUnique Contracts', () => {
|
|
const dropTables = getEmbeddedDropTables()
|
|
|
|
const dummyTerrain = {
|
|
widthPx: 1000,
|
|
heightPx: 1000,
|
|
overlap: () => 0,
|
|
}
|
|
|
|
function createTestEngine(difficulty: 'normal' | 'nightmare' | 'hell' = 'normal') {
|
|
return new GameEngine(dummyTerrain, {
|
|
spawn: { x: 0, y: 0 },
|
|
stats: [],
|
|
xpTable: [0, 100, 200],
|
|
dropTables,
|
|
difficulty,
|
|
skills: [],
|
|
npcDefs: [],
|
|
questDefs: [],
|
|
combatOptions: {} as any,
|
|
talkRadius: 50,
|
|
pickupRadius: 50,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
})
|
|
}
|
|
|
|
function lastDropArgs(spy: any) {
|
|
expect(spy).toHaveBeenCalled()
|
|
const calls = spy.mock.calls
|
|
return calls[calls.length - 1][1]
|
|
}
|
|
|
|
it('Requirement R1: dropTables.monsterKinds has exactly 734 kinds conforming to 1.13c', () => {
|
|
expect(dropTables.monsterKinds.size).toBe(734)
|
|
|
|
// Key iconic 1.13c entities
|
|
expect(dropTables.monsterKinds.has('fallen1')).toBe(true)
|
|
expect(dropTables.monsterKinds.has('andariel')).toBe(true)
|
|
expect(dropTables.monsterKinds.has('duriel')).toBe(true)
|
|
expect(dropTables.monsterKinds.has('mephisto')).toBe(true)
|
|
expect(dropTables.monsterKinds.has('diablo')).toBe(true)
|
|
expect(dropTables.monsterKinds.has('baalcrab')).toBe(true)
|
|
expect(dropTables.monsterKinds.has('Expansion')).toBe(true)
|
|
|
|
// Verify all 734 monster kinds define valid TCs that exist in tcTable
|
|
for (const [id, kind] of dropTables.monsterKinds) {
|
|
expect(kind.id).toBe(id)
|
|
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
|
|
for (const type of [1, 2, 3, 4] as const) {
|
|
const tc = getMonsterTreasureClass(kind, diff, type)
|
|
if (tc) {
|
|
expect(
|
|
Boolean(dropTables.tcTable.get(tc)),
|
|
`TC "${tc}" from monster "${id}" (${diff}, type ${type}) must exist in tcTable`,
|
|
).toBe(true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('Requirement R1: MonStats noRatio contract correctly hydrates across summons and suppresses TC upgrade', () => {
|
|
const noRatioMonsters = [
|
|
'claygolem',
|
|
'bloodgolem',
|
|
'irongolem',
|
|
'firegolem',
|
|
'valkyrie',
|
|
'necroskeleton',
|
|
'necromage',
|
|
'wakeofdestruction',
|
|
]
|
|
|
|
for (const id of noRatioMonsters) {
|
|
const kind = dropTables.monsterKinds.get(id)
|
|
expect(kind, `Monster ${id} should exist`).toBeDefined()
|
|
expect(kind!.noRatio, `Monster ${id} should have noRatio: true`).toBe(true)
|
|
}
|
|
|
|
// Standard monsters must NOT have noRatio
|
|
expect(dropTables.monsterKinds.get('fallen1')!.noRatio).toBeUndefined()
|
|
expect(dropTables.monsterKinds.get('andariel')!.noRatio).toBeUndefined()
|
|
|
|
// Test that isNoRatio strictly gates TC upgrade in executeDropPipeline
|
|
const upgradeSpy = vi.spyOn(treasureClass, 'resolveTreasureClassGroup')
|
|
dropPipeline.executeDropPipeline({
|
|
tcName: 'Act 1 H2H A',
|
|
nLevel: 90,
|
|
difficulty: 'hell',
|
|
isNoRatio: true,
|
|
dropTables,
|
|
})
|
|
expect(upgradeSpy).not.toHaveBeenCalled()
|
|
upgradeSpy.mockRestore()
|
|
})
|
|
|
|
it('Requirement R2: dropTables.superUniques has exactly 66 SuperUniques with authentic 3-difficulty TCs', () => {
|
|
expect(dropTables.superUniques.size).toBe(66)
|
|
expect(dropTables.superUniques.has('Expansion')).toBe(false)
|
|
|
|
for (const [id, su] of dropTables.superUniques) {
|
|
expect(su.id).toBe(id)
|
|
expect(su.monsterId, `SuperUnique ${id} must specify a monsterId`).toBeTruthy()
|
|
expect(
|
|
dropTables.monsterKinds.has(su.monsterId),
|
|
`SuperUnique ${id} monsterId "${su.monsterId}" must exist in monsterKinds`,
|
|
).toBe(true)
|
|
|
|
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
|
|
const tc = typeof su.getTreasureClass === 'function' ? su.getTreasureClass(diff) : su.treasureClass
|
|
if (id.startsWith('Ancient Barbarian')) {
|
|
expect(tc, `Ancient Barbarian ${id} on ${diff} must have empty TC`).toBe('')
|
|
} else {
|
|
expect(tc, `SuperUnique ${id} on ${diff} must have non-empty TC`).toBeTruthy()
|
|
expect(
|
|
Boolean(dropTables.tcTable.get(tc)),
|
|
`SuperUnique ${id} TC "${tc}" on ${diff} must exist in tcTable`,
|
|
).toBe(true)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('Requirement R2: SuperUnique minion inheritance and case-insensitive resolution under extreme inputs', () => {
|
|
const engine = createTestEngine('hell')
|
|
const dropSpy = vi.spyOn(dropPipeline, 'executeDropPipeline')
|
|
|
|
// Adversarial whitespace, mixed casing, and minion rank
|
|
engine.world.pendingKills = [
|
|
{
|
|
kind: 'kill',
|
|
x: 100,
|
|
y: 100,
|
|
subjectId: '', // missing subjectId
|
|
monsterRank: 'minion',
|
|
superUniqueId: '\r\n\t BiShIbOsH \t ',
|
|
monsterLevel: 85,
|
|
monsterType: 1, // Adversarial: tries to downgrade minion to normal type 1
|
|
} as any,
|
|
]
|
|
|
|
engine.tick({
|
|
movement: { x: 0, y: 0 },
|
|
attacking: false,
|
|
pickingUp: false,
|
|
talking: false,
|
|
saving: false,
|
|
loading: false,
|
|
digits: [],
|
|
})
|
|
|
|
expect(engine.metrics.dropsRolled).toBe(1)
|
|
const minionArgs = lastDropArgs(dropSpy)
|
|
// Bishibosh host monster is fallenshaman1. On Hell, fallenshaman1 TreasureClass3 is 'Act 1 (H) Unique A'
|
|
expect(minionArgs.tcName).toBe('Act 1 (H) Unique A')
|
|
expect(minionArgs.monsterType).toBe(3) // Minion strictly resolves to 3
|
|
expect(minionArgs.isBoss).toBe(false)
|
|
expect(minionArgs.difficulty).toBe('hell')
|
|
|
|
dropSpy.mockRestore()
|
|
})
|
|
|
|
it('Requirement R2: loadDropTables throws explicit Error when SuperUniques.txt fails to load', async () => {
|
|
if (!hasD2) return
|
|
const archives = await openDropDataArchives('samples/d2')
|
|
const realRead = archives.read.bind(archives)
|
|
archives.read = async (path: string) => {
|
|
if (path.includes('SuperUniques.txt')) {
|
|
throw new Error('Simulated I/O disk failure reading SuperUniques.txt')
|
|
}
|
|
return realRead(path)
|
|
}
|
|
|
|
await expect(loadDropTables(archives)).rejects.toThrow(
|
|
/Failed to load SuperUniques\.txt: Simulated I\/O disk failure/,
|
|
)
|
|
})
|
|
|
|
it('Requirement R1: pack-canonical-drop-data audit achieves 100% 1.13c parity', async () => {
|
|
if (!hasD2) return
|
|
const audit = await auditDropParity()
|
|
expect(audit.monstatsMatch).toBe(true)
|
|
expect(audit.superUniquesMatch).toBe(true)
|
|
expect(audit.monsterKindsCount).toBe(734)
|
|
expect(audit.superUniquesCount).toBe(66)
|
|
})
|
|
})
|