create_location_name.js

import { createCharacterName as ccn } from "./create_character_name.js";
import { logger } from "./utils.js";
import { mapLoader, percentTable, rarityTable } from "./data_loaders.js";
import { random, roll, test } from "./random_utils.js";
import { resolve } from "./string_utils.js";
import RarityTable from "./tables/rarity_table.js";
import Table from "./tables/table.js";

const REF_STRING = "ref-";
const SEQ_STRING = "seq-";
// refer to a sequence, but do not increment it
const REF_REGEX = /\{ref-[^\}]+\}/g;
// refer to a sequence, and then increment it
const SEQ_REGEX = /\{seq-[^\}]+\}/g;

const map = await mapLoader("location.names.data");

const GEO_FEATURE_TYPES = {
  Depression: map.get("features.depression"),
  Prairie: map.get("features.prairie"),
  Hill: map.get("features.hill"),
  Water: map.get("features.water"),
  Lake: map.get("features.lake"),
  River: map.get("features.river"),
  Junction: map.get("features.junction"),
  Forest: map.get("features.forest"),
};
const RIVER_FORKS = map.get("features.river.forks");
const NATURAL_PLACE_ADJECTIVES = map.get("natural.place.adjectives");
const HUMBLE = map.get("humble");
const PRESTIGIOUS = map.get("prestigious");

const CITY_NAMES = map.get("city.names");
const CITY_DIRS = map.get("city.dirs");
const CITY_SUFFIXES = map.get("city.suffixes");

const MOBILE_PARK_TYPES = percentTable(map.get("mobile.park.types"));

const SUBURB_DESCRIPTORS = map.get("suburb.descriptors");
const SUBURB_SUFFIXES = map.get("suburb.suffixes");
const SUBURB_TYPES = map.get("suburb.types");

const STREET_TYPES = map.get("street.types");
const DIRS = map.get("street.dirs");
const CORP_NAMES_1 = map.get("corp.names.1");
const CORP_NAMES_2 = map.get("corp.names.2");
const CORP_NAMES_3 = map.get("corp.names.3");
const CORP_NAMES_4 = map.get("corp.names.4");
const CORP_NAMES_5 = map.get("corp.names.5");
const BAR_NAMES = Object.freeze({
  "clientele:lobrow": map.get("bars.lobrow.names"),
  "clientele:nobrow": map.get("bars.nobrow.names"),
  "clientele:hibrow": map.get("bars.hibrow.names"),
});
const BAR_TYPES = Object.freeze({
  "clientele:lobrow": map.get("bars.lobrow.types"),
  "clientele:nobrow": map.get("bars.nobrow.types"),
  "clientele:hibrow": map.get("bars.hibrow.types"),
});
const RESTAURANT_NAMES = map.get("restaurant.names");
const RESTAURANT_TYPES = rarityTable(map.get("restaurant.types"));

const RESTAURANTS = new RarityTable()
  .add("common", () => `${random(RESTAURANT_NAMES)}`)
  .add("common", () => `${random(RESTAURANT_NAMES)} ${RESTAURANT_TYPES.get()}`)
  .add("uncommon", ({ owner }) => `${ownerName(owner, true)} ${RESTAURANT_TYPES.get()}`);

const HOTEL_TYPES = rarityTable(map.get("hotel.types"));
const MOTEL_TYPES = rarityTable(map.get("motel.types"));

const CLIENTELE = Object.freeze(Object.keys(BAR_NAMES));

// RANDOMIZATION TABLES

const NATURAL_AREAS = new RarityTable()
  // East Pond
  .add("common", () => random(NATURAL_PLACE_ADJECTIVES))
  // Red Hollow
  .add("common", () => random(HUMBLE))
  // Williams Crossing
  .add("uncommon", () => charPartialName())
  // West Bison Lake
  .add("uncommon", (loc) => regionByLocation(loc) + " " + random(HUMBLE))
  // Ford Trail Pond
  .add("rare", () => (test(50) ? random(HUMBLE) : charPartialName()) + " Trail")
  // Alfalfa
  .add("rare", () => random(CITY_NAMES))
  // West Anderson Junction
  .add("rare", (loc) => regionByLocation(loc) + " " + charPartialName());

