diff --git a/src/net/lockstep.ts b/src/net/lockstep.ts index 15c7923..ada1895 100644 --- a/src/net/lockstep.ts +++ b/src/net/lockstep.ts @@ -230,7 +230,7 @@ export class LockstepSession { } -export function computeLockstepHash(world: any, inventory: any, ground: readonly any[], quests: readonly any[]): number { +export function computeLockstepHash(world: any, inventories: readonly any[], ground: readonly any[], quests: readonly any[]): number { let hash = 2166136261 function mix(value: number) { @@ -267,16 +267,19 @@ export function computeLockstepHash(world: any, inventory: any, ground: readonly mixString(monster.state) } - // 2. Hash Inventory (sort by some stable key like x,y to ensure determinism if order varies) - // Actually, placed items shouldn't vary in order, but to be 100% deterministic, we can sort by index or coordinate. - if (inventory && inventory.placed) { - const placed = [...inventory.placed].sort((a, b) => a.y !== b.y ? a.y - b.y : a.x - b.x) - for (const entry of placed) { - mix(entry.x) - mix(entry.y) - mixString(entry.item.name) - mix(entry.item.stack ?? 1) - mix(entry.item.value ?? 0) + // 2. Hash Inventories + if (inventories) { + for (const inventory of inventories) { + if (inventory && inventory.contents) { + const placed = [...inventory.contents].sort((a, b) => a.y !== b.y ? a.y - b.y : a.x - b.x) + for (const entry of placed) { + mix(entry.x) + mix(entry.y) + mixString(entry.item.name) + mix(entry.item.stack ?? 1) + mix(entry.item.value ?? 0) + } + } } } diff --git a/src/scene/net-scene.ts b/src/scene/net-scene.ts index 5f23d46..a8889ca 100644 --- a/src/scene/net-scene.ts +++ b/src/scene/net-scene.ts @@ -47,7 +47,10 @@ import { GameLoop } from '../sim/loop.ts' import { KeyboardInput, directionOf } from '../sim/input.ts' import { NetplaySession } from '../net/netplay.ts' import { socketTransport } from '../net/transport.ts' -import { LockstepSession } from '../net/lockstep.ts' +import { LockstepSession, computeLockstepHash } from '../net/lockstep.ts' +import { Inventory } from '../game/items.ts' +import { QuestLog } from '../game/quests.ts' + import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts' /** Collision box at the character's feet. */ @@ -153,7 +156,7 @@ const state: NetState = { actorSource: 'marker', monstersSpawned: 0, socketFrames: 0, socketOpened: false, socketClosed: false, malformed: 0, socketInfo: 'none', } -window.__d2webNet = state +if (typeof window !== 'undefined') window.__d2webNet = state /** Raw members the scene needs. */ interface NetSources { @@ -395,16 +398,7 @@ function startScene( * monsters — exactly once per tick, which is why it is one call and not one per * player. */ - const simulation: LockstepSimulation = { - advance: (inputs: readonly InputFrame[]) => { - const frames: CombatInput[] = inputs.map(input => ({ - movement: input.movement, - attack: input.attack, - })) - tickCombatMulti(world, frames, options, terrain, DEMO_EXPERIENCE) - }, - hash: () => LockstepSession.digest(digestWorld(world)), - } + const { simulation } = createNetSimulation(world, options, terrain, DEMO_EXPERIENCE, Math.max(peers, 1)) let session: NetplaySession | null = null let socketView: WebSocket | null = null @@ -526,15 +520,6 @@ const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000] * @param world - the world. * @returns the digest string. */ -function digestWorld(world: CombatWorld): string { - const round = (value: number): number => Math.round(value * 1000) - return JSON.stringify({ - tick: world.tick, - kills: world.kills, - players: world.players.map(player => [round(player.x), round(player.y), player.hp, player.xp, player.level, player.alive]), - monsters: world.monsters.map(monster => [round(monster.x), round(monster.y), monster.hp, monster.state]), - }) -} /** * Refresh the exposed state and the network HUD. @@ -630,3 +615,39 @@ if (typeof window !== 'undefined') { /** Whether the page is running inside a document (avoids running under Node). */ if (typeof document !== 'undefined') boot() + +export function createNetSimulation( + world: CombatWorld, + options: CombatOptions, + terrain: any, + xpTable: readonly number[], + numPeers: number, +) { + const inventories = Array.from({ length: Math.max(numPeers, 1) }, () => new Inventory(10, 4)) + const quests = new QuestLog([]) + const ground: any[] = [] + + const simulation: LockstepSimulation = { + advance: (inputs: readonly InputFrame[]) => { + const frames: CombatInput[] = inputs.map(input => ({ + movement: input.movement, + attack: input.attack, + })) + tickCombatMulti(world, frames, options, terrain, xpTable) + + inputs.forEach((input, index) => { + if (input.pickup && ground.length > 0) { + const player = world.players[index] + if (player) { + const bestIndex = ground.findIndex(g => Math.hypot(g.x - player.x, g.y - player.y) <= 40) + if (bestIndex !== -1 && inventories[index]!.add(ground[bestIndex].item) !== null) { + ground.splice(bestIndex, 1) + } + } + } + }) + }, + hash: () => computeLockstepHash(world, inventories, ground, quests.all), + } + return { simulation, inventories, quests, ground } +} diff --git a/tests/lockstep-hash.test.ts b/tests/lockstep-hash.test.ts index 2f1d846..b5a09c0 100644 --- a/tests/lockstep-hash.test.ts +++ b/tests/lockstep-hash.test.ts @@ -7,57 +7,57 @@ describe('computeLockstepHash', () => { const ground: any[] = [] const quests: any[] = [] - const inv1 = { width: 10, height: 4, placed: [ + const inv1 = { width: 10, height: 4, contents: [ { x: 0, y: 0, item: { name: 'A', stack: 1, value: 5 } } ]} - const inv2 = { width: 10, height: 4, placed: [ + const inv2 = { width: 10, height: 4, contents: [ { x: 0, y: 0, item: { name: 'B', stack: 1, value: 5 } } ]} - const hash1 = computeLockstepHash(world, inv1, ground, quests) - const hash2 = computeLockstepHash(world, inv2, ground, quests) + const hash1 = computeLockstepHash(world, [inv1], ground, quests) + const hash2 = computeLockstepHash(world, [inv2], ground, quests) expect(hash1).not.toBe(hash2) }) it('detects changes in quest progress', () => { const world = { tick: 1, kills: 0, players: [], monsters: [] } - const inv = { width: 10, height: 4, placed: [] } + const inv = { width: 10, height: 4, contents: [] } const ground: any[] = [] const quests1 = [ { def: { id: 'q1' }, status: 'active', kills: 0 } ] const quests2 = [ { def: { id: 'q1' }, status: 'completed', kills: 0 } ] - const hash1 = computeLockstepHash(world, inv, ground, quests1) - const hash2 = computeLockstepHash(world, inv, ground, quests2) + const hash1 = computeLockstepHash(world, [inv], ground, quests1) + const hash2 = computeLockstepHash(world, [inv], ground, quests2) expect(hash1).not.toBe(hash2) }) it('detects changes in ground items', () => { const world = { tick: 1, kills: 0, players: [], monsters: [] } - const inv = { width: 10, height: 4, placed: [] } + const inv = { width: 10, height: 4, contents: [] } const quests: any[] = [] const ground1 = [ { x: 5, y: 5, item: { name: 'Gold' } } ] const ground2 = [ { x: 5, y: 5, item: { name: 'Potion' } } ] - const hash1 = computeLockstepHash(world, inv, ground1, quests) - const hash2 = computeLockstepHash(world, inv, ground2, quests) + const hash1 = computeLockstepHash(world, [inv], ground1, quests) + const hash2 = computeLockstepHash(world, [inv], ground2, quests) expect(hash1).not.toBe(hash2) }) it('produces identical hashes for identical states', () => { const world = { tick: 1, kills: 0, players: [], monsters: [] } - const inv = { width: 10, height: 4, placed: [ + const inv = { width: 10, height: 4, contents: [ { x: 1, y: 1, item: { name: 'Sword', stack: 1, value: 100 } } ]} const ground = [ { x: 5, y: 5, item: { name: 'Gold' } } ] const quests = [ { def: { id: 'q1' }, status: 'active', kills: 0 } ] - const hash1 = computeLockstepHash(world, inv, ground, quests) + const hash1 = computeLockstepHash(world, [inv], ground, quests) // Deep clone to ensure stability across new references const world2 = JSON.parse(JSON.stringify(world)) @@ -65,7 +65,7 @@ describe('computeLockstepHash', () => { const ground2 = JSON.parse(JSON.stringify(ground)) const quests2 = JSON.parse(JSON.stringify(quests)) - const hash2 = computeLockstepHash(world2, inv2, ground2, quests2) + const hash2 = computeLockstepHash(world2, [inv2], ground2, quests2) expect(hash1).toBe(hash2) }) diff --git a/tests/net-scene-integration.test.ts b/tests/net-scene-integration.test.ts new file mode 100644 index 0000000..b3dbca9 --- /dev/null +++ b/tests/net-scene-integration.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { createNetSimulation } from '../src/scene/net-scene.ts' +import { createWorld, addPlayer } from '../src/game/combat.ts' +import { LockstepSession } from '../src/net/lockstep.ts' + +describe('net-scene lockstep integration', () => { + it('detects inventory desyncs when one peer picks up an item', () => { + // Setup identical honest and tampered simulations + const worldA = createWorld(0, 0) + addPlayer(worldA, 10, 0) + + // Create worldB identical to worldA + const worldB = createWorld(0, 0) + addPlayer(worldB, 10, 0) + + const options = { + playerSpeed: 100, playerReach: 20, playerCooldownTicks: 10, + playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 10, + } + const terrain = { overlap: () => 0 } + + const simA = createNetSimulation(worldA, options, terrain, [0, 100], 2) + const simB = createNetSimulation(worldB, options, terrain, [0, 100], 2) + + // BOTH have an item at the same coordinate. + // However, A has Gold, B has a Potion. + simA.ground.push({ x: 0, y: 0, item: { name: 'Gold', stack: 100, value: 100, base: {} as any, prefix: null, suffix: null, level: 1, invWidth: 1, invHeight: 1, stats: {} } }) + simB.ground.push({ x: 0, y: 0, item: { name: 'Potion', stack: 1, value: 5, base: {} as any, prefix: null, suffix: null, level: 1, invWidth: 1, invHeight: 1, stats: {} } }) + + const sessionA = new LockstepSession({ peers: 2, inputDelayTicks: 0 }, simA.simulation) + const sessionB = new LockstepSession({ peers: 2, inputDelayTicks: 0 }, simB.simulation) + + // Tick 0: BOTH pick up! + // At the end of the tick, both grounds are empty. But inventories differ. + sessionA.submit(0, { tick: 0, movement: {x:0, y:0}, attack: false, pickup: true, talk: false, skill: 0 }) + sessionA.submit(1, { tick: 0, movement: {x:0, y:0}, attack: false, pickup: false, talk: false, skill: 0 }) + + sessionB.submit(0, { tick: 0, movement: {x:0, y:0}, attack: false, pickup: true, talk: false, skill: 0 }) + sessionB.submit(1, { tick: 0, movement: {x:0, y:0}, attack: false, pickup: false, talk: false, skill: 0 }) + + const outA = sessionA.step() + const outB = sessionB.step() + + expect(outA.kind).toBe('stepped') + expect(outB.kind).toBe('stepped') + expect((outA as any).hash).not.toBe((outB as any).hash) + }) +})