392 lines
12 KiB
TypeScript
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
|
|
},
|
|
}
|
|
}
|