core/store.js

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);
    });
  },
};