const MOTELS = new Table();
MOTELS.add(20, () => `${random(PRESTIGIOUS)} ${MOTEL_TYPES.get()}`);
MOTELS.add(55, () => `${random(HUMBLE)} ${MOTEL_TYPES.get()}`);
MOTELS.add(15, ({ owner }) => `${ownerName(owner, false)} ${MOTEL_TYPES.get()}`);
MOTELS.add(10, ({ owner }) => `${ownerName(owner, true)} ${MOTEL_TYPES.get()}`);

const HOTELS = new Table();
HOTELS.add(15, () => `The ${random(PRESTIGIOUS)} Hotel`);
HOTELS.add(35, () => `Hotel ${random(PRESTIGIOUS)}`);
HOTELS.add(35, () => `${random(HUMBLE)} ${HOTEL_TYPES.get()}`);
HOTELS.add(10, ({ owner }) => `${ownerName(owner, false)} ${HOTEL_TYPES.get()}`);
HOTELS.add(5, ({ owner }) => `${ownerName(owner, true)} ${HOTEL_TYPES.get()}`);

const MOBILE_HOMES = new RarityTable({ outFunction: (f) => f() })
  .add("common", () => `${random(HUMBLE)} ${MOBILE_PARK_TYPES.get()}`)
  .add("common", () => `${random(CITY_NAMES)} ${MOBILE_PARK_TYPES.get()}`)
  .add("uncommon", () => `${random(PRESTIGIOUS)} ${MOBILE_PARK_TYPES.get()}`);

const CORP_1 = new Table();
CORP_1.add(10, () => random(CORP_NAMES_1));
CORP_1.add(10, () => random(CORP_NAMES_2));
CORP_1.add(10, () => random(PRESTIGIOUS));
CORP_1.add(10, () => `${random(PRESTIGIOUS)} ${random(CORP_NAMES_2)}`);
CORP_1.add(30, () => `${random(CORP_NAMES_1)} ${random(CORP_NAMES_2)}`);
CORP_1.add(15, (o) => `${o.family} ${random(CORP_NAMES_2)}`);
CORP_1.add(15, (o) => o.family);

const CORP_2 = new Table();
CORP_2.add(25, () => ` ${random(CORP_NAMES_3)}`);
CORP_2.add(25, () => ` ${random(CORP_NAMES_4)}`);
CORP_2.add(50, () => ` ${random(CORP_NAMES_3)} ${random(CORP_NAMES_4)}`);

const CORP_3 = new Table();
CORP_3.add(90, (o) => `${CORP_1.get()(o)}${CORP_2.get()()}`);
CORP_3.add(10, () => `${random(CORP_NAMES_5)} ${random(CORP_NAMES_4)}`);

const RESEARCH_FACILITY_TYPE = "{Research|Lab|Labs}{| Facility| Facilities| Division}";

const BARS = new Table({ outFunction: (f) => f() });
BARS.add(5, () => `${placeName()} ${random(BAR_TYPES["clientele:nobrow"])}`);
BARS.add(5, () => `The ${ownerName(ccn(), true)} ${random(BAR_TYPES["clientele:nobrow"])}`);
BARS.add(10, () => `The ${ownerName(ccn(), false)} ${random(BAR_TYPES["clientele:nobrow"])}`);
BARS.add(
  80,
  () => `${random(BAR_NAMES[random(CLIENTELE)])} ${random(BAR_TYPES["clientele:nobrow"])}`,
);

const STREET_NAMES = new Table({ outFunction: (f) => f() });
STREET_NAMES.add(80, () => `${random(SUBURB_DESCRIPTORS)} ${random(STREET_TYPES)}`);
STREET_NAMES.add(20, () => `${random(SUBURB_DESCRIPTORS)} ${random(STREET_TYPES)} ${random(DIRS)}`);

