diablo2-web/tests/client/batch1-adversarial-stress.t...

554 lines
20 KiB
TypeScript

/**
* Batch 1 Gate Adversarial Stress Test Suite (Challenger 2)
*
* Exhaustive empirical verification and adversarial stress-testing of Batch 1:
* 1. Netproto & Entity Identity (#563, #564, #567, #569, #570):
* - 1,000-case randomized secondary player spawn packet sequences (0x59 AssignPlayer)
* with extreme/boundary coordinates, verifying local selfId is never corrupted or flipped.
* - Interleaved multi-player death scenarios with 0x8E / 0x74 CorpseAssign packets,
* verifying living players are NEVER marked dead and party state is never polluted.
* - Fail-fast boundary enforcement on PlrMode (0..19) and MonMode (0..15).
* 2. Skill Animation Modes (#565, #566):
* - Full differential testing oracle verifying all 357 skills against raw Skills.txt rows.
* - Out-of-range fallback safety checks.
* - End-to-end player and monster animation mode resolution in ClientWorld.apply.
* - Knockback mode 13 transition, arrival reset, preservation of casting modes (S1..S4),
* and 'gh' clip resolution in WorldRenderer.
*/
import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { ClientUnit } from '../../src/client/world/client-unit.ts'
import {
CANONICAL_SKILL_ANIM_MODES,
getSkillAnimModes,
} from '../../src/common/skills/skill-anim-modes.ts'
import { decode0x59AssignPlayer } from '../../src/netproto/d2gs/s2c/units.ts'
import {
decode0x74PlayerCorpseAssign,
decode0x8ECorpseAssign,
} from '../../src/netproto/d2gs/s2c/party.ts'
import { decode0x0DPlayerStop } from '../../src/netproto/d2gs/s2c/movement.ts'
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
describe('Batch 1 Gate Adversarial Stress Tests (Challenger 2)', () => {
// ─── 1. Netproto & Entity Identity Stability (#563, #564, #567, #569, #570) ───
describe('1.1. 0x59 AssignPlayer Stress Fuzzing & Local selfId Stability (#563)', () => {
it('1000 randomized secondary player spawns with varying non-zero coordinates NEVER corrupt selfId or self state', () => {
const world = new ClientWorld()
world.applyServerEvent({
type: 'LoadAct',
act: 0,
mapSeed: 0xfeedbeef,
areaId: 1, // Town
})
// Establish local player identity
const localId = 42
world.applyServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 0,
id: localId,
classId: 1, // Sorceress
name: 'EmpiricalSorceress',
x: 5000,
y: 5000,
},
})
expect(world.selfId).toBe(localId)
expect(world.self.unitId).toBe(localId)
expect(world.self.name).toBe('EmpiricalSorceress')
expect(world.self.charClass).toBe(1)
expect(world.self.x).toBe(5000)
expect(world.self.y).toBe(5000)
// Pseudorandom generator with fixed seed for deterministic reproducibility
let seed = 123456789
function nextRand(): number {
seed = (seed * 1103515245 + 12345) & 0x7fffffff
return seed / 0x7fffffff
}
const testClasses = [0, 1, 2, 3, 4, 5, 6]
const boundaryCoords = [
[0, 0],
[1, 1],
[5000, 5000],
[5100, 5200],
[0x7fff, 0x7fff],
[0xffff, 0xffff],
[1234, 5678],
[65535, 0],
[0, 65535],
]
const spawnedRemoteIds: number[] = []
// Stress-test 1000 secondary player spawns
for (let i = 0; i < 1000; i++) {
const remoteId = 1000 + i
spawnedRemoteIds.push(remoteId)
const charClass = testClasses[Math.floor(nextRand() * testClasses.length)]!
const coordPair =
i < boundaryCoords.length
? boundaryCoords[i]!
: [Math.floor(nextRand() * 65536), Math.floor(nextRand() * 65536)]
const x = coordPair[0]!
const y = coordPair[1]!
const name = `Remote_${i}_${charClass}`
const w = new ByteWriter()
w.u8(0x59)
w.u32LE(remoteId)
w.u8(charClass)
w.fixedString(name, 16)
w.u16LE(x)
w.u16LE(y)
const ev = decode0x59AssignPlayer(w.toUint8Array())
expect(ev.type).toBe('UnitAssign')
if (ev.type === 'UnitAssign') {
// Packet decoder must NEVER flag isSelf based on non-zero coordinates
expect(ev.isSelf).toBeUndefined()
}
// Apply packet to world
world.applyServerEvent(ev)
// Strict Invariants: Local player identity MUST remain completely unaffected
expect(world.selfId).toBe(localId)
expect(world.self.unitId).toBe(localId)
expect(world.self.name).toBe('EmpiricalSorceress')
expect(world.self.charClass).toBe(1)
expect(world.self.x).toBe(5000)
expect(world.self.y).toBe(5000)
expect(world.self.targetX).toBe(5000)
expect(world.self.targetY).toBe(5000)
// Remote player must be stored in world.units
const remoteUnit = world.getUnit(0, remoteId)
expect(remoteUnit).toBeDefined()
expect(remoteUnit?.id).toBe(remoteId)
expect(remoteUnit?.name).toBe(name)
expect(remoteUnit?.classId).toBe(charClass)
expect(remoteUnit?.x).toBe(x)
expect(remoteUnit?.y).toBe(y)
// Interleaved departure: every 10 spawns, despawn an earlier player via UnitRemove (0x0A)
if (i % 10 === 0 && spawnedRemoteIds.length > 5) {
const despawnId = spawnedRemoteIds.shift()!
world.applyServerEvent({
type: 'UnitRemove',
unitType: 0,
id: despawnId,
})
expect(world.getUnit(0, despawnId)).toBeUndefined()
expect(world.selfId).toBe(localId)
expect(world.self.name).toBe('EmpiricalSorceress')
}
}
})
})
describe('1.2. 0x8E / 0x74 CorpseAssign Under Simultaneous Player Deaths (#564)', () => {
it('handles simultaneous 8-player party wipe where CorpseAssign NEVER marks living players dead', () => {
const world = new ClientWorld()
world.applyServerEvent({
type: 'LoadAct',
act: 0,
mapSeed: 0x11223344,
areaId: 2, // Wilderness
})
const localId = 1
world.applyServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 0, id: localId, classId: 0, name: 'LocalHero', x: 2000, y: 2000 },
})
// Spawn 7 other players in the session
const remotePlayerIds = [2, 3, 4, 5, 6, 7, 8]
for (const rId of remotePlayerIds) {
world.applyServerEvent({
type: 'UnitAssign',
isSelf: false,
unit: { unitType: 0, id: rId, classId: rId % 7, name: `Hero_${rId}`, x: 2000 + rId * 10, y: 2000 },
})
}
// Precondition: All 8 players are alive
expect(world.isSelfDead).toBe(false)
expect(world.self.isDead).toBe(false)
for (const id of [localId, ...remotePlayerIds]) {
expect(world.getUnit(0, id)?.dead).toBe(false)
}
// Scenario: Server sends 0x8E and 0x74 corpse assignments BEFORE or DURING death sequence
// In 1.13c, corpse assignment attaches a corpse entity (dwUnitId) to an owner (dwOwnerId).
// A living player who has a corpse assigned (e.g. from an earlier death or packet reordering)
// MUST NOT be marked dead!
// Assign corpse 801 to local player via 0x8E
const w8e = new ByteWriter()
w8e.u8(0x8e).u8(1).u32LE(localId).u32LE(801)
world.applyServerEvent(decode0x8ECorpseAssign(w8e.toUint8Array()))
// Assign corpse 802 to player 2 via 0x74
const w74 = new ByteWriter()
w74.u8(0x74).u8(1).u32LE(2).u32LE(802)
world.applyServerEvent(decode0x74PlayerCorpseAssign(w74.toUint8Array()))
// Assert: Both corpses tracked in world.corpses
expect(world.corpses.get(801)).toBe(localId)
expect(world.corpses.get(802)).toBe(2)
// CRITICAL ASSERTION: Living players MUST NOT be marked dead!
expect(world.isSelfDead).toBe(false)
expect(world.self.isDead).toBe(false)
expect(world.party.has(801)).toBe(false)
expect(world.party.has(802)).toBe(false)
// Multiple corpses per player (player dies repeatedly)
world.applyServerEvent({
type: 'CorpseAssign',
assign: 1,
ownerId: localId,
corpseId: 803,
})
expect(world.corpses.get(801)).toBe(localId)
expect(world.corpses.get(803)).toBe(localId)
expect(world.isSelfDead).toBe(false)
// Now local player actually dies via 0x0D action 0x08 (DT=0)
const wDt = new ByteWriter()
wDt.u8(0x0d).u8(0).u32LE(localId).u8(0x08).u16LE(2000).u16LE(2000).u8(0).u8(0)
world.applyServerEvent(decode0x0DPlayerStop(wDt.toUint8Array()))
// Now local player IS legitimately dead
expect(world.isSelfDead).toBe(true)
expect(world.self.isDead).toBe(true)
expect(world.getUnit(0, localId)?.mode).toBe(0) // DT = 0
// Unassign corpse 801 (e.g. looted or cleaned up)
world.applyServerEvent({
type: 'CorpseAssign',
assign: 0,
ownerId: localId,
corpseId: 801,
})
expect(world.corpses.has(801)).toBe(false)
expect(world.corpses.get(803)).toBe(localId)
})
})
describe('1.3. Fail-Fast Out-Of-Range Mode Validation (#570)', () => {
it('throws RangeError for all out-of-range MonMode values (<0 or >15)', () => {
const world = new ClientWorld()
const monsterId = 99
world.applyServerEvent({
type: 'UnitAssign',
unit: { unitType: 1, id: monsterId, classId: 5, x: 100, y: 100, mode: 1 },
})
// Exhaustive sweep of invalid MonModes
const invalidMonModes = [-1000, -100, -5, -1, 16, 17, 20, 50, 531, 1000, 65535]
for (const badMode of invalidMonModes) {
expect(() => {
world.applyServerEvent({
type: 'UnitMode',
unitType: 1,
id: monsterId,
mode: badMode,
})
}).toThrow(RangeError)
}
// Valid MonModes 0..15 must all succeed
for (let validMode = 0; validMode <= 15; validMode++) {
expect(() => {
world.applyServerEvent({
type: 'UnitMode',
unitType: 1,
id: monsterId,
mode: validMode,
})
}).not.toThrow()
expect(world.getUnit(1, monsterId)?.mode).toBe(validMode)
}
})
it('throws RangeError for all out-of-range PlrMode values (<0 or >19)', () => {
const world = new ClientWorld()
const playerId = 50
world.applyServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 0, id: playerId, classId: 0, x: 100, y: 100, mode: 1 },
})
const invalidPlrModes = [-1000, -50, -1, 20, 21, 50, 100, 531, 65535]
for (const badMode of invalidPlrModes) {
expect(() => {
world.applyServerEvent({
type: 'UnitMode',
unitType: 0,
id: playerId,
mode: badMode,
})
}).toThrow(RangeError)
}
// Valid PlrModes 0..19 must all succeed (in wilderness, NU=1 does not translate to TN=5)
world.applyServerEvent({ type: 'LoadAct', act: 0, mapSeed: 1, areaId: 2 })
for (let validMode = 0; validMode <= 19; validMode++) {
expect(() => {
world.applyServerEvent({
type: 'UnitMode',
unitType: 0,
id: playerId,
mode: validMode,
})
}).not.toThrow()
expect(world.getUnit(0, playerId)?.mode).toBe(validMode)
}
})
})
// ─── 2. Skill Animation Modes & Knockback Parity (#565, #566) ──────────────────
describe('2.1. Differential Testing Oracle against raw Skills.txt (All 357 Skills) (#565)', () => {
it('CANONICAL_SKILL_ANIM_MODES matches raw Skills.txt rows with 100% precision across all 357 skills', () => {
const skillsTxtPath = path.resolve(
process.cwd(),
'samples/fixtures/data/global/excel/Skills.txt',
)
expect(fs.existsSync(skillsTxtPath)).toBe(true)
const rawLines = fs.readFileSync(skillsTxtPath, 'utf8').split('\n')
const headers = rawLines[0]!.split('\t')
const idIdx = headers.indexOf('Id')
const animIdx = headers.indexOf('anim')
const monanimIdx = headers.indexOf('monanim')
expect(idIdx).toBe(1)
expect(animIdx).toBe(124)
expect(monanimIdx).toBe(126)
// Independent Oracle: PlrMode mapping
const ORACLE_PLR_ANIM: Record<string, number> = {
A1: 7,
A2: 8,
SC: 10,
TH: 11,
KK: 12,
S1: 13,
S2: 14,
S3: 15,
S4: 16,
SQ: 18,
'': 10,
}
// Independent Oracle: MonMode mapping
const ORACLE_MON_ANIM: Record<string, number> = {
A1: 4,
A2: 5,
SC: 7,
S1: 8,
S2: 9,
S3: 10,
S4: 11,
NU: 1,
}
let evaluatedCount = 0
for (let i = 1; i < rawLines.length; i++) {
const line = rawLines[i]!.trim()
if (!line) continue
const cols = rawLines[i]!.split('\t')
const skillId = parseInt(cols[idIdx]!, 10)
if (isNaN(skillId)) continue
const animRaw = (cols[animIdx] || '').trim().toUpperCase()
const monanimRaw = (cols[monanimIdx] || '').trim().toUpperCase()
const expectedPlrMode = ORACLE_PLR_ANIM[animRaw] ?? 10
let expectedMonMode: number
if (monanimRaw in ORACLE_MON_ANIM) {
expectedMonMode = ORACLE_MON_ANIM[monanimRaw]!
} else if (['A1', 'A2', 'KK', 'TH'].includes(animRaw)) {
expectedMonMode = 4 // A1 for physical/weapon attacks
} else {
expectedMonMode = 7 // SC for spell casts
}
const actual = CANONICAL_SKILL_ANIM_MODES[skillId]
expect(actual, `Missing CANONICAL_SKILL_ANIM_MODES entry for skill ${skillId}`).toBeDefined()
expect(
actual?.playerMode,
`Skill ${skillId} (${cols[0]}): expected playerMode ${expectedPlrMode} from anim='${animRaw}', got ${actual?.playerMode}`,
).toBe(expectedPlrMode)
expect(
actual?.monsterMode,
`Skill ${skillId} (${cols[0]}): expected monsterMode ${expectedMonMode} from monanim='${monanimRaw}', got ${actual?.monsterMode}`,
).toBe(expectedMonMode)
evaluatedCount++
}
// 357 skills must all be evaluated and passed
expect(evaluatedCount).toBe(357)
})
it('getSkillAnimModes returns authentic modes for valid IDs and safe fallback for unknown IDs', () => {
// Valid IDs
expect(getSkillAnimModes(0)).toEqual({ playerMode: 7, monsterMode: 4, anim: 'A1', monanim: 'A1' })
expect(getSkillAnimModes(107)).toEqual({ playerMode: 18, monsterMode: 7, anim: 'SQ', monanim: 'XX' }) // Charge
expect(getSkillAnimModes(54)).toEqual({ playerMode: 10, monsterMode: 7, anim: 'SC', monanim: 'XX' }) // Teleport
expect(getSkillAnimModes(2)).toEqual({ playerMode: 11, monsterMode: 4, anim: 'TH', monanim: 'XX' }) // Throw
// Invalid / Out-of-bounds IDs
expect(getSkillAnimModes(-1)).toEqual({ playerMode: 10, monsterMode: 7 })
expect(getSkillAnimModes(357)).toEqual({ playerMode: 10, monsterMode: 7 })
expect(getSkillAnimModes(9999)).toEqual({ playerMode: 10, monsterMode: 7 })
expect(getSkillAnimModes(NaN)).toEqual({ playerMode: 10, monsterMode: 7 })
})
it('ClientWorld applies authentic skill animation modes to players and monsters', () => {
const world = new ClientWorld()
world.applyServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: { unitType: 0, id: 1, classId: 3, name: 'Necromancer', x: 100, y: 100 },
})
world.applyServerEvent({
type: 'UnitAssign',
unit: { unitType: 1, id: 10, classId: 1, name: 'Skeleton', x: 110, y: 100 },
})
const player = world.getUnit('player', 1)!
const monster = world.getUnit('monster', 10)
// Player casts Teeth (67) -> anim SC (PlrMode 10)
world.applyServerEvent({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skillId: 67,
target: { x: 120, y: 100 },
})
expect(player.mode).toBe(10)
// Player casts Kick (1) -> anim KK (PlrMode 12)
world.applyServerEvent({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skillId: 1,
target: { x: 105, y: 100 },
})
expect(player.mode).toBe(12)
// Player casts Charge (107) -> anim SQ (PlrMode 18)
world.applyServerEvent({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skillId: 107,
target: { x: 115, y: 100 },
})
expect(player.mode).toBe(18)
})
})
describe('2.2. Monster Knockback Mode 13, Arrival Reset, and Clip Resolution (#566)', () => {
it('sets monster mode to 13 (KB) during knockback, resets to 1 (NU) upon arrival, while protecting casting modes', () => {
const monster = new ClientUnit({
id: 55,
unitType: 'monster',
classId: 1,
x: 100,
y: 100,
name: 'Zombie',
})
// 1. applyMove with knockback sets mode 13
monster.applyMove('knockback', { x: 110, y: 100 }, { x: 100, y: 100 })
expect(monster.mode).toBe(13)
expect(monster.moveMode).toBe('knockback')
// 2. Integration into ClientWorld simulation
const world = new ClientWorld()
world.applyServerEvent({
type: 'UnitAssign',
unit: { unitType: 1, id: 201, classId: 1, x: 100, y: 100 },
})
const kbMonster = world.getUnit('monster', 201)!
// Move via UnitMove knockback
world.applyServerEvent({
type: 'UnitMove',
unitType: 'monster',
id: 201,
mode: 'knockback',
from: { x: 100, y: 100 },
to: { x: 100.1, y: 100 },
})
expect(kbMonster.mode).toBe(13)
expect(kbMonster.moveMode).toBe('knockback')
// Step simulation tick (dt=40ms): monster arrives at destination
world.tick(40, 1000)
expect(kbMonster.mode).toBe(1) // Resets to NU
expect(kbMonster.moveMode).toBe('stop')
// 3. Negative check: Monster in mode 9 (S2 skill cast) must NEVER be reset to 1
world.applyServerEvent({
type: 'UnitAssign',
unit: { unitType: 1, id: 202, classId: 1, x: 200, y: 200 },
})
const s2Monster = world.getUnit('monster', 202)!
s2Monster.mode = 9 // MonMode.S2
s2Monster.moveMode = 'stop'
s2Monster.targetX = 200
s2Monster.targetY = 200
world.tick(40, 1040)
expect(s2Monster.mode).toBe(9) // Mode 9 is PRESERVED!
// 4. Other casting modes (S1=8, S3=10, S4=11, A1=4, A2=5) are also preserved
for (const castingMode of [4, 5, 8, 10, 11]) {
s2Monster.mode = castingMode
world.tick(40, 1080)
expect(s2Monster.mode).toBe(castingMode)
}
})
it('resolves MonMode 13 (KB) to clip "gh" in WorldRenderer MONSTER_MODE_TO_CLIP', async () => {
// Dynamically verify MONSTER_MODE_TO_CLIP in world-renderer
const worldRendererSource = fs.readFileSync(
path.resolve(process.cwd(), 'src/client/scene/world-renderer.ts'),
'utf8',
)
// Regex verify MONSTER_MODE_TO_CLIP table contains 13: 'gh'
const match13 = /13:\s*['"]gh['"]/i.test(worldRendererSource)
expect(match13, 'MONSTER_MODE_TO_CLIP must map mode 13 to clip "gh"').toBe(true)
// Negative check: 13 must NOT map to 'rn'
const matchWrong13 = /13:\s*['"]rn['"]/i.test(worldRendererSource)
expect(matchWrong13, 'MONSTER_MODE_TO_CLIP must not map mode 13 to "rn"').toBe(false)
// Mode 9 must map to 's2'
const match9 = /9:\s*['"]s2['"]/i.test(worldRendererSource)
expect(match9, 'MONSTER_MODE_TO_CLIP must map mode 9 to "s2"').toBe(true)
})
})
})