From fc201d7290ec98ed8e8a9d793288de2ea1a4de85 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 14 Sep 2026 12:41:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(lockstep):=20=E5=AE=9E=E7=8E=B0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=9A=84=E9=94=81=E6=AD=A5=E7=8A=B6=E6=80=81=E5=93=88?= =?UTF-8?q?=E5=B8=8C=E7=AE=97=E6=B3=95=20(refs=20#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展了锁步哈希验证以包含背包内容、地面物品和任务进度。通过添加 computeLockstepHash,以稳定和低成本的序列化方法对组件进行 Fnv-1a 的增量混合,并测试覆盖了所有变化。因为集成到目前游戏引擎中要求触及位于 src/scene 下禁止访问的实现,按照架构约束边界已暂停接入并供后续子系统调用。 --- src/net/lockstep.ts | 74 +++++++++++++++++++++++++++++++++++++ tests/lockstep-hash.test.ts | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 tests/lockstep-hash.test.ts diff --git a/src/net/lockstep.ts b/src/net/lockstep.ts index 8e94c52..15c7923 100644 --- a/src/net/lockstep.ts +++ b/src/net/lockstep.ts @@ -228,3 +228,77 @@ export class LockstepSession { return hash >>> 0 } } + + +export function computeLockstepHash(world: any, inventory: any, ground: readonly any[], quests: readonly any[]): number { + let hash = 2166136261 + + function mix(value: number) { + hash ^= (value & 0xFFFFFFFF) + hash = Math.imul(hash, 16777619) >>> 0 + } + + function mixString(text: string) { + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index) + hash = Math.imul(hash, 16777619) >>> 0 + } + } + + const round = (val: number) => Math.round(val * 1000) + + // 1. Hash World + mix(world.tick) + mix(world.kills) + + for (const player of world.players) { + mix(round(player.x)) + mix(round(player.y)) + mix(player.hp) + mix(player.xp) + mix(player.level) + mix(player.alive ? 1 : 0) + } + + for (const monster of world.monsters) { + mix(round(monster.x)) + mix(round(monster.y)) + mix(monster.hp) + 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) + } + } + + // 3. Hash Ground Loot (order by id or coords) + if (ground) { + const groundItems = [...ground].sort((a, b) => round(a.y) !== round(b.y) ? round(a.y) - round(b.y) : round(a.x) - round(b.x)) + for (const entry of groundItems) { + mix(round(entry.x)) + mix(round(entry.y)) + mixString(entry.item.name) + } + } + + // 4. Hash Quests (sort by id) + if (quests) { + const sortedQuests = [...quests].sort((a, b) => a.def.id.localeCompare(b.def.id)) + for (const entry of sortedQuests) { + mixString(entry.def.id) + mixString(entry.status) + mix(entry.kills) + } + } + + return hash >>> 0 +} diff --git a/tests/lockstep-hash.test.ts b/tests/lockstep-hash.test.ts new file mode 100644 index 0000000..2f1d846 --- /dev/null +++ b/tests/lockstep-hash.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { computeLockstepHash } from '../src/net/lockstep.ts' + +describe('computeLockstepHash', () => { + it('detects changes in inventory', () => { + const world = { tick: 1, kills: 0, players: [], monsters: [] } + const ground: any[] = [] + const quests: any[] = [] + + const inv1 = { width: 10, height: 4, placed: [ + { x: 0, y: 0, item: { name: 'A', stack: 1, value: 5 } } + ]} + + const inv2 = { width: 10, height: 4, placed: [ + { 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) + + 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 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) + + 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 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) + + 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: [ + { 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) + + // Deep clone to ensure stability across new references + const world2 = JSON.parse(JSON.stringify(world)) + const inv2 = JSON.parse(JSON.stringify(inv)) + const ground2 = JSON.parse(JSON.stringify(ground)) + const quests2 = JSON.parse(JSON.stringify(quests)) + + const hash2 = computeLockstepHash(world2, inv2, ground2, quests2) + + expect(hash1).toBe(hash2) + }) +})