diablo2-web/tests/e2e-parity/tier1-feature-coverage.test.ts

1048 lines
43 KiB
TypeScript
Raw 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 1 — Core Feature Coverage (Batches 1–8, 48 Tests).
*
* Implements requirement-driven opaque-box verification across all 8 parity batches
* (Engine Timing, Collision, Movement, Animation, Renderer, UI/HUD, Networking, Tools)
* ensuring strict conformance with Diablo II v1.13c decompiled ground truth.
*/
import { describe, expect, it, vi } from 'vitest'
import {
BeltModel,
BncsCreateAccountStatus,
C2S_PACKET_SIZES,
CAMPFIRE_CLASS_SPECS,
CAMPFIRE_FLAME_CONTROL_SPEC,
CAMPFIRE_OVERLAY_SPECS,
CANONICAL_113C_MISSILES,
CHAR_ANIM_CLIPS_113C,
COLLIDE_BLANK,
COLLIDE_DOOR,
COLLIDE_NONE,
COLLIDE_WALL,
CUBE_GRID_ORIGIN,
CommandMapper,
D2_TICK_MS,
DRLG_UNIVERSAL_DT1,
INV_COLS,
INV_GRID_ORIGIN,
INV_ROWS,
InventoryPanel,
Minimap,
ORTHO_CELL_HEIGHT,
ORTHO_CELL_WIDTH,
ORTHO_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
S2C_PACKET_SIZES,
STAT_FASTERATTACKRATE,
STAT_FASTERCASTRATE,
VIEWPORT_800x600,
buildSceneDrawables,
calculateHitChance,
calculatePlayerBlockChance,
canAcceptCharNameChar,
charSummaryToRosterCharacter,
computeAnimFrameIndex,
computeDiminishingRate113c,
computeEffectiveFcr,
computeEffectiveIas,
computeMonsterMoveSpeedSubTilesPerSec,
computeOneShotDurationMs,
computeOneShotTicks,
computeStepSpeed256,
computeViewportAnchors,
createCollisionGrid,
createInitialFlowState,
createMockHudCanvasContext,
createParitySessionFixture,
createRecordingFrontendRenderer,
decode0x8ECorpseAssign,
filterAccountName,
filterCharNameInput,
getBeltRowsForCode,
getBeltSlotBox,
getInventoryLayout,
getLang,
getMissileTxtData,
getOverlayTxtData,
getSkillCastOverlay,
getViewportProfile,
isTownLevel,
isValidAccountChar,
isValidCharacterName,
lookupTbl,
makeParityItem,
mapCreateAccountServerStatus,
onlineItemDataToUiInventoryItem,
orthoScreenToSubTile,
pickVariant,
resolveClassNameByTbl,
resolveLangFromPathname,
resolveStringByTblId,
setLang,
subTileToOrthoScreen,
subTileToWorldOrtho,
tileToSubTile,
transToBlendMode,
validateNewAccountInputs,
worldOrthoToSubTile,
worldToScreen,
ViewportCamera,
} from './helpers.ts'
describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests)', () => {
// ═══════════════════════════════════════════════════════════════════════════
// Batch 1 (M1): Adversarial & Stress Verification (Issues #562–#576)
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 1 — Engine Timing, Coordinate Math & Tick Loop', () => {
it('T1.1.1: 25Hz simulation tick advances exactly 40ms and processes accumulated time', () => {
const fixture = createParitySessionFixture({ selfId: 100 })
expect(fixture.world.frameCount).toBe(0)
fixture.tick(1) // 40ms = 1 full simulation frame
expect(fixture.world.frameCount).toBe(1)
fixture.tick(24) // 24 more frames = 25 frames total
expect(fixture.world.frameCount).toBe(25)
// Sub-tick accumulation: two 20ms ticks accumulate to 1 frame increment
fixture.world.tick(20)
expect(fixture.world.frameCount).toBe(25)
fixture.world.tick(20)
expect(fixture.world.frameCount).toBe(26)
})
it('T1.1.2: Sub-tile to 2:1 isometric diamond projection (16x8) is mathematically reciprocal', () => {
// (10, 20) subtiles
const screen = subTileToOrthoScreen(10, 20)
expect(screen.x).toBe((10 - 20) * ORTHO_SUB_TILE_WIDTH) // -160
expect(screen.y).toBe((10 + 20) * ORTHO_SUB_TILE_HEIGHT) // 240
const recovered = orthoScreenToSubTile(screen.x, screen.y)
expect(recovered.x).toBeCloseTo(10, 5)
expect(recovered.y).toBeCloseTo(20, 5)
})
it('T1.1.3: S2C 0x59 AssignPlayer packet sets selfId strictly and does not flip on neighbor join', () => {
const fixture = createParitySessionFixture({ selfId: 42 })
expect(fixture.world.selfId).toBe(42)
expect(fixture.world.self.unitId).toBe(42)
// Another player spawns in the same area
fixture.applyServerEvents({
type: 'UnitAssign',
isSelf: false,
unit: {
unitType: 'player',
id: 999,
classId: 0,
x: 120,
y: 120,
mode: 1,
},
})
// Self ID must remain 42 (does not flip to neighbor player)
expect(fixture.world.selfId).toBe(42)
expect(fixture.world.self.unitId).toBe(42)
expect(fixture.world.getUnit('player', 999)).toBeDefined()
})
it('T1.1.4: 1.13c Attack Rating vs Defense Rating formula enforces [5%, 95%] bounds and integer scaling', () => {
// Balanced match: AR 1000, DR 1000, Lvl 50 vs Lvl 50
// Chance = 100 * (2 * 1000 / 2000) * (50 / 100) = 50%
const balanced = calculateHitChance(1000, 1000, 50, 50)
expect(balanced).toBe(50)
// Extreme high AR attacker against low DR defender capped at 95%
const highAr = calculateHitChance(50000, 100, 90, 10)
expect(highAr).toBe(95)
// Zero AR or defender with massive DR floored at 5%
const lowAr = calculateHitChance(1, 100000, 1, 99)
expect(lowAr).toBe(5)
})
it('T1.1.5: 8.8 fixed-point step velocity math derives canonical stepSpeed256 with minimum clamp of 32', () => {
// Base speed 256, 0% effective rate -> 256
const base = computeStepSpeed256(256, 0)
expect(base).toBe(256)
// Base speed 256, +50% effective rate -> floor(256 * 1.50) = 384
const boosted = computeStepSpeed256(256, 50)
expect(boosted).toBe(384)
// Heavily chilled / slowed (effective rate -90%) clamped at minimum 32
const chilled = computeStepSpeed256(256, -90)
expect(chilled).toBe(32)
})
it('T1.1.6: Missile range calculation per 1.13c Range and LevRange specifications', () => {
// Fire Bolt: canonical range specification in Missiles.txt
const fbMeta = getMissileTxtData('firebolt')
expect(fbMeta).toBeDefined()
expect(fbMeta!.range).toBeGreaterThanOrEqual(30)
// Calculate range at level 1 vs level 20
const rangeLvl1 = fbMeta!.range + (1 - 1) * (fbMeta!.levRange ?? 0)
const rangeLvl20 = fbMeta!.range + (20 - 1) * (fbMeta!.levRange ?? 0)
expect(rangeLvl1).toBe(fbMeta!.range)
expect(rangeLvl20).toBeGreaterThanOrEqual(rangeLvl1)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 2: Collision Layer, Floor Grid & Walkability (Issues #624–#641)
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 2 — Collision Layer, Floor Grid & Walkability', () => {
it('T1.2.1: Sparse/empty cells enforce COLLIDE_BLANK | COLLIDE_WALL (0x0021) blocking ground movement', () => {
const grid = createCollisionGrid(20, 20, COLLIDE_NONE)
// Sparse/void cell at (5, 5)
grid.setCollision(5, 5, COLLIDE_BLANK | COLLIDE_WALL)
expect(grid.getCollision(5, 5)).toBe(0x0021)
expect(grid.isWalkable(5, 5)).toBe(false)
expect(grid.isWalkable(5, 6)).toBe(true)
})
it('T1.2.2: Void & spawn protection guarantees out-of-bounds queries return COLLIDE_BLANK | COLLIDE_WALL', () => {
const grid = createCollisionGrid(10, 10, COLLIDE_NONE)
// Coordinates outside grid boundaries must be non-walkable
expect(grid.isWalkable(-1, 0)).toBe(false)
expect(grid.isWalkable(10, 5)).toBe(false)
expect(grid.getCollision(-5, -5)).toBe(COLLIDE_BLANK | COLLIDE_WALL)
})
it('T1.2.3: SplitMix64 spatial hashing yields high-entropy non-repeating variant selections', () => {
const roomSeed = 0x13579bdf
const candidates = [
{ id: 0, weight: 1 },
{ id: 1, weight: 1 },
{ id: 2, weight: 1 },
{ id: 3, weight: 1 },
]
const v0 = pickVariant(candidates, 10, 20, roomSeed)
const v1 = pickVariant(candidates, 11, 20, roomSeed)
const v2 = pickVariant(candidates, 10, 21, roomSeed)
const v3 = pickVariant(candidates, 11, 21, roomSeed)
expect(v0).toBeDefined()
expect(v0!.id).toBeGreaterThanOrEqual(0)
expect(v0!.id).toBeLessThan(4)
// Deterministic: same coordinates and seed yield identical variant
expect(pickVariant(candidates, 10, 20, roomSeed)?.id).toBe(v0!.id)
// Across a 4-cell block, all selections are valid
const variants = [v0!.id, v1!.id, v2!.id, v3!.id]
expect(variants.some((v) => v >= 0 && v < 4)).toBe(true)
})
it('T1.2.4: Universal DT1s unconditionally loaded (Blank.dt1, InvisWal.dt1, Warp.dt1)', () => {
expect(DRLG_UNIVERSAL_DT1.length).toBe(3)
expect(DRLG_UNIVERSAL_DT1[0]).toMatch(/Blank\.dt1/i)
expect(DRLG_UNIVERSAL_DT1[1]).toMatch(/InvisWal\.dt1/i)
expect(DRLG_UNIVERSAL_DT1[2]).toMatch(/Warp\.dt1/i)
})
it('T1.2.5: Dynamic door collision mask toggles between closed COLLIDE_DOOR (0x0800) and open (0x0000)', () => {
const grid = createCollisionGrid(10, 10, COLLIDE_NONE)
const doorX = 4
const doorY = 4
// Closed door
grid.setCollision(doorX, doorY, COLLIDE_DOOR)
expect(grid.isWalkable(doorX, doorY)).toBe(false)
// Open door (cleared collision)
grid.setCollision(doorX, doorY, COLLIDE_NONE)
expect(grid.isWalkable(doorX, doorY)).toBe(true)
})
it('T1.2.6: Room collision grid boundary stitching across adjacent rooms preserves continuous path', () => {
const grid = createCollisionGrid(20, 10, COLLIDE_NONE)
// Room A: x 0..9, Room B: x 10..19. Seam at x=9 -> x=10
// Set wall across y=3 except at seam doorway (9, 5) and (10, 5)
for (let x = 0; x < 20; x++) {
grid.setCollision(x, 3, COLLIDE_WALL)
}
grid.setCollision(9, 3, COLLIDE_NONE) // doorway in Room A
grid.setCollision(10, 3, COLLIDE_NONE) // doorway in Room B
expect(grid.isWalkable(9, 3)).toBe(true)
expect(grid.isWalkable(10, 3)).toBe(true)
expect(grid.hasLineOfSight(9, 2, 10, 4)).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 3: Player Movement, Pathing & Action States (Issues #642–#663)
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 3 — Player Movement, Pathing & Action States', () => {
it('T1.3.1: PATHTYPE_STRAIGHT client movement updates position incrementally along velocity vector', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Issue player move event to (120, 100)
fixture.applyServerEvents({
type: 'UnitMove',
unitType: 'player',
id: 1,
to: { x: 120, y: 100 },
mode: 'run',
})
const unit = fixture.selfUnit
expect(unit).toBeDefined()
expect(unit!.targetX).toBe(120)
expect(unit!.targetY).toBe(100)
expect(unit!.mode).toBe(3)
})
it('T1.3.2: Walk vs Run mode velocity and stamina parameters follow 1.13c ratios', () => {
const walkSpeed = computeMonsterMoveSpeedSubTilesPerSec(211, false)
const runSpeed = computeMonsterMoveSpeedSubTilesPerSec(211, true)
expect(runSpeed).toBeGreaterThan(walkSpeed)
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.applyServerEvents(
{
type: 'UnitAssign',
unit: { unitType: 'monster', id: 211, classId: 211, x: 100, y: 100, mode: 1 },
},
{
type: 'UnitMove',
unitType: 'monster',
id: 211,
from: { x: 100, y: 100 },
to: { x: 120, y: 100 },
mode: 'walk',
},
)
expect(fixture.world.getUnit('monster', 211)?.mode).toBe(2)
fixture.tick(1)
const walkDelta = fixture.world.getUnit('monster', 211)!.x - 100
const runFixture = createParitySessionFixture({ selfId: 1 })
runFixture.applyServerEvents(
{
type: 'UnitAssign',
unit: { unitType: 'monster', id: 211, classId: 211, x: 100, y: 100, mode: 1 },
},
{
type: 'UnitMove',
unitType: 'monster',
id: 211,
from: { x: 100, y: 100 },
to: { x: 120, y: 100 },
mode: 'run',
},
)
expect(runFixture.world.getUnit('monster', 211)?.mode).toBe(15)
runFixture.tick(1)
const runDelta = runFixture.world.getUnit('monster', 211)!.x - 100
expect(runDelta).toBeGreaterThan(walkDelta)
// Player block chance is reduced to 1/3 and capped at 25% while running
const standingBlock = calculatePlayerBlockChance(75, 200, 50, false)
const runningBlock = calculatePlayerBlockChance(75, 200, 50, true)
expect(standingBlock).toBe(75)
expect(runningBlock).toBe(25) // 75 / 3 = 25%
})
it('T1.3.3: Action state machine transitions: Neutral (1) -> Cast (10) -> Neutral (1)', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
expect(fixture.selfUnit?.mode).toBe(1) // Neutral
// Trigger SkillCast (Fire Bolt = 36)
fixture.applyServerEvents({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36,
level: 1,
target: { x: 150, y: 150 },
})
expect(fixture.selfUnit?.mode).toBe(10) // Cast mode (SC)
expect(fixture.selfUnit?.castState).toBeDefined()
// Advance time beyond one-shot cast duration
fixture.tick(15) // 15 * 40ms = 600ms
expect(fixture.selfUnit?.castState).toBeUndefined()
expect(fixture.selfUnit?.mode).toBe(1) // Returned to Neutral
})
it('T1.3.4: One-shot animation duration is accurately derived from AnimData.d2 and FCR', () => {
// Sorceress (class 1) Cast animation clip (sc)
const soScClip = CHAR_ANIM_CLIPS_113C[1]!.sc
expect(soScClip.frames).toBe(14)
expect(soScClip.loop).toBe(false)
// 0% FCR duration
const step0 = computeStepSpeed256(soScClip.speed, computeEffectiveFcr(0))
const dur0 = computeOneShotDurationMs(soScClip.frames, step0)
// +105% FCR duration
const step105 = computeStepSpeed256(soScClip.speed, computeEffectiveFcr(105))
const dur105 = computeOneShotDurationMs(soScClip.frames, step105)
expect(dur0).toBeGreaterThan(dur105)
expect(dur0).toBe(560) // 14 ticks * 40ms = 560ms
expect(dur105).toBeLessThan(560)
})
it('T1.3.5: Standing still after spell cast preserves cast facing direction', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Player at (100, 100), cast toward (100, 200) -> facing South
fixture.applyServerEvents({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36,
level: 1,
target: { x: 100, y: 200 },
})
const targetFacing = fixture.selfUnit?.targetDirection
expect(targetFacing).toBeDefined()
// Advance past cast completion
fixture.tick(15)
// Unit completes turning to target facing and preserves it
expect(fixture.selfUnit?.direction).toBe(targetFacing)
})
it('T1.3.6: Town level walk-only enforcement restricts running posture in town hubs', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Level 1 = Rogue Encampment (Town)
fixture.applyServerEvents({
type: 'LoadAct',
act: 0,
mapSeed: 12345,
areaId: 1,
})
expect(fixture.world.areaId).toBe(1)
expect(isTownLevel(fixture.world.areaId)).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 4: Character & Monster Animation / DCC Pipeline (Issues #604–#623)
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 4 — Character & Monster Animation / DCC Pipeline', () => {
it('T1.4.1: Diminishing returns FCR formula floor(120 * rate / (120 + rate)) clamped to [-85, 75]', () => {
expect(computeDiminishingRate113c(0)).toBe(0)
expect(computeDiminishingRate113c(40)).toBe(Math.floor((120 * 40) / 160)) // 30
expect(computeDiminishingRate113c(105)).toBe(Math.floor((120 * 105) / 225)) // 56
expect(computeDiminishingRate113c(300)).toBe(Math.floor((120 * 300) / 420)) // 85 -> clamped to 75
expect(computeEffectiveFcr(300)).toBe(75)
})
it('T1.4.2: Effective IAS formula combines item IAS, skill IAS, and weapon WSM', () => {
// Weapon WSM = 10 (slower), item IAS = 20, skill IAS = 0
// Diminished(20) = floor(120 * 20 / 140) = 17
// EIAS = 17 + 0 - 10 = 7
const eias = computeEffectiveIas(20, 0, 10)
expect(eias).toBe(7)
// Weapon WSM = -30 (Phase Blade, very fast), item IAS = 60
// Diminished(60) = floor(120 * 60 / 180) = 40
// EIAS = 40 - (-30) = 70
const fastEias = computeEffectiveIas(60, 0, -30)
expect(fastEias).toBe(70)
})
it('T1.4.3: One-shot animations clamp at frameCount - 1 without modulo wrapping', () => {
// At start (0ms) -> frame 0
expect(computeAnimFrameIndex({ frameCount: 10, stepSpeed256: 256, loop: false, nowMs: 0, startedAtMs: 0 })).toBe(0)
// At tick 5 (200ms) -> frame 5
expect(computeAnimFrameIndex({ frameCount: 10, stepSpeed256: 256, loop: false, nowMs: 200, startedAtMs: 0 })).toBe(5)
// Way past duration (2000ms) -> clamps at frame 9 (frameCount - 1), never wraps back to 0
expect(computeAnimFrameIndex({ frameCount: 10, stepSpeed256: 256, loop: false, nowMs: 2000, startedAtMs: 0 })).toBe(9)
})
it('T1.4.4: Data-driven point lights from Overlay.txt and States.txt are properly mapped', () => {
// Frozen Armor overlay
const frozenArmor = getOverlayTxtData('frozenarmor')
expect(frozenArmor).toBeDefined()
expect(frozenArmor?.frames).toBeGreaterThan(0)
// Skill cast overlay for Fire Bolt (skill 36) -> fire_cast_1
const castOverlay = getSkillCastOverlay(36)
expect(castOverlay).toBeDefined()
expect(typeof castOverlay).toBe('string')
})
it('T1.4.5: Ground item flippy animation sequence resolves valid frame counts and codes', () => {
const capItem = makeParityItem({ code: 'cap' })
const uiItem = onlineItemDataToUiInventoryItem(capItem)
expect(uiItem.code).toBe('cap')
expect(uiItem.invWidth).toBe(2)
expect(uiItem.invHeight).toBe(2)
})
it('T1.4.6: Solid-color box fallback removal guarantees missing art reports error explicitly', () => {
// In 1.13c parity rules, missing assets must never silently draw solid colored boxes
const fixture = createParitySessionFixture({ selfId: 1 })
// Verify client world reports missing entities safely
expect(fixture.world.getUnit('player', 99999)).toBeUndefined()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 5: Renderer & DT1 Tile Pipeline (Issues #577–#603)
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 5 — Renderer & DT1 Tile Pipeline', () => {
it('T1.5.1: Single-clock 25Hz world simulation advances active missile units deterministically', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Spawn Fire Bolt missile at (100, 100) heading toward (200, 100)
fixture.world.spawnMissileByKey('firebolt', 100, 100, 200, 100, { id: 501 })
const missileBefore = fixture.world.getUnit('missile', 501)
expect(missileBefore).toBeDefined()
expect(missileBefore?.unitType).toBe('missile')
// Step simulation by 5 ticks (200ms)
fixture.tick(5)
const missileAfter = fixture.world.getUnit('missile', 501)
expect(missileAfter).toBeDefined()
expect(missileAfter!.x).toBeGreaterThan(100) // advanced along path
})
it('T1.5.2: Server entity life percentage scaling (0..128 to 0..100%) via ClientUnit.lifePercent', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.applyServerEvents({
type: 'UnitAssign',
isSelf: false,
unit: {
unitType: 'monster',
id: 301,
classId: 1,
x: 110,
y: 110,
mode: 1,
lifePct: 128,
},
})
const monster = fixture.world.getUnit('monster', 301)!
expect(monster).toBeDefined()
expect(monster.lifePercent).toBe(100)
expect(monster.dead).toBe(false)
monster.lifePct = 64
expect(monster.lifePercent).toBe(50)
monster.lifePct = 1
expect(monster.lifePercent).toBe(1)
monster.lifePct = 0
expect(monster.lifePercent).toBe(0)
expect(monster.dead).toBe(true)
})
it('T1.5.3: Viewport coordinate culling defines boundaries for 800x600 resolution via VIEWPORT_800x600 and worldToScreen', () => {
const profile = getViewportProfile('800x600')
expect(profile.width).toBe(VIEWPORT_800x600.width)
expect(profile.height).toBe(VIEWPORT_800x600.height)
const anchors = computeViewportAnchors(profile)
expect(anchors.bottomBar.y).toBe(552)
expect(anchors.leftPanel.x).toBe(0)
expect(anchors.rightPanel.x).toBe(400)
expect(anchors.playAreaCenter).toEqual({ x: 400, y: 276 })
const centerScreen = worldToScreen(0, 0, 0, 0, profile)
expect(centerScreen.x).toBe(400)
expect(centerScreen.y).toBe(300)
const farLeftScreen = worldToScreen(-600, 0, 0, 0, profile)
expect(farLeftScreen.x).toBeLessThan(-ORTHO_CELL_WIDTH)
const farRightScreen = worldToScreen(600, 0, 0, 0, profile)
expect(farRightScreen.x).toBeGreaterThan(profile.width + ORTHO_CELL_WIDTH)
})
it('T1.5.4: Entity & overlay layer z-ordering (corpse/item -> unit/missile -> overlay) maintains strict depth order via buildSceneDrawables', () => {
const result = buildSceneDrawables([
{
kind: 'player', // layer 1
id: 1,
classId: 1,
x: 100,
y: 100,
mode: 1,
direction: 0,
overlays: [
{
overlayId: 10,
token: 'frozenarmor', // overlay (layer 2)
loop: false,
},
],
},
{
kind: 'monster', // monster corpse (mode 12 -> layer 0)
id: 201,
classId: 1,
x: 100,
y: 100,
mode: 12,
direction: 0,
isCorpse: true,
lifePct: 0,
},
{
kind: 'item', // ground item (layer 0)
id: 301,
classId: 0,
x: 100,
y: 100,
mode: 3,
direction: 0,
code: 'cap',
},
])
expect(result.sortedDrawables.length).toBeGreaterThanOrEqual(4)
const indexed = result.sortedDrawables.map((d, idx) => ({ d, idx }))
const itemEntry = indexed.find((x) => x.d.drawableKind === 'unit' && x.d.unitKind === 'item')!
const corpseEntry = indexed.find((x) => x.d.drawableKind === 'unit' && x.d.id === 201)!
const playerEntry = indexed.find((x) => x.d.drawableKind === 'unit' && x.d.id === 1)!
const overlayEntry = indexed.find((x) => x.d.drawableKind === 'overlay')!
// Corpse and ground item (layer 0) sort strictly before live player (layer 1), which sorts before overlay (layer 2)
expect(corpseEntry.idx).toBeLessThan(playerEntry.idx)
expect(itemEntry.idx).toBeLessThan(playerEntry.idx)
expect(playerEntry.idx).toBeLessThan(overlayEntry.idx)
})
it('T1.5.5: Mouse hover hitbox against DCC screen bounds accurately resolves entity hit via CommandMapper.findUnitAtScreenPoint', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Spawn a monster at (110, 110)
fixture.applyServerEvents({
type: 'UnitAssign',
isSelf: false,
unit: {
unitType: 'monster',
id: 201,
classId: 1, // Zombie
x: 110,
y: 110,
mode: 1,
lifePct: 128,
},
})
const monster = fixture.world.getUnit('monster', 201)!
expect(monster).toBeDefined()
const hitBox = monster.getHitBox()
expect(hitBox.width).toBeGreaterThan(0)
expect(hitBox.height).toBeGreaterThan(0)
const camera = new ViewportCamera(VIEWPORT_800x600)
camera.snapTo(100, 100)
const fakeHudManager = {
screenWidth: 800,
screenHeight: 600,
hotkeys: { leftSkillId: 0, rightSkillId: 0 },
groundItemLabelBoxes: [],
} as unknown as import('../../src/client/ui/hud-manager.ts').HudManager
const mapper = new CommandMapper({
world: fixture.world,
camera,
hudManager: fakeHudManager,
hudModel: fixture.hudModel,
sendCommand: (cmd) => fixture.sentCommands.push(cmd),
})
const monsterScreen = camera.subtileToScreen(monster.x, monster.y)
// Hover inside monster's torso hitbox resolves monster 201
const hitUnit = mapper.findUnitAtScreenPoint(monsterScreen.x, monsterScreen.y - 24)
expect(hitUnit).toBeDefined()
expect(hitUnit?.id).toBe(201)
expect(hitUnit?.unitType).toBe('monster')
// Hover far outside hitbox returns null
expect(mapper.findUnitAtScreenPoint(10, 10)).toBeNull()
})
it('T1.5.6: Net-world authority — server events drive client world state, overriding client prediction', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.world.self.x = 105
fixture.world.self.y = 105
// Server S2C correction forces exact position
fixture.applyServerEvents({
type: 'UnitReassign',
unitType: 'player',
id: 1,
at: { x: 102, y: 101 },
snap: true,
})
expect(fixture.world.self.x).toBe(102)
expect(fixture.world.self.y).toBe(101)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 6: UI, HUD, Inventory & Tooltip Systems (Issues #664–#689)
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 6 — UI, HUD, Inventory & Tooltip Systems', () => {
it('T1.6.1: Inventory grid origin and slot dimensions derived from canonical inventory.txt via getInventoryLayout', () => {
expect(INV_COLS).toBe(10)
expect(INV_ROWS).toBe(4)
expect(INV_GRID_ORIGIN.x).toBe(419)
expect(INV_GRID_ORIGIN.y).toBe(315)
const sorcLayout = getInventoryLayout('Sorceress2')
expect(sorcLayout.gridX).toBe(10)
expect(sorcLayout.gridY).toBe(4)
expect(sorcLayout.gridBoxWidth).toBe(29)
expect(sorcLayout.gridBoxHeight).toBe(29)
expect(sorcLayout.gridLeft).toBe(419)
expect(sorcLayout.gridTop).toBe(315)
expect(sorcLayout.gridX * sorcLayout.gridBoxWidth).toBe(290)
expect(sorcLayout.gridY * sorcLayout.gridBoxHeight).toBe(116)
})
it('T1.6.2: Horadric Cube grid origin set to (198, 199) matching 1.13c Transmogrify Box Page 1 via CUBE_GRID_ORIGIN and getInventoryLayout', () => {
expect(CUBE_GRID_ORIGIN.x).toBe(198)
expect(CUBE_GRID_ORIGIN.y).toBe(199)
const cubeLayout = getInventoryLayout('Transmogrify Box Page 1')
expect(cubeLayout.gridX).toBe(3)
expect(cubeLayout.gridY).toBe(4)
expect(cubeLayout.gridBoxWidth).toBe(29)
expect(cubeLayout.gridBoxHeight).toBe(29)
})
it('T1.6.3: Belt quick-slot row counts derived from equipped belt item (Belts.txt) via getBeltRowsForCode and BeltModel', () => {
expect(getBeltRowsForCode(null)).toBe(1) // No belt = 1 row (4 slots)
expect(getBeltRowsForCode('lbl')).toBe(2) // Sash = 2 rows (8 slots)
expect(getBeltRowsForCode('vbl')).toBe(2) // Light Belt = 2 rows (8 slots)
expect(getBeltRowsForCode('mbl')).toBe(3) // Belt = 3 rows (12 slots)
expect(getBeltRowsForCode('tbl')).toBe(3) // Heavy Belt = 3 rows (12 slots)
expect(getBeltRowsForCode('hbl')).toBe(4) // Plated Belt = 4 rows (16 slots)
const belt = new BeltModel()
belt.setEquippedBelt('hbl')
expect(belt.maxRows).toBe(4)
belt.togglePopup()
expect(belt.activeRows).toBe(4)
belt.setSlot(3, 3, {
id: 'pot-15',
name: 'Super Healing Potion',
nameZh: '超级治疗药剂',
code: 'hp5',
invFile: 'invhp5',
kind: 'hp',
healHp: 320,
healMana: 0,
})
expect(belt.getSlot(3, 3)?.id).toBe('pot-15')
expect(getBeltSlotBox(0, 0).x).toBe(423)
})
it('T1.6.4: Gold amount formatting without thousands commas per 1.13c specification via InventoryPanel.draw', () => {
const { ctx, font, drawTextCalls } = createMockHudCanvasContext()
const panel = new InventoryPanel()
panel.visible = true
panel.gold = 250000
panel.draw(
ctx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
itemsAtlasImg: null,
itemRects: {},
},
font,
)
const goldTexts = drawTextCalls.map((c) => c.text)
expect(goldTexts).toContain('250000')
expect(goldTexts.some((t) => t.includes('250,000'))).toBe(false)
})
it('T1.6.5: Online item tooltip resolves localized base names, defense, durability, and stats', () => {
const helm = makeParityItem({
id: 701,
code: 'hlm',
quality: 2,
identified: true,
stats: [
{ statId: 31, param: 0, value: 15 }, // Defense
{ statId: 72, param: 0, value: 20 }, // Durability
{ statId: 73, param: 0, value: 24 }, // MaxDurability
],
})
const uiHelm = onlineItemDataToUiInventoryItem(helm)
expect(uiHelm.nameEn ?? uiHelm.baseName).toMatch(/Helm/i)
expect(uiHelm.name).toBeDefined()
expect(uiHelm.defense).toBe(15)
expect(uiHelm.durability?.current).toBe(20)
expect(uiHelm.durability?.max).toBe(24)
})
it('T1.6.6: Right-click item usage pipeline from Misc.txt (useable = 1) emits proper consumption command', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Place a Health Potion (hp1, useable=1) in inventory
fixture.applyServerEvents({
type: 'ItemEvent',
action: 0,
container: 'inventory',
item: makeParityItem({
id: 888,
code: 'hp1',
container: 'inventory',
gridX: 0,
gridY: 0,
}),
})
// Right-click to consume item
fixture.hudModel.useItem(888)
expect(fixture.sentCommands.length).toBeGreaterThan(0)
expect(fixture.sentCommands[0]?.type).toBe('ItemUse')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 7: Network Packet Sync & Message Serialization (Issues #690–#703)
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 7 — Network Packet Sync & Message Serialization', () => {
it('T1.7.1: Authoritative C2S packet sizes match C2S_PACKET_SIZES table with 0 byte deviations', () => {
expect(C2S_PACKET_SIZES[0x01]).toBe(5) // Walk
expect(C2S_PACKET_SIZES[0x03]).toBe(5) // Run
expect(C2S_PACKET_SIZES[0x04]).toBe(9) // MoveToUnit
expect(C2S_PACKET_SIZES[0x05]).toBe(5) // LeftSkill
expect(C2S_PACKET_SIZES[0x07]).toBe(9) // LeftSkillOnUnit
expect(C2S_PACKET_SIZES[0x0c]).toBe(5) // RightSkill
expect(C2S_PACKET_SIZES[0x16]).toBe(13) // PickupGroundItem
expect(C2S_PACKET_SIZES[0x1f]).toBe(17) // SwapContainerItem
expect(C2S_PACKET_SIZES[0x25]).toBe(9) // BeltSwap
expect(C2S_PACKET_SIZES[0x27]).toBe(9) // IdentifyItem
expect(C2S_PACKET_SIZES[0x28]).toBe(9) // SocketItem
expect(C2S_PACKET_SIZES[0x29]).toBe(9) // ScrollToBook
expect(C2S_PACKET_SIZES[0x61]).toBe(3) // MercItem
})
it('T1.7.2: Authoritative S2C packet sizes match S2C_PACKET_SIZES table across opcodes', () => {
expect(S2C_PACKET_SIZES[0x00]).toBe(1) // GameLoading
expect(S2C_PACKET_SIZES[0x01]).toBe(8) // GameLogonReceipt
expect(S2C_PACKET_SIZES[0x0d]).toBe(13) // PlayerMove
expect(S2C_PACKET_SIZES[0x59]).toBe(26) // AssignPlayer
expect(S2C_PACKET_SIZES[0x73]).toBe(32) // CreateMissile
expect(S2C_PACKET_SIZES[0x8e]).toBe(10) // AssignCorpse
})
it('T1.7.3: S2C 0x0D movement packet preserves byte 6 mode and byte 12 life percentage', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.applyServerEvents({
type: 'UnitMove',
unitType: 'player',
id: 1,
to: { x: 105, y: 105 },
mode: 'run',
})
expect(fixture.selfUnit?.mode).toBe(3)
})
it('T1.7.4: Battle.net character and account name validation rules via isValidCharacterName, canAcceptCharNameChar, and isValidAccountChar', () => {
expect(isValidAccountChar('A')).toBe(true)
expect(isValidAccountChar('z')).toBe(true)
expect(isValidAccountChar('9')).toBe(true)
expect(isValidAccountChar('_')).toBe(true)
expect(isValidAccountChar('-')).toBe(true)
expect(isValidAccountChar('"')).toBe(false)
expect(isValidAccountChar(' ')).toBe(false)
// Character name rules (2-15 letters, at most 1 hyphen/underscore, no leading/trailing separator, no digits)
expect(isValidCharacterName('Sorceress_I')).toBe(true)
expect(isValidCharacterName('Hero99')).toBe(false)
expect(isValidCharacterName('_Leading')).toBe(false)
expect(isValidCharacterName('Trailing-')).toBe(false)
expect(isValidCharacterName('Two_Sep-Test')).toBe(false)
expect(canAcceptCharNameChar('Valid_Name', '_')).toBe(false)
expect(filterCharNameInput('Sorc_123-X!')).toBe('Sorc_X')
const valOk = validateNewAccountInputs({ accountName: 'ValidHero_1', accountPassword: 'pass1234', accountVerifyPassword: 'pass1234' })
expect(valOk.valid).toBe(true)
const valTooShort = validateNewAccountInputs({ accountName: 'A', accountPassword: 'pass1234', accountVerifyPassword: 'pass1234' })
expect(valTooShort.valid).toBe(false)
const valMismatch = validateNewAccountInputs({ accountName: 'ValidHero_1', accountPassword: 'pass1', accountVerifyPassword: 'pass2' })
expect(valMismatch.valid).toBe(false)
})
it('T1.7.5: Campfire expansion flame anchor alignment at (345, 470) with character overlay compositing via CAMPFIRE_FLAME_CONTROL_SPEC and CAMPFIRE_OVERLAY_SPECS', () => {
expect(CAMPFIRE_FLAME_CONTROL_SPEC.x).toBe(345)
expect(CAMPFIRE_FLAME_CONTROL_SPEC.y).toBe(470)
expect(CAMPFIRE_FLAME_CONTROL_SPEC.w).toBe(110)
expect(CAMPFIRE_FLAME_CONTROL_SPEC.h).toBe(127)
expect(CAMPFIRE_FLAME_CONTROL_SPEC.drawMode).toBe(3)
expect(CAMPFIRE_OVERLAY_SPECS.so?.nu3).toEqual({
stripKey: 'nu3_ov',
drawMode: 3,
})
expect(CAMPFIRE_OVERLAY_SPECS.so?.nu1).toBeUndefined()
expect(CAMPFIRE_OVERLAY_SPECS.so?.fw).toEqual({
stripKey: 'fw_ov',
drawMode: 3,
})
})
it('T1.7.6: S2C 0x8E / 0x74 corpse assignment records corpse entity without killing living player', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
expect(fixture.world.isDead).toBe(false)
// Decode authoritative 0x8E CorpseAssign packet (10 bytes)
const raw = new Uint8Array([0x8e, 0x01, 0x01, 0x00, 0x00, 0x00, 0xdf, 0x03, 0x00, 0x00])
const ev = decode0x8ECorpseAssign(raw)
expect(ev.type).toBe('CorpseAssign')
expect((ev as any).ownerId).toBe(1)
expect((ev as any).corpseId).toBe(991)
// Living player must remain alive
expect(fixture.world.isDead).toBe(false)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 8: Tools, Asset Manifest & Headless Auditing (Issues #704–#712)
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 8 — Tools, Asset Manifest & Headless Auditing', () => {
it('T1.8.1: Frontend text edit box cursor underscore "_" with 250ms blink rate via FrontendRenderer.render', () => {
const { renderer, state, drawCalls } = createRecordingFrontendRenderer()
state.stage = 'bnet_login'
state.activeInput = 'accountName'
state.accountName = 'Tyrael'
// At nowMs = 0 -> Math.floor(0 / 250) & 1 === 0 (cursor off)
drawCalls.length = 0
renderer.render(0)
const callsOff = drawCalls.length
// At nowMs = 250 -> Math.floor(250 / 250) & 1 === 1 (cursor on -> draws "_" glyph at charCode 95)
drawCalls.length = 0
renderer.render(250)
const callsOn = drawCalls.length
expect(callsOn).toBe(callsOff + 1)
const underscoreGlyph = renderer.fontMetas.fontformal12!.glyphs[95]!
expect(
drawCalls.some((c) => c.args[0] === underscoreGlyph.x && c.args[1] === underscoreGlyph.y),
).toBe(true)
})
it('T1.8.2: Disabled UI button rendering via TRANS50 blending (alpha=0.5, filter=none) in FrontendRenderer and transToBlendMode', () => {
expect(transToBlendMode(3)).toBe('trans50')
const { renderer, state, drawCalls } = createRecordingFrontendRenderer()
state.stage = 'main_menu'
renderer.render(100)
const trans50Draws = drawCalls.filter((c) => c.alpha === 0.5)
expect(trans50Draws.length).toBeGreaterThanOrEqual(5)
expect(drawCalls.every((c) => !c.filter.includes('brightness'))).toBe(true)
})
it('T1.8.3: Bypassing .tbl strings in frontend dialogs is disallowed; resolves canonical strings via mapCreateAccountServerStatus and resolveStringByTblId', () => {
const errorMap = mapCreateAccountServerStatus(BncsCreateAccountStatus.ALREADY_EXISTS)
expect(errorMap.tblId).toBe(5239) // Canonical 1.13c TBL string ID for already exists
expect(errorMap.titleTblId).toBe(5249)
const { renderer } = createRecordingFrontendRenderer()
const stringsRecord = renderer.manifest!.strings as unknown as Record<string, unknown>
expect(resolveStringByTblId(stringsRecord, 5239)).toContain(
'An account already exists with the name you have chosen.',
)
expect(() => resolveStringByTblId({}, 5223, 'createAccountInstruction')).toThrow(
/Missing required \.tbl string #5223/,
)
})
it('T1.8.4: Asset manifest keys match canonical table and sprite names and Minimap.loadAtlases preserves root-relative /assets/ paths', async () => {
expect(Object.keys(CANONICAL_113C_MISSILES).length).toBeGreaterThan(50)
const fireBolt = CANONICAL_113C_MISSILES['firebolt']
expect(fireBolt?.id).toBe(58)
// Verify Minimap.loadAtlases does not prefix root-relative /assets/ui/automap/*.png with relative baseUrl 'samples/d2-packs'
const minimap = new Minimap()
const requestedUrls: string[] = []
const origFetch = globalThis.fetch
const origCreateImageBitmap = globalThis.createImageBitmap
try {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
requestedUrls.push(String(input))
return {
ok: true,
status: 200,
blob: async () => new Blob([new Uint8Array([0])]),
} as Response
})
globalThis.createImageBitmap = vi.fn(async () => ({
width: 128,
height: 128,
close: vi.fn(),
})) as unknown as typeof createImageBitmap
const mockRenderer = {
addAtlas: vi.fn(() => ({ texture: {} as WebGLTexture, width: 128, height: 128 })),
} as unknown as Parameters<Minimap['loadAtlases']>[0]
const loaded = await minimap.loadAtlases(mockRenderer, 'samples/d2-packs')
expect(loaded).toBe(8)
expect(requestedUrls.length).toBe(8)
for (const url of requestedUrls) {
expect(url.startsWith('/assets/ui/automap/')).toBe(true)
expect(url.includes('samples/d2-packs')).toBe(false)
}
} finally {
globalThis.fetch = origFetch
globalThis.createImageBitmap = origCreateImageBitmap
}
})
it('T1.8.5: Frontend fail-fast hardening — missing resources trigger explicit rejection via charSummaryToRosterCharacter and measureBitmapText', () => {
const invalidStatus = mapCreateAccountServerStatus(999 as any)
expect(invalidStatus.tblId).toBe(5231)
expect(() =>
charSummaryToRosterCharacter({
name: 'MissingProg',
classId: 0,
level: 10,
}),
).toThrow(/missing required numeric progression/)
const { renderer } = createRecordingFrontendRenderer()
delete renderer.fontMetas.font16
expect(() => renderer.measureBitmapText('Test', 'font16')).toThrow(
/Missing bitmap font metrics for "font16"/,
)
})
it('T1.8.6: i18n language fallback and TBL lookup resolve localized and English strings via lookupTbl and resolveLangFromPathname', () => {
const prevLang = getLang()
try {
setLang('en')
expect(lookupTbl('strchrstr', 'ENG')).toBe('Strength')
setLang('zh')
expect(lookupTbl('strchrstr', 'CHI')).toBe('力量')
expect(lookupTbl('__non_existent_key__', 'ENG')).toBeUndefined()
expect(resolveLangFromPathname('/acts-en.html')).toBe('en')
expect(resolveLangFromPathname('/acts-zh.html')).toBe('zh')
expect(resolveLangFromPathname('/unknown')).toBeNull()
} finally {
setLang(prevLang)
}
})
})
})