create_historical_layer.js

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

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

function parseEntriesNum(totalNodes, per) {
  const numEntries = Math.round(totalNodes / per);
  return numEntries === 0 && test(40) ? 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];
}

/**
 * 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 {Map} options.map - A location map keyed by location type. Modified in place.
 * @returns {void}
 */
export function createHistoricalLayer({ map } = {}) {
  if (!map) {
    throw new Error("Map is required to create a historical layer.");
  }
  for (const entry of ENTRIES) {
    const per = entry.per;
    const totalNodes = countLocations(map, entry.locations);
    const numEntries = parseEntriesNum(totalNodes, per);

    console.log(`Altering ${numEntries} of ${totalNodes} locations for ${entry.name}`);

    for (let i = 0; i < numEntries; i++) {
      let location = findLocation(map, entry.locations, entry.sel, entry.has);
      // if nothing matches in this production, or the node has already been altered, skip it
      if (!location || location.h) {
        continue;
      }
      location.h = true; // mark as altered
      // 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 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 findLocation(map, locations, selType, childType) {
  const selectedLocations = [];
  for (let type of locations) {
    let locList = map.get(type) || [];
    if (selType === "child") {
      locList = locList.map((loc) => findTerminalChild(loc));
    }
    if (selType === "selfOrChild") {
      locList = locList.map((loc) => findTerminalChild(loc));
      locList.push(map.get(type));
    }
    if (childType) {
      locList = locList.filter((loc) => hasChildType(loc, childType));
    }
    selectedLocations.push(...locList);
  }
  return random(selectedLocations);
}

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 findTerminalChild(location) {
  if (location.children.length === 0) {
    return location;
  }
  const child = random(location.children);
  if (child?.children?.length === 0) {
    return child;
  }
  return findTerminalChild(child);
}

function hasChildType(location, type) {
  if (!type) {
    return true;
  }
  return (
    location.children.filter((child) => {
      return child.type === type || hasChildType(child, type);
    }).length > 0
  );
}

function countLocations(map, types = []) {
  let count = 0;
  for (const type of types) {
    const list = map.get(type) || [];
    count += list.length;
  }
  return count;
}

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