models/bag.js

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

/**
 * Converts a bag's contents to a human-readable string representation.
 *
 * @private
 * @param {Bag} bag - The bag to convert to string
 * @param {Function} pluralizerFunc - Function to pluralize item names
 * @returns {String} Human-readable description of bag contents
 */
function toString(bag, pluralizerFunc) {
  let string = "",
    cash = 0;
  if (bag.entries.length) {
    let items = false;
    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 {
        items = true;
        string += pluralizerFunc(entry.item, entry.count);
        if (entry.item.title) {
          string += ` (${entry.item.title})`;
        }
        if (len === 1) {
          string += ".";
        } else if (len === 2) {
          string += ", and ";
        } else {
          string += ", ";
        }
        len--;
      }
    });
    if (items && cash) {
      string += " ";
    }
    if (cash) {
      string += "$" + cash.toFixed(0) + " in cash.";
    }
    string = sentenceCase(string);
  } else {
    string += "empty.";
  }
  if (bag.descriptor) {
    string = bag.descriptor + ": " + string;
  }
  return string;
}

/**
 * Creates a deep copy of bag entries array.
 *
 * @private
 * @param {Array} entries - Array of bag entries to copy
 * @returns {Array} Deep copy of the entries array
 */
function copy(entries) {
  return (entries || []).map((entry) => ({ item: new Item(entry.item), count: entry.count }));
}

/**
 * Represents a container that holds items with quantities. Provides functionality
 * for adding, removing, and querying items with support for value and encumbrance calculations.
 *
 * @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 {String[]} [data.tags] - Array of tags to categorize this bag
   */
  constructor(data = {}) {
    super(data);
    this.entries = copy(data.entries);
    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, value) => value + 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, value) => value + 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, value) => value + 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, value) => value + e.count
    );
  }

  /**
   * Converts the bag contents to a string using uncountable item names.
   *
   * @returns {String} Human-readable description with uncountable names
   */
  toUncountableString() {
    return toString(this, uncountable);
  }

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

export default Bag;