diablo2-web/tests/e2e-parity/tier3-cross-feature.test.ts

387 lines
16 KiB
TypeScript

/**
* Tier 3 — Cross-Feature Integration Suite (`tests/e2e-parity/tier3-cross-feature.test.ts`).
*
* Verifies complex pairwise and multi-subsystem feature interactions across
* movement, combat, networking, inventory, UI, timing, and rendering.
*/
import { describe, expect, it } from 'vitest'
import {
BncsCreateAccountStatus,
C2S_PACKET_SIZES,
CAMPFIRE_CLASS_SPECS,
CAMPFIRE_FLAME_CONTROL_SPEC,
CANONICAL_113C_MISSILES,
CHAR_ANIM_CLIPS_113C,
COLLIDE_BLANK,
COLLIDE_DOOR,
COLLIDE_MISSILE_BARRIER,
COLLIDE_MONSTER,
COLLIDE_NONE,
COLLIDE_WALL,
D2_TICK_MS,
ORTHO_CELL_HEIGHT,
ORTHO_CELL_WIDTH,
ORTHO_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
S2C_PACKET_SIZES,
STAT_FASTERCASTRATE,
VIEWPORT_800x600,
WEAPON_SWAP_TABS_BOUNDS,
calculateHitChance,
calculatePlayerBlockChance,
computeAnimFrameIndex,
computeEffectiveFcr,
computeEffectiveIas,
computeOneShotDurationMs,
computeStepSpeed256,
createCollisionGrid,
createInitialFlowState,
createParitySessionFixture,
enterCharCreate,
filterAccountName,
getMissileTxtData,
getOverlayTxtData,
getSkillCastOverlay,
getViewportProfile,
isTownLevel,
isValidAccountChar,
makeParityItem,
onlineItemDataToUiInventoryItem,
orthoScreenToSubTile,
parseMcpAvatarStatstring,
pickVariant,
resolveWeaponSwapTabClick,
selectCampfireClass,
subTileToOrthoScreen,
validateNewAccountInputs,
worldToScreen,
} from './helpers.ts'
describe('Tier 3 — Pairwise Cross-Feature Integration Tests', () => {
// ─────────────────────────────────────────────────────────────────────────
// Interaction 1: Movement + Weapon Swap + Skill Cast
// ─────────────────────────────────────────────────────────────────────────
describe('X1: Movement + Weapon Swap + Skill Cast', () => {
it('swapping weapon sets via resolveWeaponSwapTabClick and hudModel.swapWeapons updates FCR and interrupts run on cast', () => {
const fixture = createParitySessionFixture({ selfId: 1, charClass: 1 }) // Sorceress
// 1. Player is running toward (150, 100)
fixture.applyServerEvents({
type: 'UnitMove',
unitType: 'player',
id: 1,
to: { x: 150, y: 100 },
mode: 'run',
})
expect(fixture.selfUnit?.mode).toBe(3) // Run mode
// 2. Click Weapon Swap Tab II on Inventory Panel and dispatch WeaponSwitch (C2S 0x60)
const rightSlotTabClick = resolveWeaponSwapTabClick(
WEAPON_SWAP_TABS_BOUNDS.right.x + 60,
WEAPON_SWAP_TABS_BOUNDS.right.y + 10,
)
expect(rightSlotTabClick).toBe(1)
fixture.clearCommands()
fixture.hudModel.swapWeapons()
expect(fixture.sentCommands).toContainEqual({ type: 'SwapWeapons' })
fixture.world.self.weaponSet = 1
expect(fixture.hudModel.getSnapshot().weaponSet).toBe(1)
// Server updates player STAT_FASTERCASTRATE (105) after weapon switch
fixture.applyServerEvents({
type: 'StatSet',
stat: STAT_FASTERCASTRATE,
value: 105,
})
expect(fixture.world.self.stats.get(STAT_FASTERCASTRATE)).toBe(105)
// Set 0 has 0% FCR, Set 1 has +105% FCR
const durSet0 = computeOneShotDurationMs(14, computeStepSpeed256(256, computeEffectiveFcr(0)))
const durSet1 = computeOneShotDurationMs(14, computeStepSpeed256(256, computeEffectiveFcr(105)))
expect(durSet1).toBeLessThan(durSet0)
// 3. Cast Fire Bolt with new weapon set
fixture.applyServerEvents({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36,
level: 1,
target: { x: 160, y: 100 },
})
// Run mode interrupted by Cast mode (mode 10)
expect(fixture.selfUnit?.mode).toBe(10)
expect(fixture.selfUnit?.moveMode).toBe('stop')
// Complete cast duration
fixture.tick(15)
expect(fixture.selfUnit?.mode).toBe(1) // Neutral
})
})
// ─────────────────────────────────────────────────────────────────────────
// Interaction 2: Collision Grid + Teleport + Town Portal
// ─────────────────────────────────────────────────────────────────────────
describe('X2: Collision Grid + Teleport + Town Portal', () => {
it('teleport bypasses wall barriers and stepping through portal switches area to town', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
const grid = createCollisionGrid(20, 20, COLLIDE_NONE)
// Place solid wall barrier at x = 105
for (let y = 90; y <= 110; y++) {
grid.setCollision(105, y, COLLIDE_WALL | COLLIDE_BLANK)
}
// Ground path from 100 to 110 is blocked by wall
expect(grid.isWalkable(105, 100)).toBe(false)
// 1. Cast Teleport (skill 54) to bypass wall directly to (110, 100)
fixture.applyServerEvents({
type: 'UnitReassign',
unitType: 'player',
id: 1,
at: { x: 110, y: 100 },
snap: true,
})
expect(fixture.world.self.x).toBe(110)
expect(fixture.world.self.y).toBe(100)
// 2. Open Town Portal in wilderness (area 2 = Blood Moor)
fixture.applyServerEvents({
type: 'LoadAct',
act: 0,
mapSeed: 98765,
areaId: 2,
})
expect(isTownLevel(fixture.world.areaId)).toBe(false)
// 3. Step through Town Portal into Rogue Encampment (area 1)
fixture.applyServerEvents({
type: 'LoadAct',
act: 0,
mapSeed: 98765,
areaId: 1,
})
expect(isTownLevel(fixture.world.areaId)).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────────────────
// Interaction 3: Missile Trajectory + Wall Collision + Impact Overlay
// ─────────────────────────────────────────────────────────────────────────
describe('X3: Missile Trajectory + Wall Collision + Impact Overlay', () => {
it('projectile traverses open cells, impacts monster, and triggers visual overlay', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Spawn target monster at (110, 100)
fixture.applyServerEvents({
type: 'UnitAssign',
isSelf: false,
unit: {
unitType: 'monster',
id: 250,
classId: 1, // Fallen
x: 110,
y: 100,
mode: 1,
lifePct: 128,
},
})
// Spawn Fire Bolt missile heading toward monster
fixture.world.spawnMissileByKey('firebolt', 100, 100, 110, 100, {
id: 601,
sourceSkillId: 36,
})
const missile = fixture.world.getUnit('missile', 601)
expect(missile).toBeDefined()
// Advance simulation ticks
fixture.tick(3)
// Missile coordinates advance toward target
expect(missile!.x).toBeGreaterThan(100)
// Target overlay metadata is authoritative from Overlay.txt
const fireCastOverlay = getSkillCastOverlay(36)
expect(fireCastOverlay).toBeDefined()
const overlayMeta = getOverlayTxtData(fireCastOverlay!)
expect(overlayMeta.frames).toBeGreaterThan(0)
})
})
// ─────────────────────────────────────────────────────────────────────────
// Interaction 4: Network Server Correction + Unit Motion Synchronization
// ─────────────────────────────────────────────────────────────────────────
describe('X4: S2C Server Correction + Unit Motion Synchronization', () => {
it('client predicted position is authoritatively corrected by S2C UnitReassign packet', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Local client predictive movement sets position to (115, 115)
fixture.world.self.x = 115
fixture.world.self.y = 115
// Authoritative D2GS 0x15 ReassignPlayer / UnitReassign packet arrives: true pos is (112, 110)
fixture.applyServerEvents({
type: 'UnitReassign',
unitType: 'player',
id: 1,
at: { x: 112, y: 110 },
snap: true,
})
// Client world immediately conforms to server position authority
expect(fixture.world.self.x).toBe(112)
expect(fixture.world.self.y).toBe(110)
expect(fixture.selfUnit?.x).toBe(112)
expect(fixture.selfUnit?.y).toBe(110)
})
})
// ─────────────────────────────────────────────────────────────────────────
// Interaction 5: Item Drop + Inventory Placement + Tooltip Stat Generation
// ─────────────────────────────────────────────────────────────────────────
describe('X5: Item Drop + Inventory Placement + Tooltip Stat Generation', () => {
it('ground item enters inventory container at valid slot and produces formatted UI item', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Create raw item data from ground pickup
const cap = makeParityItem({
id: 550,
code: 'cap',
container: 'inventory',
gridX: 2,
gridY: 1,
invWidth: 2,
invHeight: 2,
})
// Apply item event
fixture.applyServerEvents({
type: 'ItemEvent',
action: 0,
container: 'inventory',
item: cap,
})
expect(fixture.world.containers.inventory.size).toBe(1)
const storedItem = fixture.world.containers.inventory.get(550)
expect(storedItem).toBeDefined()
expect(storedItem?.gridX).toBe(2)
expect(storedItem?.gridY).toBe(1)
// Bridge to UI inventory item
const uiItem = onlineItemDataToUiInventoryItem(storedItem!)
expect(uiItem.id).toBe('550')
expect(uiItem.code).toBe('cap')
expect(uiItem.invWidth).toBe(2)
expect(uiItem.invHeight).toBe(2)
})
})
// ─────────────────────────────────────────────────────────────────────────
// Interaction 6: Character Selection + Account Validation + Flame Anchor
// ─────────────────────────────────────────────────────────────────────────
describe('X6: Character Selection + Account Validation + Flame Anchor', () => {
it('validates account credentials, selects campfire class state, and verifies CAMPFIRE_FLAME_CONTROL_SPEC', () => {
// 1. Account name filtering
const rawInput = ' Warrior_123 '
const cleanName = filterAccountName(rawInput)
expect(cleanName).toBe('Warrior_123')
// 2. Account credential validation
const validation = validateNewAccountInputs({
accountName: cleanName,
accountPassword: 'SecretPass123',
accountVerifyPassword: 'SecretPass123',
})
expect(validation.valid).toBe(true)
// 3. Campfire flame anchor point (345, 470) and class step transitions via flow-state
expect(CAMPFIRE_FLAME_CONTROL_SPEC.x).toBe(345)
expect(CAMPFIRE_FLAME_CONTROL_SPEC.y).toBe(470)
expect(CAMPFIRE_FLAME_CONTROL_SPEC.drawMode).toBe(3)
const state = createInitialFlowState()
enterCharCreate(state)
expect(state.stage).toBe('char_create')
selectCampfireClass(state, 'so')
expect(state.selectedClass).toBe('so')
expect(state.campfireClasses.so.phase).toBe('fw')
expect(CAMPFIRE_CLASS_SPECS.so.frameCount.fw).toBe(52)
// 4. Parse MCP 0x19 statstring for character roster avatar
const statBytes = new Uint8Array(33).fill(0xff)
statBytes[0] = 0x84
statBytes[1] = 0x80
statBytes[13] = 0x02
statBytes[25] = 10
const parsed = parseMcpAvatarStatstring(statBytes, 'so')
expect(parsed.hasEquippedComponents).toBe(false)
expect(parsed.weaponClass).toBe('hth')
})
})
// ─────────────────────────────────────────────────────────────────────────
// Interaction 7: Line-of-Sight Raycast + Viewport Coordinate Culling
// ─────────────────────────────────────────────────────────────────────────
describe('X7: Line-of-Sight Raycast + Viewport Coordinate Culling', () => {
it('determines visibility via Bresenham raycast and projects screen coordinates via worldToScreen and VIEWPORT_800x600', () => {
const grid = createCollisionGrid(30, 30, COLLIDE_NONE)
// Unit A at (5, 5), Unit B at (15, 5). Clear line of sight
expect(grid.hasLineOfSight(5, 5, 15, 5)).toBe(true)
// Build wall at x = 10
for (let y = 0; y < 30; y++) {
grid.setCollision(10, y, COLLIDE_WALL | COLLIDE_MISSILE_BARRIER)
}
// Line of sight blocked across both mask and underlying ClientWorld sub-tile collision
expect(grid.hasLineOfSight(5, 5, 15, 5)).toBe(false)
expect(grid.world.isSubTileBlocked(10, 5)).toBe(true)
// Viewport projection at 800x600 resolution via production worldToScreen
const profile = getViewportProfile('800x600')
const centerProj = worldToScreen(0, 0, 0, 0, profile)
expect(centerProj.x).toBe(400)
expect(centerProj.y).toBe(300)
expect(
centerProj.x >= 0 &&
centerProj.x <= VIEWPORT_800x600.width &&
centerProj.y >= 0 &&
centerProj.y <= VIEWPORT_800x600.height,
).toBe(true)
const offscreenRight = worldToScreen(550, 0, 0, 0, profile)
expect(offscreenRight.x).toBeGreaterThan(VIEWPORT_800x600.width)
const offscreenLeft = worldToScreen(-550, 0, 0, 0, profile)
expect(offscreenLeft.x).toBeLessThan(0)
})
})
// ─────────────────────────────────────────────────────────────────────────
// Interaction 8: Stamina / Posture Toggle + Block Chance Degradation
// ─────────────────────────────────────────────────────────────────────────
describe('X8: Stamina / Posture Toggle + Block Chance Degradation', () => {
it('running posture reduces block chance to one-third capped at 25%', () => {
// Base shield block chance: 75%
const standingBlock = calculatePlayerBlockChance(75, 200, 50, false)
expect(standingBlock).toBe(75)
// While running (runMode = true), block chance degrades to floor(75 / 3) = 25%
const runningBlock = calculatePlayerBlockChance(75, 200, 50, true)
expect(runningBlock).toBe(25)
// Shield with lower block chance (45%) while running degrades to floor(45 / 3) = 15%
const lowStandingBlock = calculatePlayerBlockChance(45, 115, 50, false)
expect(lowStandingBlock).toBe(45)
const lowRunningBlock = calculatePlayerBlockChance(45, 115, 50, true)
expect(lowRunningBlock).toBe(15)
})
})
})