create_location.js

import { asModel } from "./persist.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 { intersection } from "./set_utils.js";
import { newLocation, Location } from "./models/location.js";
import { logger, tryUntil } from "./utils.js";
import { toList } from "./string_utils.js";
import { random, randomElement, roll, test } from "./random_utils.js";
import { selectElements } from "./random_selection.js";
import { LOCATION_DATABASE as DATABASE, BARRIERS } from "./location_loader.js";

const LIST = [];
const MAP = new Map();
const LOCATIONS_BY_GUID = new Map();

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

export function getLocationTypes() {
  return LOCATION_TYPES;
}

export function getLocationIcon({ type }) {
  const loc = DATABASE.findOne({ type });
  return loc && loc.icon ? loc.icon : "question";
}

// The historical/event layers mutate the same object references registered here, so this
// cache is already current after createMapEnrichment runs — no separate write-back needed.
export function getLocation(guid) {
  return LOCATIONS_BY_GUID.get(guid) ?? null;
}

/**
 * Repopulates LOCATIONS_BY_GUID from a previously-generated location tree (or array of
 * trees) — the only worker-side registry that needs rehydrating after a reload, since
 * templates are now derived by type (see expandLocation). Used when loading a saved map:
 * its locations were generated (and possibly enriched) in a prior session/worker instance,
 * so this worker's registries start out empty for those guids.
 */
export function registerLocations(locations) {
  const list = Array.isArray(locations) ? locations : [locations];
  list.forEach((loc) => registerTree(asModel(loc)));
}

function registerTree(node) {
  LOCATIONS_BY_GUID.set(node.guid, node);
  (node.children ?? []).forEach(registerTree);
}

/**
 * Should be called after all locations have been created in a context, preferably in the
 * worker thread, to enrich the locations after initial presentation. The GUIDS of enriched
 * locations are returned and from there, they can be retrieved via getLocation.
 *
 * Takes the full list of locations involved (registerLocations() runs first, as this
 * function's own first step) so callers never have to sequence registration before
 * enrichment themselves — safe to call unconditionally whether or not this worker instance
 * created these locations itself (re-registering an already-registered location is a
 * harmless no-op, same object reference).
 */
export async function createMapEnrichment({ locations = [] } = {}) {
  logger.start("createMapEnrichment", {});
  registerLocations(locations);
  const enriched1 = await createHistoricalLayer({ list: LIST });
  const enriched2 = await createEventLayer({ map: MAP });

  // clear the context.
  LIST.length = 0;
  MAP.clear();
  return logger.end(enriched1.concat(enriched2));
}

/**
 * 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), shim = false } = {}) {
  logger.start("createLocation", { type, shim });
  let template = DATABASE.findOne({ type });
  if (!template) {
    throw new Error("No location template found for type: " + type);
  }
  let root = createInstance(newLocation(template, { sequences: [] }), template, shim);
  walkTemplate(root, template, shim);
  if (root.is("abstract") && root.children.length === 1) {
    return logger.end(root.children[0]);
  }
  return logger.end(root);
}

function selectUniqueElements(parent, template) {
  // TODO: uniq forces lower roles given some:d3d type syntax, since lower counts are more likely to
  // be unique. A solution to this would presumably be more sophisticated than selectElements.
  const uniq = template.uniq || parent?.uniq;
  return tryUntil(
    () => selectElements(template.ch),
    (arr) => !uniq || arr.length == new Set(arr).size,
  );
}

function walkTemplate(parent, template, shim) {
  var elements = selectUniqueElements(parent, template);

  elements.forEach((type) => {
    let childTemplate = DATABASE.findOne({ type });
    if (!childTemplate) {
      throw Error("Could not find template for type: " + 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, shim);
    } else {
      let childLocation = createInstance(parent, childTemplate, shim);
      walkTemplate(childLocation, childTemplate, shim);
    }
  });
}

function createInstance(parent, childTemplate, shim) {
  let child = newLocation(childTemplate, parent);
  child.name = createLocationName({ child }).value;
  if (parent) {
    parent.addChild(child);
  }
  if (shim) {
    child.shim = true;
  } else {
    createInstanceInternal(child, childTemplate);
  }
  LIST.push(child);
  const arr = MAP.get(child.type) ?? [];
  arr.push(child);
  MAP.set(child.type, arr);
  LOCATIONS_BY_GUID.set(child.guid, child);
  return child;
}

// IMPORTANT: If something can be added through history or events, a shimmed copy cannot later
// overwrite this information in this method. That is why we are concatenating to the contents
// array. Barriers are looted here in this method, but if you moved it to the events layer—
// then you'd have to account for that in this method.
function createInstanceInternal(child, childTemplate = null) {
  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.older) {
      child.owner.older = retrainToProfession(childTemplate.owner, traits, child.owner.older);
      child.owner.younger = retrainToProfession(childTemplate.owner, traits, child.owner.younger);
    }
  }
  const looted = test(childTemplate.looted);
  if (childTemplate.barriers.length) {
    const barriers = createBarriers(looted, childTemplate.barriers);
    child.description.unshift(barriers);
  }
  // contents is what exists in a room or place, including containers
  child.contents = determineContents(childTemplate, looted).concat(child.contents);
}

// create a description of all barriers into/out of a location
function createBarriers(looted, barriers) {
  const index = looted ? roll(barriers.length) - 1 : -2;
  return (
    barriers
      .map((barr, i) => {
        const locks = randomElement(barr.locks);
        return index === i
          ? `<b>${barr.entrance}</b> was secured by ${toList(locks.map(brokenBarrier))}`
          : `<b>${barr.entrance}</b> secured by ${toList(locks.map(barrier))}`;
      })
      .join(". ") + "."
  );
}

// describe a compromised barrier
function brokenBarrier(lock) {
  const config = BARRIERS.get(lock);
  return `${random(config.descr)}, ${random(config.br)}`;
}

// describe a functional barrier
function barrier(lock) {
  const config = BARRIERS.get(lock);
  return `${random(config.descr)} (${random(config.challenge)})`;
}

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

// Use the contents array to generate stuff at this location
function determineContents(template, looted) {
  return selectElements(template.contents)
    .map(createContents)
    .filter((el) => el !== null)
    .reduce(combineBags, [])
    .map(lootBag(looted));
}

// Create a bag or a container depending on the syntax
function createContents(child) {
  if (child.includes("(")) {
    const spec = bagSpecParser(child);
    return createBag(spec);
  }
  return createContainer({ type: child });
}

// combine anonymous bags next to each other in the array, this is easier to read
function combineBags(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;
}

// if location has been looted, remove valuable items
// TODO: This isn't recursive, eventually that will matter
function lootBag(looted) {
  return (bag) => {
    if (looted) {
      bag.entries = bag.entries.filter(
        (entry) =>
          entry.item.not("cash") &&
          entry.item.not("food") &&
          entry.item.not("ammo") &&
          entry.item.value <= 3,
      );
    }
    return bag;
  };
}

export function expandShim(guid) {
  const child = LOCATIONS_BY_GUID.get(guid);
  if (!child) {
    console.warn(`expandShim: no canonical location registered for guid ${guid}`);
    return null;
  }
  return expandLocation(child);
}

// Must operate on the canonical location object registered in LOCATIONS_BY_GUID (looked
// up by guid in expandShim), not a clone that arrived over the Comlink boundary — otherwise
// this mutates a copy that createMapEnrichment's LIST/MAP-based enrichment never sees, and
// the two diverge instead of composing.
function expandLocation(child) {
  if (!child.shim) {
    return child;
  }
  delete child.shim;

  // Every node carries its own `.type`, and DATABASE.findOne({type}) is exactly how
  // createLocation resolves the same template for the root — no guid-keyed cache needed,
  // which also means this works for a location this worker instance never created itself
  // (e.g. one just rehydrated via registerLocations() after a page reload).
  const childTemplate = DATABASE.findOne({ type: child.type });
  if (!childTemplate) {
    throw Error("Could not find template for type: " + child.type);
  }
  createInstanceInternal(child, childTemplate);
  child.revision = (child.revision ?? 0) + 1;
  // also render all room descendants: a room template can itself have room children (e.g.
  // Terminal contains Ticket Counter, Waiting Area, etc.), so rooms aren't always leaf nodes.
  child.children.filter((ch) => ch.is("room")).forEach(expandLocation);
  return child;
}