fix(game): 移除不安全的类型转换并增加存档校验 (refs #7)

在 character.ts 中使用 flatMap 结合类型缩窄(null 检查),移除了对 DccFile 的强制类型转换,保证内部逻辑的编译期安全。在 save.ts 中增加了对背包数据的严格运行时有效性验证,避免由于恶意或破损存档导致的隐式游戏损坏,并用专用的反序列化测试覆盖新校验过程。
This commit is contained in:
troytt 2026-09-14 12:41:32 +00:00
parent fc201d7290
commit 4290e5feb2
3 changed files with 43 additions and 5 deletions

View File

@ -189,10 +189,12 @@ export async function loadCharacterSheet(
// the priority table is the authority on what covers what.
const order = cofLayerOrder(cof, direction, index)
const ordered = order
.map(layerIndex => { const sprite = sprites[layerIndex]; return { layerIndex, sprite } })
.filter(entry => entry.sprite !== null && entry.sprite !== undefined)
.flatMap(layerIndex => {
const sprite = sprites[layerIndex]
return sprite != null ? [{ layerIndex, sprite }] : []
})
for (const entry of ordered) {
const sprite = entry.sprite as DccFile
const sprite = entry.sprite
const dccDir = dir64ToDcc(dir64, sprite.directions.length)
const layerDirection = sprite.directions[dccDir]
const frame = layerDirection?.frames[index]

View File

@ -133,7 +133,16 @@ export function parseSnapshot(text: string): GameSnapshot {
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 || !Array.isArray(candidate.inventory.placed)) throw new Error('save has no inventory')
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
@ -171,7 +180,7 @@ export function restoreSnapshot(
// live world would otherwise leave the previous player list in place.
world: rebindPlayer({ ...snapshot.world, monsters: [...snapshot.world.monsters], players: [], events: [] }),
rngState: snapshot.rngState,
inventory: build.inventory(snapshot.inventory.width, snapshot.inventory.height, snapshot.inventory.placed as PlacedItem[]),
inventory: build.inventory(snapshot.inventory.width, snapshot.inventory.height, snapshot.inventory.placed),
quests: build.quests(snapshot.quests),
ground: snapshot.ground.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })),
}

27
tests/save-schema.test.ts Normal file
View File

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { parseSnapshot } from '../src/game/save.ts'
describe('parseSnapshot', () => {
it('rejects malformed placed items in inventory', () => {
const badSnapshot = {
version: 1,
rngState: 0,
world: {
tick: 0,
player: {},
monsters: [],
},
inventory: {
width: 10,
height: 4,
placed: [
{ x: 1, y: 1 }, // Missing item
]
},
quests: [],
ground: []
}
expect(() => parseSnapshot(JSON.stringify(badSnapshot))).toThrow('inventory placed item [0] has no item')
})
})