import { Registry } from "./registry.js";
import { Terrain } from "./terrain.js";
import { Agent } from "./agent.js";
import { Item } from "./item.js";
import { PLAYER } from "./flags.js";
import { ROWS, COLUMNS } from "./board.js";
const DB_NAME = "asciiroth";
const DB_VERSION = 1;
const STORE_NAME = "saves";
/** Open (or reuse) the IndexedDB database. */
function _openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "slotName" });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// ── Board serialization ───────────────────────────────────────────────────────
/**
* Serialize a Board's cell state to a plain object (safe to JSON.stringify).
* Player position is NOT saved here (managed by game.js).
*
* @param {Board} board
* @returns {object}
*/
export function serializeBoard(board) {
const cells = [];
for (let y = 0; y < ROWS; y++) {
const row = [];
for (let x = 0; x < COLUMNS; x++) {
const cell = board.cells[x][y];
// Only write fields that carry actual data — omitting null/empty fields
// cuts typical board size by ~50% since most cells have no agent or items.
const cd = {};
if (cell.terrain) cd.t = Registry.serialize(cell.terrain);
// Don't save the player agent — it's placed by the game controller
if (cell.agent && !cell.agent.is(PLAYER))
cd.a = Registry.serialize(cell.agent);
const items = cell.items.map((item) => Registry.serialize(item));
if (items.length) cd.i = items;
row.push(cd);
}
cells.push(row);
}
return {
cells,
outside: board.outside,
boardID: board.boardID,
startX: board.startX,
startY: board.startY,
scenarioName: board.scenarioName,
creator: board.creator,
description: board.description,
startInv: board.startInv,
adjacentBoards: Object.fromEntries(board._adjacentBoards),
};
}
/**
* Restore a Board's state from a serialized data object.
* @param {object} data
* @param {Board} board
*/
export function deserializeBoard(data, board) {
board.outside = data.outside ?? false;
board.boardID = data.boardID ?? "";
board.startX = data.startX ?? 0;
board.startY = data.startY ?? 0;
board.scenarioName = data.scenarioName ?? null;
board.creator = data.creator ?? null;
board.description = data.description ?? null;
board.startInv = data.startInv ?? null;
for (const [dirName, path] of Object.entries(data.adjacentBoards ?? {})) {
board.setAdjacentBoard(dirName, path);
}
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLUMNS; x++) {
const cellData = data.cells?.[y]?.[x];
if (!cellData) continue;
const cell = board.cells[x][y];
if (cellData.t) {
const t = Registry.get(cellData.t);
if (t instanceof Terrain) cell.setTerrain(t);
}
if (cellData.a) {
const a = Registry.get(cellData.a);
if (a instanceof Agent) cell.setAgent(a);
}
for (const itemKey of cellData.i ?? []) {
const item = Registry.get(itemKey);
if (item instanceof Item) cell.addItem(item);
}
}
}
}
// ── Player serialization ──────────────────────────────────────────────────────
/**
* Serialize the player to a plain object.
* @param {Player} player
* @returns {object}
*/
export function serializePlayer(player, board) {
return {
name: player.name,
scenarioURL: player.scenarioURL,
boardID: player.boardID,
startX: player.startX,
startY: player.startY,
playerX: board?.playerX ?? -1,
playerY: board?.playerY ?? -1,
health: player.health,
flags: player.flags,
bagEntries: player.bag.entries.slice(1).map((e) => ({
key: Registry.serialize(e.piece),
count: e.count,
ammo: e.ammo,
})),
bagSelectedIndex: player.bag.entries.indexOf(
player.bag.entries.find((e) => player.bag.isSelected(e)),
),
unsavedMaps: [...player.unsavedMaps.entries()].map(([k, v]) => [k, v]),
};
}
/**
* Restore the player's mutable state from a serialized object.
* The player instance must already exist; this mutates it.
* @param {object} data
* @param {Player} player
*/
export function deserializePlayer(data, player) {
player.health = data.health ?? 255;
// Restore flags (keep PLAYER | NOT_EDITABLE | ORGANIC, replace status flags)
const BASE_FLAGS = player.flags & 0b1111111; // preserve immutable bits
player.flags = BASE_FLAGS | ((data.flags ?? 0) & ~0b1111111);
// Restore bag (clear and re-add, keeping EMPTY_HANDED at index 0)
player.bag._entries.length = 1;
for (const entry of data.bagEntries ?? []) {
const item = Registry.get(entry.key);
if (!(item instanceof Item)) continue;
for (let i = 0; i < (entry.count ?? 1); i++) {
player.bag.add(item);
}
}
player.bag.setInitialSelection(data.bagSelectedIndex ?? 0);
player.unsavedMaps = new Map(data.unsavedMaps ?? []);
}
// ── Store singleton ───────────────────────────────────────────────────────────
/**
* IndexedDB-backed wrapper for game saves.
* All methods are async. Quota is effectively unlimited (50% of available disk).
*/
export const store = {
/**
* Save the current game state under the given slot name.
* @param {Player} player
* @param {Board} board
* @param {string} slotName
* @returns {Promise<void>}
*/
async save(player, board, slotName) {
// Snapshot the current board into the player's unsavedMaps
player.unsavedMaps.set(
player.boardID,
JSON.stringify(serializeBoard(board)),
);
const payload = {
slotName,
savedAt: Date.now(),
player: serializePlayer(player, board),
};
const db = await _openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(payload);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
/**
* Return the raw save payload for the given slot, or null if it doesn't exist.
* @param {string} slotName
* @returns {Promise<object|null>}
*/
async load(slotName) {
const db = await _openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).get(slotName);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
},
/**
* Return a list of all saved games, sorted newest-first.
* @returns {Promise<Array<{slotName: string, savedAt: number}>>}
*/
async listSaves() {
const db = await _openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => {
const saves = req.result.map(({ slotName, savedAt }) => ({
slotName,
savedAt: savedAt ?? 0,
}));
saves.sort((a, b) => b.savedAt - a.savedAt);
resolve(saves);
};
req.onerror = () => reject(req.error);
});
},
/**
* True if at least one named save exists.
* @returns {Promise<boolean>}
*/
async hasSave() {
const db = await _openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).count();
req.onsuccess = () => resolve(req.result > 0);
req.onerror = () => reject(req.error);
});
},
/**
* Delete the named save slot.
* @param {string} slotName
* @returns {Promise<void>}
*/
async delete(slotName) {
const db = await _openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(slotName);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
};