554 lines
20 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|