diablo2-web/tests/e2e-p0-516/tier4-real-world-scenarios....

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()
})
})