diablo2-web/tests/e2e-parity/helpers.ts

392 lines
12 KiB
TypeScript

/**
* Shared Test Helpers for the Diablo II v1.13c Parity E2E Test Suite (`tests/e2e-parity/`).
*
* Provides authoritative fixtures, mock environments, math oracles, and packet
* validators adhering strictly to Diablo II v1.13c decompiled specifications.
*/
import { vi } from 'vitest'
import {
ORTHO_CELL_HEIGHT,
ORTHO_CELL_WIDTH,
ORTHO_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
COLLIDE_BLANK,
COLLIDE_CORPSE,
COLLIDE_DOOR,
COLLIDE_ITEM,
COLLIDE_MASK_INVALID,
COLLIDE_MISSILE,
COLLIDE_MISSILE_BARRIER,
COLLIDE_MONSTER,
COLLIDE_NONE,
COLLIDE_NOPLAYER,
COLLIDE_NO_PATH,
COLLIDE_OBJECT,
COLLIDE_PET,
COLLIDE_PLAYER,
COLLIDE_PRESET,
COLLIDE_VISIBLE,
COLLIDE_WALL,
COLLIDE_WATER,
pickVariant,
} from '../../src/common/world/d2map.ts'
import { DRLG_UNIVERSAL_DT1 } from '../../src/common/drlg/drlg-map.ts'
import {
CHAR_ANIM_CLIPS_113C,
computeAnimFrameIndex,
computeDiminishingRate113c,
computeEffectiveFcr,
computeEffectiveFhr,
computeEffectiveIas,
computeOneShotDurationMs,
computeOneShotTicks,
computeStepSpeed256,
D2_TICK_MS,
STAT_FASTERATTACKRATE,
STAT_FASTERBLOCKRATE,
STAT_FASTERCASTRATE,
STAT_FASTERHITRECOVERY,
STAT_SKILL_IAS,
type CharAnimClip113c,
type CharAnimClipKey,
} from '../../src/common/units/char-anim-speed.ts'
import {
calculateHitChance,
calculatePlayerBlockChance,
} from '../../src/common/skills/formulas.ts'
import {
CANONICAL_113C_MISSILES,
getMissileTxtData,
getOverlayTxtData,
getSkillCastOverlay,
} from '../../src/common/skills/missiles-data.ts'
import {
C2S_PACKET_SIZES,
S2C_PACKET_SIZES,
BncsCreateAccountError,
BncsCreateAccountStatus,
BncsOpcode,
encodeBncsCreateAccount2,
type ClientCommand,
type ItemData,
type ServerEvent,
} from '../../src/netproto/index.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
import { onlineItemDataToUiInventoryItem } from '../../src/common/items/item-bridge.ts'
import {
createInitialFlowState,
filterAccountName,
isValidAccountChar,
mapCreateAccountServerStatus,
validateNewAccountInputs,
} from '../../src/client/frontend/flow-state.ts'
import { isTownLevel } from '../../src/common/world/scene-population.ts'
import { decode0x8ECorpseAssign } from '../../src/netproto/d2gs/s2c/party.ts'
export {
isTownLevel,
decode0x8ECorpseAssign,
// Map & Collision Constants
ORTHO_CELL_HEIGHT,
ORTHO_CELL_WIDTH,
ORTHO_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
COLLIDE_BLANK,
COLLIDE_CORPSE,
COLLIDE_DOOR,
COLLIDE_ITEM,
COLLIDE_MASK_INVALID,
COLLIDE_MISSILE,
COLLIDE_MISSILE_BARRIER,
COLLIDE_MONSTER,
COLLIDE_NONE,
COLLIDE_NOPLAYER,
COLLIDE_NO_PATH,
COLLIDE_OBJECT,
COLLIDE_PET,
COLLIDE_PLAYER,
COLLIDE_PRESET,
COLLIDE_VISIBLE,
COLLIDE_WALL,
COLLIDE_WATER,
pickVariant,
DRLG_UNIVERSAL_DT1,
// Animation & Speed Constants / Math
CHAR_ANIM_CLIPS_113C,
computeAnimFrameIndex,
computeDiminishingRate113c,
computeEffectiveFcr,
computeEffectiveFhr,
computeEffectiveIas,
computeOneShotDurationMs,
computeOneShotTicks,
computeStepSpeed256,
D2_TICK_MS,
STAT_FASTERATTACKRATE,
STAT_FASTERBLOCKRATE,
STAT_FASTERCASTRATE,
STAT_FASTERHITRECOVERY,
STAT_SKILL_IAS,
// Formulas
calculateHitChance,
calculatePlayerBlockChance,
// Missiles & Overlays
CANONICAL_113C_MISSILES,
getMissileTxtData,
getOverlayTxtData,
getSkillCastOverlay,
// Protocol & Tables
C2S_PACKET_SIZES,
S2C_PACKET_SIZES,
BncsCreateAccountError,
BncsCreateAccountStatus,
BncsOpcode,
encodeBncsCreateAccount2,
// Client Classes
ClientWorld,
HudModel,
onlineItemDataToUiInventoryItem,
// Frontend flow
createInitialFlowState,
filterAccountName,
isValidAccountChar,
mapCreateAccountServerStatus,
validateNewAccountInputs,
}
// ─────────────────────────────────────────────────────────────────────────────
// Coordinate Conversions (Sub-Tile <-> Ortho Isometric Screen)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Projects continuous sub-tile coordinates into 2:1 isometric screen coordinates.
* Each sub-tile diamond is 16x8 pixels.
*/
export function subTileToOrthoScreen(subX: number, subY: number): { x: number; y: number } {
return {
x: (subX - subY) * ORTHO_SUB_TILE_WIDTH,
y: (subX + subY) * ORTHO_SUB_TILE_HEIGHT,
}
}
/**
* Inverse projects 2:1 isometric screen coordinates back to continuous sub-tile coordinates.
*/
export function orthoScreenToSubTile(screenX: number, screenY: number): { x: number; y: number } {
const normX = screenX / ORTHO_SUB_TILE_WIDTH
const normY = screenY / ORTHO_SUB_TILE_HEIGHT
return {
x: (normY + normX) / 2,
y: (normY - normX) / 2,
}
}
/**
* Converts tile cell coordinates (each 5x5 sub-tiles) to top-left sub-tile coordinates.
*/
export function tileToSubTile(tileX: number, tileY: number, subOffset = { x: 0, y: 0 }): { x: number; y: number } {
return {
x: tileX * 5 + subOffset.x,
y: tileY * 5 + subOffset.y,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock Collision Grid Oracle
// ─────────────────────────────────────────────────────────────────────────────
export interface CollisionGridOracle {
readonly width: number
readonly height: number
getCollision(subX: number, subY: number): number
setCollision(subX: number, subY: number, mask: number): void
isWalkable(subX: number, subY: number): boolean
hasLineOfSight(x0: number, y0: number, x1: number, y1: number): boolean
}
/**
* Creates an in-memory collision grid conforming to 1.13c multi-layer collision flags.
*/
export function createCollisionGrid(width: number, height: number, defaultMask = COLLIDE_NONE): CollisionGridOracle {
const data = new Uint16Array(width * height).fill(defaultMask)
const oracle: CollisionGridOracle = {
width,
height,
getCollision(subX: number, subY: number): number {
if (subX < 0 || subX >= width || subY < 0 || subY >= height) {
return COLLIDE_BLANK | COLLIDE_WALL // Void out-of-bounds protection
}
return data[subY * width + subX]!
},
setCollision(subX: number, subY: number, mask: number): void {
if (subX >= 0 && subX < width && subY >= 0 && subY < height) {
data[subY * width + subX] = mask
}
},
isWalkable(subX: number, subY: number): boolean {
const flags = oracle.getCollision(subX, subY)
// Any wall, blank, closed door, or obstacle blocks ground walking
const blocking = COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_OBJECT | COLLIDE_DOOR
return (flags & blocking) === 0
},
hasLineOfSight(x0: number, y0: number, x1: number, y1: number): boolean {
// Bresenham raycast checking COLLIDE_VISIBLE and COLLIDE_MISSILE_BARRIER
let dx = Math.abs(x1 - x0)
let dy = Math.abs(y1 - y0)
let sx = x0 < x1 ? 1 : -1
let sy = y0 < y1 ? 1 : -1
let err = dx - dy
let currX = x0
let currY = y0
while (true) {
const mask = oracle.getCollision(currX, currY)
if ((mask & (COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER | COLLIDE_WALL)) !== 0) {
// Blocked by barrier
if (currX !== x0 || currY !== y0) {
return false
}
}
if (currX === x1 && currY === y1) break
const e2 = 2 * err
if (e2 > -dy) {
err -= dy
currX += sx
}
if (e2 < dx) {
err += dx
currY += sy
}
}
return true
},
}
return oracle
}
// ─────────────────────────────────────────────────────────────────────────────
// Item Data Builder
// ─────────────────────────────────────────────────────────────────────────────
export function makeParityItem(partial: Partial<ItemData>): ItemData {
const id = partial.id ?? partial.itemId ?? 1000
const code = partial.code ?? 'cap'
const container = partial.container ?? 'inventory'
return {
id,
itemId: id,
opcode: 0x9c,
action: 0,
category: 0,
flags: 0,
rawFlags: 0,
version: 101,
location: 0,
container,
bodyLoc: 0,
equippedSlot: 0,
gridX: 0,
gridY: 0,
storePage: 0,
storagePage: 0,
worldX: 0,
worldY: 0,
x: 0,
y: 0,
code,
rawCode: code.padEnd(4, ' '),
compact: false,
simple: false,
identified: true,
socketed: false,
ethereal: false,
runeword: false,
personalized: false,
isEar: false,
newItem: false,
starterItem: false,
broken: false,
inSocket: false,
equipped: false,
socketedChildCount: 0,
invWidth: 2,
invHeight: 2,
totalSockets: 0,
usedSockets: 0,
quantity: 1,
stats: [],
...partial,
...(partial.id !== undefined || partial.itemId !== undefined ? { id, itemId: id } : {}),
} as ItemData
}
// ─────────────────────────────────────────────────────────────────────────────
// Parity Client Session Fixture
// ─────────────────────────────────────────────────────────────────────────────
export interface ParitySessionFixture {
readonly world: ClientWorld
readonly hudModel: HudModel
readonly sentCommands: ClientCommand[]
readonly selfUnit: import('../../src/client/world/client-unit.ts').ClientUnit | undefined
tick(ticks?: number): void
applyServerEvents(...events: ServerEvent[]): void
clearCommands(): void
}
export function createParitySessionFixture(options: { selfId?: number; charClass?: number } = {}): ParitySessionFixture {
const selfId = options.selfId ?? 1
const world = new ClientWorld()
const sentCommands: ClientCommand[] = []
// Initialize self player in ClientWorld via PlayerAssign
world.apply({
type: 'PlayerAssign',
unitId: selfId,
classId: options.charClass ?? 1, // 1 = Sorceress
x: 100,
y: 100,
mode: 1, // Neutral
})
const hudModel = new HudModel({
world,
onCommand: (cmd) => sentCommands.push(cmd),
})
hudModel.syncFromWorld()
return {
world,
hudModel,
sentCommands,
get selfUnit() {
return world.getUnit('player', world.selfId)
},
tick(ticks = 1) {
for (let i = 0; i < ticks; i++) {
world.tick(D2_TICK_MS)
}
hudModel.syncFromWorld()
},
applyServerEvents(...events: ServerEvent[]) {
for (const ev of events) {
world.apply(ev)
}
hudModel.syncFromWorld()
},
clearCommands() {
sentCommands.length = 0
},
}
}