330 lines
14 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|