263 lines
9.6 KiB
TypeScript
263 lines
9.6 KiB
TypeScript
import { describe, it, expect, beforeAll } from 'vitest'
|
|
import { ACT_BOSSES, BOSS_LIST, type BossDefinition } from '../src/boss.ts'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
import {
|
|
executeDropPipeline,
|
|
resolveItemTypeFlags,
|
|
type DropTables,
|
|
} from '../src/game/drop-pipeline.ts'
|
|
import type { UpgradableItemBase } from '../src/game/item-upgrade.ts'
|
|
import type { Item } from '../src/game/items.ts'
|
|
import { runParityAudit } from '../scripts/audit-parity.ts'
|
|
|
|
export type BaseTier = 'normal' | 'exceptional' | 'elite'
|
|
|
|
export function classifyItemBaseTier(item: Item): BaseTier | null {
|
|
if (item.base.kind !== 'weapon' && item.base.kind !== 'armor') {
|
|
return null
|
|
}
|
|
const b = item.base as UpgradableItemBase
|
|
const id = (b.id ?? '').trim().toLowerCase()
|
|
const uber = (b.ubercode ?? '').trim().toLowerCase()
|
|
const ultra = (b.ultracode ?? '').trim().toLowerCase()
|
|
|
|
if (ultra && ultra !== 'xxx' && id === ultra) {
|
|
return 'elite'
|
|
}
|
|
if (uber && uber !== 'xxx' && id === uber) {
|
|
return 'exceptional'
|
|
}
|
|
return 'normal'
|
|
}
|
|
|
|
describe('Diablo II 1.13c Parity Baseline & Boss monsterType=4 Upgrade Odds (Issue #126)', () => {
|
|
let dropTables: DropTables
|
|
|
|
beforeAll(() => {
|
|
dropTables = getEmbeddedDropTables()
|
|
})
|
|
|
|
it('1. BOSS_LIST and ACT_BOSSES define monsterType === 4 for all 5 Act Bosses', () => {
|
|
expect(BOSS_LIST).toBeDefined()
|
|
expect(BOSS_LIST).toHaveLength(5)
|
|
expect(BOSS_LIST).toBe(ACT_BOSSES)
|
|
|
|
for (const boss of BOSS_LIST) {
|
|
const def: BossDefinition = boss
|
|
expect(def.monsterType, `Boss ${def.id} must explicitly set monsterType: 4`).toBe(4)
|
|
}
|
|
})
|
|
|
|
it('2. Difficultylevels.txt Good odds (UberCodeOddsGood / UltraCodeOddsGood) exceed Normal odds in Nightmare and Hell', () => {
|
|
const nmOdds = dropTables.difficultyLevels.get('Nightmare')!
|
|
const hellOdds = dropTables.difficultyLevels.get('Hell')!
|
|
|
|
expect(nmOdds.uberCodeOddsGood).toBeGreaterThan(nmOdds.uberCodeOddsNormal)
|
|
expect(hellOdds.uberCodeOddsGood).toBeGreaterThan(hellOdds.uberCodeOddsNormal)
|
|
expect(hellOdds.ultraCodeOddsGood).toBeGreaterThan(hellOdds.ultraCodeOddsNormal)
|
|
})
|
|
|
|
it('3. monsterType=4 (Boss) yields significantly higher Exceptional/Elite base upgrades than monsterType=1 (Normal) over identical seeds', () => {
|
|
const sampleKills = 400
|
|
let normalMonsterUpgraded = 0
|
|
let normalMonsterEquipCount = 0
|
|
let bossMonsterUpgraded = 0
|
|
let bossMonsterEquipCount = 0
|
|
|
|
for (let i = 0; i < sampleKills; i++) {
|
|
const seed = (0x12600000 + i * 7919) >>> 0
|
|
|
|
// Run with monsterType = 1 (Normal monster: uses uberCodeOddsNormal=20, ultraCodeOddsNormal=30)
|
|
const dropsNormalType = executeDropPipeline({
|
|
tcName: 'Mephistoq (H)',
|
|
nLevel: 87,
|
|
monsterType: 1,
|
|
dropTables,
|
|
difficulty: 'hell',
|
|
playerMf: 250,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: seed,
|
|
})
|
|
|
|
for (const item of dropsNormalType) {
|
|
const tier = classifyItemBaseTier(item)
|
|
if (tier !== null) {
|
|
normalMonsterEquipCount++
|
|
if (tier === 'exceptional' || tier === 'elite') {
|
|
normalMonsterUpgraded++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run with monsterType = 4 (Act Boss: uses uberCodeOddsGood=40, ultraCodeOddsGood=40)
|
|
const dropsBossType = executeDropPipeline({
|
|
tcName: 'Mephistoq (H)',
|
|
nLevel: 87,
|
|
monsterType: 4,
|
|
dropTables,
|
|
difficulty: 'hell',
|
|
playerMf: 250,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: seed,
|
|
})
|
|
|
|
for (const item of dropsBossType) {
|
|
const tier = classifyItemBaseTier(item)
|
|
if (tier !== null) {
|
|
bossMonsterEquipCount++
|
|
if (tier === 'exceptional' || tier === 'elite') {
|
|
bossMonsterUpgraded++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(normalMonsterEquipCount).toBeGreaterThan(500)
|
|
expect(bossMonsterEquipCount).toBe(normalMonsterEquipCount)
|
|
|
|
const normalUpgradeRate = normalMonsterUpgraded / normalMonsterEquipCount
|
|
const bossUpgradeRate = bossMonsterUpgraded / bossMonsterEquipCount
|
|
|
|
// Boss (Good odds: 40/1024 + 40/1024) must strictly exceed Normal (20/1024 + 30/1024)
|
|
expect(bossMonsterUpgraded).toBeGreaterThan(normalMonsterUpgraded)
|
|
expect(bossUpgradeRate).toBeGreaterThan(normalUpgradeRate)
|
|
})
|
|
|
|
it('4. executeDropPipeline works properly with monsterType: 4 across all 5 Act Bosses in Hell', () => {
|
|
for (const boss of BOSS_LIST) {
|
|
const drops = executeDropPipeline({
|
|
tcName: boss.questTc.hell,
|
|
nLevel: boss.mlvl.hell,
|
|
monsterType: boss.monsterType ?? 4,
|
|
dropTables,
|
|
difficulty: 'hell',
|
|
playerMf: 250,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: 1337 + boss.act,
|
|
})
|
|
|
|
expect(drops.length).toBeGreaterThan(0)
|
|
for (const item of drops) {
|
|
expect(item.ilvl).toBe(boss.mlvl.hell)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('5. runParityAudit generates structured JSON summary of quality, base upgrade, and affix distributions', () => {
|
|
const summary = runParityAudit({
|
|
kills: 100,
|
|
bossId: 'mephisto',
|
|
difficulty: 'hell',
|
|
playerMf: 250,
|
|
gamePlayers: 1,
|
|
})
|
|
|
|
expect(summary.config.boss).toBe('mephisto')
|
|
expect(summary.config.monsterType).toBe(4)
|
|
expect(summary.totalItems).toBeGreaterThan(0)
|
|
expect(summary.baseTierUpgrade.totalEquipment).toBeGreaterThan(0)
|
|
expect(summary.baseTierUpgrade.upgradedRatio).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('ItemTypes.txt Quality Flags & classSpecific Propagation (Issue #127 / A2)', () => {
|
|
let dropTables: DropTables
|
|
|
|
beforeAll(() => {
|
|
dropTables = getEmbeddedDropTables()
|
|
})
|
|
|
|
it('1. resolveItemTypeFlags returns isAlwaysMagic: true for rin, amu, jew, cm1, cm2, cm3', () => {
|
|
const alwaysMagicCodes = ['rin', 'amu', 'jew', 'cm1', 'cm2', 'cm3']
|
|
for (const code of alwaysMagicCodes) {
|
|
const base = dropTables.getBase(code)
|
|
expect(base, `Base ${code} must exist`).toBeDefined()
|
|
const flags = resolveItemTypeFlags(base!, dropTables.itemTypes, dropTables.isA)
|
|
expect(flags.isAlwaysMagic, `${code} must have isAlwaysMagic === true`).toBe(true)
|
|
expect(flags.isNormalOnly, `${code} must have isNormalOnly === false`).toBe(false)
|
|
if (code === 'cm1' || code === 'cm2' || code === 'cm3') {
|
|
expect(flags.canBeRare, `${code} charm must have canBeRare === false`).toBe(false)
|
|
} else {
|
|
expect(flags.canBeRare, `${code} must have canBeRare === true`).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('2. 200 rolls of Ring / Amulet / Small Charm at playerMf: -100 and 0, nLevel: 1 NEVER produce normal/superior/low (and cm1 NEVER produces rare)', () => {
|
|
const validQualities = new Set(['magic', 'rare', 'set', 'unique'])
|
|
const forbiddenQualities = new Set(['normal', 'superior', 'low'])
|
|
|
|
for (const mf of [-100, 0]) {
|
|
for (const code of ['rin', 'amu', 'cm1']) {
|
|
let countNormalOrSuperiorOrLow = 0
|
|
let countRare = 0
|
|
let totalGenerated = 0
|
|
|
|
for (let i = 0; i < 200; i++) {
|
|
const drops = executeDropPipeline({
|
|
tcName: code,
|
|
nLevel: 1,
|
|
monsterType: 1,
|
|
dropTables,
|
|
difficulty: 'normal',
|
|
playerMf: mf,
|
|
gamePlayers: 1,
|
|
partyPlayers: 1,
|
|
monsterRng: (0x12700000 + i * 1009 + (mf === 0 ? 500000 : 0)) >>> 0,
|
|
})
|
|
|
|
expect(drops.length).toBe(1)
|
|
const item = drops[0]!
|
|
totalGenerated++
|
|
|
|
const q = item.rarity ?? ''
|
|
if (forbiddenQualities.has(q)) {
|
|
countNormalOrSuperiorOrLow++
|
|
}
|
|
if (q === 'rare') {
|
|
countRare++
|
|
}
|
|
expect(validQualities.has(q), `Item ${item.name} (${code}) rolled invalid quality ${q} at MF=${mf}`).toBe(true)
|
|
expect(item.name).not.toMatch(/^Cracked /)
|
|
expect(item.name).not.toMatch(/^Superior /)
|
|
}
|
|
|
|
expect(totalGenerated).toBe(200)
|
|
expect(countNormalOrSuperiorOrLow).toBe(0)
|
|
if (code === 'cm1') {
|
|
expect(countRare).toBe(0)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('3. resolveItemTypeFlags identifies class-specific equipment bases (Sorceress Orb, Paladin Shield, Amazon Bow, etc.) with isClassSpecific === true', () => {
|
|
// Sorceress Orb: ob1 (Eagle Orb), obc (Eldritch Orb), obf (Dimensional Shard)
|
|
// Paladin Auric Shield: pa1 (Targe), paa (Akaran Targe), paf (Vortex Shield)
|
|
// Amazon Bow: am1 (Stag Bow), am6 (Ashwood Bow), amb (Matriarchal Bow)
|
|
// Barbarian Primal Helm: ba1 (Jawbone Cap)
|
|
// Druid Pelt: dr1 (Wolf Head)
|
|
// Necromancer Voodoo Head: ne1 (Preserved Head)
|
|
// Assassin Katar: ktr (Katar)
|
|
const classSpecificCodes = ['ob1', 'obf', 'pa1', 'paf', 'am1', 'amb', 'ba1', 'dr1', 'ne1', 'ktr']
|
|
for (const code of classSpecificCodes) {
|
|
const base = dropTables.getBase(code)
|
|
expect(base, `Class-specific base ${code} must exist`).toBeDefined()
|
|
const flags = resolveItemTypeFlags(base!, dropTables.itemTypes, dropTables.isA)
|
|
expect(flags.isClassSpecific, `${code} (${base!.name}) must have isClassSpecific === true`).toBe(true)
|
|
}
|
|
|
|
// Generic equipment bases must have isClassSpecific === false
|
|
const genericCodes = ['cap', 'uap', 'uit', 'lsd', 'crn', 'rin', 'amu', 'cm1']
|
|
for (const code of genericCodes) {
|
|
const base = dropTables.getBase(code)
|
|
expect(base, `Generic base ${code} must exist`).toBeDefined()
|
|
const flags = resolveItemTypeFlags(base!, dropTables.itemTypes, dropTables.isA)
|
|
expect(flags.isClassSpecific, `${code} (${base!.name}) must have isClassSpecific === false`).toBe(false)
|
|
}
|
|
})
|
|
})
|