const URBAN = new RarityTable({ outFunction: (f) => f() })
  .add("common", () => `${cityForSuffix()}${suffix()}`) // Canonville
  .add("common", () => `${nounForCity()}${suffix()}`) // Cinderville
  .add("common", () => `${nameForCity("family")}${suffix()}`) // Aliceville
  .add("common", () => `${nameForCity("given")}${suffix()}`) // Davisville
  .add("common", () => `${random(CITY_DIRS)} ${random(CITY_NAMES)}`) // Northern Deadditch
  .add("common", () => `${random(CITY_NAMES)}`); // Deadditch

// Misnames: "Hugh undefined Ranch"
const RANCH = new RarityTable({ outFunction: (f) => f() })
  .add("common", () => `${ccn().family} Ranch`)
  .add("common", () => `The ${ccn().family} Family Ranch`)
  .add("common", () => `${placeName()} Ranch`);

const COUNTY = new RarityTable({ outFunction: (f) => f() })
  .add("common", () => `${random(HUMBLE)} County`)
  .add("common", () => `${ccn().family} County`);

const OFFICE_BUILDING = new RarityTable({ outFunction: (f) => f() })
  .add("common", () => `The ${random(PRESTIGIOUS)} Building`)
  .add("rare", () => `The Historical ${random(PRESTIGIOUS)} Building`)
  .add("rare", () => `The Commercial ${random(PRESTIGIOUS)} Building`);

const SUBURBS = new RarityTable()
  .add("common", "{descriptor} {feature} {postfix} (Suburb)")
  .add("uncommon", "{adj} {descriptor} {feature} {postfix} (Suburb)")
  .add("uncommon", "{feature} {postfix} (Suburb)")
  .add("rare", "{adj} {feature} {postfix} (Suburb)")
  .add("rare", "{descriptor} {postfix} (Suburb)")
  .add("rare", "{adj} {descriptor} {postfix} (Suburb)");

const HOUSING_TRACT = new RarityTable()
  .add("uncommon", "{descriptor}{suffix} {feature} {type} (Housing Tract)")
  .add("common", "{descriptor} {feature} {type} (Housing Tract)")
  .add("uncommon", "{descriptor}{suffix} {type} (Housing Tract)")
  .add("common", "{descriptor} {type} (Housing Tract)");

// UTILITIES

function regionByLocation(location) {
  return random(location === "River" && test(70) ? RIVER_FORKS : NATURAL_PLACE_ADJECTIVES);
}

function charPartialName() {
  const name = ccn();
  return test(10) ? name.given : name.family;
}

function ownerName(owner, possessive) {
  const name = test(60) ? owner.family : test(60) ? owner.given : owner.toString();
  const p = /[sz]$/.test(name) ? "’" : "’s";
  return possessive ? name + p : name;
}

function parentByType(start, type) {
  for (let n = start; n != null; n = n.parent) {
    if (n.type === type) {
      return n;
    }
  }
}

function geoFeature({ type = random(Object.keys(GEO_FEATURE_TYPES)) } = {}) {
  return random(GEO_FEATURE_TYPES[type]);
}

function selectUntil(selector, testor) {
  do {
    var result = selector();
  } while (!testor(result));
  return result;
}

function nameForCity(field) {
  return selectUntil(() => ccn()[field], takesSuffix);
}

function nounForCity() {
  return selectUntil(() => random(HUMBLE), takesSuffix);
}

function cityForSuffix() {
  return selectUntil(() => random(CITY_NAMES), takesSuffix);
}

function takesSuffix(word) {
  return word.length < 7 && /[nselra]$/.test(word) && !word.includes(" ") && !word.endsWith("ton");
}

function suffix() {
  return random(CITY_SUFFIXES).replace("_", " ");
}

// EXPORT FUNCTIONS

export function getLocationNameTypes() {
  return Object.keys(CREATORS);
}

// GENERATOR FUNCTIONS

function countyName({ parent }) {
  return parentByType(parent, "County")?.name ?? COUNTY.get();
}

function bar({ parent }) {
  if (parent.type === "Casino") {
    return `${random(BAR_NAMES[random("clientele:hibrow")])} ${random(
      BAR_TYPES["clientele:hibrow"],
    )}`;
  }
  return BARS.get();
}

