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