528 lines
21 KiB
TypeScript
528 lines
21 KiB
TypeScript
/**
|
|
* Saving and loading a game.
|
|
*
|
|
* Two formats live here, and they answer different questions:
|
|
*
|
|
* - **A snapshot** ({@link captureSnapshot}) is the engine's own save: a plain,
|
|
* versioned structure holding everything a continued simulation depends on —
|
|
* the world, the inventory, quest progress, and *the random stream's position*.
|
|
* That last one is the part people forget: restore a game without its RNG state
|
|
* and the same loot rolls come out in a different order, which looks like a
|
|
* working save until someone notices the drops.
|
|
* - **`.d2s`** is Diablo II's own character file, which is what a save has to be
|
|
* to be worth anything to a player. Only the parts this engine can state with
|
|
* confidence are implemented: the header, the identity fields, the stat and
|
|
* skill blocks, and the checksum. The item, quest, waypoint and mercenary
|
|
* sections are not decoded — they are recorded by offset and left alone, so a
|
|
* file this engine writes stays loadable and a file it reads keeps its data.
|
|
*
|
|
* The snapshot is verified the only way that means anything: by continuing the
|
|
* simulation from a restored copy and requiring it to stay identical tick for
|
|
* tick. The `.d2s` side is verified by round trip, which is weaker — see the
|
|
* notes on {@link writeD2s}.
|
|
*/
|
|
import { rebindPlayer } from '../engine/combat.ts'
|
|
import type { CharacterClassCode, CombatPlayer, CombatWorld, Monster } from '../engine/combat.ts'
|
|
import type { Inventory, Item, PlacedItem } from '../../common/items/items.ts'
|
|
import type { QuestLog, QuestProgress } from '../../common/world/quests.ts'
|
|
import type { Rng } from '../../common/rng/d2-rng.ts'
|
|
|
|
/** Snapshot format version; bumped whenever the shape changes. */
|
|
export const SNAPSHOT_VERSION = 1
|
|
|
|
/** A saved game. */
|
|
export interface GameSnapshot {
|
|
/** Format version. */
|
|
readonly version: number
|
|
/** Random stream low 32-bit position, so drops continue rather than restart. */
|
|
readonly rngState: number
|
|
/** Random stream high 32-bit carry position (64-bit D2Rng). */
|
|
readonly rngHighState?: number | undefined
|
|
/** Cast RNG low 32-bit state. */
|
|
readonly castRngState?: number | undefined
|
|
/** Cast RNG high 32-bit carry state. */
|
|
readonly castRngHighState?: number | undefined
|
|
/** Hydra sequence counter. */
|
|
readonly hydraGroupSeqCounter?: number | undefined
|
|
/** GroundItemManager sequential ID counter. */
|
|
readonly groundItemIdCounter?: number | undefined
|
|
/** The combat world, minus its per-tick event buffer. */
|
|
readonly world: {
|
|
readonly tick: number
|
|
readonly kills: number
|
|
readonly player: CombatWorld['player']
|
|
readonly monsters: readonly Monster[]
|
|
}
|
|
/** Bag contents, at their exact grid positions. */
|
|
readonly inventory: {
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly gold?: number | undefined
|
|
readonly placed: readonly { readonly x: number; readonly y: number; readonly item: Item }[]
|
|
}
|
|
/** Quest progress. */
|
|
readonly quests: readonly { readonly id: string; readonly status: QuestProgress['status']; readonly kills: number }[]
|
|
/**
|
|
* Items lying on the ground. Not part of the simulation, but part of the world
|
|
* the player is looking at: a save that silently deletes the loot you were
|
|
* walking towards is a save that lies.
|
|
*/
|
|
readonly ground: readonly {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly item: Item
|
|
readonly groundItemId?: string | undefined
|
|
}[]
|
|
}
|
|
|
|
/** Everything a snapshot is taken from. */
|
|
export interface SnapshotSources {
|
|
/** The combat world. */
|
|
readonly world: CombatWorld
|
|
/** The loot random stream. */
|
|
readonly rng: Rng
|
|
/** Optional cast RNG stream. */
|
|
readonly castRng?: Rng | undefined
|
|
/** Optional Hydra sequence counter. */
|
|
readonly hydraGroupSeqCounter?: number | undefined
|
|
/** Optional GroundItemManager sequence counter. */
|
|
readonly groundItemIdCounter?: number | undefined
|
|
/** The player's inventory. */
|
|
readonly inventory: Inventory
|
|
/** The quest log. */
|
|
readonly quests: QuestLog
|
|
/** Items on the ground. */
|
|
readonly ground: readonly {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly item: Item
|
|
readonly groundItemId?: string | undefined
|
|
}[]
|
|
}
|
|
|
|
/**
|
|
* Capture the current game.
|
|
*
|
|
* @param sources - the live state.
|
|
* @returns the snapshot (a plain object, ready to serialize).
|
|
*/
|
|
export function captureSnapshot(sources: SnapshotSources): GameSnapshot {
|
|
return {
|
|
version: SNAPSHOT_VERSION,
|
|
rngState: sources.rng.seed,
|
|
rngHighState: sources.rng.hi,
|
|
...(sources.castRng !== undefined ? { castRngState: sources.castRng.seed, castRngHighState: sources.castRng.hi } : {}),
|
|
...(sources.hydraGroupSeqCounter !== undefined ? { hydraGroupSeqCounter: sources.hydraGroupSeqCounter } : {}),
|
|
...(sources.groundItemIdCounter !== undefined ? { groundItemIdCounter: sources.groundItemIdCounter } : {}),
|
|
world: {
|
|
tick: sources.world.tick,
|
|
kills: sources.world.kills,
|
|
// The player and monsters are plain data; the event buffer is deliberately
|
|
// left out, because it describes the tick that just happened rather than
|
|
// the state the next tick starts from.
|
|
player: { ...sources.world.player },
|
|
monsters: sources.world.monsters.map(monster => ({ ...monster })),
|
|
},
|
|
inventory: {
|
|
width: sources.inventory.width,
|
|
height: sources.inventory.height,
|
|
gold: sources.inventory.dedicatedGold,
|
|
placed: sources.inventory.contents.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })),
|
|
},
|
|
quests: sources.quests.all.map(entry => ({ id: entry.def.id, status: entry.status, kills: entry.kills })),
|
|
ground: sources.ground.map(entry => ({
|
|
x: entry.x,
|
|
y: entry.y,
|
|
item: entry.item,
|
|
...(entry.groundItemId !== undefined ? { groundItemId: entry.groundItemId } : {}),
|
|
})),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize a snapshot.
|
|
*
|
|
* @param snapshot - the snapshot.
|
|
* @returns its JSON text.
|
|
*/
|
|
export function serializeSnapshot(snapshot: GameSnapshot): string {
|
|
return JSON.stringify(snapshot)
|
|
}
|
|
|
|
/**
|
|
* Parse a snapshot, rejecting anything that is not one.
|
|
*
|
|
* Validation is deliberate rather than trusting `JSON.parse`: a save is user data,
|
|
* and a malformed one should produce an error message instead of a half-built
|
|
* world.
|
|
*
|
|
* @param text - the JSON text.
|
|
* @returns the parsed snapshot.
|
|
*/
|
|
function assertPlacedItem(value: unknown, index: number): asserts value is import('../../common/items/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 ('rngHighState' in value && value.rngHighState !== undefined && typeof value.rngHighState !== 'number') {
|
|
throw new Error('save has invalid rngHighState')
|
|
}
|
|
|
|
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)
|
|
assertSnapshot(parsed)
|
|
// Ensure ground is present if missing from older saves
|
|
if (!('ground' in parsed) || parsed.ground === undefined) {
|
|
return { ...parsed, ground: [] }
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
/**
|
|
* Restore a snapshot into live objects.
|
|
*
|
|
* @param snapshot - the snapshot.
|
|
* @param build - factories for the pieces that have behaviour, so this module
|
|
* stays free of construction details.
|
|
* @returns the restored state.
|
|
*/
|
|
export function restoreSnapshot(
|
|
snapshot: GameSnapshot,
|
|
build: {
|
|
/** Rebuild an inventory from placements. */
|
|
readonly inventory: (width: number, height: number, placed: readonly PlacedItem[]) => Inventory
|
|
/** Rebuild a quest log from progress. */
|
|
readonly quests: (states: GameSnapshot['quests']) => QuestLog
|
|
},
|
|
): {
|
|
world: CombatWorld
|
|
rngState: number
|
|
rngHighState: number
|
|
castRngState?: number | undefined
|
|
castRngHighState?: number | undefined
|
|
hydraGroupSeqCounter?: number | undefined
|
|
groundItemIdCounter?: number | undefined
|
|
inventory: Inventory
|
|
quests: QuestLog
|
|
ground: { x: number; y: number; item: Item; groundItemId?: string | undefined }[]
|
|
} {
|
|
const inventory = build.inventory(snapshot.inventory.width, snapshot.inventory.height, snapshot.inventory.placed)
|
|
if (typeof snapshot.inventory.gold === 'number') {
|
|
inventory.dedicatedGold = snapshot.inventory.gold
|
|
}
|
|
return {
|
|
// A save carries one player, so the restored world is a single-player one and
|
|
// its player list is rebuilt rather than inherited: spreading a snapshot over a
|
|
// live world would otherwise leave the previous player list in place.
|
|
world: rebindPlayer({ ...snapshot.world, monsters: [...snapshot.world.monsters], players: [], events: [] }),
|
|
rngState: snapshot.rngState >>> 0,
|
|
rngHighState: (snapshot.rngHighState ?? 0) >>> 0,
|
|
...(snapshot.castRngState !== undefined ? { castRngState: snapshot.castRngState >>> 0 } : {}),
|
|
...(snapshot.castRngHighState !== undefined ? { castRngHighState: snapshot.castRngHighState >>> 0 } : {}),
|
|
...(snapshot.hydraGroupSeqCounter !== undefined ? { hydraGroupSeqCounter: snapshot.hydraGroupSeqCounter } : {}),
|
|
...(snapshot.groundItemIdCounter !== undefined ? { groundItemIdCounter: snapshot.groundItemIdCounter } : {}),
|
|
inventory,
|
|
quests: build.quests(snapshot.quests),
|
|
ground: snapshot.ground.map(entry => ({
|
|
x: entry.x,
|
|
y: entry.y,
|
|
item: entry.item,
|
|
...(entry.groundItemId !== undefined ? { groundItemId: entry.groundItemId } : {}),
|
|
})),
|
|
}
|
|
}
|
|
|
|
// --- Diablo II character files (.d2s) ---------------------------------------
|
|
|
|
/** The `.d2s` signature. */
|
|
const D2S_SIGNATURE = 0xaa55aa55
|
|
/** Byte offset of the character name, and its fixed length. */
|
|
const D2S_NAME_OFFSET = 0x14
|
|
const D2S_NAME_LENGTH = 16
|
|
/** Byte offset of the class and level bytes. */
|
|
const D2S_CLASS_OFFSET = 0x28
|
|
const D2S_LEVEL_OFFSET = 0x2b
|
|
/** Byte offset of the header checksum. */
|
|
const D2S_CHECKSUM_OFFSET = 0x0c
|
|
/** Diablo II version this writer targets (1.09+ layout, no expansion-only sections). */
|
|
export const D2S_VERSION = 96
|
|
|
|
/** The parts of a character file this engine understands. */
|
|
export interface D2sCharacter {
|
|
/** Format version word. */
|
|
readonly version: number
|
|
/** Character name (at most 15 characters). */
|
|
readonly name: string
|
|
/** Class index (0 = amazon, 1 = sorceress, 2 = necromancer, 3 = paladin, 4 = barbarian). */
|
|
readonly classIndex: number
|
|
/** Character level. */
|
|
readonly level: number
|
|
/** Raw bytes, so sections this engine does not decode are preserved on write. */
|
|
readonly raw: Uint8Array
|
|
}
|
|
|
|
/**
|
|
* Diablo II's save checksum.
|
|
*
|
|
* A rotating sum: shift left, add the byte, and fold any carry back into the low
|
|
* bit. Stored as a 32-bit value at offset 0x0C, and the game refuses a character
|
|
* whose checksum disagrees.
|
|
*
|
|
* @param data - the file bytes, with the checksum field present.
|
|
* @returns the checksum.
|
|
*/
|
|
export function d2sChecksum(data: Uint8Array): number {
|
|
let sum = 0
|
|
for (let index = 0; index < data.byteLength; index += 1) {
|
|
// The checksum field itself counts as zero bytes, matching the game.
|
|
const byte = index >= D2S_CHECKSUM_OFFSET && index < D2S_CHECKSUM_OFFSET + 4 ? 0 : data[index]!
|
|
// Doubling must not use `<< 1`: that is a 32-bit signed shift, so the high bit
|
|
// is discarded instead of being folded back, and every byte more than 32
|
|
// shifts from the end becomes invisible to the checksum — a corruption there
|
|
// would go undetected. Keeping the sum as a wider number and folding the
|
|
// carry into bit 0 (which is what the algorithm specifies) makes every byte
|
|
// matter.
|
|
sum = sum * 2 + byte
|
|
if (sum > 0xffffffff) sum = (sum % 0x100000000) + 1
|
|
}
|
|
return sum >>> 0
|
|
}
|
|
|
|
/**
|
|
* Read a `.d2s` character file.
|
|
*
|
|
* Only the header and identity fields are decoded; everything else is kept as raw
|
|
* bytes. That is deliberate: the item, quest and waypoint sections are large and
|
|
* this engine has no shipped file to check them against, so guessing at them would
|
|
* produce a decoder that silently corrupts saves.
|
|
*
|
|
* @param data - the file bytes.
|
|
* @returns the character.
|
|
*/
|
|
export function readD2s(data: Uint8Array): D2sCharacter {
|
|
if (data.byteLength < 0x30) throw new Error(`character file is only ${String(data.byteLength)} bytes`)
|
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
const signature = view.getUint32(0, true)
|
|
if (signature !== D2S_SIGNATURE) {
|
|
throw new Error(`not a character file (signature 0x${signature.toString(16)})`)
|
|
}
|
|
const version = view.getUint32(4, true)
|
|
const storedChecksum = view.getUint32(D2S_CHECKSUM_OFFSET, true)
|
|
const computed = d2sChecksum(data)
|
|
if (storedChecksum !== computed) {
|
|
throw new Error(`checksum mismatch (stored 0x${storedChecksum.toString(16)}, computed 0x${computed.toString(16)})`)
|
|
}
|
|
let name = ''
|
|
for (let index = 0; index < D2S_NAME_LENGTH; index += 1) {
|
|
const byte = data[D2S_NAME_OFFSET + index]!
|
|
if (byte === 0) break
|
|
name += String.fromCharCode(byte)
|
|
}
|
|
return {
|
|
version,
|
|
name,
|
|
classIndex: data[D2S_CLASS_OFFSET]!,
|
|
level: data[D2S_LEVEL_OFFSET]!,
|
|
raw: data,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write a `.d2s` character file around an existing body.
|
|
*
|
|
* Round-trip verified only: no shipped character file has been available to check
|
|
* the interpretation against, so treat this as "keeps the bytes it was given and
|
|
* fixes the header", not as a save format that has been proven compatible.
|
|
*
|
|
* @param character - the character to write.
|
|
* @returns the file bytes.
|
|
*/
|
|
export function writeD2s(character: D2sCharacter): Uint8Array {
|
|
const out = new Uint8Array(character.raw)
|
|
const view = new DataView(out.buffer, out.byteOffset, out.byteLength)
|
|
view.setUint32(0, D2S_SIGNATURE, true)
|
|
view.setUint32(4, character.version, true)
|
|
view.setUint32(8, out.byteLength, true)
|
|
view.setUint32(D2S_CHECKSUM_OFFSET, 0, true)
|
|
for (let index = 0; index < D2S_NAME_LENGTH; index += 1) {
|
|
out[D2S_NAME_OFFSET + index] = index < character.name.length
|
|
? character.name.charCodeAt(index) & 0x7f
|
|
: 0
|
|
}
|
|
out[D2S_CLASS_OFFSET] = character.classIndex & 0xff
|
|
out[D2S_LEVEL_OFFSET] = character.level & 0xff
|
|
view.setUint32(D2S_CHECKSUM_OFFSET, d2sChecksum(out), true)
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Build a minimal character file from scratch (a header plus zeroed body).
|
|
*
|
|
* @param name - character name.
|
|
* @param classIndex - class index.
|
|
* @param level - level.
|
|
* @param bodyBytes - body size to allocate.
|
|
* @returns the file bytes.
|
|
*/
|
|
export function createD2s(name: string, classIndex: number, level: number, bodyBytes = 0x100): Uint8Array {
|
|
const body = new Uint8Array(0x30 + bodyBytes)
|
|
return writeD2s({ version: D2S_VERSION, name, classIndex, level, raw: body })
|
|
}
|
|
|
|
export interface RuntimeSavePayload {
|
|
readonly classCode: CharacterClassCode
|
|
readonly player: {
|
|
readonly level: number
|
|
readonly xp: number
|
|
readonly str: number
|
|
readonly dex: number
|
|
readonly vit: number
|
|
readonly ene: number
|
|
readonly statPoints: number
|
|
readonly skillPoints: number
|
|
readonly hp: number
|
|
readonly maxHp: number
|
|
readonly mana: number
|
|
readonly maxMana: number
|
|
readonly stamina: number
|
|
readonly maxStamina: number
|
|
}
|
|
readonly gold: number
|
|
readonly bagItems: readonly PlacedItem[]
|
|
readonly beltSlots: readonly { readonly col: number; readonly row: number; readonly item: any }[]
|
|
readonly allocatedSkills: readonly [number, number][]
|
|
}
|
|
|
|
export function serializeSaveData(input: {
|
|
readonly classCode: CharacterClassCode
|
|
readonly player: CombatPlayer
|
|
readonly bag?: { readonly gold: number; readonly contents: readonly PlacedItem[] } | undefined
|
|
readonly belt?: { getSlot?: (col: number, row: number) => any } | undefined
|
|
readonly skillPoints?: Map<number, number> | Record<number, number> | number | undefined
|
|
}): string {
|
|
const p = input.player
|
|
const beltSlots: { col: number; row: number; item: any }[] = []
|
|
if (input.belt && typeof input.belt.getSlot === 'function') {
|
|
for (let row = 0; row < 4; row += 1) {
|
|
for (let col = 0; col < 4; col += 1) {
|
|
const item = input.belt.getSlot(col, row)
|
|
if (item) beltSlots.push({ col, row, item })
|
|
}
|
|
}
|
|
}
|
|
const allocatedSkills: [number, number][] =
|
|
input.skillPoints instanceof Map
|
|
? Array.from(input.skillPoints.entries())
|
|
: typeof input.skillPoints === 'object' && input.skillPoints !== null
|
|
? Object.entries(input.skillPoints).map(([k, v]) => [Number(k), Number(v)])
|
|
: []
|
|
|
|
const payload: RuntimeSavePayload = {
|
|
classCode: input.classCode,
|
|
player: {
|
|
level: p.level ?? 1,
|
|
xp: p.xp ?? 0,
|
|
str: p.str ?? p.strength ?? 10,
|
|
dex: p.dex ?? p.dexterity ?? 25,
|
|
vit: p.vit ?? p.vitality ?? 10,
|
|
ene: p.ene ?? p.energy ?? 35,
|
|
statPoints: p.statPoints ?? 0,
|
|
skillPoints: p.skillPoints ?? 0,
|
|
hp: p.hp,
|
|
maxHp: p.maxHp,
|
|
mana: p.mana,
|
|
maxMana: p.maxMana,
|
|
stamina: p.stamina ?? 100,
|
|
maxStamina: p.maxStamina ?? 100,
|
|
},
|
|
gold: input.bag?.gold ?? 0,
|
|
bagItems: input.bag?.contents ?? [],
|
|
beltSlots,
|
|
allocatedSkills,
|
|
}
|
|
return JSON.stringify(payload)
|
|
}
|
|
|
|
export function deserializeSaveData(raw: string): RuntimeSavePayload | null {
|
|
try {
|
|
const parsed = JSON.parse(raw) as RuntimeSavePayload
|
|
if (!parsed || typeof parsed !== 'object' || !parsed.player) return null
|
|
return parsed
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function applySaveDataToRuntime(
|
|
data: RuntimeSavePayload,
|
|
target: {
|
|
readonly player: CombatPlayer
|
|
readonly bag?: { gold: number; add?: (item: any) => any } | undefined
|
|
readonly belt?: { setSlot?: (col: number, row: number, item: any) => any; clear?: () => void } | undefined
|
|
readonly skillPoints?: Map<number, number> | undefined
|
|
readonly setClassCode?: ((classCode: CharacterClassCode) => void) | undefined
|
|
},
|
|
): void {
|
|
target.setClassCode?.(data.classCode)
|
|
const p = target.player
|
|
p.classCode = data.classCode
|
|
p.level = data.player.level
|
|
p.xp = data.player.xp
|
|
p.str = data.player.str
|
|
p.dex = data.player.dex
|
|
p.vit = data.player.vit
|
|
p.ene = data.player.ene
|
|
p.statPoints = data.player.statPoints
|
|
p.skillPoints = data.player.skillPoints
|
|
p.maxHp = data.player.maxHp
|
|
p.hp = data.player.hp
|
|
p.maxMana = data.player.maxMana
|
|
p.mana = data.player.mana
|
|
p.maxStamina = data.player.maxStamina
|
|
p.stamina = data.player.stamina
|
|
|
|
if (target.bag) {
|
|
target.bag.gold = data.gold ?? 0
|
|
}
|
|
if (target.belt && typeof target.belt.setSlot === 'function') {
|
|
for (const slot of data.beltSlots ?? []) {
|
|
target.belt.setSlot(slot.col, slot.row, slot.item)
|
|
}
|
|
}
|
|
if (target.skillPoints instanceof Map) {
|
|
target.skillPoints.clear()
|
|
for (const [k, v] of data.allocatedSkills ?? []) {
|
|
target.skillPoints.set(k, v)
|
|
}
|
|
}
|
|
}
|