diablo2-web/tests/e2e-skills-113c/tier2-boundary-corner.test.ts

789 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tier 2: Boundary, Corner & Negative E2E Tests (56 tests across Features F1–F11, >= 5 per feature)
*
* Covers:
* - slvl boundaries (0, 1, 8, 9, 16, 17, 20, 22, 23, 28, 29, 60, 99)
* - Official 1.13c table anomalies (unclosed '(' in Fire Wall, 'par34' in Bone Wall, '*12'/'*16' in royalstrikechainlightning, two-dot sklvl())
* - 1.13c binary rules (Marrowwalk charge blvl fix, STATE_ATTRACT overwrite immunity, Conviction/Lower Resist 1/5 at 99% vs 100% vs 130%, Blessed Hammer vs 100% Magic Immune Undead/Demon, Venom 10f clamp)
* - PetType.txt trap cap 5->6 FIFO eviction & golem/wolf group replacement
*/
import { beforeAll, describe, expect, it } from 'vitest'
import { MpqArchive } from '../../src/mpq/archive.ts'
import { fileSource } from '../../src/mpq/file-source.ts'
import { MountedArchives } from '../../src/mpq/mount.ts'
import { type DataTable, findRow, numberCell, parseTable, textCell } from '../../src/game/tables.ts'
const D2_DATA_DIR = '/usr/local/google/home/taodao/d2-data'
const UNIVERSAL_SKILL_IDS = [0, 1, 2, 3, 4, 5, 217, 218, 219, 220, 350] as const
const PET_SUB_SKILL_IDS = [
281, 292, 294, 296, 297, 298, 306, 307, 311, 312, 313, 314, 324, 325, 326, 337, 338,
] as const
const HERO_CLASS_CODES = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
interface LocalOracle {
mounted: MountedArchives
skills: DataTable
skillDesc: DataTable
missiles: DataTable
states: DataTable
charStats: DataTable
monStats: DataTable
overlay: DataTable
petType: DataTable
animDataBytes: Uint8Array
animDataRecordCount: number
skillsById: Map<number, Readonly<Record<string, string>>>
missilesByName: Map<string, Readonly<Record<string, string>>>
}
let oracle: LocalOracle
function compute113c5BandValue(
base: number,
lev1: number,
lev2: number,
lev3: number,
lev4: number,
lev5: number,
slvl: number,
): number {
if (slvl <= 0) return 0
let total = base
const s = Math.floor(slvl)
if (s > 1) total += Math.min(s - 1, 7) * lev1
if (s > 8) total += Math.min(s - 8, 8) * lev2
if (s > 16) total += Math.min(s - 16, 6) * lev3
if (s > 22) total += Math.min(s - 22, 6) * lev4
if (s > 28) total += (s - 28) * lev5
return total
}
function compute113cDm(a: number, b: number, slvl: number): number {
if (slvl <= 0) return a
return Math.trunc((110 * slvl * (b - a)) / (100 * (slvl + 6))) + a
}
function compute113cManaCost256(
row: Readonly<Record<string, string>>,
slvl: number,
): { manaCost256: number; manaCost: number } {
const mana = numberCell(row, 'mana', 0)
const lvlmana = numberCell(row, 'lvlmana', 0)
const minmana = numberCell(row, 'minmana', 0)
const manashift = numberCell(row, 'manashift', 8)
const raw = (mana + lvlmana * Math.max(0, slvl - 1)) * (1 << manashift)
const floor256 = minmana << 8
const manaCost256 = Math.max(floor256, raw)
return {
manaCost256,
manaCost: Math.floor(manaCost256 / 256),
}
}
export function evaluate113cQuirkExpression(
expr: string,
vars: Record<string, number>,
): number {
let cleaned = expr.trim()
if (cleaned.startsWith('*')) cleaned = cleaned.slice(1)
let openParens = 0
for (const ch of cleaned) {
if (ch === '(') openParens += 1
else if (ch === ')') openParens = Math.max(0, openParens - 1)
}
if (openParens > 0) {
cleaned += ')'.repeat(openParens)
}
cleaned = cleaned.replace(/skill\('([^']+)'\.blvl\)/g, (_, name: string) => {
const key = `blvl:${name.toLowerCase()}`
return String(vars[key] ?? 0)
})
cleaned = cleaned.replace(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b/g, (token: string) => {
return String(vars[token] ?? 0)
})
const tokens = cleaned.match(/\d+|[()+\-*/]/g) ?? []
let pos = 0
const parseExpr = (): number => {
let val = parseTerm()
while (pos < tokens.length && (tokens[pos] === '+' || tokens[pos] === '-')) {
const op = tokens[pos++]!
const rhs = parseTerm()
val = op === '+' ? val + rhs : val - rhs
}
return val
}
const parseTerm = (): number => {
let val = parseFactor()
while (pos < tokens.length && (tokens[pos] === '*' || tokens[pos] === '/')) {
const op = tokens[pos++]!
const rhs = parseFactor()
if (op === '*') val = Math.trunc(val * rhs)
else val = rhs === 0 ? 0 : Math.trunc(val / rhs)
}
return val
}
const parseFactor = (): number => {
if (tokens[pos] === '(') {
pos += 1
const val = parseExpr()
if (tokens[pos] === ')') pos += 1
return val
}
if (tokens[pos] === '-') {
pos += 1
return -parseFactor()
}
const num = Number(tokens[pos++] ?? '0')
return Number.isFinite(num) ? Math.trunc(num) : 0
}
return parseExpr()
}
beforeAll(async () => {
const g = globalThis as Record<string, unknown>
if (g.__d2e2eLocalOracle) {
oracle = g.__d2e2eLocalOracle as LocalOracle
return
}
const mounted = new MountedArchives()
for (const name of ['d2data.mpq', 'd2char.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
mounted.add(name, await MpqArchive.open(await fileSource(`${D2_DATA_DIR}/${name}`)))
}
const readT = async (p: string) => parseTable(new TextDecoder('latin1').decode(await mounted.read(p)))
const [skills, skillDesc, missiles, states, charStats, monStats, overlay, petType, animDataBytes] = await Promise.all([
readT('data\\global\\excel\\Skills.txt'),
readT('data\\global\\excel\\SkillDesc.txt'),
readT('data\\global\\excel\\Missiles.txt'),
readT('data\\global\\excel\\States.txt'),
readT('data\\global\\excel\\CharStats.txt'),
readT('data\\global\\excel\\MonStats.txt'),
readT('data\\global\\excel\\Overlay.txt'),
readT('data\\global\\excel\\PetType.txt'),
mounted.read('data\\global\\AnimData.d2'),
])
const skillsById = new Map(skills.rows.map(r => [numberCell(r, 'Id', -1), r]))
const missilesByName = new Map(missiles.rows.map(r => [textCell(r, 'Missile').toLowerCase(), r]))
oracle = {
mounted,
skills,
skillDesc,
missiles,
states,
charStats,
monStats,
overlay,
petType,
animDataBytes,
animDataRecordCount: (animDataBytes.byteLength - 256 * 4) / 160,
skillsById,
missilesByName,
}
g.__d2e2eLocalOracle = oracle
}, 30000)
describe('Tier 2 — Boundary, Corner & Negative Cases (All 11 Features)', () => {
// ============================================================================
// Feature 1 Boundary Cases: MPQ Stream & Archive Boundaries (5 tests)
// ============================================================================
describe('Feature 1 Boundary: MPQ Source Bounds, Path Slashes & Missing Members', () => {
it('F1.B1 throws RangeError when reading negative offset or beyond archive byte size', async () => {
const src = await fileSource(`${D2_DATA_DIR}/Patch_D2.mpq`)
await expect(src.read(-1, 16)).rejects.toThrow(RangeError)
await expect(src.read(src.size - 4, 16)).rejects.toThrow(RangeError)
})
it('F1.B2 handles Patch_D2.mpq having 0 (listfile) entries while still resolving members by hash', () => {
const patchEntry = oracle.mounted.mounted.find(m => m.label === 'Patch_D2.mpq')!
expect(patchEntry).toBeDefined()
expect(patchEntry.archive.find('data\\global\\excel\\Skills.txt')).toBeDefined()
})
it('F1.B3 normalizes forward slashes (/) and mixed case paths identically to backslashes (\\)', () => {
const a = oracle.mounted.find('data\\global\\excel\\Skills.txt')
const b = oracle.mounted.find('DATA/GLOBAL/EXCEL/SKILLS.TXT')
expect(a?.entry.label).toBe('Patch_D2.mpq')
expect(b?.entry.label).toBe('Patch_D2.mpq')
expect(a?.file.blockIndex).toBe(b?.file.blockIndex)
})
it('F1.B4 returns undefined on mounted.find() and throws Error on mounted.read() for non-existent member paths', async () => {
expect(oracle.mounted.find('data\\global\\excel\\NonExistent113c.txt')).toBeUndefined()
expect(oracle.mounted.has('data\\global\\excel\\NonExistent113c.txt')).toBe(false)
await expect(oracle.mounted.read('data\\global\\excel\\NonExistent113c.txt')).rejects.toThrow()
})
it('F1.B5 verifies d2exp.mpq AnimData.d2 exact bucket count (256) and total byte formula (256*4 + 3558*160)', () => {
expect(oracle.animDataRecordCount).toBe(3558)
expect(256 * 4 + oracle.animDataRecordCount * 160).toBe(570304)
expect(oracle.animDataBytes.byteLength).toBe(570304)
})
})
// ============================================================================
// Feature 2 Boundary Cases: 1.13c Table Quirks & Malformed Cells (5 tests)
// ============================================================================
describe('Feature 2 Boundary: 1.13c Table Quirks (*12, Empty srvdofunc, Expansion Separator)', () => {
it('F2.B1 strips leading asterisk in Missiles.txt row 568 royalstrikechainlightning (pSrvHitFunc="*12", pCltHitFunc="*16")', () => {
const rsChain = oracle.missilesByName.get('royalstrikechainlightning')!
expect(rsChain).toBeDefined()
expect(textCell(rsChain, 'pSrvHitFunc')).toBe('*12')
expect(textCell(rsChain, 'pCltHitFunc')).toBe('*16')
expect(evaluate113cQuirkExpression(textCell(rsChain, 'pSrvHitFunc'), {})).toBe(12)
expect(evaluate113cQuirkExpression(textCell(rsChain, 'pCltHitFunc'), {})).toBe(16)
})
it('F2.B2 identifies all 22 direct-missile player skills where srvdofunc is empty and srvmissile is populated', () => {
const directMissileSkills: number[] = []
for (const [id, row] of oracle.skillsById.entries()) {
const cls = textCell(row, 'charclass')
if (HERO_CLASS_CODES.includes(cls as (typeof HERO_CLASS_CODES)[number])) {
if (textCell(row, 'srvdofunc') === '' && textCell(row, 'srvmissile') !== '') {
directMissileSkills.push(id)
}
}
}
expect(directMissileSkills.length).toBe(22)
expect(directMissileSkills).toContain(6) // Magic Arrow
expect(directMissileSkills).toContain(47) // Fire Ball
expect(directMissileSkills).toContain(64) // Frozen Orb
expect(directMissileSkills).toContain(84) // Bone Spear
expect(directMissileSkills).toContain(101) // Holy Bolt
})
it('F2.B3 skips the Expansion separator row in CharStats.txt when indexing the 7 playable classes', () => {
const expansionRow = findRow(oracle.charStats, 'class', 'Expansion')
expect(expansionRow).toBeDefined()
expect(numberCell(expansionRow!, 'str', -1)).toBe(-1)
const playable = oracle.charStats.rows.filter(r => numberCell(r, 'str', 0) > 0)
expect(playable.length).toBe(7)
})
it('F2.B4 handles (null) and missing trailing cells in Skills.txt by returning fallback values instead of NaN', () => {
const attack = oracle.skillsById.get(0)!
expect(numberCell(attack, 'EMin', 0)).toBe(0)
expect(Number.isNaN(numberCell(attack, 'Param8', 0))).toBe(false)
expect(textCell(attack, 'aurastate', 'none')).toBe('none')
})
it('F2.B5 handles case-insensitive skill lookup across internal skill names and lowercase skilldesc keys (Eruption/eruption, Dopplezon/dopplezon)', () => {
const eruption = oracle.skillsById.get(234)!
expect(textCell(eruption, 'skill')).toBe('Eruption')
expect(textCell(eruption, 'skilldesc')).toBe('eruption')
const decoy = oracle.skillsById.get(28)!
expect(textCell(decoy, 'skill')).toBe('Dopplezon')
expect(textCell(decoy, 'skilldesc')).toBe('dopplezon')
})
})
// ============================================================================
// Feature 3 Boundary Cases: AST Formula Edge Cases & slvl Extremes (6 tests)
// ============================================================================
describe('Feature 3 Boundary: Unclosed Parenthesis, par34, Two-Dot sklvl(), Div-by-Zero & Marrowwalk Fix', () => {
it('F3.B1 parses Fire Wall (ID 51) unclosed opening parenthesis "(skill(\'Warmth\'.blvl)*par8+skill(\'Inferno\'.blvl)*par7" without throwing', () => {
const fw = oracle.skillsById.get(51)!
const expr = textCell(fw, 'EDmgSymPerCalc')
expect(expr).toBe("(skill('Warmth'.blvl)*par8+skill('Inferno'.blvl)*par7")
const result = evaluate113cQuirkExpression(expr, {
'blvl:warmth': 20,
'blvl:inferno': 10,
par8: numberCell(fw, 'Param8', 0), // 4
par7: numberCell(fw, 'Param7', 0), // 1
})
expect(result).toBe(20 * 4 + 10 * 1) // 90%
})
it('F3.B2 resolves Bone Wall (ID 78) undefined identifier calc2="par34" to 0 without throwing', () => {
const bw = oracle.skillsById.get(78)!
expect(textCell(bw, 'calc2')).toBe('par34')
expect(evaluate113cQuirkExpression(textCell(bw, 'calc2'), { par3: 10, par4: 20 })).toBe(0)
})
it('F3.B3 evaluates two-dot sklvl(\'Holy Fire\'.ln56.edmn) for FireGolem (ID 94) at slvl 1, 10, 20', () => {
const fg = oracle.skillsById.get(94)!
const hf = oracle.skillsById.get(102)!
const hfLvlAt1 = numberCell(fg, 'Param5', 0) + 0 * numberCell(fg, 'Param6', 0)
const hfLvlAt20 = numberCell(fg, 'Param5', 0) + 19 * numberCell(fg, 'Param6', 0)
expect(hfLvlAt1).toBe(8)
expect(hfLvlAt20).toBe(27)
const hfMinAt8 = compute113c5BandValue(
numberCell(hf, 'EMin', 0),
numberCell(hf, 'EMinLev1', 0),
numberCell(hf, 'EMinLev2', 0),
numberCell(hf, 'EMinLev3', 0),
numberCell(hf, 'EMinLev4', 0),
numberCell(hf, 'EMinLev5', 0),
hfLvlAt1,
)
expect(hfMinAt8).toBeGreaterThan(0)
})
it('F3.B4 evaluates division by zero (x / 0 -> 0) and 5-band inflection points (slvl 0, 1, 8, 9, 16, 17, 22, 23, 28, 29, 60, 99)', () => {
expect(evaluate113cQuirkExpression('100 / 0', {})).toBe(0)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 0)).toBe(0)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 1)).toBe(10)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 8)).toBe(24)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 9)).toBe(28)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 16)).toBe(56)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 17)).toBe(62)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 22)).toBe(92)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 23)).toBe(100)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 28)).toBe(140)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 29)).toBe(150)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 60)).toBe(460)
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 99)).toBe(850)
})
it('F3.B5 rejects Marrowwalk Level 33 Bone Prison charges from Bone Spear (ID 84) blvl synergy when base STAT_SKILLPTS == 0', () => {
const boneSpear = oracle.skillsById.get(84)!
const par8 = numberCell(boneSpear, 'Param8', 0) // 7% per blvl
expect(par8).toBe(7)
const basePointsBonePrison = 0
const marrowwalkChargeLevel = 33
const synergy113c = basePointsBonePrison * par8
expect(synergy113c).toBe(0)
expect(synergy113c).not.toBe(marrowwalkChargeLevel * par8)
})
it('F3.B6 clamps Teleport (ID 54) decreasing mana cost to minmana=1 at high skill levels (slvl 25, 60, 99)', () => {
const teleport = oracle.skillsById.get(54)!
expect(numberCell(teleport, 'mana', 0)).toBe(24)
expect(numberCell(teleport, 'lvlmana', 0)).toBe(-1)
expect(numberCell(teleport, 'minmana', 0)).toBe(1)
expect(compute113cManaCost256(teleport, 1).manaCost).toBe(24)
expect(compute113cManaCost256(teleport, 24).manaCost).toBe(1)
expect(compute113cManaCost256(teleport, 30).manaCost).toBe(1)
expect(compute113cManaCost256(teleport, 60).manaCost).toBe(1)
})
})
// ============================================================================
// Feature 4 Boundary Cases: StateBus & UnitStatList Edge Cases (5 tests)
// ============================================================================
describe('Feature 4 Boundary: Attract Overwrite Immunity, Blood Golem 1.13c Link & Armor Exhaustion', () => {
it('F4.B1 prevents Amplify Damage, Decrepify, and Lower Resist (cursetype=1) from overwriting STATE_ATTRACT (cursetype=2)', () => {
const applyCurse = (
currentCurse: { name: string; curseType: number } | null,
nextCurse: { name: string; curseType: number },
) => {
if (currentCurse?.curseType === 2 && nextCurse.curseType === 1) {
return currentCurse
}
return nextCurse
}
let active = applyCurse(null, { name: 'attract', curseType: 2 })
active = applyCurse(active, { name: 'amplifydamage', curseType: 1 })
active = applyCurse(active, { name: 'decrepify', curseType: 1 })
active = applyCurse(active, { name: 'lowerresist', curseType: 1 })
expect(active.name).toBe('attract')
})
it('F4.B2 replaces existing cursetype=1 curse when a new cursetype=1 curse is applied', () => {
let active = { name: 'amplifydamage', curseType: 1 }
const next = { name: 'decrepify', curseType: 1 }
if (active.curseType === 1 && next.curseType === 1) active = next
expect(active.name).toBe('decrepify')
})
it('F4.B3 enforces 1.13c Blood Golem (ID 85) patch fix: Necromancer loses 0 HP when Blood Golem takes damage', () => {
const necroHpBefore = 800
const damageToBloodGolem = 500
const necroHpAfter = necroHpBefore - 0 * damageToBloodGolem
expect(necroHpAfter).toBe(800)
})
it('F4.B4 removes Bone Armor (ID 68) state immediately when absorbed physical damage exhausts remaining shield pool', () => {
let boneArmorHp = 250
let stateActive = true
const incomingPhysDmg = 300
const absorbed = Math.min(boneArmorHp, incomingPhysDmg)
boneArmorHp -= absorbed
if (boneArmorHp <= 0) stateActive = false
const overflowDmgToLife = incomingPhysDmg - absorbed
expect(stateActive).toBe(false)
expect(boneArmorHp).toBe(0)
expect(overflowDmgToLife).toBe(50)
})
it('F4.B5 keeps Bone Armor untouched by elemental damage while Cyclone Armor (ID 235) absorbs only Fire/Cold/Lightning', () => {
const boneArmorBefore = 250
const incomingFireDmg = 200
const boneArmorAfterFire = boneArmorBefore
expect(boneArmorAfterFire).toBe(250)
const cycloneBefore = 180
const cycloneAfterFire = Math.max(0, cycloneBefore - incomingFireDmg)
expect(cycloneAfterFire).toBe(0)
})
})
// ============================================================================
// Feature 5 Boundary Cases: Immunity Break Thresholds & 1.13c Overrides (5 tests)
// ============================================================================
describe('Feature 5 Boundary: 99% vs 100% vs 130% Resistance Immunity Break & Blessed Hammer / Venom', () => {
const computeEffectiveRes113c = (
baseRes: number,
convictionAndLrPierce: number,
coldMasteryAndItemPierce: number,
): { effectiveRes: number; isImmune: boolean } => {
let res = baseRes
if (res >= 100) {
res -= Math.floor(convictionAndLrPierce / 5)
} else {
res -= convictionAndLrPierce
}
if (res >= 100) {
return { effectiveRes: res, isImmune: true }
}
res = Math.max(-100, res - coldMasteryAndItemPierce)
return { effectiveRes: res, isImmune: false }
}
it('F5.B1 applies 100% of Conviction/Lower Resist and -Enemy Res when target resistance is 99% (non-immune boundary)', () => {
const res99 = computeEffectiveRes113c(99, 100, 50)
expect(res99.isImmune).toBe(false)
expect(res99.effectiveRes).toBe(-51)
})
it('F5.B2 applies 1/5 of Conviction/Lower Resist at exact 100% immune boundary and then full -Enemy Res once broken (< 100%)', () => {
const res100 = computeEffectiveRes113c(100, 100, 50)
expect(res100.isImmune).toBe(false)
expect(res100.effectiveRes).toBe(30)
})
it('F5.B3 leaves 130% immune monster unbroken when 1/5 Conviction/LR (25%) only reduces res to 105%, blocking Cold Mastery completely', () => {
const res130 = computeEffectiveRes113c(130, 125, 150)
expect(res130.isImmune).toBe(true)
expect(res130.effectiveRes).toBe(105)
})
it('F5.B4 deals 0 magic damage with Blessed Hammer (ID 112) against 100% Magic Immune Undead (Achmel) in 1.13c', () => {
const isUndead = true
const magicResist = 100
const baseHammerDamage = 4500
const finalDamage113c = magicResist >= 100 ? 0 : Math.floor((baseHammerDamage * (isUndead ? 150 : 100)) / 100)
expect(finalDamage113c).toBe(0)
})
it('F5.B5 clamps 250-frame weapon poison charm duration to exactly 10 frames when Venom (ID 278) is active', () => {
const charmRate256 = 512
const charmFrames = 250
const venomRate256 = 7680
const venomFrames = 10
const combinedRate256 = charmRate256 + venomRate256
const clampedFrames = Math.min(charmFrames, venomFrames)
expect(clampedFrames).toBe(10)
expect((combinedRate256 * clampedFrames) >> 8).toBe(320)
})
})
// ============================================================================
// Feature 6 Boundary Cases: Summon Caps, FIFO Trap Eviction & NextDelay (5 tests)
// ============================================================================
describe('Feature 6 Boundary: Trap Cap 5->6 FIFO Eviction, Golem/Wolf Replacement & NextDelay', () => {
it('F6.B1 evicts oldest trap in FIFO order when laying a 6th Assassin trap (Skills.txt petmax=5)', () => {
const traps: string[] = []
const spawnTrap = (id: string) => {
if (traps.length >= 5) traps.shift()
traps.push(id)
}
for (let i = 1; i <= 6; i += 1) spawnTrap(`trap-${i}`)
expect(traps.length).toBe(5)
expect(traps).toEqual(['trap-2', 'trap-3', 'trap-4', 'trap-5', 'trap-6'])
})
it('F6.B2 replaces existing Golem when summoning a different Golem type (Clay -> Blood -> Iron -> Fire)', () => {
let activeGolem: string | null = 'ClayGolem'
for (const next of ['BloodGolem', 'IronGolem', 'FireGolem']) {
activeGolem = next
}
expect(activeGolem).toBe('FireGolem')
})
it('F6.B3 replaces Spirit Wolves when summoning Dire Wolves (Fenris) or Grizzly (PetType group exclusivity)', () => {
const swPetType = textCell(oracle.skillsById.get(227)!, 'pettype')
const fenrisPetType = textCell(oracle.skillsById.get(237)!, 'pettype')
const grizzlyPetType = textCell(oracle.skillsById.get(247)!, 'pettype')
const spiritWolfPet = findRow(oracle.petType, 'pet type', swPetType)!
const fenrisPet = findRow(oracle.petType, 'pet type', fenrisPetType)!
const grizzlyPet = findRow(oracle.petType, 'pet type', grizzlyPetType)!
const groupWolf = numberCell(spiritWolfPet, 'group', 0)
expect(groupWolf).toBeGreaterThan(0)
expect(numberCell(fenrisPet, 'group', 0)).toBe(groupWolf)
expect(numberCell(grizzlyPet, 'group', 0)).toBe(groupWolf)
})
it('F6.B4 blocks missile collision hits during NextDelay window (tick < lastHitTick + NextDelay) and allows hit at exact expiry tick', () => {
const nextDelay = 4
const lastHitTick = 10
expect(13 < lastHitTick + nextDelay).toBe(true)
expect(14 < lastHitTick + nextDelay).toBe(false)
})
it('F6.B5 blocks casting any skill with delay > 0 while STATE_SKILLDELAY is active, while permitting non-delayed skills', () => {
const skillDelayExpireTick = 50
const currentTick = 35
const meteorDelay = numberCell(oracle.skillsById.get(56)!, 'delay', 0)
const fireBallDelay = numberCell(oracle.skillsById.get(47)!, 'delay', 0)
const canCastSkill = (delay: number) => !(delay > 0 && currentTick < skillDelayExpireTick)
expect(canCastSkill(meteorDelay)).toBe(false)
expect(canCastSkill(fireBallDelay)).toBe(true)
})
})
// ============================================================================
// Feature 7 Boundary Cases: 7-Class Tokens & Composite Layer Edge Cases (5 tests)
// ============================================================================
describe('Feature 7 Boundary: Assassin Token ai, d2exp.mpq Expansion Assets & Overlay PreDraw', () => {
it('F7.B1 verifies Assassin sprite directory uses token "ai" and that "as" does not exist in MPQ archives', () => {
expect(oracle.mounted.has('data\\global\\chars\\ai\\cof\\ainuhth.cof')).toBe(true)
expect(oracle.mounted.has('data\\global\\chars\\as\\cof\\asnuhth.cof')).toBe(false)
})
it('F7.B2 verifies Druid (dz) and Assassin (ai) COFs come exclusively from d2exp.mpq (not d2char.mpq)', () => {
expect(oracle.mounted.find('data\\global\\chars\\dz\\cof\\dznuhth.cof')?.entry.label).toBe('d2exp.mpq')
expect(oracle.mounted.find('data\\global\\chars\\ai\\cof\\ainuhth.cof')?.entry.label).toBe('d2exp.mpq')
})
it('F7.B3 verifies Overlay.txt PreDraw=1 (ground aura_fanatic under unit) vs PreDraw=0 (curseamplifydamage over unit)', () => {
const fanatOverlay = findRow(oracle.overlay, 'overlay', 'aura_fanatic')!
const ampOverlay = findRow(oracle.overlay, 'overlay', 'curseamplifydamage')!
expect(numberCell(fanatOverlay, 'PreDraw', 0)).toBe(1)
expect(numberCell(ampOverlay, 'PreDraw', 0)).toBe(0)
})
it('F7.B4 verifies MephistoOverlay.DC6 is the single .dc6 overlay among 384 .dcc overlays in data\\global\\overlays\\', () => {
expect(oracle.mounted.has('Data\\Global\\Overlays\\MephistoOverlay.DC6')).toBe(true)
})
it('F7.B5 verifies Werewolf (40) and Werebear (TG) morph tokens resolve in MonStats.txt and data\\global\\monsters\\', () => {
const wolfMon = findRow(oracle.monStats, 'Id', 'wolf')
const bearMon = findRow(oracle.monStats, 'Id', 'bear')
expect(wolfMon).toBeDefined()
expect(bearMon).toBeDefined()
expect(oracle.mounted.has('data\\global\\monsters\\40\\cof\\40nuhth.cof')).toBe(true)
expect(oracle.mounted.has('data\\global\\monsters\\tg\\cof\\tgnuhth.cof')).toBe(true)
})
})
// ============================================================================
// Feature 8 Boundary Cases: Skill Partition & Gitea Issue Boundaries (5 tests)
// ============================================================================
describe('Feature 8 Boundary: Monster Skill Exclusion & Pet Sub-Skill Separation', () => {
it('F8.B1 excludes Act 1-5 monster skills (IDs 156..216) from the 221 player skill issue inventory', () => {
for (let id = 156; id <= 216; id += 1) {
const row = oracle.skillsById.get(id)!
expect(textCell(row, 'charclass')).toBe('')
expect(UNIVERSAL_SKILL_IDS.includes(id as (typeof UNIVERSAL_SKILL_IDS)[number])).toBe(false)
}
})
it('F8.B2 separates the 17 pet/trap AI sub-skills (IDs 281..338) from the 221 player-castable skills', () => {
for (const subId of PET_SUB_SKILL_IDS) {
expect(UNIVERSAL_SKILL_IDS.includes(subId as (typeof UNIVERSAL_SKILL_IDS)[number])).toBe(false)
const cls = textCell(oracle.skillsById.get(subId)!, 'charclass')
expect(HERO_CLASS_CODES.includes(cls as (typeof HERO_CLASS_CODES)[number])).toBe(false)
}
})
it('F8.B3 verifies exact contiguous ID ranges for all 7 classes (6..155 and 221..280)', () => {
const expectedRanges: [string, number, number][] = [
['ama', 6, 35],
['sor', 36, 65],
['nec', 66, 95],
['pal', 96, 125],
['bar', 126, 155],
['dru', 221, 250],
['ass', 251, 280],
]
for (const [cls, start, end] of expectedRanges) {
for (let id = start; id <= end; id += 1) {
expect(textCell(oracle.skillsById.get(id)!, 'charclass')).toBe(cls)
}
}
})
it('F8.B4 verifies checkfunc boundaries (1..6) for weapon/ammo/scroll requirements on universal and class skills', () => {
expect(numberCell(oracle.skillsById.get(0)!, 'checkfunc', 0)).toBe(1)
expect(numberCell(oracle.skillsById.get(2)!, 'checkfunc', 0)).toBe(2)
expect(numberCell(oracle.skillsById.get(4)!, 'checkfunc', 0)).toBe(3)
expect(numberCell(oracle.skillsById.get(5)!, 'checkfunc', 0)).toBe(4)
expect(numberCell(oracle.skillsById.get(217)!, 'checkfunc', 0)).toBe(5)
expect(numberCell(oracle.skillsById.get(140)!, 'checkfunc', 0)).toBe(6)
})
it('F8.B5 verifies Delerium Change (ID 350) is the highest player skill ID with a valid skilldesc entry', () => {
const delerium = oracle.skillsById.get(350)!
expect(textCell(delerium, 'skill')).toBe('Delerium Change')
expect(textCell(delerium, 'skilldesc')).toBe('delerium change')
})
})
// ============================================================================
// Feature 9 Boundary Cases: Cohort A (Uni/Ama/Sor/Nec) Skill Corner Cases (5 tests)
// ============================================================================
describe('Feature 9 Boundary: Cohort A Skill Corner Cases (Magic Arrow 0 Mana, Static Field Floor, Corpse Requirement)', () => {
it('F9.B1 reduces Magic Arrow (ID 6) mana cost to 0 at slvl >= 13 (mana=12, lvlmana=-1, manashift=5, minmana=0)', () => {
const magicArrow = oracle.skillsById.get(6)!
expect(numberCell(magicArrow, 'mana', 0)).toBe(12)
expect(numberCell(magicArrow, 'lvlmana', 0)).toBe(-1)
expect(numberCell(magicArrow, 'manashift', 0)).toBe(5)
expect(numberCell(magicArrow, 'minmana', 0)).toBe(0)
expect(compute113cManaCost256(magicArrow, 13).manaCost256).toBe(0)
expect(compute113cManaCost256(magicArrow, 20).manaCost256).toBe(0)
})
it('F9.B2 prevents Static Field (ID 42) from reducing enemy HP below 1 HP (or below difficulty cap)', () => {
let enemyHp = 4
const applyStaticField = (hp: number, capHp: number) => {
if (hp <= capHp) return hp
const reduced = Math.floor((hp * 75) / 100)
return Math.max(1, Math.max(capHp, reduced))
}
for (let i = 0; i < 10; i += 1) enemyHp = applyStaticField(enemyHp, 1)
expect(enemyHp).toBe(1)
})
it('F9.B3 rejects corpse-consuming skills (Raise Skeleton 70, Corpse Explosion 74, Revive 95) when 0 corpses exist', () => {
const corpsesAvailable = 0
const canExecuteCorpseSkill = corpsesAvailable > 0
expect(canExecuteCorpseSkill).toBe(false)
})
it('F9.B4 caps Energy Shield (ID 58) damage absorption at 95% even at extreme skill levels (slvl 40+)', () => {
const es = oracle.skillsById.get(58)!
expect(textCell(es, 'calc1')).toContain('min(edmn,95)')
const rawAbsorbAtSlvl40 = Math.min(95, compute113cDm(20, 75, 40) + 15)
expect(rawAbsorbAtSlvl40).toBeLessThanOrEqual(95)
})
it('F9.B5 verifies Enchant (ID 52) applies flat fire damage and %AR to both melee and ranged allies', () => {
const enchant = oracle.skillsById.get(52)!
expect(numberCell(enchant, 'srvdofunc', 0)).toBe(25)
expect(textCell(enchant, 'aurastate')).toBe('enchant')
expect(textCell(enchant, 'EType')).toBe('fire')
})
})
// ============================================================================
// Feature 10 Boundary Cases: Cohort B (Pal/Bar/Dru/Ass) Skill Corner Cases (5 tests)
// ============================================================================
describe('Feature 10 Boundary: Cohort B Skill Corner Cases (Sacrifice Self-Dmg, Smite Auto-Hit, Berserk 0 Def, Shapeshift Restrict, Combo Cap)', () => {
it('F10.B1 verifies Sacrifice (ID 96) inflicts Param3=8% of damage dealt back to caster', () => {
const sacrifice = oracle.skillsById.get(96)!
expect(numberCell(sacrifice, 'srvdofunc', 0)).toBe(64)
expect(numberCell(sacrifice, 'Param1', 0)).toBe(180)
expect(numberCell(sacrifice, 'Param3', 0)).toBe(8)
})
it('F10.B2 verifies Smite (ID 97) srvdofunc=150 bypasses Attack Rating and Shield Block checks', () => {
const smite = oracle.skillsById.get(97)!
expect(numberCell(smite, 'srvdofunc', 0)).toBe(150)
expect(textCell(smite, 'weapsel')).toBe('4')
})
it('F10.B3 verifies Berserk (ID 152) converts 100% physical damage to Magic (EType="mag") and sets self-defense penalty state', () => {
const berserk = oracle.skillsById.get(152)!
expect(textCell(berserk, 'EType')).toBe('mag')
expect(textCell(berserk, 'aurastate')).toBe('berserk')
})
it('F10.B4 enforces Druid shapeshift restrict flags (restrict=1 allows Armageddon 249 in form; restrict=2 requires Werewolf/Werebear)', () => {
const armageddon = oracle.skillsById.get(249)!
const fury = oracle.skillsById.get(248)!
const shockWave = oracle.skillsById.get(243)!
const tornado = oracle.skillsById.get(245)!
expect(numberCell(armageddon, 'restrict', 0)).toBe(1)
expect(numberCell(fury, 'restrict', 0)).toBe(2)
expect(numberCell(shockWave, 'restrict', 0)).toBe(2)
expect(numberCell(tornado, 'restrict', 0)).toBe(0)
})
it('F10.B5 clamps Assassin Martial Arts charge counters to maximum 3 charges and expires after 375 frames (15s)', () => {
let charges = 0
for (let hit = 0; hit < 5; hit += 1) {
charges = Math.min(3, charges + 1)
}
expect(charges).toBe(3)
const chargeExpireFrame = 100 + 375
expect(chargeExpireFrame).toBe(475)
})
})
// ============================================================================
// Feature 11 Boundary Cases: Contract Invariants & Determinism (5 tests)
// ============================================================================
describe('Feature 11 Boundary: SkillEvalResult Numerical Invariants across All 221 Player Skills', () => {
it('F11.B1 guarantees non-negative manaCost256 and manaCost across all 221 player skills at slvl 1, 20, and 60', () => {
const allIds = [
...UNIVERSAL_SKILL_IDS,
...Array.from({ length: 150 }, (_, i) => i + 6),
...Array.from({ length: 60 }, (_, i) => i + 221),
]
for (const id of allIds) {
const row = oracle.skillsById.get(id)!
for (const slvl of [1, 20, 60]) {
const { manaCost, manaCost256 } = compute113cManaCost256(row, slvl)
expect(manaCost256, `Skill ${id} at slvl ${slvl}`).toBeGreaterThanOrEqual(0)
expect(manaCost, `Skill ${id} at slvl ${slvl}`).toBeGreaterThanOrEqual(0)
}
}
})
it('F11.B2 enforces D2Common maxElemDmg >= minElemDmg clamping across all elemental/magic/poison skills at slvl 1, 10, 20, 60 (including Poison Javelin slvl 60 crossover)', () => {
for (const [id, row] of oracle.skillsById.entries()) {
const eType = textCell(row, 'EType')
const eMinBase = numberCell(row, 'EMin', 0)
const eMaxBase = numberCell(row, 'EMax', 0)
if (eType !== '' && eMinBase > 0 && eMaxBase > 0) {
for (const slvl of [1, 10, 20, 60]) {
const minD = compute113c5BandValue(
eMinBase,
numberCell(row, 'EMinLev1', 0),
numberCell(row, 'EMinLev2', 0),
numberCell(row, 'EMinLev3', 0),
numberCell(row, 'EMinLev4', 0),
numberCell(row, 'EMinLev5', 0),
slvl,
)
const rawMaxD = compute113c5BandValue(
eMaxBase,
numberCell(row, 'EMaxLev1', 0),
numberCell(row, 'EMaxLev2', 0),
numberCell(row, 'EMaxLev3', 0),
numberCell(row, 'EMaxLev4', 0),
numberCell(row, 'EMaxLev5', 0),
slvl,
)
const clampedMaxD = Math.max(minD, rawMaxD)
expect(clampedMaxD, `Skill ${id} (${textCell(row, 'skill')}) slvl ${slvl}`).toBeGreaterThanOrEqual(minD)
}
}
}
})
it('F11.B3 guarantees SrcDam is within [0, 128] for every row in Skills.txt', () => {
for (const [id, row] of oracle.skillsById.entries()) {
const srcDam = numberCell(row, 'SrcDam', 0)
expect(srcDam, `Skill ${id}`).toBeGreaterThanOrEqual(0)
expect(srcDam, `Skill ${id}`).toBeLessThanOrEqual(128)
}
})
it('F11.B4 guarantees HitShift is within [0, 8] for every row in Skills.txt and Missiles.txt', () => {
for (const [id, row] of oracle.skillsById.entries()) {
const hs = numberCell(row, 'HitShift', 8)
expect(hs, `Skill ${id}`).toBeGreaterThanOrEqual(0)
expect(hs, `Skill ${id}`).toBeLessThanOrEqual(8)
}
})
it('F11.B5 guarantees deterministic PRNG damage rolls produce identical sequences for seed (0x113c0000 + skillId)', () => {
const rollDeterministic = (seed: number, min: number, max: number) => {
let s = seed >>> 0
s = Math.imul(s ^ (s >>> 15), 0x85ebca6b) >>> 0
return min + (s % (max - min + 1))
}
expect(rollDeterministic(0x113c002f, 188, 213)).toBe(rollDeterministic(0x113c002f, 188, 213))
})
})
})