647 lines
22 KiB
TypeScript
647 lines
22 KiB
TypeScript
/**
|
|
* Tier 4: Real-World End-to-End Gameplay Scenarios (`tests/e2e-p0-516/tier4-real-world-scenarios.test.ts`).
|
|
*
|
|
* 10 full user journey scenarios exercising all 10 P0 issues (`#517`, `#518`, `#520`, `#521`, `#522`, `#523`, `#524`, `#528`, `#529`, `#530`) under umbrella `#516`.
|
|
*/
|
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
import { startRelay, decodeFrames, encodeFrame as encodeRelayFrame } from '../../scripts/net-relay.ts'
|
|
import {
|
|
LockstepSession,
|
|
NetplaySession,
|
|
encodeMessage,
|
|
decodeMessage,
|
|
computeLockstepHash,
|
|
memoryTransportPair,
|
|
type InputFrame,
|
|
} from '../../src/net/lockstep-manager.ts'
|
|
import { GameEngine, type EngineInput, type WorldMapProvider } from '../../src/game/engine.ts'
|
|
import { GroundItemManager, calculateBounceHeight } from '../../src/game/ground-items.ts'
|
|
import { goldItem, type Item, type ItemBase } from '../../src/game/items.ts'
|
|
import { InventoryPanel, getInventoryGoldLimit } from '../../src/ui/inventory.ts'
|
|
import { WorldPanelsHud, STASH_GOLD_CAP } from '../../src/ui/world-panels.ts'
|
|
import { HudManager } from '../../src/ui/hud-manager.ts'
|
|
import { AudioManager } from '../../src/audio/manager.ts'
|
|
import { playInventoryFullFeedback } from '../../src/scene/frontend/overhead-labels.ts'
|
|
import { LoadGeneration, SpriteRenderer, type AtlasHandle } from '../../src/render/renderer.ts'
|
|
import { disposeRuntimeAtlases } from '../../src/scene/frontend/pack-loader.ts'
|
|
import { resolveGroundItemFlippyFrame, drawGroundItem } from '../../src/scene/frontend/ground-item-renderer.ts'
|
|
import { drawMissileProjectile, drawExplosion } from '../../src/scene/frontend/missile-overlay-renderer.ts'
|
|
import { D2Rng } from '../../src/game/d2-rng.ts'
|
|
import { CANONICAL_AUTOMAGIC_TABLE } from '../../src/game/automagic.ts'
|
|
import { getSharedDataRegistry } from '../../src/game/engine/data-registry.ts'
|
|
import { UnitStatList } from '../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../src/game/engine/state-bus.ts'
|
|
import { MissileEngine } from '../../src/game/engine/missile-engine.ts'
|
|
import { AuraScanner } from '../../src/game/engine/aura-scanner.ts'
|
|
import { SummonManager } from '../../src/game/engine/summon-manager.ts'
|
|
import { evaluateSkill113c, executeSkill113c } from '../../src/game/skills/registry.ts'
|
|
|
|
function makeItem(id: string, name: string, invWidth = 1, invHeight = 1, value = 50): Item {
|
|
const base: ItemBase = {
|
|
id,
|
|
name,
|
|
kind: 'misc',
|
|
invWidth,
|
|
invHeight,
|
|
maxStack: 1,
|
|
value,
|
|
damage: 0,
|
|
defense: 0,
|
|
tags: ['misc'],
|
|
level: 1,
|
|
}
|
|
return {
|
|
base,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 1,
|
|
name,
|
|
stats: {},
|
|
invWidth,
|
|
invHeight,
|
|
stack: 1,
|
|
value,
|
|
code: id,
|
|
}
|
|
}
|
|
|
|
function makeEngine(seed = 12345): GameEngine {
|
|
const terrain: WorldMapProvider = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
|
|
return new GameEngine(terrain, {
|
|
spawn: { x: 200, y: 200 },
|
|
stats: [{ id: 'zombie', name: 'Zombie', hp: 30, speed: 60, reach: 32, damage: 5, cooldownTicks: 20, xp: 600, aggroRadius: 200 }],
|
|
xpTable: [0, 500, 1500, 3750],
|
|
skills: [
|
|
{
|
|
id: 'firebolt',
|
|
name: 'Fire Bolt',
|
|
projectile: true,
|
|
manaCost: 2,
|
|
cooldownTicks: 4,
|
|
baseMinDamage: 20,
|
|
baseMaxDamage: 25,
|
|
damagePerLevel: 2,
|
|
speed: 320,
|
|
range: 600,
|
|
radius: 10,
|
|
},
|
|
],
|
|
npcDefs: [],
|
|
questDefs: [],
|
|
combatOptions: {
|
|
playerSpeed: 170,
|
|
playerReach: 48,
|
|
playerCooldownTicks: 5,
|
|
playerDamage: 20,
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 50,
|
|
},
|
|
lootSeed: seed,
|
|
talkRadius: 64,
|
|
pickupRadius: 64,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
monsterCount: 2,
|
|
})
|
|
}
|
|
|
|
const IDLE_INPUT: EngineInput = {
|
|
movement: { x: 0, y: 0 },
|
|
attacking: false,
|
|
pickingUp: false,
|
|
talking: false,
|
|
digits: [],
|
|
saving: false,
|
|
loading: false,
|
|
}
|
|
|
|
function createMockWebGLCanvas() {
|
|
let nextTexId = 1
|
|
const liveTextures = new Set<number>()
|
|
const gl = {
|
|
NO_ERROR: 0,
|
|
TRIANGLES: 4,
|
|
TEXTURE_2D: 0x0de1,
|
|
TEXTURE0: 0x84c0,
|
|
TEXTURE1: 0x84c1,
|
|
RGBA: 0x1908,
|
|
R8: 0x8229,
|
|
RED: 0x1903,
|
|
UNSIGNED_BYTE: 0x1401,
|
|
NEAREST: 0x2600,
|
|
CLAMP_TO_EDGE: 0x812f,
|
|
TEXTURE_MIN_FILTER: 0x2801,
|
|
TEXTURE_MAG_FILTER: 0x2800,
|
|
TEXTURE_WRAP_S: 0x2802,
|
|
TEXTURE_WRAP_T: 0x2803,
|
|
UNPACK_ALIGNMENT: 0x0cf5,
|
|
ARRAY_BUFFER: 0x8892,
|
|
DYNAMIC_DRAW: 0x88e8,
|
|
FLOAT: 0x1406,
|
|
BLEND: 0x0be2,
|
|
SRC_ALPHA: 0x0302,
|
|
ONE_MINUS_SRC_ALPHA: 0x0303,
|
|
ONE: 1,
|
|
COLOR_BUFFER_BIT: 0x4000,
|
|
VERTEX_SHADER: 0x8b31,
|
|
FRAGMENT_SHADER: 0x8b30,
|
|
COMPILE_STATUS: 0x8b81,
|
|
LINK_STATUS: 0x8b82,
|
|
getError: vi.fn(() => 0),
|
|
createShader: vi.fn(() => ({})),
|
|
shaderSource: vi.fn(),
|
|
compileShader: vi.fn(),
|
|
getShaderParameter: vi.fn(() => true),
|
|
getShaderInfoLog: vi.fn(() => ''),
|
|
deleteShader: vi.fn(),
|
|
createProgram: vi.fn(() => ({})),
|
|
attachShader: vi.fn(),
|
|
linkProgram: vi.fn(),
|
|
getProgramParameter: vi.fn(() => true),
|
|
getProgramInfoLog: vi.fn(() => ''),
|
|
useProgram: vi.fn(),
|
|
deleteProgram: vi.fn(),
|
|
getUniformLocation: vi.fn(() => ({})),
|
|
uniform1i: vi.fn(),
|
|
uniform2f: vi.fn(),
|
|
createVertexArray: vi.fn(() => ({})),
|
|
bindVertexArray: vi.fn(),
|
|
deleteVertexArray: vi.fn(),
|
|
createBuffer: vi.fn(() => ({})),
|
|
bindBuffer: vi.fn(),
|
|
bufferData: vi.fn(),
|
|
bufferSubData: vi.fn(),
|
|
deleteBuffer: vi.fn(),
|
|
enableVertexAttribArray: vi.fn(),
|
|
vertexAttribPointer: vi.fn(),
|
|
createTexture: vi.fn(() => {
|
|
const id = nextTexId++
|
|
liveTextures.add(id)
|
|
return { id }
|
|
}),
|
|
bindTexture: vi.fn(),
|
|
activeTexture: vi.fn(),
|
|
pixelStorei: vi.fn(),
|
|
texParameteri: vi.fn(),
|
|
texImage2D: vi.fn(),
|
|
deleteTexture: vi.fn((tex: { id?: number } | null) => {
|
|
if (tex?.id !== undefined) liveTextures.delete(tex.id)
|
|
}),
|
|
enable: vi.fn(),
|
|
blendFunc: vi.fn(),
|
|
viewport: vi.fn(),
|
|
clearColor: vi.fn(),
|
|
clear: vi.fn(),
|
|
drawArrays: vi.fn(),
|
|
}
|
|
const canvas = {
|
|
width: 800,
|
|
height: 600,
|
|
getContext: vi.fn((type: string) => (type === 'webgl2' ? gl : null)),
|
|
} as unknown as HTMLCanvasElement
|
|
return { canvas, gl, liveTextures }
|
|
}
|
|
|
|
function createMock2DCanvas(): HTMLCanvasElement {
|
|
const ctx2d = {
|
|
save: vi.fn(),
|
|
restore: vi.fn(),
|
|
clearRect: vi.fn(),
|
|
fillRect: vi.fn(),
|
|
strokeRect: vi.fn(),
|
|
beginPath: vi.fn(),
|
|
moveTo: vi.fn(),
|
|
lineTo: vi.fn(),
|
|
arc: vi.fn(),
|
|
closePath: vi.fn(),
|
|
fill: vi.fn(),
|
|
stroke: vi.fn(),
|
|
fillText: vi.fn(),
|
|
strokeText: vi.fn(),
|
|
measureText: vi.fn((text: string) => ({ width: text.length * 7 })),
|
|
drawImage: vi.fn(),
|
|
putImageData: vi.fn(),
|
|
createImageData: vi.fn((w: number, h: number) => ({
|
|
width: w,
|
|
height: h,
|
|
data: new Uint8ClampedArray(w * h * 4),
|
|
})),
|
|
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
|
createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
|
setTransform: vi.fn(),
|
|
translate: vi.fn(),
|
|
scale: vi.fn(),
|
|
clip: vi.fn(),
|
|
}
|
|
return {
|
|
width: 800,
|
|
height: 600,
|
|
getContext: vi.fn(() => ctx2d),
|
|
} as unknown as HTMLCanvasElement
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('Tier 4 — Real-World End-to-End Gameplay Scenarios (10 scenarios)', () => {
|
|
// Scenario 1: Blood Moor Combat, Monster Kill, Flippy Bounce, Landing SFX, and Loot Pickup
|
|
it('T4-01: Blood Moor combat loop — cast Fire Bolt, kill monster, bounce loot, play SFX, and pick up', () => {
|
|
const engine = makeEngine(20260929)
|
|
const audio = new AudioManager()
|
|
const sfxSpy = vi.spyOn(audio, 'playSfx')
|
|
const { canvas } = createMockWebGLCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
|
|
// Cast Fire Bolt and kill monster 0
|
|
engine.tick({ ...IDLE_INPUT, digits: [1] })
|
|
expect(engine.projectiles.length).toBeGreaterThan(0)
|
|
|
|
const killRes = engine.damageMonster(0, 500, 'player')
|
|
expect(killRes.killed).toBe(true)
|
|
expect(engine.world.level).toBeGreaterThanOrEqual(2)
|
|
|
|
// Spawn a gold pile and sword with bounce
|
|
const droppedSword = engine.dropItem(makeItem('swd', 'Short Sword', 1, 3, 120), 205, 205, 10, 10)
|
|
const droppedGold = engine.groundItems.add(goldItem(450), 10, 10, 202, 202, {
|
|
now: 1000,
|
|
bounceDurationMs: 350,
|
|
peakHeightPx: 30,
|
|
})
|
|
|
|
// Render mid-bounce frame
|
|
renderer.begin(engine.world.player.x, engine.world.player.y)
|
|
for (const p of engine.projectiles) drawMissileProjectile(renderer, p, null, null)
|
|
for (const g of engine.groundItems.all) drawGroundItem(renderer, g, 1150, null)
|
|
renderer.end()
|
|
|
|
// Complete bounce -> triggers drop SFX
|
|
engine.groundItems.updateBounces(1400, audio)
|
|
expect(sfxSpy).toHaveBeenCalled()
|
|
|
|
// Pick up both gold and sword
|
|
const goldPick = engine.pickupGold(droppedGold.id)
|
|
const itemPick = engine.pickupItem(droppedSword.id)
|
|
expect(goldPick.picked).toBe(true)
|
|
expect(itemPick.picked).toBe(true)
|
|
expect(engine.gold).toBeGreaterThanOrEqual(450)
|
|
|
|
renderer.dispose()
|
|
audio.dispose()
|
|
})
|
|
|
|
// Scenario 2: Town Economy Loop — Vendor Trade, Stash Deposit/Withdraw, and Level Gold Cap
|
|
it('T4-02: Rogue Encampment town economy — sell loot to Akara, deposit to stash cap, level up, withdraw', () => {
|
|
const hud = new HudManager(createMock2DCanvas(), {
|
|
onToggleAutomap: () => {},
|
|
onWaypointTeleport: () => {},
|
|
})
|
|
hud.inventory.playerLevel = 1 // 10,000 cap
|
|
hud.inventory.gold = 9_500
|
|
|
|
const armor = makeItem('qui', 'Quilted Armor', 2, 3, 400)
|
|
expect(hud.inventory.autoPlaceInGrid(armor)).toBe(true)
|
|
|
|
hud.worldPanels.openVendorForNpc('Akara', 'trade', 1, 'normal')
|
|
expect(hud.worldPanels.sellToActiveVendor(armor, hud.inventory).ok).toBe(true)
|
|
expect(hud.inventory.gridItems).toHaveLength(0)
|
|
|
|
// Deposit 8,000 into stash
|
|
const dep = hud.worldPanels.depositGold(8_000, hud.inventory)
|
|
expect(dep).toBe(8_000)
|
|
expect(hud.worldPanels.stashGold).toBe(8_000)
|
|
|
|
// Allocate stat points on CharacterSheet after level up
|
|
hud.charSheet.attrs.level = 5
|
|
hud.charSheet.attrs.statPoints = 5
|
|
expect(hud.charSheet.allocateStat('str')).toBe(true)
|
|
expect(hud.charSheet.allocateStat('vit')).toBe(true)
|
|
hud.inventory.playerLevel = hud.charSheet.attrs.level
|
|
hud.syncPublishedState()
|
|
expect(hud.inventory.maxGold).toBe(50_000)
|
|
|
|
// Withdraw all 8,000 back from stash
|
|
const wit = hud.worldPanels.withdrawGold(8_000, hud.inventory)
|
|
expect(wit).toBe(8_000)
|
|
expect(hud.worldPanels.stashGold).toBe(0)
|
|
})
|
|
|
|
// Scenario 3: 2-Peer Multiplayer Co-Op Lockstep Session with Relay Framing & State Hash Verification
|
|
it('T4-03: 2-peer multiplayer co-op session — handshake, synchronized combat ticks, and FNV-1a hash agreement', () => {
|
|
const [t0, t1] = memoryTransportPair()
|
|
const eng0 = makeEngine(88888)
|
|
const eng1 = makeEngine(88888)
|
|
|
|
const stepSim = (eng: GameEngine, inputs: readonly InputFrame[]) => {
|
|
const combined = inputs[0] ?? { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }
|
|
eng.tick({
|
|
movement: combined.movement,
|
|
attacking: combined.attack,
|
|
pickingUp: combined.pickup,
|
|
talking: combined.talk,
|
|
digits: combined.skill > 0 ? [combined.skill] : [],
|
|
saving: false,
|
|
loading: false,
|
|
})
|
|
}
|
|
|
|
const p0 = new NetplaySession(
|
|
{ peer: 0, peers: 2, seed: 88888, inputDelayTicks: 2, hashInterval: 2 },
|
|
{
|
|
advance: (inputs) => stepSim(eng0, inputs),
|
|
hash: () => computeLockstepHash(eng0.world, [eng0.bag], eng0.ground, []),
|
|
},
|
|
t0,
|
|
)
|
|
const p1 = new NetplaySession(
|
|
{ peer: 1, peers: 2, seed: 88888, inputDelayTicks: 2, hashInterval: 2 },
|
|
{
|
|
advance: (inputs) => stepSim(eng1, inputs),
|
|
hash: () => computeLockstepHash(eng1.world, [eng1.bag], eng1.ground, []),
|
|
},
|
|
t1,
|
|
)
|
|
|
|
p0.start()
|
|
p1.start()
|
|
|
|
for (let tick = 0; tick < 12; tick++) {
|
|
p0.setIntent({ movement: { x: 1, y: 0 }, attack: tick % 4 === 0, pickup: false, talk: false, skill: 0 })
|
|
p1.setIntent({ movement: { x: 0, y: 1 }, attack: false, pickup: false, talk: false, skill: 0 })
|
|
t0.flush()
|
|
t1.flush()
|
|
p0.pump()
|
|
p1.pump()
|
|
}
|
|
|
|
expect(p0.stats.ready).toBe(true)
|
|
expect(p1.stats.ready).toBe(true)
|
|
expect(p0.desyncReport).toBeNull()
|
|
expect(p1.desyncReport).toBeNull()
|
|
expect(p0.stats.hashesAgreed).toBeGreaterThan(0)
|
|
expect(eng0.world.player.x).toBe(eng1.world.player.x)
|
|
})
|
|
|
|
// Scenario 4: Rapid Multi-Act Waypoint Teleportation without WebGL Texture Leaks
|
|
it('T4-04: Rapid 15-waypoint level transitions — LoadGeneration cancels stale loads and zero WebGL textures leak', () => {
|
|
const { canvas, liveTextures } = createMockWebGLCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const baseline = renderer.liveTextureCount
|
|
const lg = new LoadGeneration()
|
|
|
|
let activeRuntime: { wallAtlas: AtlasHandle; floorAtlas: AtlasHandle } | null = null
|
|
|
|
for (let wp = 1; wp <= 15; wp++) {
|
|
const { gen, signal } = lg.next()
|
|
const wall = renderer.addAtlas(new Uint8Array(16 * 16 * 4), 16, 16)
|
|
const floor = renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16)
|
|
|
|
if (wp < 15) {
|
|
// Simulate next waypoint clicked before previous finished committing
|
|
if (activeRuntime) {
|
|
disposeRuntimeAtlases(activeRuntime as never, renderer)
|
|
}
|
|
activeRuntime = { wallAtlas: wall, floorAtlas: floor }
|
|
} else {
|
|
expect(lg.isCurrent(gen)).toBe(true)
|
|
expect(signal.aborted).toBe(false)
|
|
if (activeRuntime) {
|
|
disposeRuntimeAtlases(activeRuntime as never, renderer)
|
|
}
|
|
activeRuntime = { wallAtlas: wall, floorAtlas: floor }
|
|
}
|
|
}
|
|
|
|
// Only the final level's 2 atlases (+ baseline) should remain live
|
|
expect(renderer.liveTextureCount).toBe(baseline + 2)
|
|
|
|
disposeRuntimeAtlases(activeRuntime as never, renderer)
|
|
expect(renderer.liveTextureCount).toBe(baseline)
|
|
renderer.dispose()
|
|
expect(liveTextures.size).toBe(0)
|
|
})
|
|
|
|
// Scenario 5: Authentic 1.13c Skill Progression, Summons, Auras, and Missiles via D2DataRegistry
|
|
it('T4-05: Full 1.13c skill pipeline — Paladin Might aura, Necromancer Raise Skeleton, Sorceress Frozen Orb', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const statList = new UnitStatList(reg)
|
|
const stateBus = new StateBus(reg, statList)
|
|
const caster = { id: 'hero', name: 'Hero', statList, stateBus, x: 200, y: 200 }
|
|
const missileEngine = new MissileEngine(reg)
|
|
const auraScanner = new AuraScanner(reg)
|
|
const summonManager = new SummonManager(reg)
|
|
|
|
// 1. Paladin Might Aura (skillId 98)
|
|
const might = reg.getSkillById(98)!
|
|
const mightEval = evaluateSkill113c({ registry: reg, skill: might, slvl: 10, blvl: 10, statList, stateBus })
|
|
const mightOut = executeSkill113c({
|
|
registry: reg,
|
|
skill: might,
|
|
evalResult: mightEval,
|
|
caster,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager,
|
|
currentTick: 1,
|
|
targetX: 200,
|
|
targetY: 200,
|
|
})
|
|
expect(mightOut.executed).toBe(true)
|
|
|
|
// 2. Necromancer Raise Skeleton (skillId 70) with corpse
|
|
const raiseSkel = reg.getSkillById(70)!
|
|
const skelEval = evaluateSkill113c({ registry: reg, skill: raiseSkel, slvl: 5, blvl: 5, statList, stateBus })
|
|
const corpses = [{ id: 'corpse-1', consumed: false, baseMaxHp: 100, x: 220, y: 220 }]
|
|
const skelOut = executeSkill113c({
|
|
registry: reg,
|
|
skill: raiseSkel,
|
|
evalResult: skelEval,
|
|
caster,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses,
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager,
|
|
currentTick: 2,
|
|
targetX: 220,
|
|
targetY: 220,
|
|
})
|
|
expect(skelOut.executed).toBe(true)
|
|
expect(skelOut.corpsesConsumed).toBe(1)
|
|
|
|
// 3. Sorceress Frozen Orb (skillId 64)
|
|
const frozenOrb = reg.getSkillById(64)!
|
|
const orbEval = evaluateSkill113c({ registry: reg, skill: frozenOrb, slvl: 15, blvl: 15, statList, stateBus })
|
|
const orbOut = executeSkill113c({
|
|
registry: reg,
|
|
skill: frozenOrb,
|
|
evalResult: orbEval,
|
|
caster,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager,
|
|
currentTick: 3,
|
|
targetX: 350,
|
|
targetY: 200,
|
|
})
|
|
expect(orbOut.executed).toBe(true)
|
|
expect(orbOut.missilesSpawned.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
// Scenario 6: Save/Load Snapshot Mid-Session Continuity with 64-Bit RNG & Inventory
|
|
it('T4-06: Save/Load snapshot mid-combat preserves exact 64-bit RNG trajectory, gold, and inventory', () => {
|
|
const engine1 = makeEngine(31415926)
|
|
engine1.gold = 7_777
|
|
engine1.bag.add(makeItem('amu', 'Amulet of the Viper', 1, 1, 500))
|
|
for (let i = 0; i < 10; i++) {
|
|
engine1.tick({ ...IDLE_INPUT, movement: { x: 1, y: 0 }, attacking: i % 2 === 0 })
|
|
}
|
|
|
|
const snap = engine1.saveSnapshot()
|
|
|
|
// Continue engine1 for 10 more ticks
|
|
for (let i = 0; i < 10; i++) {
|
|
engine1.tick({ ...IDLE_INPUT, movement: { x: 0, y: 1 }, attacking: i % 3 === 0 })
|
|
}
|
|
|
|
// Restore into engine2 and run the exact same 10 ticks
|
|
const engine2 = makeEngine(1)
|
|
expect(engine2.loadSnapshot(snap)).toBe(true)
|
|
for (let i = 0; i < 10; i++) {
|
|
engine2.tick({ ...IDLE_INPUT, movement: { x: 0, y: 1 }, attacking: i % 3 === 0 })
|
|
}
|
|
|
|
expect(engine2.gold).toBe(7_777)
|
|
expect(engine2.world.player.x).toBeCloseTo(engine1.world.player.x, 5)
|
|
expect(engine2.world.player.y).toBeCloseTo(engine1.world.player.y, 5)
|
|
expect(engine2.loot.getSeed()).toEqual(engine1.loot.getSeed())
|
|
})
|
|
|
|
// Scenario 7: Adversarial Network Relay & Lockstep Resilience under Malformed/Oversized Packets
|
|
it('T4-07: Relay and NetplaySession reject oversized/malformed packets while keeping valid session alive', async () => {
|
|
const relay = await startRelay(0, { maxPayloadBytes: 4096 })
|
|
try {
|
|
// Oversized relay frame rejected with 1009
|
|
const badRelayFrame = Buffer.from([0x82, 0x80 | 126, (5000 >> 8) & 0xff, 5000 & 0xff, 1, 2, 3, 4])
|
|
const relayRes = decodeFrames(badRelayFrame, 4096)
|
|
expect(relayRes.error?.code).toBe(1009)
|
|
|
|
// NetplaySession records malformed packet stat without crashing
|
|
const [t0, t1] = memoryTransportPair()
|
|
const p0 = new NetplaySession(
|
|
{ peer: 0, peers: 2, seed: 555, inputDelayTicks: 1 },
|
|
{ advance: () => {}, hash: () => 10 },
|
|
t0,
|
|
)
|
|
const p1 = new NetplaySession(
|
|
{ peer: 1, peers: 2, seed: 555, inputDelayTicks: 1 },
|
|
{ advance: () => {}, hash: () => 10 },
|
|
t1,
|
|
)
|
|
p0.start()
|
|
p1.start()
|
|
// Inject garbage bytes on t1 -> t0
|
|
t1.send(new Uint8Array([0xde, 0xad, 0xbe, 0xef]))
|
|
t0.flush()
|
|
t1.flush()
|
|
p0.pump()
|
|
p1.pump()
|
|
t0.flush()
|
|
t1.flush()
|
|
p0.pump()
|
|
p1.pump()
|
|
expect(p0.stats.malformed).toBeGreaterThanOrEqual(1)
|
|
expect(p0.stats.ready).toBe(true)
|
|
} finally {
|
|
await relay.close()
|
|
}
|
|
})
|
|
|
|
// Scenario 8: Full Inventory Tetris, Paperdoll Equipment Swap, and Stash Quick-Transfer
|
|
it('T4-08: Inventory tetris — equip weapons/armor, swap equipped gear, quick-transfer to stash, and reject overflow', () => {
|
|
const inv = new InventoryPanel()
|
|
const wp = new WorldPanelsHud()
|
|
const audio = new AudioManager()
|
|
const sfxSpy = vi.spyOn(audio, 'playSfx')
|
|
|
|
const sword1 = makeItem('swd', 'Short Sword', 1, 3, 100)
|
|
const sword2 = makeItem('lsd', 'Long Sword', 2, 3, 250)
|
|
const helm = makeItem('cap', 'Cap', 2, 2, 50)
|
|
|
|
// Equip sword1 and helm
|
|
inv.cursorItem = sword1
|
|
expect(inv.clickEquipSlot('mainHand').action).toBe('equip')
|
|
inv.cursorItem = helm
|
|
expect(inv.clickEquipSlot('head').action).toBe('equip')
|
|
|
|
// Swap sword2 with sword1 in mainHand
|
|
inv.cursorItem = sword2
|
|
const swapRes = inv.clickEquipSlot('mainHand')
|
|
expect(swapRes.action).toBe('swap')
|
|
expect(inv.equipped.get('mainHand')).toBe(sword2)
|
|
expect(inv.cursorItem).toBe(sword1)
|
|
|
|
// Place sword1 into grid and quick-transfer to stash
|
|
expect(inv.clickGridCell(0, 0).cursorItem).toBeNull()
|
|
expect(wp.quickTransferToStash(sword1, inv)).toBe(true)
|
|
expect(wp.stashItems).toHaveLength(1)
|
|
|
|
// Trigger inventory full feedback
|
|
playInventoryFullFeedback(audio)
|
|
expect(sfxSpy).toHaveBeenCalled()
|
|
audio.dispose()
|
|
})
|
|
|
|
// Scenario 9: Class-Specific AutoMagic Affix Generation with 64-Bit D2Rng
|
|
it('T4-09: Class-specific AutoMagic affix roll for Paladin shield and Sorceress orb using 64-bit D2Rng', () => {
|
|
const rng = new D2Rng(0x1337c0de, 0x0000029a)
|
|
const palEligible = CANONICAL_AUTOMAGIC_TABLE.getEligible('ashd', 60, true)
|
|
const sorEligible = CANONICAL_AUTOMAGIC_TABLE.getEligible('orb', 60, true)
|
|
|
|
expect(palEligible.length).toBeGreaterThan(0)
|
|
expect(sorEligible.length).toBeGreaterThan(0)
|
|
|
|
const chosenPal = palEligible[rng.rand(palEligible.length)]!
|
|
const chosenSor = sorEligible[rng.rand(sorEligible.length)]!
|
|
expect(chosenPal.mods.length).toBeGreaterThan(0)
|
|
expect(chosenSor.mods.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
// Scenario 10: Desync Detection & Recovery Inspection in Multiplayer Lockstep
|
|
it('T4-10: Multiplayer lockstep detects state divergence and populates structured DesyncReport', () => {
|
|
const [t0, t1] = memoryTransportPair()
|
|
let state0 = 1000
|
|
let state1 = 1000
|
|
|
|
const p0 = new NetplaySession(
|
|
{ peer: 0, peers: 2, seed: 999, inputDelayTicks: 1, hashInterval: 1 },
|
|
{ advance: () => { state0 += 10 }, hash: () => state0 },
|
|
t0,
|
|
)
|
|
const p1 = new NetplaySession(
|
|
{ peer: 1, peers: 2, seed: 999, inputDelayTicks: 1, hashInterval: 1 },
|
|
{ advance: () => { state1 += 999 }, hash: () => state1 }, // intentional divergence
|
|
t1,
|
|
)
|
|
|
|
p0.start()
|
|
p1.start()
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
t0.flush()
|
|
t1.flush()
|
|
p0.pump()
|
|
p1.pump()
|
|
}
|
|
|
|
expect(p0.desyncReport ?? p1.desyncReport).not.toBeNull()
|
|
})
|
|
})
|