1048 lines
43 KiB
TypeScript
1048 lines
43 KiB
TypeScript
/**
|
||
* 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)
|
||
}
|
||
})
|
||
})
|
||
})
|