create_location.js

import { bagSpecParser } from "./string_utils.js";
import { createCharacter } from "./create_character.js";
import { createContainer } from "./create_container.js";
import { createHistoricalLayer } from "./create_historical_layer.js";
import { createLocationName } from "./create_location_name.js";
import { FREQ } from "./constants.js";
import { indentLoader, yamlishParser } from "./data_loaders.js";
import { Location, LocationTemplate } from "./models/location.js";
import { logger } from "./utils.js";
import { createBag } from "./create_bag.js";
import { parseDelimiters } from "./string_utils.js";
import { random, selectElements } from "./random_utils.js";
import LocationDatabase from "./db/location_database.js";

const ALIASES = [];
const AS_SYNTAX_REGEX = /^(.+?)\s+as\s+"([^"]+)"$/;
const DICE_NOTATION = /^[\dd\-+]+\d?$/;
const FREQ_KEYS = Object.keys(FREQ);
const NEW_CONFIG_REGEX = /^(\S+(?:\s+(?![("#])\S+)*)(?:\s+"([^"]+)")?(?:\s+\(([^)]+)\))?$/;
const STARTS_WITH_PERCENT = /^\d{1,3}%\s/;
const STARTS_WITH_RARITY = /^[CUR]\s/;

// Regex to match 'Type as "Label"' syntax - captures the type and the label
// record of "Label as Type" declarations
const MAP = new Map();

const PARSER = yamlishParser({
  lists: ["oneOf", "allOf", "anyOf", "contents", "content", "traits", "inventory"],
  keyProp: "name",
});

// Type "Name" (attr)
function parseName(line) {
  if (!NEW_CONFIG_REGEX.test(line)) {
    throw new Error(`Invalid location: ${line} , MUST be in format 'Type "Name" (attr)'`);
  }
  const [, type, name, attsString] = line.match(NEW_CONFIG_REGEX);
  const tags = attsString ? attsString.split(" ").map((a) => a.trim()) : [];
  return { type, name, ch: {}, contents: {}, tags, sequences: [] };
}

// exported for testing
export function parseLocations(lines) {
  const templates = [];
  for (const entry of PARSER(lines)) {
    const params = parseName(entry.name);

    // CONTENT PROPERTIES ("contents" == allOf, "content" == oneOf)
    const fieldName = entry.contents ? "anyOf" : entry.content ? "oneOf" : null;
    const contentArray = entry.contents ?? entry.content;
    if (fieldName) {
      // starts with a percentage (doesn't match use of % within bags, etc.)
      params.contents[fieldName] = arrayToSelect(contentArray);
    }
    // CHILDREN PROPERTIES
    params.ch = {};
    if (entry.allOf) {
      params.ch.allOf = arrayToSelect(entry.allOf);
    }
    if (entry.oneOf) {
      params.ch.oneOf = arrayToSelect(entry.oneOf);
    }
    if (entry.anyOf) {
      params.ch.anyOf = arrayToSelect(entry.anyOf);
    }
    // props in the format "1d3: A, B, C" - we're assuming the value is a list, for now
    Object.keys(entry)
      .filter((key) => DICE_NOTATION.test(key))
      .forEach((key) => {
        // dice notation
        const someOfKey = "someOf:" + key;
        entry[someOfKey] = entry[key].split(",").map((str) => str.trim());
        params.ch[someOfKey] = arrayToSelect(entry[someOfKey]);
      });

    // OTHER PROPERTIES
    for (const key of Object.keys(entry)) {
      params.image = entry.image;
      params.owner = entry.owner;
      params.inventory = entry.inventory ?? {};
      params.description = entry.descr;
      if (key.startsWith("seq-")) {
        let [, id] = key.split("-");
        params.sequences.push({ id, options: entry[key].split("/").map((s) => s.trim()) });
      }
    }
    for (const { type, alias } of ALIASES) {
      addToSequence(params, type, alias);
    }
    ALIASES.length = 0;
    templates.push(new LocationTemplate(params));
  }
  return templates;
}

// ["A (10%)", "B (10%)"] -> { "A": 10, "B": 10 } (or similar for rarity values)
// or ["10% A", "10% B"] -> { "A": 10, "B": 10 }
function arrayToSelect(array) {
  if (array.length === 0) {
    return {};
  }
  array = array.map(captureAlias);
  if (STARTS_WITH_PERCENT.test(array[0]) || STARTS_WITH_RARITY.test(array[0])) {
    return array.reduce((obj, ref) => {
      const i = ref.indexOf(" ");
      const chance = ref.substring(0, i);
      const item = ref.substring(i + 1);
      obj[swapParens(item)] = chance.includes("%") ? parseInt(chance) : chance;
      return obj;
    }, {});
  }
  return array.map(swapParens);
}

// "A as B" returns A and stores "B" to create in-line sequences
function captureAlias(ref) {
  const match = ref.match(AS_SYNTAX_REGEX);
  if (match) {
    ALIASES.push({ type: match[1].trim(), alias: match[2].trim() });
    return match[1].trim();
  }
  return ref;
}

function swapParens(ref) {
  const [hasParams, base, parens] = parseDelimiters(ref);
  if (hasParams && (DICE_NOTATION.test(parens) || FREQ_KEYS.includes(parens))) {
    return `${parens}:${base}`;
  }
  return ref;
}

/**
 * Add or append to a sequence in the props object.
 * If a sequence with the given id exists, append the alias to its options.
 * Otherwise, create a new sequence with the id and alias.
 * @param {Object} props - The props object containing sequences
 * @param {string} id - The sequence id (typically the type name)
 * @param {string} alias - The alias to add to the sequence options
 */
function addToSequence(props, id, alias) {
  props.sequences = props.sequences ?? [];
  const existing = props.sequences.find((seq) => seq.id === id);
  if (existing) {
    existing.options.push(alias);
  } else {
    props.sequences.push({ id, options: [alias] });
  }
}

function addToMap(location) {
  const list = MAP.get(location.type) ?? [];
  list.push(location);
  MAP.set(location.type, list);
}

const lines = await indentLoader("locations.data");
const templates = parseLocations(lines);

export const database = new LocationDatabase();
database.add.apply(database, templates);
database.verify();

const LOCATION_TYPES = database.models
  .filter((m) => m.not("room") && m.not("abstract"))
  .map((m) => m.type)
  .sort();

const STORE_TYPES = database.models
  .filter((m) => m.owner)
  .map((m) => m.type)
  .sort();

export function getLocationTypes() {
  return LOCATION_TYPES;
}

export function getStoreTypes() {
  return STORE_TYPES;
}

export function getAllExistingLocationTypes() {
  return Array.from(MAP.keys());
}

export function getExistingLocationByType({ type = random(getAllExistingLocationTypes()) } = {}) {
  let list = MAP.get(type) ?? [];
  if (list.length === 0) {
    return null;
  }
  return random(list);
}

/**
 * Create a location. You must supply the type of a location template and an instance tree
 * will be created from that point in the template hierarchy, working downward throw all
 * child nodes templates, returning the resulting instance tree. Tags are not currently used
 * in selection of templates, but may be in the future.
 */
export function createLocation({ type = random(LOCATION_TYPES), history = true } = {}) {
  logger.start("createLocation", { type });
  MAP.clear();
  let template = database.findOne({ type });
  if (!template) {
    throw new Error("No location template found for type: " + type);
  }
  let root = createInstance(new Location({ sequences: [] }), template);
  walkTemplate(root, template);
  if (history) {
    createHistoricalLayer({ map: MAP });
  }
  return logger.end(root);
}

// Intended as a replacement for create_store.js, but the metadata needs to be moved from that file
// to the locations.data file.
export function createStore({ type = random(STORE_TYPES) } = {}) {
  logger.start("createStore", { type });
  MAP.clear();
  let template = database.findOne({ type });
  let store = createInstance(new Location({ sequences: [] }), template);
  walkTemplate(store, template);
  return logger.end(store);
}

function walkTemplate(parent, template) {
  let elements = selectElements(template.ch);
  elements.forEach((type) => {
    let childTemplate = database.findOne({ type });
    if (childTemplate.is("abstract")) {
      // Abstract templates are not instantiated, but their children are processed. We can add
      // other fields besides image, if appropriate.
      parent.image = childTemplate.image;
      walkTemplate(parent, childTemplate);
    } else {
      let childLocation = createInstance(parent, childTemplate);
      walkTemplate(childLocation, childTemplate);
    }
  });
}

function createInstance(parent, childTemplate) {
  let child = new Location(childTemplate, parent);
  if (childTemplate.owner) {
    const traits = childTemplate.traits.reduce((acc, trait) => {
      acc[trait] = 1;
      return acc;
    }, {});
    child.owner = createCharacter({ postProfession: childTemplate.owner, traits });
    child.onhand = createContainer({ type: "Cash On Hand" });
  }
  // contents is what exists in a room or place, including containers that contain things
  child.contents = determineContents(childTemplate, "contents");
  // inventory is specifically for stores; it's possible to have inventory and contents.
  child.inventory = determineContents(childTemplate, "inventory");
  if (parent) {
    parent.addChild(child);
  }
  child.name = createLocationName({ child });
  addToMap(child);
  return child;
}

function determineContents(template, propName) {
  return selectElements(template[propName])
    .map((child, i) => {
      // TODO: Instead of parsing while creating, we could do this when parsing the
      // templates, above
      if (template["_content" + i]) {
        const spec = template["_content" + i];
        return createBag(spec);
      } else {
        if (child.startsWith("Bag") || child.startsWith("Stock")) {
          const spec = bagSpecParser(child);
          template["_content" + i] = spec;
          return createBag(spec);
        }
      }
      return createContainer({ type: child });
    })
    .filter((el) => el !== null);
}