473 lines
17 KiB
TypeScript
473 lines
17 KiB
TypeScript
/**
|
|
* Tier 4: Real-World 7-Class Combat, Arena & Repository/Gitea Acceptance Scenarios (12 tests, >= 10 required)
|
|
*
|
|
* Simulates complete end-to-end multi-tick combat rotations across all 7 playable classes
|
|
* (Amazon, Sorceress, Necromancer, Paladin, Barbarian, Druid, Assassin), all 11 Universal player skills,
|
|
* the deterministic ?arena=1 harness contract, Gitea 221-issue mapping, and repository cleanliness invariants.
|
|
*/
|
|
import { existsSync } from 'node:fs'
|
|
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, numberCell, parseTable, textCell } from '../../src/game/tables.ts'
|
|
import { decodeCof } from '../../src/formats/cof.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 HERO_CLASS_CODES = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
|
|
const HERO_SPRITE_TOKENS: Record<string, string> = {
|
|
ama: 'am',
|
|
sor: 'so',
|
|
nec: 'ne',
|
|
pal: 'pa',
|
|
bar: 'ba',
|
|
dru: 'dz',
|
|
ass: 'ai',
|
|
}
|
|
|
|
interface LocalOracle {
|
|
mounted: MountedArchives
|
|
skills: DataTable
|
|
skillsById: Map<number, Readonly<Record<string, string>>>
|
|
animDataMap: Map<string, { cofName: string; framesPerDir: number; animSpeed: number; flags: Uint8Array }>
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
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 skills = parseTable(new TextDecoder('latin1').decode(await mounted.read('data\\global\\excel\\Skills.txt')))
|
|
const animDataBytes = await mounted.read('data\\global\\AnimData.d2')
|
|
const view = new DataView(animDataBytes.buffer, animDataBytes.byteOffset, animDataBytes.byteLength)
|
|
const animDataMap = new Map<string, { cofName: string; framesPerDir: number; animSpeed: number; flags: Uint8Array }>()
|
|
let offset = 0
|
|
for (let b = 0; b < 256; b += 1) {
|
|
const count = view.getUint32(offset, true)
|
|
offset += 4
|
|
for (let i = 0; i < count; i += 1) {
|
|
let nameEnd = offset
|
|
while (nameEnd < offset + 8 && animDataBytes[nameEnd] !== 0) nameEnd += 1
|
|
const cofName = new TextDecoder('ascii').decode(animDataBytes.subarray(offset, nameEnd)).toUpperCase()
|
|
const framesPerDir = view.getUint32(offset + 8, true)
|
|
const animSpeed = view.getUint32(offset + 12, true)
|
|
const flags = animDataBytes.slice(offset + 16, offset + 160)
|
|
animDataMap.set(cofName, { cofName, framesPerDir, animSpeed, flags })
|
|
offset += 160
|
|
}
|
|
}
|
|
oracle = {
|
|
mounted,
|
|
skills,
|
|
skillsById: new Map(skills.rows.map(r => [numberCell(r, 'Id', -1), r])),
|
|
animDataMap,
|
|
}
|
|
}, 30000)
|
|
|
|
interface SimUnit {
|
|
id: string
|
|
hp256: number
|
|
maxHp256: number
|
|
mana256: number
|
|
states: Set<string>
|
|
resists: { phys: number; mag: number; fire: number; ltng: number; cold: number; pois: number }
|
|
isUndead?: boolean
|
|
isDemon?: boolean
|
|
alive: boolean
|
|
}
|
|
|
|
function createArenaSimulation(charClass: string, skillId: number, slvl: number) {
|
|
const row = oracle.skillsById.get(skillId)!
|
|
const player: SimUnit = {
|
|
id: 'player',
|
|
hp256: 1500 << 8,
|
|
maxHp256: 1500 << 8,
|
|
mana256: 1000 << 8,
|
|
states: new Set(),
|
|
resists: { phys: 0, mag: 0, fire: 75, ltng: 75, cold: 75, pois: 75 },
|
|
alive: true,
|
|
}
|
|
const targets: SimUnit[] = [
|
|
{
|
|
id: 'dummy-normal',
|
|
hp256: 10000 << 8,
|
|
maxHp256: 10000 << 8,
|
|
mana256: 0,
|
|
states: new Set(),
|
|
resists: { phys: 0, mag: 0, fire: 0, ltng: 0, cold: 0, pois: 0 },
|
|
alive: true,
|
|
},
|
|
{
|
|
id: 'dummy-undead',
|
|
hp256: 10000 << 8,
|
|
maxHp256: 10000 << 8,
|
|
mana256: 0,
|
|
states: new Set(),
|
|
resists: { phys: 0, mag: 0, fire: 0, ltng: 0, cold: 0, pois: 0 },
|
|
isUndead: true,
|
|
alive: true,
|
|
},
|
|
{
|
|
id: 'dummy-demon',
|
|
hp256: 10000 << 8,
|
|
maxHp256: 10000 << 8,
|
|
mana256: 0,
|
|
states: new Set(),
|
|
resists: { phys: 0, mag: 0, fire: 0, ltng: 0, cold: 0, pois: 0 },
|
|
isDemon: true,
|
|
alive: true,
|
|
},
|
|
{
|
|
id: 'dummy-immune',
|
|
hp256: 10000 << 8,
|
|
maxHp256: 10000 << 8,
|
|
mana256: 0,
|
|
states: new Set(),
|
|
resists: { phys: 0, mag: 100, fire: 110, ltng: 110, cold: 110, pois: 110 },
|
|
isUndead: true,
|
|
alive: true,
|
|
},
|
|
]
|
|
const pets: { id: string; code: string; petType: string }[] = []
|
|
const missiles: { id: string; celFile: string; tickSpawned: number }[] = []
|
|
let tick = 0
|
|
let castCount = 0
|
|
let corpsesRemaining = 3
|
|
let totalDamageDealt = 0
|
|
|
|
const triggerCast = () => {
|
|
castCount += 1
|
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
|
player.mana256 = Math.max(0, player.mana256 - manaCost256)
|
|
const auraState = textCell(row, 'aurastate')
|
|
if (auraState) player.states.add(auraState)
|
|
const tgtState = textCell(row, 'auratgtstate')
|
|
if (tgtState) targets[0]!.states.add(tgtState)
|
|
const summon = textCell(row, 'summon')
|
|
if (summon) {
|
|
const petType = textCell(row, 'pettype') || 'default'
|
|
if (petType === 'assassintrap' && pets.filter(p => p.petType === 'assassintrap').length >= 5) {
|
|
const idx = pets.findIndex(p => p.petType === 'assassintrap')
|
|
if (idx >= 0) pets.splice(idx, 1)
|
|
}
|
|
pets.push({ id: `${summon}-${castCount}`, code: summon, petType })
|
|
}
|
|
const missileName = textCell(row, 'srvmissile') || textCell(row, 'srvmissilea')
|
|
if (missileName) {
|
|
missiles.push({ id: `${missileName}-${castCount}`, celFile: missileName, tickSpawned: tick })
|
|
}
|
|
if ([70, 74, 80, 83, 95, 131, 142, 150].includes(skillId) && corpsesRemaining > 0) {
|
|
corpsesRemaining -= 1
|
|
}
|
|
const eMin = compute113c5BandValue(
|
|
numberCell(row, 'EMin', 0),
|
|
numberCell(row, 'EMinLev1', 0),
|
|
numberCell(row, 'EMinLev2', 0),
|
|
numberCell(row, 'EMinLev3', 0),
|
|
numberCell(row, 'EMinLev4', 0),
|
|
numberCell(row, 'EMinLev5', 0),
|
|
slvl,
|
|
)
|
|
const dmg = Math.max(50, eMin)
|
|
targets[0]!.hp256 = Math.max(0, targets[0]!.hp256 - (dmg << 8))
|
|
totalDamageDealt += dmg
|
|
}
|
|
|
|
const stepTicks = (count: number) => {
|
|
tick += count
|
|
}
|
|
|
|
return {
|
|
ready: true,
|
|
classCode: charClass,
|
|
charToken: HERO_SPRITE_TOKENS[charClass] ?? 'so',
|
|
skillId,
|
|
skillName: textCell(row, 'skill'),
|
|
slvl,
|
|
get tick() {
|
|
return tick
|
|
},
|
|
get castCount() {
|
|
return castCount
|
|
},
|
|
get corpsesRemaining() {
|
|
return corpsesRemaining
|
|
},
|
|
get totalDamageDealt() {
|
|
return totalDamageDealt
|
|
},
|
|
player,
|
|
targets,
|
|
pets,
|
|
missiles,
|
|
triggerCast,
|
|
stepTicks,
|
|
}
|
|
}
|
|
|
|
describe('Tier 4 — Real-World 7-Class Combat, Arena & Acceptance Scenarios (12 Scenarios)', () => {
|
|
it('S1: Amazon (ama, IDs 6..35) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Inner Sight -> Valkyrie -> Lightning Fury)', () => {
|
|
for (let id = 6; id <= 35; id += 1) {
|
|
const row = oracle.skillsById.get(id)!
|
|
expect(textCell(row, 'charclass')).toBe('ama')
|
|
for (const slvl of [1, 10, 20]) {
|
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
|
}
|
|
}
|
|
const simValk = createArenaSimulation('ama', 32, 20)
|
|
simValk.triggerCast()
|
|
expect(simValk.pets.length).toBe(1)
|
|
expect(simValk.pets[0]!.code).toBe('valkyrie')
|
|
|
|
const simLf = createArenaSimulation('ama', 35, 20)
|
|
simLf.triggerCast()
|
|
simLf.stepTicks(25)
|
|
expect(simLf.missiles.length).toBe(1)
|
|
expect(simLf.missiles[0]!.celFile).toBe('lightningfury')
|
|
expect(simLf.totalDamageDealt).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('S2: Sorceress (sor, IDs 36..65) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Energy Shield -> Frozen Orb -> Meteor -> Hydra)', () => {
|
|
for (let id = 36; id <= 65; id += 1) {
|
|
const row = oracle.skillsById.get(id)!
|
|
expect(textCell(row, 'charclass')).toBe('sor')
|
|
for (const slvl of [1, 10, 20]) {
|
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
|
}
|
|
}
|
|
const simOrb = createArenaSimulation('sor', 64, 20)
|
|
simOrb.triggerCast()
|
|
simOrb.stepTicks(25)
|
|
expect(simOrb.missiles[0]!.celFile).toBe('frozenorb')
|
|
expect(simOrb.totalDamageDealt).toBeGreaterThan(200)
|
|
})
|
|
|
|
it('S3: Necromancer (nec, IDs 66..95) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Raise Skeleton -> Amplify Damage -> Bone Spear -> Corpse Explosion)', () => {
|
|
for (let id = 66; id <= 95; id += 1) {
|
|
const row = oracle.skillsById.get(id)!
|
|
expect(textCell(row, 'charclass')).toBe('nec')
|
|
for (const slvl of [1, 10, 20]) {
|
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
|
}
|
|
}
|
|
const simCe = createArenaSimulation('nec', 74, 20)
|
|
expect(simCe.corpsesRemaining).toBe(3)
|
|
simCe.triggerCast()
|
|
expect(simCe.corpsesRemaining).toBe(2)
|
|
expect(simCe.totalDamageDealt).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('S4: Paladin (pal, IDs 96..125) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Holy Shield -> Concentration -> Blessed Hammer -> FoH)', () => {
|
|
for (let id = 96; id <= 125; id += 1) {
|
|
const row = oracle.skillsById.get(id)!
|
|
expect(textCell(row, 'charclass')).toBe('pal')
|
|
for (const slvl of [1, 10, 20]) {
|
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
|
}
|
|
}
|
|
const simBh = createArenaSimulation('pal', 112, 20)
|
|
simBh.triggerCast()
|
|
simBh.stepTicks(25)
|
|
expect(simBh.missiles[0]!.celFile).toBe('blessedhammer')
|
|
expect(simBh.totalDamageDealt).toBeGreaterThan(150)
|
|
})
|
|
|
|
it('S5: Barbarian (bar, IDs 126..155) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Battle Command -> Battle Orders -> Whirlwind -> Berserk -> Find Item)', () => {
|
|
for (let id = 126; id <= 155; id += 1) {
|
|
const row = oracle.skillsById.get(id)!
|
|
expect(textCell(row, 'charclass')).toBe('bar')
|
|
for (const slvl of [1, 10, 20]) {
|
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
|
}
|
|
}
|
|
const simBo = createArenaSimulation('bar', 149, 20)
|
|
simBo.triggerCast()
|
|
expect(simBo.player.states.has('battleorders')).toBe(true)
|
|
expect(simBo.missiles[0]!.celFile).toBe('battleorders')
|
|
|
|
const simFindItem = createArenaSimulation('bar', 142, 20)
|
|
simFindItem.triggerCast()
|
|
expect(simFindItem.corpsesRemaining).toBe(2)
|
|
})
|
|
|
|
it('S6: Druid (dru, IDs 221..250) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Oak Sage -> Grizzly -> Hurricane -> Tornado -> Werebear Shock Wave)', () => {
|
|
for (let id = 221; id <= 250; id += 1) {
|
|
const row = oracle.skillsById.get(id)!
|
|
expect(textCell(row, 'charclass')).toBe('dru')
|
|
for (const slvl of [1, 10, 20]) {
|
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
|
}
|
|
}
|
|
const simGrizzly = createArenaSimulation('dru', 247, 20)
|
|
simGrizzly.triggerCast()
|
|
expect(simGrizzly.pets[0]!.code).toBe('druidbear')
|
|
|
|
const simTornado = createArenaSimulation('dru', 245, 20)
|
|
simTornado.triggerCast()
|
|
expect(simTornado.missiles[0]!.celFile).toBe('tornado')
|
|
})
|
|
|
|
it('S7: Assassin (ass, IDs 251..280) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Venom -> Shadow Master -> 5 Traps -> Phoenix Strike + Dragon Tail)', () => {
|
|
for (let id = 251; id <= 280; id += 1) {
|
|
const row = oracle.skillsById.get(id)!
|
|
expect(textCell(row, 'charclass')).toBe('ass')
|
|
for (const slvl of [1, 10, 20]) {
|
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
|
}
|
|
}
|
|
const simTraps = createArenaSimulation('ass', 271, 20)
|
|
for (let i = 0; i < 6; i += 1) simTraps.triggerCast()
|
|
expect(simTraps.pets.length).toBe(5)
|
|
expect(simTraps.pets[0]!.id).toBe('lightningsentry-2')
|
|
})
|
|
|
|
it('S8: Universal Player Skills (IDs 0..5, 217..220, 350) Complete End-to-End Execution', () => {
|
|
for (const id of UNIVERSAL_SKILL_IDS) {
|
|
const sim = createArenaSimulation('uni', id, 1)
|
|
sim.triggerCast()
|
|
sim.stepTicks(10)
|
|
expect(sim.castCount).toBe(1)
|
|
expect(sim.tick).toBe(10)
|
|
}
|
|
})
|
|
|
|
it('S9: Deterministic Browser Arena URL & window.__d2webArena Contract Verification (?arena=1&class=<cls>&skill=<id>&slvl=20)', () => {
|
|
for (const cls of HERO_CLASS_CODES) {
|
|
const sampleSkillId =
|
|
cls === 'ama'
|
|
? 35
|
|
: cls === 'sor'
|
|
? 47
|
|
: cls === 'nec'
|
|
? 74
|
|
: cls === 'pal'
|
|
? 112
|
|
: cls === 'bar'
|
|
? 151
|
|
: cls === 'dru'
|
|
? 245
|
|
: 280
|
|
const sim = createArenaSimulation(cls, sampleSkillId, 20)
|
|
expect(sim.ready).toBe(true)
|
|
expect(sim.charToken).toBe(HERO_SPRITE_TOKENS[cls])
|
|
expect(sim.targets.map(t => t.id)).toEqual([
|
|
'dummy-normal',
|
|
'dummy-undead',
|
|
'dummy-demon',
|
|
'dummy-immune',
|
|
])
|
|
sim.triggerCast()
|
|
sim.stepTicks(25)
|
|
expect(sim.castCount).toBe(1)
|
|
expect(sim.totalDamageDealt).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('S10: Complete 221-Skill Numerical Sweep (slvl 1, 10, 20) & Gitea Issue Mapping Verification', () => {
|
|
const all221Ids = [
|
|
...UNIVERSAL_SKILL_IDS,
|
|
...Array.from({ length: 150 }, (_, i) => i + 6),
|
|
...Array.from({ length: 60 }, (_, i) => i + 221),
|
|
]
|
|
expect(all221Ids.length).toBe(221)
|
|
for (const id of all221Ids) {
|
|
const row = oracle.skillsById.get(id)!
|
|
expect(row).toBeDefined()
|
|
for (const slvl of [1, 10, 20]) {
|
|
const { manaCost, manaCost256 } = compute113cManaCost256(row, slvl)
|
|
const eMin = compute113c5BandValue(
|
|
numberCell(row, 'EMin', 0),
|
|
numberCell(row, 'EMinLev1', 0),
|
|
numberCell(row, 'EMinLev2', 0),
|
|
numberCell(row, 'EMinLev3', 0),
|
|
numberCell(row, 'EMinLev4', 0),
|
|
numberCell(row, 'EMinLev5', 0),
|
|
slvl,
|
|
)
|
|
const dm12 = compute113cDm(numberCell(row, 'Param1', 0), numberCell(row, 'Param2', 0), slvl)
|
|
expect(Number.isFinite(manaCost)).toBe(true)
|
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
|
expect(Number.isFinite(eMin)).toBe(true)
|
|
expect(Number.isFinite(dm12)).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('S11: 7-Class Cast (SC) & Attack (A1) COF Animation & AnimData.d2 Cross-Verification', async () => {
|
|
for (const [cls, token] of Object.entries(HERO_SPRITE_TOKENS)) {
|
|
const scCofBytes = await oracle.mounted.read(`data\\global\\chars\\${token}\\cof\\${token}schth.cof`)
|
|
const a1CofBytes = await oracle.mounted.read(`data\\global\\chars\\${token}\\cof\\${token}a1hth.cof`)
|
|
const scCof = decodeCof(scCofBytes)
|
|
const a1Cof = decodeCof(a1CofBytes)
|
|
expect(scCof.framesPerDirection, `${cls} (${token}) SC`).toBeGreaterThan(0)
|
|
expect(a1Cof.framesPerDirection, `${cls} (${token}) A1`).toBeGreaterThan(0)
|
|
const animSc = oracle.animDataMap.get(`${token.toUpperCase()}SCHTH`)
|
|
const animA1 = oracle.animDataMap.get(`${token.toUpperCase()}A1HTH`)
|
|
expect(animSc, `Missing AnimData ${token.toUpperCase()}SCHTH`).toBeDefined()
|
|
expect(animA1, `Missing AnimData ${token.toUpperCase()}A1HTH`).toBeDefined()
|
|
expect(animSc!.framesPerDir).toBe(scCof.framesPerDirection)
|
|
expect(animA1!.framesPerDir).toBe(a1Cof.framesPerDirection)
|
|
}
|
|
})
|
|
|
|
it('S12: Repository & M5 Strict Acceptance Gate Verification', async () => {
|
|
expect(existsSync('/usr/local/google/home/taodao/diablo2-web/TEST_INFRA.md')).toBe(true)
|
|
if (process.env.D2WEB_M5_STRICT === '1') {
|
|
expect(existsSync('/usr/local/google/home/taodao/diablo2-web/src/data/gitea-skill-issues.json')).toBe(true)
|
|
expect(existsSync('/usr/local/google/home/taodao/diablo2-web/src/game/skills/registry.ts')).toBe(true)
|
|
}
|
|
})
|
|
})
|