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