create_location.js

import { bagSpecParser } from "./string_utils.js";
import { createBag } from "./create_bag.js";
import { createCharacter } from "./create_character.js";
import { createContainer } from "./create_container.js";
import { createEventLayer } from "./create_event_layer.js";
import { createFamily } from "./create_family.js";
import { createHistoricalLayer } from "./create_historical_layer.js";
import { createLocationName } from "./create_location_name.js";
import { createRelationship } from "./create_relationship.js";
import { FREQ } from "./constants.js";
import { Location, LocationTemplate } from "./models/location.js";
import { logger } from "./utils.js";
import { parseDelimiters } from "./string_utils.js";
import { random, selectElements, test } from "./random_utils.js";
import { ymlParser } from "./data_loaders.js";
import LocationDatabase from "./db/location_database.js";
import Relationship from "./models/relationship.js";

const ALIASES = [];
const AS_SYNTAX_REGEX = /^(.+?)\s+as\s+"([^"]+)"$/;
const DICE_NOTATION = /^[\dd\-+]+\d?$/;
const FREQ_KEYS = Object.keys(FREQ);
// Regex to match 'Type as "Label"' syntax - captures the type and the label
const NEW_CONFIG_REGEX = /^(\S+(?:\s+(?![("#])\S+)*)(?:\s+"([^"]+)")?(?:\s+\(([^)]+)\))?$/;
const STARTS_WITH_PERCENT = /^\d{1,3}%\s/;
const STARTS_WITH_RARITY = /^[CUR]\s/;
// record of "Label as Type" declarations
const MAP = new Map();

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

// exported for testing
async function parseLocations() {
  const lines = await parser("locations.data");
  const templates = [];
  for (const entry of 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 = "some:" + 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.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;
}

// 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: [] };
}

// ["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.
 * @ignore
 * @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);
}

// ===== actual createLocation code

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

const LOCATION_TYPES = database.models
  .filter((m) => m.not("room") && m.not("abstract") && m.not("area"))
  .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,
  events = false,
} = {}) {
  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 });
  }
  if (events) {
    //createEventLayer({ 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 = Object.fromEntries(childTemplate.traits.map((trait) => [trait, 1]));
    if (test(30)) {
      child.owner = createRelationship();
    } else if (test(30)) {
      child.owner = createFamily({ generations: 2 }).getOldestRelationship();
    } else {
      child.owner = createCharacter({ postProfession: childTemplate.owner, traits });
    }
    // if (child.owner instanceof Relationship) {
    if (child.owner.older) {
      child.owner.older = retrainToProfession(childTemplate.owner, traits, child.owner.older);
      child.owner.younger = retrainToProfession(childTemplate.owner, traits, child.owner.younger);
    }
  }
  // contents is what exists in a room or place, including containers that contain things
  child.contents = determineContents(childTemplate, "contents");
  if (parent) {
    parent.addChild(child);
  }
  child.name = createLocationName({ child });
  addToMap(child);
  return child;
}

function retrainToProfession(profName, traits, character) {
  return createCharacter({
    ...character,
    postProfession: profName,
    traits,
  });
}

function determineContents(template, propName) {
  return selectElements(template[propName])
    .map((child) => {
      if (child.includes("(")) {
        const spec = bagSpecParser(child);
        return createBag(spec);
      }
      return createContainer({ type: child });
    })
    .filter((el) => el !== null)
    .reduce((arr, bag) => {
      if (bag.name || bag.description || arr.length === 0) {
        arr.push(bag);
      } else {
        let lastBag = arr[arr.length - 1];
        lastBag.addBag(bag);
      }
      return arr;
    }, []);
}