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

330 lines
14 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,
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,
calculateHitChance,
calculatePlayerBlockChance,
computeAnimFrameIndex,
computeEffectiveFcr,
computeEffectiveIas,
computeOneShotDurationMs,
computeStepSpeed256,
createCollisionGrid,
createParitySessionFixture,
filterAccountName,
getMissileTxtData,
getOverlayTxtData,
getSkillCastOverlay,
isTownLevel,
isValidAccountChar,
makeParityItem,
onlineItemDataToUiInventoryItem,
orthoScreenToSubTile,
pickVariant,
subTileToOrthoScreen,
validateNewAccountInputs,
} 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 while running updates effective cast rate 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. Weapon swap event (switching from weapon set 0 to set 1)
fixture.applyServerEvents({
type: 'WeaponSetChange',
weaponSet: 1,
} as any)
// 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, filters valid characters, and aligns campfire flame anchor', () => {
// 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 anchor point (345, 470)
const flameAnchorX = 345
const flameAnchorY = 470
expect(flameAnchorX).toBe(345)
expect(flameAnchorY).toBe(470)
})
})
// ─────────────────────────────────────────────────────────────────────────
// 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 culls out-of-screen entities', () => {
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
expect(grid.hasLineOfSight(5, 5, 15, 5)).toBe(false)
// Viewport culling at 800x600 resolution
const isWithinScreen = (x: number, y: number) => x >= 0 && x <= 800 && y >= 0 && y <= 600
expect(isWithinScreen(400, 300)).toBe(true) // Center screen
expect(isWithinScreen(900, 300)).toBe(false) // Outside screen
expect(isWithinScreen(-50, 200)).toBe(false) // Outside screen
})
})
// ─────────────────────────────────────────────────────────────────────────
// 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)
})
})
})