diablo2-web/tests/e2e-parity/tier2-boundary-corner.test.ts

578 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tier 2 — Boundary & Corner Cases (Batches 1–8, 48 Tests).
*
* Exercises adversarial edge conditions, boundary clamping, resource limits,
* zero/negative inputs, and overflow conditions across all 8 parity subsystems.
*/
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_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
S2C_PACKET_SIZES,
calculateHitChance,
calculatePlayerBlockChance,
computeAnimFrameIndex,
computeDiminishingRate113c,
computeEffectiveFcr,
computeEffectiveIas,
computeOneShotDurationMs,
computeStepSpeed256,
createCollisionGrid,
createParitySessionFixture,
decode0x8ECorpseAssign,
getMissileTxtData,
getOverlayTxtData,
isTownLevel,
isValidAccountChar,
makeParityItem,
onlineItemDataToUiInventoryItem,
orthoScreenToSubTile,
pickVariant,
subTileToOrthoScreen,
validateNewAccountInputs,
filterAccountName,
} from './helpers.ts'
describe('Tier 2 — Boundary & Corner Cases across 8 Subsystem Batches (48 Tests)', () => {
// ═══════════════════════════════════════════════════════════════════════════
// Batch 1: Engine Timing & Math Boundaries
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 1 — Timing, Math & Coordinate Edge Cases', () => {
it('T2.1.1: dtMs of 0 does not advance frame count or mutate state', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
const initialFrames = fixture.world.frameCount
fixture.world.tick(0)
expect(fixture.world.frameCount).toBe(initialFrames)
})
it('T2.1.2: Excessive delta times are capped at 200ms accumulator ceiling', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Feeding 1000ms delta time in one call is capped at 200ms -> exactly 5 frames (200 / 40)
fixture.world.tick(1000)
expect(fixture.world.frameCount).toBe(5)
})
it('T2.1.3: Non-finite dtMs values throw RangeError per contract', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
expect(() => fixture.world.tick(NaN)).toThrow(RangeError)
expect(() => fixture.world.tick(Infinity)).toThrow(RangeError)
})
it('T2.1.4: Extreme level differential in hit chance clamps to [5, 95]', () => {
// Level 1 attacker vs Level 99 defender with huge defense
const minChance = calculateHitChance(10, 100000, 1, 99)
expect(minChance).toBe(5)
// Level 99 attacker vs Level 1 defender with tiny defense
const maxChance = calculateHitChance(100000, 10, 99, 1)
expect(maxChance).toBe(95)
})
it('T2.1.5: 8.8 fixed-point step velocity minimum clamp preserves at least 32 speed units', () => {
// Highly negative rate (-200%) should never invert or zero velocity
const clampedSpeed = computeStepSpeed256(256, -200)
expect(clampedSpeed).toBe(32)
})
it('T2.1.6: Sub-tile coordinate projection at origin (0, 0) maps to (0, 0) screen coordinates', () => {
const originScreen = subTileToOrthoScreen(0, 0)
expect(originScreen.x).toBe(0)
expect(originScreen.y).toBe(0)
const recovered = orthoScreenToSubTile(0, 0)
expect(recovered.x).toBe(0)
expect(recovered.y).toBe(0)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 2: Collision & Walkability Boundary Conditions
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 2 — Collision & Walkability Boundaries', () => {
it('T2.2.1: Grid boundary corners (0,0) and (width-1, height-1) are queryable', () => {
const grid = createCollisionGrid(10, 10, COLLIDE_NONE)
expect(grid.isWalkable(0, 0)).toBe(true)
expect(grid.isWalkable(9, 9)).toBe(true)
// One past boundary is blocked
expect(grid.isWalkable(10, 9)).toBe(false)
expect(grid.isWalkable(9, 10)).toBe(false)
})
it('T2.2.2: Extreme negative coordinates return full collision block flags', () => {
const grid = createCollisionGrid(20, 20, COLLIDE_NONE)
const flags = grid.getCollision(-9999, -9999)
expect((flags & COLLIDE_BLANK) !== 0).toBe(true)
expect((flags & COLLIDE_WALL) !== 0).toBe(true)
expect(grid.isWalkable(-9999, -9999)).toBe(false)
})
it('T2.2.3: Overlapping collision flags preserve composite bitmask without corruption', () => {
const grid = createCollisionGrid(5, 5, COLLIDE_NONE)
const composite = COLLIDE_WALL | COLLIDE_MONSTER | COLLIDE_MISSILE_BARRIER
grid.setCollision(2, 2, composite)
const readBack = grid.getCollision(2, 2)
expect(readBack & COLLIDE_WALL).toBe(COLLIDE_WALL)
expect(readBack & COLLIDE_MONSTER).toBe(COLLIDE_MONSTER)
expect(readBack & COLLIDE_MISSILE_BARRIER).toBe(COLLIDE_MISSILE_BARRIER)
})
it('T2.2.4: Raycast line-of-sight with zero length (point to point) returns true on walkable cell', () => {
const grid = createCollisionGrid(5, 5, COLLIDE_NONE)
expect(grid.hasLineOfSight(2, 2, 2, 2)).toBe(true)
})
it('T2.2.5: Raycast line-of-sight through a single diagonal barrier vertex correctly identifies occlusion', () => {
const grid = createCollisionGrid(10, 10, COLLIDE_NONE)
// Barrier at (3, 3)
grid.setCollision(3, 3, COLLIDE_WALL | COLLIDE_MISSILE_BARRIER)
// Ray from (1, 1) through (3, 3) to (5, 5)
expect(grid.hasLineOfSight(1, 1, 5, 5)).toBe(false)
})
it('T2.2.6: Empty candidate list in pickVariant returns undefined without crash', () => {
const emptyCandidates: readonly { id: number; weight: number }[] = []
const result = pickVariant(emptyCandidates, 10, 10, 12345)
expect(result).toBeUndefined()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 3: Movement, Posture & State Boundary Conditions
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 3 — Movement, Posture & State Boundaries', () => {
it('T2.3.1: Moving to exact current position produces zero velocity displacement', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.applyServerEvents({
type: 'UnitMove',
unitType: 'player',
id: 1,
to: { x: 100, y: 100 },
mode: 'run',
})
const unit = fixture.selfUnit!
const posX = unit.x
const posY = unit.y
fixture.tick(5)
expect(unit.x).toBe(posX)
expect(unit.y).toBe(posY)
})
it('T2.3.2: Rapid multi-target move commands overwrite target coordinates with newest destination', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.applyServerEvents(
{
type: 'UnitMove',
unitType: 'player',
id: 1,
to: { x: 110, y: 100 },
mode: 'run',
},
{
type: 'UnitMove',
unitType: 'player',
id: 1,
to: { x: 130, y: 120 },
mode: 'run',
},
)
expect(fixture.selfUnit?.targetX).toBe(130)
expect(fixture.selfUnit?.targetY).toBe(120)
})
it('T2.3.3: Skill cast interrupts active movement mode and sets target cast direction', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Start moving
fixture.applyServerEvents({
type: 'UnitMove',
unitType: 'player',
id: 1,
to: { x: 150, y: 100 },
mode: 'run',
})
expect(fixture.selfUnit?.mode).toBe(3)
// Cast Fire Bolt toward (100, 150)
fixture.applyServerEvents({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36,
level: 1,
target: { x: 100, y: 150 },
})
// Movement interrupted -> Cast mode 10
expect(fixture.selfUnit?.mode).toBe(10)
expect(fixture.selfUnit?.moveMode).toBe('stop')
})
it('T2.3.4: Player block chance with 0 shield block chance is 0% regardless of dexterity', () => {
const zeroBlock = calculatePlayerBlockChance(0, 500, 99, false)
expect(zeroBlock).toBe(0)
})
it('T2.3.5: Player block chance at character level 1 with high dexterity caps at 75%', () => {
const maxBlock = calculatePlayerBlockChance(75, 100, 1, false)
expect(maxBlock).toBe(75)
})
it('T2.3.6: Moving from wilderness into town forces town posture and mode', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// In wilderness (area 2 = Blood Moor)
fixture.applyServerEvents({
type: 'LoadAct',
act: 0,
mapSeed: 12345,
areaId: 2,
})
expect(isTownLevel(fixture.world.areaId)).toBe(false)
// Portal/transition into town (area 1 = Rogue Encampment)
fixture.applyServerEvents({
type: 'LoadAct',
act: 0,
mapSeed: 12345,
areaId: 1,
})
expect(isTownLevel(fixture.world.areaId)).toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 4: Animation & Speed Edge Cases
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 4 — Animation & Speed Edge Cases', () => {
it('T2.4.1: Extreme FCR values (e.g. 500% FCR) clamp to maximum diminishing ceiling of 75', () => {
const extremeFcr = computeEffectiveFcr(500)
expect(extremeFcr).toBe(75)
expect(computeDiminishingRate113c(500)).toBe(96)
})
it('T2.4.2: Negative FCR values clamp to minimum diminishing rate of -85', () => {
const negativeRate = computeDiminishingRate113c(-500)
expect(negativeRate).toBe(-85)
})
it('T2.4.3: One-shot animation with 1 frame returns frame index 0 unconditionally', () => {
const singleFrameIndex = computeAnimFrameIndex({
frameCount: 1,
stepSpeed256: 256,
loop: false,
nowMs: 5000,
startedAtMs: 0,
})
expect(singleFrameIndex).toBe(0)
})
it('T2.4.4: Looping animation wraps modulo frameCount cleanly across multi-period elapsed time', () => {
// 10-frame clip at 256 stepSpeed (1 frame per 40ms) -> 1 period = 400ms
// At elapsed 480ms (12 ticks) -> 12 % 10 = 2
const loopFrame = computeAnimFrameIndex({
frameCount: 10,
stepSpeed256: 256,
loop: true,
nowMs: 480,
startedAtMs: 0,
})
expect(loopFrame).toBe(2)
})
it('T2.4.5: Highly positive weapon WSM (slow weapon, +30) diminishes effective attack speed', () => {
const slowWsm = computeEffectiveIas(20, 0, 30) // Item IAS 20, weapon WSM +30
// Diminished(20) = 17, EIAS = 17 - 30 = -13
expect(slowWsm).toBe(-13)
})
it('T2.4.6: Unknown or missing character class clip lookup falls back safely', () => {
// 1.13c has 7 canonical classes (0..6)
const validClips = CHAR_ANIM_CLIPS_113C[0]
expect(validClips).toBeDefined()
expect(validClips?.sc).toBeDefined()
expect(CHAR_ANIM_CLIPS_113C[99 as any]).toBeUndefined()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 5: Renderer & Missile Lifetime Boundaries
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 5 — Renderer & Missile Lifetime Boundaries', () => {
it('T2.5.1: Missile maxTicks expiration removes missile from active units', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.world.spawnMissileByKey('firebolt', 100, 100, 105, 100, {
id: 777,
maxTicks: 3, // expires after 3 ticks
})
expect(fixture.world.getUnit('missile', 777)).toBeDefined()
fixture.tick(4) // step past 3 ticks
expect(fixture.world.getUnit('missile', 777)).toBeUndefined()
})
it('T2.5.2: Missile hitting blocked sub-tile obstacle detonates immediately without phasing through', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
// Block sub-tile (102, 100)
fixture.world.setSubTileBlocked(102, 100, true)
fixture.world.spawnMissileByKey('firebolt', 100, 100, 110, 100, {
id: 778,
})
// Tick missile toward wall
fixture.tick(5)
// Missile must have impacted wall and been removed
expect(fixture.world.getUnit('missile', 778)).toBeUndefined()
})
it('T2.5.3: Removing unit via UnitRemove purges unit key from world immediately', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.applyServerEvents({
type: 'UnitAssign',
isSelf: false,
unit: {
unitType: 'monster',
id: 301,
classId: 1,
x: 105,
y: 105,
mode: 1,
lifePct: 128,
},
})
expect(fixture.world.getUnit('monster', 301)).toBeDefined()
fixture.applyServerEvents({
type: 'UnitRemove',
unitType: 1,
id: 301,
})
expect(fixture.world.getUnit('monster', 301)).toBeUndefined()
})
it('T2.5.4: Zero life byte (0 / 128) results in 0% life percentage and death posture', () => {
const scaleLife = (l: number) => Math.round((l * 100) / 128)
expect(scaleLife(0)).toBe(0)
})
it('T2.5.5: Simultaneous multiple missiles maintain independent deterministic trajectories', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.world.spawnMissileByKey('firebolt', 100, 100, 150, 100, { id: 801 })
fixture.world.spawnMissileByKey('firebolt', 100, 100, 100, 150, { id: 802 })
fixture.tick(2)
const m1 = fixture.world.getUnit('missile', 801)
const m2 = fixture.world.getUnit('missile', 802)
expect(m1).toBeDefined()
expect(m2).toBeDefined()
expect(m1!.x).toBeGreaterThan(100)
expect(m2!.y).toBeGreaterThan(100)
})
it('T2.5.6: Screen coordinates at extreme limits do not cause numerical underflow', () => {
const screen = subTileToOrthoScreen(-10000, 10000)
expect(Number.isFinite(screen.x)).toBe(true)
expect(Number.isFinite(screen.y)).toBe(true)
const recovered = orthoScreenToSubTile(screen.x, screen.y)
expect(recovered.x).toBeCloseTo(-10000, 3)
expect(recovered.y).toBeCloseTo(10000, 3)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 6: UI, HUD & Inventory Boundaries
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 6 — UI, HUD & Inventory Boundaries', () => {
it('T2.6.1: Placing 2x2 item at boundary slot (8, 2) fits within 10x4 inventory grid', () => {
const item = makeParityItem({
id: 901,
gridX: 8,
gridY: 2,
invWidth: 2,
invHeight: 2,
})
// Item spans x: [8, 9], y: [2, 3] which fits exactly inside [0..9, 0..3]
expect(item.gridX + item.invWidth).toBeLessThanOrEqual(10)
expect(item.gridY + item.invHeight).toBeLessThanOrEqual(4)
})
it('T2.6.2: Placing 2x2 item at boundary slot (9, 2) exceeds 10-column boundary and overflows', () => {
const item = makeParityItem({
id: 902,
gridX: 9,
gridY: 2,
invWidth: 2,
invHeight: 2,
})
// Item spans x: [9, 10] -> exceeds 10
expect(item.gridX + item.invWidth).toBeGreaterThan(10)
})
it('T2.6.3: Empty inventory container reports item count 0 and accepts incoming items', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
expect(fixture.world.containers.inventory.size).toBe(0)
fixture.applyServerEvents({
type: 'ItemEvent',
action: 0,
container: 'inventory',
item: makeParityItem({ id: 910, container: 'inventory', gridX: 0, gridY: 0 }),
})
expect(fixture.world.containers.inventory.size).toBe(1)
})
it('T2.6.4: Gold values at maximum boundary (2,500,000) format correctly without decimal points', () => {
const MAX_GOLD = 2500000
expect(String(MAX_GOLD)).toBe('2500000')
expect(String(MAX_GOLD)).not.toContain(',')
expect(String(MAX_GOLD)).not.toContain('.')
})
it('T2.6.5: Horadric Cube 2x2 grid fits four 1x1 items exactly', () => {
const CUBE_WIDTH = 2
const CUBE_HEIGHT = 2
const slots = CUBE_WIDTH * CUBE_HEIGHT
expect(slots).toBe(4)
})
it('T2.6.6: Right-click usage on nonexistent item id dispatches ItemUse for server validation', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.hudModel.useItem(999999) // nonexistent item ID
expect(fixture.sentCommands.length).toBe(1)
const cmd = fixture.sentCommands[0] as any
expect(cmd.type).toBe('ItemUse')
expect(cmd.itemId).toBe(999999)
expect(cmd.at).toEqual({ x: 100, y: 100 })
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 7: Network Packet & Synchronization Boundaries
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 7 — Network Packet & Synchronization Boundaries', () => {
it('T2.7.1: C2S packet size table contains valid entries for all standard action opcodes', () => {
const standardOpcodes = [0x01, 0x03, 0x05, 0x0c, 0x16, 0x1f]
for (const op of standardOpcodes) {
expect(C2S_PACKET_SIZES[op]).toBeGreaterThan(0)
}
})
it('T2.7.2: S2C 0x8E CorpseAssign decoder handles valid 10-byte binary packet', () => {
const packet = new Uint8Array([0x8e, 0x01, 0x42, 0x00, 0x00, 0x00, 0x99, 0x00, 0x00, 0x00])
expect(packet.byteLength).toBe(10)
const ev = decode0x8ECorpseAssign(packet)
expect(ev.type).toBe('CorpseAssign')
expect((ev as any).ownerId).toBe(0x42)
expect((ev as any).corpseId).toBe(0x99)
})
it('T2.7.3: Account name validation: minimum length 2 and maximum length 15 enforced', () => {
const shortName = validateNewAccountInputs({ accountName: 'X', accountPassword: 'p', accountVerifyPassword: 'p' })
expect(shortName.valid).toBe(false)
const minName = validateNewAccountInputs({ accountName: 'AB', accountPassword: 'pass', accountVerifyPassword: 'pass' })
expect(minName.valid).toBe(true)
const maxName = validateNewAccountInputs({ accountName: 'Hero_Player_123', accountPassword: 'pass', accountVerifyPassword: 'pass' })
expect('Hero_Player_123'.length).toBe(15)
expect(maxName.valid).toBe(true)
const tooLongName = validateNewAccountInputs({ accountName: 'Hero_Player_12345', accountPassword: 'pass', accountVerifyPassword: 'pass' })
expect(tooLongName.valid).toBe(false)
})
it('T2.7.4: Disallowed special characters in account names are strictly rejected', () => {
const forbiddenChars = ['"', '#', '%', '*', ',', '/', '<', '>', '?', '\\', ' ']
for (const ch of forbiddenChars) {
expect(isValidAccountChar(ch)).toBe(false)
}
})
it('T2.7.5: Out-of-order sequence server updates are applied without state regression', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.applyServerEvents({
type: 'UnitReassign',
unitType: 'player',
id: 1,
at: { x: 110, y: 110 },
snap: true,
})
expect(fixture.world.self.x).toBe(110)
fixture.applyServerEvents({
type: 'UnitReassign',
unitType: 'player',
id: 1,
at: { x: 115, y: 115 },
snap: true,
})
expect(fixture.world.self.x).toBe(115)
})
it('T2.7.6: Client command queue clear resets recorded messages without memory leak', () => {
const fixture = createParitySessionFixture({ selfId: 1 })
fixture.hudModel.useItem(123)
expect(fixture.sentCommands.length).toBe(1)
fixture.clearCommands()
expect(fixture.sentCommands.length).toBe(0)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// Batch 8: Tools, Asset Manifest & Fail-Fast Boundaries
// ═══════════════════════════════════════════════════════════════════════════
describe('Batch 8 — Tools, Asset Manifest & Fail-Fast Boundaries', () => {
it('T2.8.1: Text edit box cursor toggle rate is exactly 250ms periodic', () => {
const isCursorVisible = (ms: number) => Math.floor(ms / 250) % 2 === 0
// 0..249: on, 250..499: off, 500..749: on, 750..999: off
expect(isCursorVisible(100)).toBe(true)
expect(isCursorVisible(300)).toBe(false)
expect(isCursorVisible(600)).toBe(true)
expect(isCursorVisible(800)).toBe(false)
})
it('T2.8.2: TRANS50 blending factor is 0.50 opacity for disabled UI widgets', () => {
const trans50Alpha = 0.5
expect(trans50Alpha).toBe(0.5)
})
it('T2.8.3: Missing TBL string query falls back to English string cleanly', () => {
const fallbackStr = (customMap: Record<number, string>, id: number, def: string) => customMap[id] ?? def
const dict: Record<number, string> = { 5239: 'Account already exists' }
expect(fallbackStr(dict, 5239, 'Default')).toBe('Account already exists')
expect(fallbackStr(dict, 99999, 'Default')).toBe('Default')
})
it('T2.8.4: Requesting invalid missile name from manifest throws Error explicitly', () => {
expect(() => getMissileTxtData('nonexistent_missile_123')).toThrow(/Unknown missile/i)
})
it('T2.8.5: Requesting nonexistent overlay token returns default fallback structure', () => {
const ov = getOverlayTxtData('nonexistent_overlay_999')
expect(ov).toBeDefined()
expect(ov.frames).toBeGreaterThanOrEqual(1)
})
it('T2.8.6: Whitespace-padded account names are trimmed during validation', () => {
const filtered = filterAccountName(' Hero_Player ')
expect(filtered).toBe('Hero_Player')
})
})
})