create_historical_layer.js

import { createCharacter } from "./create_character.js";
import { createContainer } from "./create_container.js";
import { createFamily } from "./create_family.js";
import { createItem } from "./create_item.js";
import { matcher } from "./tag_utils.js";
import { random, roll, shuffle, test } from "./random_utils.js";
import { resolve, parseDelimiters, sentenceCase } from "./string_utils.js";
import { ymlParser } from "./data_loaders.js";
import Bag from "./models/bag.js";

const parser = ymlParser({
  lists: ["params", "except", "items"],
  bools: ["child", "always"],
  ints: ["per"],
  keyProp: "name",
});
const ENTRIES = await parser("historical.data");

function parseEntriesNum(totalNodes, per) {
  const numEntries = Math.round(totalNodes / per);
  return numEntries === 0 && test(10) ? 1 : numEntries;
}

// children except A,B to ["children", ["A", "B"]]
function parseClear(clear) {
  const [target, excStr] = clear.split(" except ");
  const exceptions = excStr ? excStr.split(",").map((s) => s.trim()) : [];
  return [target, exceptions];
}

// Not sure why locks are in the location generator since breaking them open would be something
// that involves the historical layer. It's also clear we need to know about being inside of
// secured areas—how does a scavenger die inside a locked building, for example.

/**
 * Enriches a location map with pre-collapse historical narrative content. Selects locations
 * and modifies them according to history rules: adding descriptions, renaming locations,
 * placing items or containers, and optionally clearing their existing contents.
 *
 * @param {Object} options - Historical layer options
 * @param {Location[]} options.list - A list of locations (which are also in a parent/child relationship,
 *    as is produced by the createLocation function)
 * @returns {void}
 */
export function createHistoricalLayer({ list } = {}) {
  if (!list) {
    throw new Error("List is required to create a historical layer.");
  }
  shuffle(ENTRIES);
  for (const entry of ENTRIES) {
    const per = entry.per;
    const eligibleNodes = list.filter(
      (loc) => entry.always || (matcher(entry.select, loc.tags) && hasChildType(entry.has, loc)),
    );
    const totalNodes = eligibleNodes.length;
    let numEntries = parseEntriesNum(totalNodes, per);
    if (entry.always && numEntries === 0) {
      numEntries = 1;
    }

    if (numEntries === 0 || totalNodes === 0) {
      continue;
    }
    for (let i = 0; i < numEntries; i++) {
      let location = random(eligibleNodes);
      // if nothing matches in this production, or the node has already been altered, skip it
      let parent = getParent(location);
      if (!location || parent.h) {
        continue;
      }
      parent.h = true;
      // not accurate since if we selected a previously selected node, we skip this iteration
      console.log(
        `Changing ${numEntries} of ${totalNodes} locations for historical event ${entry.name}`,
      );

      // finally we begin with this

      // generate template parameters
      let params = getLocationParams({}, location);
      if (entry.params?.includes("character")) {
        params = getCharacterParams(params);
      }
      if (entry.params?.includes("family")) {
        params = getFamilyParams(params);
      }
      // use template parameters
      if (entry.text) {
        location.description.push(resolve(entry.text, params));
      }
      if (entry.rename) {
        location.name = resolve(entry.rename, params);
      }

      if (entry.container) {
        location.contents.push(createContainer({ type: entry.container }));
      }
      if (entry.items) {
        location.contents.push(addItems(entry.items));
      }
      if (entry.clear) {
        let [target, exceptions] = parseClear(entry.clear);
        if (target === "room") {
          clearLocation(location);
        } else if (target === "children") {
          clearRecursively(location, exceptions);
          // now do it again to trim any children that are now empty
          clearRecursively(location, exceptions);
          clearLocation(location);
        }
      }
    }
  }
}

function getParent(location) {
  for (let n = location; ; n = n.parent) {
    if (n != null && !n.tags.has("room") && !n.tags.has("area")) {
      return n;
    }
  }
}

function hasChildType(sel, location) {
  if (!sel || matcher(sel, location.tags)) {
    return true;
  }
  return location.children.some((child) => hasChildType(sel, child));
}

function addItems(items) {
  let bag = new Bag();
  items.forEach((tag) => {
    const [hasParens, tagStr, rollStr] = parseDelimiters(tag);
    const item = createItem({ tags: tagStr });
    if (item) {
      const count = hasParens ? roll(rollStr) : 1;
      bag.add(item, count);
    }
  });
  return bag;
}

function clearRecursively(location, exceptions) {
  console.log(`Clearing location ${location.type} children except for: ${exceptions}`);
  location.children = location.children.filter((child) => {
    return exceptions.includes(child.type) || child.children.length > 0;
  });
  location.children.forEach((child) => clearRecursively(child, exceptions));
}

function clearLocation(location) {
  delete location.image;
  location.contents = [];
}

function getFamilyParams(params) {
  const family = createFamily({ generations: 1 });
  params.familyName = family.parent.partner.name.family;
  params.fatherName = family.parent.partner.name.toString();
  params.motherName = family.parent.name.toString();
  return params;
}

function getCharacterParams(params) {
  // "Traveling" will set motorcycles and horses, and that's weird (Some isn’t an option)
  const character = createCharacter({ possessions: "Some" });

  params.personal = character.personal;
  params.personalUpper = sentenceCase(character.personal);
  params.clothing = character.clothing.toString().toLowerCase();
  params.clothingUpper = sentenceCase(character.clothing.toString());
  params.possessions = character.possessions.toString().toLowerCase();
  params.possessionsUpper = sentenceCase(character.possessions.toString());
  params.professionName = character.profession.toLowerCase();
  params.professionNameUpper = character.profession;
  return params;
}

function getLocationParams(params = {}, location) {
  params.locationName = location.name.toLowerCase();
  params.locationNameUpper = location.name;
  params.locationType = location.type.toLowerCase();
  params.locationTypeUpper = location.type;
  return params;
}