function suburb() {
  return resolve(SUBURBS.get(), {
    descriptor: random(SUBURB_DESCRIPTORS),
    feature: geoFeature(),
    postfix: random(SUBURB_TYPES),
    adj: random(NATURAL_PLACE_ADJECTIVES),
  }).replace(" :", ":");
}

function housingTract() {
  return resolve(HOUSING_TRACT.get(), {
    descriptor: random(SUBURB_DESCRIPTORS),
    feature: geoFeature(),
    suffix: random(SUBURB_SUFFIXES),
    type: random(SUBURB_TYPES),
  });
}

function street({ child }) {
  const name = STREET_NAMES.get();
  child.streetName = name;
  return name;
}

function house({ parent }) {
  const street = parentByType(parent, "Street") ?? parent;
  if (!street.name) {
    street.name = STREET_NAMES.get();
  }
  if (!street.streetNumber) {
    street.streetNumber = roll("1d20") * random([100, 100]) + roll("1d9*10");
  }
  street.streetNumber += random([2, 3, 4, 5]);
  return `${street.streetNumber} ${street.name}`;
}

// this is called from some generators that don’t pass in a type. We could change
// that but for now, provide it if not passed in.
function placeName({ type = random(getLocationNameTypes()) } = {}) {
  return `${random(NATURAL_AREAS.get())} ${random(GEO_FEATURE_TYPES[type])}`;
}

function corporateName({ type }) {
  return selectUntil(
    () => corporateNameInt({ type }),
    (name) => name.split(" ").length <= 5,
  );
}

function corporateNameInt({ type }) {
  const bizHq = type !== "Industrial Research Facility";
  const person = ccn();
  let oneName = selectUntil(
    () => CORP_3.get()(person),
    (name) => bizHq || name.split(" ").length < 3,
  );
  oneName = oneName.replace(/_/g, " ");
  return bizHq ? oneName : `${oneName} ${random(RESEARCH_FACILITY_TYPE)}`;
}

function highway() {
  // Interstate 50, Interstate 60, Interstate 23, Interstate 27
  // "U.S. Route {50|60|23|27}"
  return random("{Highway|U.S. Route} {2|3|4|5|6|7|8|9}{0|1|2|3|4|5|6|7|8|9}");
}

function officeBuildingFloor({ name, type }) {
  return test(80) ? `${name}: General Offices` : `${name}: ${corporateName({ type })}`;
}

function fairgrounds({ parent }) {
  return random(`${countyName(parent)} Fair{|grounds}`);
}

function grangeBuilding({ parent }) {
  return `${countyName(parent)} Grange`;
}

function radioStation() {
  const r1 = roll(2) - 1;
  const r2 = roll(12) - 1;
  const r3 = roll(26) - 1;
  const l1 = "AF".substring(r1, r1 + 1);
  const l2 = "ABCDEFGHIJKL".substring(r2, r2 + 1);
  const l3 = "ABCDEFGHIJKLMNOPQRSTUVWXYX".substring(r3, r3 + 1);
  return `Radio Station K${l1}${l2}${l3}`;
}

const CREATORS = {
  "Bar": bar,
  "Business Headquarters": corporateName,
  "County": () => COUNTY.get(),
  "OBS Mid Floor": officeBuildingFloor,
  "OBS Top Floor": officeBuildingFloor,
  "OBM Mid Floor": officeBuildingFloor,
  "OBM Top Floor": officeBuildingFloor,
  "OBL Mid Floor": officeBuildingFloor,
  "OBL Top Floor": officeBuildingFloor,
  "Fairground": fairgrounds,
  "Forest": placeName,
  "Grange Building": grangeBuilding,
  "Highway": highway,
  "Hill": placeName,
  "Hotel": () => HOTELS.get()({ owner: ccn() }),
  "House": house,
  "Housing Tract": housingTract,
  "Industrial Research Facility": corporateName,
  "Lake": placeName,
  "Mobile Home Park": () => MOBILE_HOMES.get(),
  "Motel": () => MOTELS.get()({ owner: ccn() }),
  "Office Building": () => OFFICE_BUILDING.get(),
  "Post Bar": bar,
  "Prairie": placeName,
  "Radio Station": radioStation,
  "Ranch": () => RANCH.get(),
  "Restaurant": () => RESTAURANTS.get()({ owner: ccn() }),
  "River": placeName,
  "Street": street,
  "Suburb": suburb,
  "Urban": () => URBAN.get(),
};

