feat(lockstep): 接入实际组件状态哈希并修复场景验证死区 (refs #7)
替换了 net-scene 中的闭包测试用 digestWorld 状态逻辑,通过真实注入的 inventories, quests, groundLoot 清单确保联机场景具备全覆盖的一致性锁步哈希验证。增加了 integration 测实验证 pickup 产生的完整分歧,确保从引擎层面而非独立计算侧暴露漏洞。
This commit is contained in:
parent
a37f5f0805
commit
45b6a2caa7
|
|
@ -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
|
let hash = 2166136261
|
||||||
|
|
||||||
function mix(value: number) {
|
function mix(value: number) {
|
||||||
|
|
@ -267,16 +267,19 @@ export function computeLockstepHash(world: any, inventory: any, ground: readonly
|
||||||
mixString(monster.state)
|
mixString(monster.state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Hash Inventory (sort by some stable key like x,y to ensure determinism if order varies)
|
// 2. Hash Inventories
|
||||||
// Actually, placed items shouldn't vary in order, but to be 100% deterministic, we can sort by index or coordinate.
|
if (inventories) {
|
||||||
if (inventory && inventory.placed) {
|
for (const inventory of inventories) {
|
||||||
const placed = [...inventory.placed].sort((a, b) => a.y !== b.y ? a.y - b.y : a.x - b.x)
|
if (inventory && inventory.contents) {
|
||||||
for (const entry of placed) {
|
const placed = [...inventory.contents].sort((a, b) => a.y !== b.y ? a.y - b.y : a.x - b.x)
|
||||||
mix(entry.x)
|
for (const entry of placed) {
|
||||||
mix(entry.y)
|
mix(entry.x)
|
||||||
mixString(entry.item.name)
|
mix(entry.y)
|
||||||
mix(entry.item.stack ?? 1)
|
mixString(entry.item.name)
|
||||||
mix(entry.item.value ?? 0)
|
mix(entry.item.stack ?? 1)
|
||||||
|
mix(entry.item.value ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,10 @@ import { GameLoop } from '../sim/loop.ts'
|
||||||
import { KeyboardInput, directionOf } from '../sim/input.ts'
|
import { KeyboardInput, directionOf } from '../sim/input.ts'
|
||||||
import { NetplaySession } from '../net/netplay.ts'
|
import { NetplaySession } from '../net/netplay.ts'
|
||||||
import { socketTransport } from '../net/transport.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'
|
import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts'
|
||||||
|
|
||||||
/** Collision box at the character's feet. */
|
/** Collision box at the character's feet. */
|
||||||
|
|
@ -153,7 +156,7 @@ const state: NetState = {
|
||||||
actorSource: 'marker', monstersSpawned: 0,
|
actorSource: 'marker', monstersSpawned: 0,
|
||||||
socketFrames: 0, socketOpened: false, socketClosed: false, malformed: 0, socketInfo: 'none',
|
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. */
|
/** Raw members the scene needs. */
|
||||||
interface NetSources {
|
interface NetSources {
|
||||||
|
|
@ -395,16 +398,7 @@ function startScene(
|
||||||
* monsters — exactly once per tick, which is why it is one call and not one per
|
* monsters — exactly once per tick, which is why it is one call and not one per
|
||||||
* player.
|
* player.
|
||||||
*/
|
*/
|
||||||
const simulation: LockstepSimulation = {
|
const { simulation } = createNetSimulation(world, options, terrain, DEMO_EXPERIENCE, Math.max(peers, 1))
|
||||||
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)),
|
|
||||||
}
|
|
||||||
|
|
||||||
let session: NetplaySession | null = null
|
let session: NetplaySession | null = null
|
||||||
let socketView: WebSocket | 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.
|
* @param world - the world.
|
||||||
* @returns the digest string.
|
* @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.
|
* 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). */
|
/** Whether the page is running inside a document (avoids running under Node). */
|
||||||
if (typeof document !== 'undefined') boot()
|
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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,57 +7,57 @@ describe('computeLockstepHash', () => {
|
||||||
const ground: any[] = []
|
const ground: any[] = []
|
||||||
const quests: 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 } }
|
{ 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 } }
|
{ x: 0, y: 0, item: { name: 'B', stack: 1, value: 5 } }
|
||||||
]}
|
]}
|
||||||
|
|
||||||
const hash1 = computeLockstepHash(world, inv1, ground, quests)
|
const hash1 = computeLockstepHash(world, [inv1], ground, quests)
|
||||||
const hash2 = computeLockstepHash(world, inv2, ground, quests)
|
const hash2 = computeLockstepHash(world, [inv2], ground, quests)
|
||||||
|
|
||||||
expect(hash1).not.toBe(hash2)
|
expect(hash1).not.toBe(hash2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('detects changes in quest progress', () => {
|
it('detects changes in quest progress', () => {
|
||||||
const world = { tick: 1, kills: 0, players: [], monsters: [] }
|
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 ground: any[] = []
|
||||||
|
|
||||||
const quests1 = [ { def: { id: 'q1' }, status: 'active', kills: 0 } ]
|
const quests1 = [ { def: { id: 'q1' }, status: 'active', kills: 0 } ]
|
||||||
const quests2 = [ { def: { id: 'q1' }, status: 'completed', kills: 0 } ]
|
const quests2 = [ { def: { id: 'q1' }, status: 'completed', kills: 0 } ]
|
||||||
|
|
||||||
const hash1 = computeLockstepHash(world, inv, ground, quests1)
|
const hash1 = computeLockstepHash(world, [inv], ground, quests1)
|
||||||
const hash2 = computeLockstepHash(world, inv, ground, quests2)
|
const hash2 = computeLockstepHash(world, [inv], ground, quests2)
|
||||||
|
|
||||||
expect(hash1).not.toBe(hash2)
|
expect(hash1).not.toBe(hash2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('detects changes in ground items', () => {
|
it('detects changes in ground items', () => {
|
||||||
const world = { tick: 1, kills: 0, players: [], monsters: [] }
|
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 quests: any[] = []
|
||||||
|
|
||||||
const ground1 = [ { x: 5, y: 5, item: { name: 'Gold' } } ]
|
const ground1 = [ { x: 5, y: 5, item: { name: 'Gold' } } ]
|
||||||
const ground2 = [ { x: 5, y: 5, item: { name: 'Potion' } } ]
|
const ground2 = [ { x: 5, y: 5, item: { name: 'Potion' } } ]
|
||||||
|
|
||||||
const hash1 = computeLockstepHash(world, inv, ground1, quests)
|
const hash1 = computeLockstepHash(world, [inv], ground1, quests)
|
||||||
const hash2 = computeLockstepHash(world, inv, ground2, quests)
|
const hash2 = computeLockstepHash(world, [inv], ground2, quests)
|
||||||
|
|
||||||
expect(hash1).not.toBe(hash2)
|
expect(hash1).not.toBe(hash2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('produces identical hashes for identical states', () => {
|
it('produces identical hashes for identical states', () => {
|
||||||
const world = { tick: 1, kills: 0, players: [], monsters: [] }
|
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 } }
|
{ x: 1, y: 1, item: { name: 'Sword', stack: 1, value: 100 } }
|
||||||
]}
|
]}
|
||||||
const ground = [ { x: 5, y: 5, item: { name: 'Gold' } } ]
|
const ground = [ { x: 5, y: 5, item: { name: 'Gold' } } ]
|
||||||
const quests = [ { def: { id: 'q1' }, status: 'active', kills: 0 } ]
|
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
|
// Deep clone to ensure stability across new references
|
||||||
const world2 = JSON.parse(JSON.stringify(world))
|
const world2 = JSON.parse(JSON.stringify(world))
|
||||||
|
|
@ -65,7 +65,7 @@ describe('computeLockstepHash', () => {
|
||||||
const ground2 = JSON.parse(JSON.stringify(ground))
|
const ground2 = JSON.parse(JSON.stringify(ground))
|
||||||
const quests2 = JSON.parse(JSON.stringify(quests))
|
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)
|
expect(hash1).toBe(hash2)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue