import { hasColonOutsideParens, resolve, splitAfter, splitOn } from "./string_utils.js";
import { LocationTemplate } from "./models/location.js";
import { parseDelimiters } from "./string_utils.js";
import RarityTable from "./tables/rarity_table.js";
import Table from "./tables/table.js";
const WS = /^\s*$/;
// Can convert "Room" or "Room (abstract)" into parts 1 and 2 w/ no parentheses
const LIST_PROP_FIELDS = ["inventory", "traits"];
const STRING_PROP_FIELDS = ["series", "owner", "image"];
const VARIABLES = new Map();
const LOCATIONS = [];
const NEW_CONFIG_REGEX =
/^(\S+(?:\s+(?![("#])\S+)*)(?:\s+"([^"]+)")?(?:\s+\(([^)]+)\))?(?:\s+#(.+))?$/;
// Regex to match 'Type as "Label"' syntax - captures the type and the label
const AS_SYNTAX_REGEX = /^(.+?)\s+as\s+"([^"]+)"$/;
// Helper function to load file content - works in both Node.js and browser
async function loadFileContent(fileName) {
const isNode = typeof process !== "undefined" && process.versions?.node;
const filePath = `/src/data/${fileName}`;
if (isNode) {
// Use Node.js fs module for file loading
const { readFile } = await import("node:fs/promises");
const { fileURLToPath } = await import("node:url");
const { dirname, join } = await import("node:path");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const fullPath = join(__dirname, "data", fileName);
return await readFile(fullPath, "utf-8");
} else {
// Use fetch for browser
const response = await fetch(filePath);
return await response.text();
}
}
export async function /* String[] */ lineLoader(fileName) {
const STARTS_WS = /^\s/;
const result = await loadFileContent(fileName);
return result.split("\n").reduce((acc, line) => {
if (STARTS_WS.test(line)) {
acc[acc.length - 1] += line;
} else {
acc.push(line);
}
return acc;
}, []);
}
export async function /* String[] */ indentLoader(fileName) {
const result = await loadFileContent(fileName);
return result.split("\n");
}
export async function /* Map<String, String[]> */ mapLoader(fileName, separator = ",") {
const STARTS_WS = /^\s/;
const WS = /^\s*$/;
const result = await loadFileContent(fileName);
let lastKey = null;
return result.split("\n").reduce((map, line) => {
if (STARTS_WS.test(line)) {
const array = map.get(lastKey);
const values = line
.split(separator)
.map((v) => v.trim())
.filter((v) => v.length > 0);
map.set(lastKey, array.concat(values));
} else if (WS.test(line) || line.startsWith("//")) {
// skip blank lines
} else {
const [key, valueString] = line.split("=");
const values = valueString
.split(separator)
.map((v) => v.trim())
.filter((v) => v.length > 0);
map.set(key.trim(), values);
lastKey = key;
}
return map;
}, new Map());
}
export function rarityTable(array) {
const table = new RarityTable({ useStrict: true, outFunction: (f) => resolve(f) });
for (let i = 0, len = array.length; i < len; i++) {
const [frequency, value] = array[i].split("~");
table.add(frequency.trim(), value.trim());
}
return table;
}
export function percentTable(array) {
const table = new Table({ useStrict: true, outFunction: (f) => resolve(f) });
for (let i = 0, len = array.length; i < len; i++) {
const [percent, value] = array[i].split("%");
table.add(parseInt(percent.trim()), value.trim());
}
return table;
}
// LOCATION LOADER
function flush(props) {
if (props.type.startsWith("$")) {
VARIABLES.set(props.type.trim(), props);
} else {
const template = new LocationTemplate(props);
LOCATIONS.push(template);
}
}
function parens(el) {
if (!el.includes("Bag") && !el.includes("Stock")) {
const [hasParens, value, parensValue] = parseDelimiters(el);
if (hasParens) {
return parensValue + ":" + value;
}
}
return el;
}
/**
* Parse an element that may contain 'as' syntax (e.g., 'Living Room as "Parlor"').
* Returns an object with the type and optional alias.
* @param {string} element - The element string to parse
* @returns {{type: string, alias: string|null}} - The parsed type and alias
*/
function captureInlineSequence(aliases, element) {
const match = element.match(AS_SYNTAX_REGEX);
if (match) {
aliases.push({ type: match[1].trim(), alias: match[2].trim() });
return match[1].trim();
}
return element;
}
/**
* Parse a list of elements, returning both the list and any 'as' aliases found.
* The aliases are returned separately so they can be used to create sequences.
* @param {string} line - The line to parse
* @returns {{list: Array|Object, aliases: Array<{type: string, alias: string}>}}
*/
function parseList(line) {
const aliases = [];
if (/^[^\(]*%/.test(line)) {
const els = splitAfter(line)
.split(",")
.map((s) => s.trim());
const obj = {};
for (let j = 0, len = els.length; j < len; j++) {
const element = els[j];
const [perc, el] = splitOn(element, "%");
const type = captureInlineSequence(aliases, parens(el).trim());
obj[type] = parseInt(perc.trim());
}
return { list: obj, aliases };
} else if (!hasColonOutsideParens(line)) {
const els = line.split(",").map((s) => s.trim());
const result = [];
for (let i = 0; i < els.length; i++) {
const type = captureInlineSequence(aliases, els[i]);
result.push(type);
}
return { list: result, aliases };
} else {
const els = splitAfter(line)
.split(",")
.map((s) => s.trim());
const result = [];
for (let i = 0, len = els.length; i < len; i++) {
const el = els[i];
const type = captureInlineSequence(aliases, parens(el));
result.push(type);
}
return { list: result, aliases };
}
}
/**
* 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 parseLine(field, props, line) {
const STARTS_WS = /^\s/;
if (WS.test(line) || line.startsWith("//")) {
// WHITESPACE
} else if (STARTS_WS.test(line)) {
// INDENTED PROPERTY LINE
let [command, content] = splitOn(line.trim());
if (command.startsWith("$")) {
const variable = VARIABLES.get(command);
Object.keys(variable.ch).forEach((propName) => (props.ch[propName] = variable.ch[propName]));
} else if (command === "ch" || command === "contents") {
field = command;
} else if (command === "allOf" || command === "oneOf") {
const { list, aliases } = parseList(line);
props[field][command] = list;
// Create sequences from any 'as' aliases found
for (const { type, alias } of aliases) {
addToSequence(props, type, alias);
}
} else if (command.startsWith("seq-")) {
props.sequences = props.sequences ?? [];
let [, id] = command.split("-");
props.sequences.push({ id, options: content.split("/").map((s) => s.trim()) });
} else if (command === "descr") {
props["description"] = content.trim();
} else if (STRING_PROP_FIELDS.includes(command)) {
props[command] = content.trim();
} else if (LIST_PROP_FIELDS.includes(command)) {
props[command] = parseList(content).list;
} else {
// 1d3-1: <list>
let [command, content] = splitOn(line.trim());
command = "someOf:" + command;
const prop = command === "contents" ? "contents" : "ch";
props[prop][command] = parseList(line).list;
}
} else {
if (props !== null) {
flush(props);
field = "ch";
}
return [field, startConfig(line)];
}
return [field, props];
}
// Type "Name" (attr) #ID
function startConfig(line) {
if (!NEW_CONFIG_REGEX.test(line)) {
throw new Error(`Invalid location: ${line} , MUST be in format 'Type "Name" (attr) #ID'`);
}
const type = line.split(/[\"\(\#)]/)[0].trim();
const [hasParens, , givenAtts] = parseDelimiters(line, "(", ")");
const atts = hasParens ? givenAtts.split(" ").map((a) => a.trim()) : [];
const [hasQuotes, , givenName] = parseDelimiters(line, '"', '"');
const name = hasQuotes ? givenName : type;
const hasId = line.includes("#");
const id = hasId ? line.split("#")[1].trim() : null;
const props = { id, type, name, ch: {}, contents: {}, tags: [] };
atts.forEach((att) => props.tags.push(att));
return props;
}
export async function /* LocationTemplate[]> */ locationsLoader(fileName) {
const lines = await indentLoader(fileName);
let props = null;
let field = "ch";
for (const line of lines) {
[field, props] = parseLine(field, props, line);
}
flush(props);
return LOCATIONS;
}
export function yamlishParser({ lists = [], bools = [], ints = [], keyProp = "name" } = {}) {
const STARTS_WS = /^\s/;
return function parseEntry(lines) {
const entries = [];
let entry = {};
for (const aLine of lines) {
let line = aLine.trim();
if (line.length === 0) {
entries.push(entry);
entry = {};
} else if (line.startsWith("//")) {
continue;
} else if (STARTS_WS.test(aLine)) {
const [key, value] = line.split(":").map((s) => s.trim());
if (lists.includes(key)) {
entry[key] = value.split(",").map((s) => s.trim());
} else if (ints.includes(key)) {
entry[key] = parseInt(value);
} else if (bools.includes(key)) {
entry[key] = value.toLowerCase() === "true";
} else {
entry[key] = value;
}
} else {
entry[keyProp] = line;
}
}
if (Object.keys(entry).length > 0) {
entries.push(entry);
}
return entries;
};
}