function createLocationNameByType({
  name,
  type = random(getLocationNameTypes()),
  child,
  parent = {},
} = {}) {
  if (CREATORS[type]) {
    return CREATORS[type]({ name, type, child, parent });
  }
  return name;
}

function nameInSequence(child) {
  let selfName = child.name;
  // e.g. "{pigsty|hogswallow}" takes precedence over a sequence information demarcated with braces
  if (selfName.includes("|")) {
    return random(selfName);
  }
  selfName = replaceSeq(child, selfName, REF_STRING);
  selfName = replaceSeq(child, selfName, SEQ_STRING);
  // don't do the inline sequence if we've managed to replace a sequence in the string
  if (selfName === child.name) {
    selfName = replaceInlineSeq(child, selfName);
  }

  return selfName;
}

function replaceSeq(child, selfName, indicator) {
  // work through all indicators for references or sequences
  while (selfName.includes(`{${indicator}`)) {
    const regexp = indicator === REF_STRING ? REF_REGEX : SEQ_REGEX;
    // get all syntax for the types
    const allI = selfName.match(regexp);
    allI.forEach((i) => {
      // extract the id from the reference, e.g r8 == id of 8
      const id = i.split("-")[1].replace("}", "");
      const seqData = getSequence(child, id);
      // If there's no sequence data, we will substitute in the type, if it's not already part of
      // the name (if it's part of the name, we remove the sequence information by just replacing
      // the whole name string with the type.
      if (!seqData) {
        if (selfName.indexOf(child.type) === -1) {
          selfName = selfName.replace(i, child.type);
        } else {
          selfName = child.type;
        }
        return;
      }
      const seq =
        map.get("seq." + seqData.seqType) || seqData.seqType.split(",").map((s) => s.trim());
      // nonsense around matching the right index and/or advancing for the s case. If the index is -1,
      // the sequence hasn't been referenced in that path of location creation, so we can use the value
      // "" for it. This is a little too magical but currently works where we use it.
      const index = indicator === REF_STRING ? seqData.index - 1 : seqData.index;
      const seqValue = index === -1 ? "" : seq[index % seq.length];
      selfName = selfName.replace(i, seqValue);
      if (indicator === SEQ_STRING) {
        seqData.index++;
      }
    });
  }
  return selfName;
}

// The sequence was defined alongside the definition of child nodes, and is in the sequence itself.
function replaceInlineSeq(child, selfName) {
  const seqData = child.parent?.sequences?.find((seq) => seq.id === child.type);
  if (!seqData || !seqData.options) {
    return selfName;
  }
  selfName = seqData.options[seqData.index % seqData.options.length];
  seqData.index++;
  return selfName;
}

/**
 * Generates a name for a location node based on its type and its position in the location hierarchy.
 * Supports sequence-based naming (e.g. "Room 1", "Room 2") and type-specific name generation
 * (e.g. street names, building names, geographic features).
 *
 * @param {Object} [options={}] - Location name options
 * @param {Object} options.child - The location node to name, with `type` (string) and `parent` (Object)
 *    properties representing its place in the location hierarchy.
 * @returns {string} A generated name for the location node
 */
export function createLocationName({ child } = {}) {
  logger.start("createLocationName", { child });
  child.name = createLocationNameByType({
    name: child.name,
    type: child.type,
    child: child,
    parent: child.parent,
  });
  let name = nameInSequence(child);
  return logger.end(name);
}

function getSequence(location, id) {
  for (let n = location; n != null; n = n.parent) {
    if (n.sequences?.length && n.sequences.some((seq) => seq.id === id)) {
      return n.sequences.find((seq) => seq.id === id);
    }
  }
  return null;
}