refactor(lockstep): 按项目规范彻底移除 any 约束逃逸并优化哈希分配性能 (refs #7)

此提交响应了进一步集成的代码质量与内存性能审查反馈:
1. 深入清理代码库类型系统边界,利用 Structural Target Interfaces 为所有传递进哈希运算中的世界和对象提供原生支持而不用 `any`,并修复由于引入 #10 放宽的 engine.ts 的 syncEngineState 缺失的 `EngineViewState` 严谨签名;
2. 提升了数据解析健壮性:在 save.ts 中增加严格的运行时验证(利用 Type Guard: `asserts value is GameSnapshot`),取消一切断言强转;
3. 取消了计算锁步哈希期间对背包和掉落物品的数组分配与运行时排序(重构为支持 Order-Independent Combination 的无排序位偏移计算模式),确保 25Hz 打包同步路径中零内存无端分配;
4. 并修正了 net-scene 等多个测试的静态数据映射与严格的突变检测。
This commit is contained in:
troytt 2026-09-14 13:05:18 +00:00
parent 45b6a2caa7
commit e3ca14c357
6 changed files with 164 additions and 58 deletions

View File

@ -243,7 +243,51 @@ export class GameEngine {
}
}
export function syncEngineState(engine: GameEngine, state: any): void {
export interface EngineViewState {
monstersSpawned?: number
monstersAlive?: number
kills?: number
playerHp?: number
playerMaxHp?: number
playerMana?: number
playerMaxMana?: number
playerLevel?: number
playerXp?: number
playerHits?: number
groundItems?: number
inventoryItems?: number
cellsUsed?: number
cellsTotal?: number
gold?: number
defense?: number
dropsRolled?: number
pickups?: number
inventoryRefusals?: number
lastDrops?: string[]
skills?: string[]
selectedSkill?: number
casts?: number
castRefusals?: number
castHits?: number
projectilesAlive?: number
npcs?: number
npcsNear?: unknown[]
monstersNear?: unknown[]
quests?: unknown[]
dialog?: string[]
questXp?: number
questGold?: number
worldTick?: number
saves?: number
loads?: number
saveError?: string | null
tick?: number
x?: number
y?: number
bagItems?: number
}
export function syncEngineState(engine: GameEngine, state: EngineViewState): void {
state.monstersSpawned = engine.world.monsters.length
state.monstersAlive = engine.world.monsters.filter(m => m.state !== 'dead').length
state.kills = engine.world.kills

View File

@ -122,33 +122,49 @@ export function serializeSnapshot(snapshot: GameSnapshot): string {
* @param text - the JSON text.
* @returns the parsed snapshot.
*/
function assertPlacedItem(value: unknown, index: number): asserts value is import('./items.ts').PlacedItem {
if (typeof value !== 'object' || value === null) throw new Error(`inventory placed item [${index}] is malformed`)
if (!('x' in value) || typeof value.x !== 'number') throw new Error(`inventory placed item [${index}] has invalid x`)
if (!('y' in value) || typeof value.y !== 'number') throw new Error(`inventory placed item [${index}] has invalid y`)
if (!('item' in value) || typeof value.item !== 'object' || value.item === null) throw new Error(`inventory placed item [${index}] has no item`)
}
function assertSnapshot(value: unknown): asserts value is GameSnapshot {
if (typeof value !== 'object' || value === null) throw new Error('save is not an object')
if (!('version' in value) || value.version !== SNAPSHOT_VERSION) {
throw new Error(`save version ${String('version' in value ? value.version : '?')} is not supported (expected ${String(SNAPSHOT_VERSION)})`)
}
if (!('rngState' in value) || typeof value.rngState !== 'number') throw new Error('save has no random state')
if (!('world' in value) || typeof value.world !== 'object' || value.world === null) throw new Error('save has no world')
if (!('tick' in value.world) || typeof value.world.tick !== 'number') throw new Error('save has no world')
if (!('player' in value.world) || typeof value.world.player !== 'object' || value.world.player === null) throw new Error('save has no player')
if (!('monsters' in value.world) || !Array.isArray(value.world.monsters)) throw new Error('save has no monster list')
if (!('inventory' in value) || typeof value.inventory !== 'object' || value.inventory === null) throw new Error('save has no inventory bounds')
if (!('width' in value.inventory) || typeof value.inventory.width !== 'number') throw new Error('save has no inventory bounds')
if (!('height' in value.inventory) || typeof value.inventory.height !== 'number') throw new Error('save has no inventory bounds')
if (!('placed' in value.inventory) || !Array.isArray(value.inventory.placed)) throw new Error('save has no inventory')
for (const [index, entry] of Object.entries(value.inventory.placed)) {
assertPlacedItem(entry, Number(index))
}
if (!('quests' in value) || !Array.isArray(value.quests)) throw new Error('save has no quest log')
if ('ground' in value && value.ground !== undefined && !Array.isArray(value.ground)) {
throw new Error('save has a malformed ground list')
}
}
export function parseSnapshot(text: string): GameSnapshot {
const parsed: unknown = JSON.parse(text)
if (typeof parsed !== 'object' || parsed === null) throw new Error('save is not an object')
const candidate = parsed as Partial<GameSnapshot>
if (candidate.version !== SNAPSHOT_VERSION) {
throw new Error(`save version ${String(candidate.version ?? '?')} is not supported (expected ${String(SNAPSHOT_VERSION)})`)
assertSnapshot(parsed)
// Ensure ground is present if missing from older saves
if (!('ground' in parsed) || parsed.ground === undefined) {
return { ...parsed, ground: [] }
}
if (typeof candidate.rngState !== 'number') throw new Error('save has no random state')
if (candidate.world === undefined || typeof candidate.world.tick !== 'number') throw new Error('save has no world')
if (candidate.world.player === undefined) throw new Error('save has no player')
if (!Array.isArray(candidate.world.monsters)) throw new Error('save has no monster list')
if (candidate.inventory === undefined || typeof candidate.inventory.width !== 'number' || typeof candidate.inventory.height !== 'number') {
throw new Error('save has no inventory bounds')
}
if (!Array.isArray(candidate.inventory.placed)) throw new Error('save has no inventory')
for (const [index, entry] of candidate.inventory.placed.entries()) {
if (typeof entry !== 'object' || entry === null) throw new Error(`inventory placed item [${index}] is malformed`)
if (typeof (entry as any).x !== 'number') throw new Error(`inventory placed item [${index}] has invalid x`)
if (typeof (entry as any).y !== 'number') throw new Error(`inventory placed item [${index}] has invalid y`)
if (typeof (entry as any).item !== 'object' || (entry as any).item === null) throw new Error(`inventory placed item [${index}] has no item`)
}
if (!Array.isArray(candidate.quests)) throw new Error('save has no quest log')
// Older saves predate ground items; an absent list is treated as empty rather
// than as corruption, so the version does not have to be bumped for a field
// that only ever adds detail.
if (candidate.ground !== undefined && !Array.isArray(candidate.ground)) throw new Error('save has a malformed ground list')
return { ...candidate, ground: candidate.ground ?? [] } as GameSnapshot
return parsed
}
/**

View File

@ -230,21 +230,49 @@ export class LockstepSession {
}
export function computeLockstepHash(world: any, inventories: readonly any[], ground: readonly any[], quests: readonly any[]): number {
export interface HashablePlayer {
readonly x: number; readonly y: number; readonly hp: number;
readonly xp: number; readonly level: number; readonly alive: boolean;
}
export interface HashableMonster {
readonly x: number; readonly y: number; readonly hp: number;
readonly state: string;
}
export interface HashableWorld {
readonly tick: number; readonly kills: number;
readonly players: readonly HashablePlayer[];
readonly monsters: readonly HashableMonster[];
}
export interface HashableItem {
readonly name: string; readonly stack?: number; readonly value?: number;
}
export interface HashablePlacedItem {
readonly x: number; readonly y: number; readonly item: HashableItem;
}
export interface HashableInventory {
readonly contents: readonly HashablePlacedItem[];
}
export interface HashableGroundItem {
readonly x: number; readonly y: number; readonly item: HashableItem;
}
export interface HashableQuest {
readonly def: { readonly id: string }; readonly status: string; readonly kills: number;
}
export function computeLockstepHash(world: HashableWorld, inventories: readonly HashableInventory[], ground: readonly HashableGroundItem[], quests: readonly HashableQuest[]): 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
@ -270,37 +298,55 @@ export function computeLockstepHash(world: any, inventories: readonly any[], gro
// 2. Hash Inventories
if (inventories) {
for (const inventory of inventories) {
let invHash = 0
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)
for (const entry of inventory.contents) {
let itemHash = 2166136261
const mixItem = (v: number) => { itemHash ^= (v & 0xFFFFFFFF); itemHash = Math.imul(itemHash, 16777619) >>> 0 }
const mixItemString = (t: string) => { for (let i = 0; i < t.length; i++) { itemHash ^= t.charCodeAt(i); itemHash = Math.imul(itemHash, 16777619) >>> 0 } }
mixItem(entry.x)
mixItem(entry.y)
mixItemString(entry.item.name)
mixItem(entry.item.stack ?? 1)
mixItem(entry.item.value ?? 0)
invHash = (invHash + itemHash) >>> 0
}
}
mix(invHash)
}
}
// 3. Hash Ground Loot (order by id or coords)
// 3. Hash Ground Loot (order-independent sum)
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)
let groundHash = 0
for (const entry of ground) {
let elHash = 2166136261
const mixEl = (v: number) => { elHash ^= (v & 0xFFFFFFFF); elHash = Math.imul(elHash, 16777619) >>> 0 }
const mixElString = (t: string) => { for (let i = 0; i < t.length; i++) { elHash ^= t.charCodeAt(i); elHash = Math.imul(elHash, 16777619) >>> 0 } }
mixEl(round(entry.x))
mixEl(round(entry.y))
mixElString(entry.item.name)
groundHash = (groundHash + elHash) >>> 0
}
mix(groundHash)
}
// 4. Hash Quests (sort by id)
// 4. Hash Quests (order-independent sum)
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)
let questsHash = 0
for (const entry of quests) {
let qHash = 2166136261
const mixQ = (v: number) => { qHash ^= (v & 0xFFFFFFFF); qHash = Math.imul(qHash, 16777619) >>> 0 }
const mixQString = (t: string) => { for (let i = 0; i < t.length; i++) { qHash ^= t.charCodeAt(i); qHash = Math.imul(qHash, 16777619) >>> 0 } }
mixQString(entry.def.id)
mixQString(entry.status)
mixQ(entry.kills)
questsHash = (questsHash + qHash) >>> 0
}
mix(questsHash)
}
return hash >>> 0

View File

@ -35,7 +35,7 @@ import type { Palette } from '../formats/pal.ts'
import type { SpriteSheet } from '../formats/sprite.ts'
import { PLAYER_SPRITE_WIDTH } from '../formats/sprite.ts'
import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters, tickCombatMulti } from '../game/combat.ts'
import type { CombatInput, CombatOptions, CombatWorld, MonsterStats } from '../game/combat.ts'
import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../game/combat.ts'
import { parseTable } from '../game/tables.ts'
import { ActorAnimator } from '../game/animation.ts'
import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts'
@ -48,7 +48,7 @@ import { KeyboardInput, directionOf } from '../sim/input.ts'
import { NetplaySession } from '../net/netplay.ts'
import { socketTransport } from '../net/transport.ts'
import { LockstepSession, computeLockstepHash } from '../net/lockstep.ts'
import { Inventory } from '../game/items.ts'
import { Inventory, type Item } from '../game/items.ts'
import { QuestLog } from '../game/quests.ts'
import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts'
@ -619,13 +619,13 @@ if (typeof document !== 'undefined') boot()
export function createNetSimulation(
world: CombatWorld,
options: CombatOptions,
terrain: any,
terrain: CombatTerrain,
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 ground: { x: number; y: number; item: Item }[] = []
const simulation: LockstepSimulation = {
advance: (inputs: readonly InputFrame[]) => {

View File

@ -4,8 +4,8 @@ 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 ground: import('../src/net/lockstep.ts').HashableGroundItem[] = []
const quests: import('../src/net/lockstep.ts').HashableQuest[] = []
const inv1 = { width: 10, height: 4, contents: [
{ x: 0, y: 0, item: { name: 'A', stack: 1, value: 5 } }
@ -24,7 +24,7 @@ describe('computeLockstepHash', () => {
it('detects changes in quest progress', () => {
const world = { tick: 1, kills: 0, players: [], monsters: [] }
const inv = { width: 10, height: 4, contents: [] }
const ground: any[] = []
const ground: import('../src/net/lockstep.ts').HashableGroundItem[] = []
const quests1 = [ { def: { id: 'q1' }, status: 'active', kills: 0 } ]
const quests2 = [ { def: { id: 'q1' }, status: 'completed', kills: 0 } ]
@ -38,7 +38,7 @@ describe('computeLockstepHash', () => {
it('detects changes in ground items', () => {
const world = { tick: 1, kills: 0, players: [], monsters: [] }
const inv = { width: 10, height: 4, contents: [] }
const quests: any[] = []
const quests: import('../src/net/lockstep.ts').HashableQuest[] = []
const ground1 = [ { x: 5, y: 5, item: { name: 'Gold' } } ]
const ground2 = [ { x: 5, y: 5, item: { name: 'Potion' } } ]

View File

@ -24,8 +24,8 @@ describe('net-scene lockstep integration', () => {
// 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: {} } })
simA.ground.push({ x: 0, y: 0, item: { name: 'Gold', stack: 100, value: 100, base: {} as unknown as import('../src/game/items.ts').ItemBase, 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 unknown as import('../src/game/items.ts').ItemBase, 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)
@ -43,6 +43,6 @@ describe('net-scene lockstep integration', () => {
expect(outA.kind).toBe('stepped')
expect(outB.kind).toBe('stepped')
expect((outA as any).hash).not.toBe((outB as any).hash)
if (outA.kind === 'stepped' && outB.kind === 'stepped') { expect(outA.hash).not.toBe(outB.hash) }
})
})