models/bag.js

import Item from "./item.js";
import Model from "./model.js";
import { sentenceCase } from "../string_utils.js";

function copy(entries) {
  return (entries || []).map((entry) => ({ item: new Item(entry.item), count: entry.count }));
}

function toString(bag) {
  let string = "";
  if (bag.name && bag.description) {
    string += `<b>${bag.name}</b> (${bag.description.trim()}): `;
  } else if (bag.name) {
    string += `<b>${bag.name}:</b> `;
  }
  let cash = 0;
  if (bag.entries.length) {
    let len = bag.entries.filter((entry) => entry.item.not("cash")).length;
    bag.entries.forEach((entry) => {
      if (entry.item.is("cash")) {
        cash += entry.item.value /* 100*/ * entry.count;
      } else {
        string += bag.uncountable ? entry.item.toString(-1) : entry.item.toString(entry.count);
        if (len === 1) {
          string += ".";
        } else if (len === 2) {
          string += ", and ";
        } else {
          string += ", ";
        }
        len--;
      }
    });
    if (cash) {
      string += " $" + cash.toFixed(0) + " in cash.";
    }
    string = sentenceCase(string);
  } else {
    if (bag.name || bag.description) {
      string += "empty.";
    } else {
      string += "Empty.";
    }
  }
  return string;
}

/**
 * Represents a container that holds items with quantities. Provides functionality for adding,
 * removing, and querying items with support for value and encumbrance calculations. It also
 * provides the facility to label the bag since many representations in the game are just
 * collections of items for particular simulations.
 *
 * @extends Model
 */
class Bag extends Model {
  /**
   * Creates a new Bag instance.
   *
   * @constructor
   * @param {Object} [data={}] - Configuration data for the bag
   * @param {Array} [data.entries] - Array of bag entries with item and count properties
   * @param {boolean} [data.uncountable=false] - true if the items are not counted in the
   *        bag (counts are ignored), false if they are counted individually
   * @param {String[]} [data.tags] - Array of tags to categorize this bag
   * @param {String} [data.name] - a label for the bag
   * @param {String} [data.description] - details about the bag
   */
  constructor(data = {}) {
    super(data);
    this.uncountable = data.uncountable ?? false;
    this.entries = copy(data.entries);
    this.name = data.name;
    this.description = data.description;
    this.type = "Bag";
  }

  /**
   * Adds all items from another bag to this bag.
   *
   * @param {Bag} bag - The bag whose items to add
   */
  addBag(bag) {
    ((bag && bag.entries) || []).forEach((entry) => this.add(entry.item, entry.count));
  }

  /**
   * Removes all items from another bag from this bag.
   *
   * @param {Bag} bag - The bag whose items to remove
   */
  removeBag(bag) {
    ((bag && bag.entries) || []).forEach((entry) => this.remove(entry.item, entry.count));
  }

  /**
   * Adds items to the bag. If the item already exists, increases the count.
   *
   * @param {Item} item - The item to add
   * @param {Number} [count=1] - The number of items to add
   * @returns {Number} The new total count of this item in the bag
   * @throws {Error} If no item is provided, count is negative, or item is null
   */
  add(item, count = 1) {
    if (!item) {
      throw new Error("No item passed to bag.add()");
    }
    if (count < 0) {
      throw new Error("Cannot add a negative number of items");
    }
    if (count == 0) {
      return 0;
    }
    let entry = this.#visit(null, (e) => e.item.equals(item));
    if (!entry) {
      entry = { item: new Item(item), count: 0 };
      this.entries.push(entry);
    }
    entry.count += count;
    return entry.count;
  }

  /**
   * Removes items from the bag. If count reaches zero, removes the entry entirely.
   *
   * @param {Item} item - The item to remove
   * @param {Number} [count=1] - The number of items to remove
   * @returns {Number} The new total count of this item in the bag
   * @throws {Error} If count is negative, item not in bag, or trying to remove more than available
   */
  remove(item, count = 1) {
    if (count < 0) {
      throw new Error("Cannot remove a negative number of items");
    }
    let entry = this.#visit(null, (e) => e.item.equals(item));
    if (!entry) {
      throw new Error("Can’t remove item that’s not in the bag");
    }
    if (count > entry.count) {
      throw new Error(`Can’t remove ${count} items in bag that has only ${entry.count}`);
    }
    if (count == 0) {
      return entry.count;
    }
    entry.count -= count;
    if (entry.count === 0) {
      this.entries.splice(this.entries.indexOf(entry), 1);
    }
    return entry.count;
  }

  typeOf(tag) {
    return this.#visit(
      [],
      (e) => e.item.typeOf(tag),
      (e, arr) => {
        arr.push(e.item);
        return arr;
      },
    );
  }

  /**
   * Visits each entry in the bag with a predicate and collector function.
   *
   * @param {*} startValue - Initial value for the accumulator
   * @param {Function} pred - Predicate function to test each entry
   * @param {Function} [collector] - Function to collect/transform matching entries
   * @returns {*} The accumulated result
   */
  #visit(startValue, pred, collector = (e) => e) {
    return this.entries.reduce((acc, entry) => {
      if (pred(entry, acc)) acc = collector(entry, acc);
      return acc;
    }, startValue);
  }

  /**
   * Finds an entry by item name or tag.
   *
   * @param {String} name - The name or tag to search for
   * @returns {Object|null} The matching entry or null if not found
   */
  find(name) {
    return this.#visit(null, (e) => e.item.name === name || e.item.tags.has(name));
  }

  /**
   * Calculates the total value of items in the bag.
   *
   * @param {Item} [item] - Optional specific item to calculate value for
   * @returns {Number} The total value
   */
  value(item) {
    return this.#visit(
      0,
      (e) => !item || e.item.equals(item),
      (e, value) => value + e.item.value * e.count,
    );
  }

  /**
   * Calculates the total encumbrance of items in the bag.
   *
   * @param {Item} [item] - Optional specific item to calculate encumbrance for
   * @returns {Number} The total encumbrance
   */
  enc(item) {
    return this.#visit(
      0,
      (e) => !item || e.item.equals(item),
      (e, sum) => sum + e.item.enc * e.count,
    );
  }

  /**
   * Calculates the total value of all items in the bag.
   *
   * @returns {Number} The total value of all items
   */
  totalValue() {
    return this.#visit(
      0,
      () => true,
      (e, sum) => sum + e.item.value * e.count,
    );
  }

  /**
   * Calculates the total encumbrance of all items in the bag.
   *
   * @returns {Number} The total encumbrance of all items
   */
  totalEnc() {
    return this.#visit(
      0,
      () => true,
      (e, sum) => sum + e.item.enc * e.count,
    );
  }

  /**
   * Counts the total number of items in the bag.
   *
   * @param {Item} [item] - Optional specific item to count
   * @returns {Number} The total count of items
   */
  count(item) {
    return this.#visit(
      0,
      (e) => !item || e.item.equals(item),
      (e, sum) => sum + e.count,
    );
  }

  /**
   * Converts the bag contents to a human-readable string with proper pluralization.
   *
   * @returns {String} Human-readable description of bag contents
   */
  toString() {
    return toString(this);
  }
}

export default Bag;