Pre-release
AdventureJS Docs Downloads Devlog
Score: 0 Moves: 0
// Stack.js
(function () {
  /* global AdventureJS A */

  /**
   * @ajspath AdventureJS.Atom.Stack
   * @augments AdventureJS.Atom
   * @class AdventureJS.Helpers.Stack
   * @ajsnavheading HelperClasses
   * @param {String} game_name Name of top level game instance that is scoped to window.
   * @param {String} name Instance name.
   * @summary Class that allows splitting assets into fungible instances.
   * @tutorial Helpers_Stacks
   * @classdesc
   * <p>
   * <strong>Stack</strong> is a special class
   * that allows entity assets to be split into
   * In this example, we'll set up a custom
   * class Pistachio and then create a dispenser
   * that spawns new instances.
   * </p>
   * <pre class="display"><code class="language-javascript">MyGame.createClass({
   *   class: "Pistachio",
   *   extend: "Edible",
   *   singular: "pistachio",
   *   plural: "pistachios",
   *   description: function () {
   *     let msg = "";
   *     msg = `It's a pistachio. `;
   *     return msg;
   *   },
   * });
   * MyGame.createAsset({
   *   class: "Pistachio",
   *   name: "pistachio",
   *   place: { in: "small pouch" },
   *   stack: {
   *
   *     // If use_stack_name is true, this item will be described
   *     // using the enumeration property with the class's singular
   *     // property, eg "three pistachios".
   *     // If use_stack_name is false, the asset's
   *     // name will be used, eg "the sack of pistachios".
   *     // This is intended to help distinguish dispensers.
   *     // Spawned stacks will default to use_stack_name = true.
   *     this.use_stack_name = true;
   *
   *     // enumeration determines how stack numbers are
   *     // presented. Options are: <br/>
   *     // <li>numeric: 1, 2, 3, 17, 100, etc.</li>
   *     // <li>spelled: a, two, three, seventeen, one hundred, etc.</li>
   *     // <li>grouped: a, a couple of, several, a few, some, numerous</li>
   *     // The enumeration setting carries to spawned stacks.
   *     this.enumeration = "spelled"; // numeric, spelled, grouped
   *
   *     // Making a stack a dispenser means the whole stack
   *     // is a fixed object. For example,
   *     // a sack of peanuts, where the sack is an asset,
   *     // and the peanuts always reside inside it.
   *     // New peanuts can be spawned from the stack,
   *     // but the entire stack can't be removed.
   *     // Spawned stacks will NOT be dispensers.
   *     dispenser: false,
   *
   *     // Quantity represents the current count of items
   *     // in this stack, which could be 1 or 10 or 1000.
   *     // Setting to -1 makes it infinite which is useful
   *     // for making bottomless dispensers.
   *     quantity: -1,
   *
   *     // max_quantity sets the highest quantity that
   *     // this stack can hold. Setting to -1 makes it
   *     // infinite. If quantity is set to -1, max_quantity
   *     // will be ignored.
   *     max_quantity: -1,
   *
   *     // max_per_take sets the highest quantity that
   *     // can be taken from this stack at a time.
   *     this.max_per_take = -1;
   *
   *     // max_stacks_in_game means the total number of
   *     // stacks allowed in the game world, for
   *     // example you may want to allow no more
   *     // than 50 unique breadcrumbs to be created.
   *     // Spawned stacks will always inherit this property
   *     // from their originating stack.
   *     max_stacks_in_game: 50,
   *
   *     // max_quantity_in_game means the total quantity held
   *     // by all stacks in the game world.
   *     // For instance, you may want to have 5 quivers
   *     // of arrows, but no more than 50 arrows between them all.
   *     // If any stack of this class has infinite quantity,
   *     // this will be infinite.
   *     // Spawned stacks will always inherit this property
   *     // from their originating stack.
   *     max_quantity_in_game: -1,
   *
   *     // Stacks spawned by this stack will inherit properties
   *     // from this stack, except for dispenser and use_stack_name.
   *     // max_quantity and max_per_take can be set uniquely for new stacks.
   *     spawn_properties: {},
   *
   *   },
   * });
   * </code></pre>
   **/
  class Stack extends AdventureJS.Atom {
    constructor(name, game_name, context_id) {
      super(name, game_name, context_id);
      this.class = "Stack";
      this.id = `${context_id}|stack`;

      /**
       * If use_stack_name is true, this item will be described
       * using the enumeration property with the class's singular
       * property, eg "three pistachios".
       * If use_stack_name is false, the asset's
       * name will be used, eg "the sack of pistachios".
       * This is intended to help distinguish dispensers.
       * Spawned stacks will default to use_stack_name = true.
       * @var {Boolean} AdventureJS.Helpers.Stack#use_stack_name
       */
      this.use_stack_name = true;

      /**
       * enumeration determines how stack numbers are
       * presented. Options are: <br/>
       * <li>numeric: 1, 2, 3, 17, 100, etc.</li>
       * <li>spelled: a, two, three, seventeen, one hundred, etc.</li>
       * <li>grouped: a, a couple of, several, a few, some, numerous</li>
       * The enumeration setting carries to spawned stacks.
       * @var {string} AdventureJS.Helpers.Stack#enumeration
       */
      this.enumeration = "spelled"; // numeric, spelled, grouped

      /**
       * Making a stack a dispenser means the whole stack
       * is a fixed object. For example,
       * a sack of peanuts, where the sack is an asset,
       * and the peanuts always reside inside it.
       * New peanuts can be spawned from the stack,
       * but the entire stack can't be removed.
       * Spawned stacks will NOT be dispensers.
       * @var {Boolean} AdventureJS.Helpers.Stack#dispenser
       */
      this.dispenser = false;

      /**
       * Quantity represents the current count of items
       * in this stack, which could be 1 or 10 or 1000.
       * Setting to -1 makes it infinite which is useful
       * for making bottomless dispensers.
       * Spawned stacks will start with the quantity taken.
       * @var {int} AdventureJS.Helpers.Stack#quantity
       * @default 1
       */
      this.quantity = 1;

      /**
       * max_quantity sets the highest quantity that
       * this stack can hold. Setting to -1 makes it
       * infinite. If quantity is set to -1, max_quantity
       * will be ignored.
       * @var {int} AdventureJS.Helpers.Stack#max_quantity
       * @default -1
       */
      this.max_quantity = -1;

      /**
       * max_per_take sets the highest quantity that
       * can be taken from this stack at a time.
       * Spawned stacks will inherit this value.
       * @var {int} AdventureJS.Helpers.Stack#max_take
       * @default -1
       * @note Not yet supported.
       */
      this.max_per_take = 1;

      /**
       * max_stacks_in_game means the total number of
       * stacks of this class allowed in the game world.
       * For example you may want to allow no more
       * than 50 unique breadcrumbs to be created
       * Spawned stacks will inherit this value.
       * @var {int} AdventureJS.Helpers.Stack#max_stacks_in_game
       * @note Not yet supported.
       */
      this.max_stacks_in_game = 20;

      /**
       * max_quantity_in_game means the total quantity held
       * by all stacks in the game world, excluding infinite
       * stacks. For instance, you may want to have 5 quivers
       * of arrows, but no more than 50 arrows between them all.
       * Spawned stacks will inherit this value.
       * @var {int} AdventureJS.Helpers.Stack#max_quantity_in_game
       * @note Not yet supported.
       */
      this.max_quantity_in_game = -1;

      /**
       * Stacks spawned by this stack will inherit properties
       * from this stack, except for dispenser and use_stack_name.
       * max_quantity and max_per_take can be set uniquely for new stacks.
       * @var {object} AdventureJS.Helpers.Stack#spawn_properties
       */
      this.spawn_properties = {};

      return this;
    }

    /**
     * Get stack quantity. Converts -1 to Infinity
     * for purposes of comparison.
     */
    getStackName(definite = false) {
      let name,
        article = "";

      if (this.dispenser) {
        // the bowl of grapes
        if (definite) article = this.context.definite_article;
        name = `${article} ${this.context.name}`.trim();
      } else if (!this.dispenser) {
        name =
          this.quantity === 1 ? this.context.singular : this.context.plural;
        if ("function" === typeof this.enumeration) {
          // if someone wrote a custom function, let that handle it
          article = `${article} ${this.enumeration(this.quantity)}`.trim();
        } else if ("string" === typeof this.enumeration) {
          if (this.quantity === 0 || this.quantity === 1) {
            // exclude definite article from 0 and 1
            article =
              `${this.game.dictionary.enumerate(this.enumeration, this.quantity)}`.trim();
          } else {
            if (definite) article = `${this.context.definite_article} `;
            article +=
              `${this.game.dictionary.enumerate(this.enumeration, this.quantity)}`.trim();
          }
        }
        name = `${article} ${name}`.trim();
      }

      // this is a hacky fix for a sloppy situation where
      // some of our enumeration phrase start with "a" like
      // "a hundred" or "a score of" and we end up with
      // "the a hundred things"
      if (name.startsWith("the a ")) {
        name = name.replace("the a ", "the ");
      }

      return name;
    }

    /**
     * Get stack quantity. Converts -1 to Infinity
     * for purposes of comparison.
     */
    getQuantity() {
      return this.quantity > -1 ? this.quantity : Infinity;
    }

    /**
     * subtract from the quantity of this stack.
     * @param {int} amount Number to subtract.
     */
    subtract(amount = 1) {
      if (this.quantity > -1) {
        this.quantity = this.quantity - amount;
        if (this.quantity === 0) {
          // @TODO destroy if empty?
        }
      }
    }

    /**
     * add to the quantity of this stack.
     * @param {int} amount Number to add.
     */
    add(amount = 1) {
      if (this.quantity > -1) {
        if (this.quantity + amount > this.max_quantity) {
          // @TODO max quantity check?
          // what if some portion of amount can fit?
        }
        this.quantity = this.quantity + amount;
      }
    }

    /**
     * canIncrement(amount) returns true or false.
     * @param {int} amount A property to test.
     * @returns {Boolean}
     */
    canIncrement(amount) {
      if (this.quantity > -1) {
        if (this.quantity === this.max_quantity) {
          return false;
        }
        if (this.quantity + amount > this.max_quantity) {
          // @TODO how to handle if can partially increment?
          // return false?
        }
      }
      return true;
    }

    /**
     * Find a compatible stack in a specified asset.
     * @param {int} amount A property to test.
     * @returns {Boolean}
     */
    findCompatibleStack(aspect, asset) {
      const contents = asset.getContentsAt(aspect);

      for (let i = 0; i < contents.length; i++) {
        const item = this.game.getAsset(contents[i]);
        // does asset already contain a compatible stack?
        if (
          item.stack &&
          item.class === this.context.class &&
          item.id !== this.context_id
        ) {
          // if asset contains a compatible stack, return that
          return item;
        }
      }
      return null;
    }

    /**
     * merge the quantity of another stack into this one.
     * @param {object} donor The asset to merge into this one.
     * @param {bool} clear_donor Whether to zero the donor's quantity.
     */
    merge(donor, clear_donor = false) {
      if (this.quantity === -1) {
        // nothing needs to happen
      } else if (donor.stack.quantity === -1) {
        this.quantity = -1;
        // @TODO check against this.max_quantity?
      } else if (this.quantity > -1) {
        this.quantity = this.quantity + donor.stack.quantity;
      }
      if (clear_donor && donor.stack.quantity > -1) {
        donor.stack.quantity = 0;
      }
    }

    /**
     * Spawns a new stack asset.
     * @param {int} quantity The quantity to add to the new stack.
     * @returns {object}
     */
    spawn(quantity) {
      // ensure that quantity is within limits
      quantity = Math.min(
        quantity,
        this.max_per_take < 0 ? Infinity : this.max_per_take,
        this.quantity < 0 ? Infinity : this.quantity
      );

      this.game.log(
        "L1686",
        "log",
        "high",
        `[Stack.js] spawn(): ${quantity} ${this.context.class}`,
        "Entity"
      );

      const proto = {
        class: this.context.class,
        name: this.context.class.toLowerCase(),
        id: this.context.class.toLowerCase() + A.FX.UID.get(),
      };

      const asset = this.game.addAsset(proto);
      if (!asset) return null;

      // it's possible for a custom class to have a stack
      // already so let's check for that before adding
      // a stack to the new asset
      if (!asset.stack) {
        asset.stack = new AdventureJS.Helpers.Stack(
          "stack",
          this.game_name,
          asset.id
        );
        this.game.world._stacks[asset.class] =
          this.game.world._stacks[asset.class] || [];
        if (!this.game.world._stacks[asset.class].includes(asset.id)) {
          this.game.world._stacks[asset.class].push(asset.id);
        }
      }

      // get spawned stack's properties from dispensing stack
      asset.stack.set({
        // we don't copy use_stack_name
        // we don't copy dispenser
        // quantity depends on what player asked for or dispenser limits
        quantity: quantity,
        // max_quantity and max_per_take can be set differently
        // for spawned stacks otherwise they inherit
        max_quantity: this.spawn_properties.max_quantity || this.max_quantity,
        max_per_take: this.spawn_properties.max_per_take || this.max_per_take,
        // spawned stacks always inherit these remaining properties
        enumeration: this.enumeration,
        max_stacks_in_game: this.max_stacks_in_game,
        max_quantity_in_game: this.max_quantity_in_game,
        spawn_properties: this.spawn_properties,
      });

      asset.stack.quantity = quantity;
      this.subtract(quantity);

      // put the new spawn in its source's location
      // asset.place = Object.assign(asset.place, this.context.place);
      asset.setPlace(this.context.place);

      return asset;
    }

    /**
     * Initializes a new stack.
     * @param {int} initialize Initialize this stack.
     */
    initialize() {
      this.game.log(
        "L1451",
        "log",
        "high",
        `[Stack.js] initialize()${this.context_id}.stack`,
        "Entity"
      );

      let asset = this.context;

      this.game.world._stacks[asset.class] =
        this.game.world._stacks[asset.class] || [];
      if (!this.game.world._stacks[asset.class].includes(asset.id)) {
        this.game.world._stacks[asset.class].push(asset.id);
      }

      // ensure that stacks can be taken from
      if (!asset.isIOV("take")) {
        asset.setIOV("take");
      }
    }

    /**
     * Destroys a stack.
     * @param {int} destroy Destroy this stack.
     */
    destroy() {
      this.game.log(
        "L1710",
        "log",
        "high",
        `[Stack.js] ${this.context_id}.stack.destroy()`,
        "Entity"
      );

      let asset = this.context;
      // if it's a stack, remove from world._stacks
      if (this.game.world._stacks[asset.class]?.includes(asset.id)) {
        let index = this.game.world._stacks[asset.class].indexOf(asset.id);
        this.game.world._stacks[asset.class].splice(index);
      }
    }
  }
  AdventureJS.Helpers.Stack = Stack;
})();