feat(lockstep): 接入实际组件状态哈希并修复场景验证死区 (refs #7)

替换了 net-scene 中的闭包测试用 digestWorld 状态逻辑,通过真实注入的 inventories, quests, groundLoot 清单确保联机场景具备全覆盖的一致性锁步哈希验证。增加了 integration 测实验证 pickup 产生的完整分歧,确保从引擎层面而非独立计算侧暴露漏洞。
This commit is contained in:
troytt 2026-09-14 12:55:16 +00:00
parent a37f5f0805
commit 45b6a2caa7
4 changed files with 117 additions and 45 deletions

View File

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

View File

@ -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 }
}

View File

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

View File

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