445 lines
21 KiB
JavaScript
445 lines
21 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
camelCase,
|
|
optionValue,
|
|
pipelineDirectory,
|
|
projectPath,
|
|
projectRoot,
|
|
readJson,
|
|
slugify,
|
|
} from "./lib/recipe.mjs";
|
|
import { inspectWdb } from "./lib/wdb.mjs";
|
|
|
|
const argv = process.argv.slice(2);
|
|
const rawFile = path.resolve(projectRoot, optionValue(argv, "--raw", "runewaker-export-work/all-instances-forensic-raw.json"));
|
|
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "../rom-pvt-server-backups/Runewaker"));
|
|
const resourceRoot = path.join(sourceRoot, "Resource");
|
|
const configFile = path.join(sourceRoot, "Tools", "dungeon_config.json");
|
|
const entryScriptFile = path.join(resourceRoot, "luascript", "03246.lua");
|
|
const overridesFile = path.join(pipelineDirectory, "discovery-overrides.json");
|
|
const recipesDirectory = path.join(pipelineDirectory, "recipes");
|
|
const snapshotsDirectory = path.join(pipelineDirectory, "snapshots");
|
|
const SOURCE_ANCHOR_SUPPORT_DUNGEONS = new Set([
|
|
"mystic_altar",
|
|
"treasure_trove",
|
|
"the_origin",
|
|
"demon_stronghold",
|
|
"raksha_temple",
|
|
"aeternal_circle",
|
|
]);
|
|
|
|
const clean = (value) => String(value ?? "").replaceAll(String.fromCharCode(0), "").trim();
|
|
const normalizeModelPath = (value) => clean(value)
|
|
.replaceAll(String.fromCharCode(92), "/")
|
|
.replace(/^\/+/, "")
|
|
.replace(/^resource\//i, "")
|
|
.toLowerCase();
|
|
const stableHash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
const sha256 = async (file) => createHash("sha256").update(await readFile(file)).digest("hex");
|
|
|
|
function entrySpawns(source) {
|
|
const result = new Map();
|
|
const number = "(-?\\d+(?:\\.\\d+)?)";
|
|
const pattern = new RegExp(
|
|
"id=\\\"([^\\\"]+)\\\"[^\\n]*spawn=\\{\\s*x=" + number
|
|
+ ",\\s*y=" + number + ",\\s*z=" + number + ",\\s*dir=" + number,
|
|
"g",
|
|
);
|
|
for (const match of source.matchAll(pattern)) {
|
|
result.set(match[1], {
|
|
position: match.slice(2, 5).map(Number),
|
|
direction: Number(match[5]),
|
|
evidence: "Resource/luascript/03246.lua:MS_TEST_DUNGEONS",
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizedWdb(value) {
|
|
return clean(value).replaceAll(String.fromCharCode(92), "/").replace(/^\/+/, "").replace(/^wdb\//i, "");
|
|
}
|
|
|
|
function actorIdFor(model, used) {
|
|
const base = slugify(path.parse(model).name.replace(/^act[_-]?/i, "")) || "actor";
|
|
if (!used.has(base) || used.get(base) === model) return base;
|
|
return base + "-" + stableHash(model).slice(0, 8);
|
|
}
|
|
|
|
function colorsFor(id) {
|
|
const hue = (Number(id) * 47) % 360;
|
|
return {
|
|
primaryColor: `hsl(${hue} 22% 38%)`,
|
|
accentColor: `hsl(${(hue + 28) % 360} 38% 64%)`,
|
|
};
|
|
}
|
|
|
|
function archetypeFor(model) {
|
|
if (/slime|ooze/.test(model)) return "ooze";
|
|
if (/bat|bird|harpy|dragon|wing/.test(model)) return "winged";
|
|
if (/serpent|snake|naga/.test(model)) return "serpent";
|
|
if (/turtle|tortoise/.test(model)) return "turtle";
|
|
if (/raptor/.test(model)) return "raptor";
|
|
if (/crocolisk|crocodile/.test(model)) return "crocolisk";
|
|
if (/plant|myconid|fung|flower|tree/.test(model)) return "plant";
|
|
if (/murloc/.test(model)) return "murloc";
|
|
if (/spider|lizard|salamander|frog|bug|scarab|ant/.test(model)) return "lizard";
|
|
return "humanoid";
|
|
}
|
|
|
|
function preferredModel(rows) {
|
|
const counts = new Map();
|
|
for (const row of rows) {
|
|
const model = normalizeModelPath(row.modelPath);
|
|
if (!/^model\/character\/.+\.ros$/i.test(model)) continue;
|
|
counts.set(model, (counts.get(model) ?? 0) + 1);
|
|
}
|
|
return [...counts].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))[0]?.[0] ?? null;
|
|
}
|
|
|
|
function templateDefinition(templateId, rows, model, actor) {
|
|
const first = rows[0];
|
|
const bossFlag = rows.some((row) => Number(row.sex) === 3);
|
|
const level = Math.max(...rows.map((row) => Number(row.nativeLevel ?? 0)));
|
|
const combat = Boolean(model) && level > 0;
|
|
// sex=3 is authoritative crown/boss evidence only for an actual character
|
|
// display. Several scripted doors and invisible FX controllers also carry
|
|
// sex=3; promoting those props to combat entities creates fake bosses and
|
|
// impossible objective routes.
|
|
const boss = bossFlag && combat;
|
|
const classification = boss ? "boss" : combat ? "combat" : "deferred-object";
|
|
const sourceName = clean(first.localizedName) || clean(first.roleName);
|
|
const name = sourceName && !/^sys\d+_name$/i.test(sourceName) ? sourceName : "Template " + templateId;
|
|
const proceduralFallback = boss && !actor;
|
|
return {
|
|
...colorsFor(templateId),
|
|
id: Number(templateId),
|
|
name,
|
|
level,
|
|
classification,
|
|
archetype: archetypeFor(model ?? name.toLowerCase()),
|
|
...(actor ? { actor } : {}),
|
|
...(boss ? { title: name } : {}),
|
|
...(proceduralFallback ? {
|
|
proceduralFallback: true,
|
|
fallbackReason: "Authoritative NPCObjectDB.sex=3 boss uses a runtime/dynamic display and has no static actor ROS.",
|
|
} : {}),
|
|
...(classification === "deferred-object" ? {
|
|
reason: level === 0
|
|
? "Level-zero quest, trigger, prop, or noncombat NPC retained for a later interaction pass."
|
|
: "Positive-level source object has no static character ROS and is retained for mechanics review.",
|
|
} : {}),
|
|
source: {
|
|
imageId: Number(first.imageId ?? 0),
|
|
modelPath: model,
|
|
classificationRule: boss
|
|
? "NPCObjectDB.sex=3 plus static model/character ROS"
|
|
: combat
|
|
? "positive native level plus static model/character ROS"
|
|
: bossFlag
|
|
? "NPCObjectDB.sex=3 scripted non-character object retained for mechanics review"
|
|
: "noncombat/dynamic-display source object",
|
|
},
|
|
};
|
|
}
|
|
|
|
const [raw, config, entrySource, existingOverrides] = await Promise.all([
|
|
readJson(rawFile),
|
|
readJson(configFile),
|
|
readFile(entryScriptFile, "utf8"),
|
|
readJson(overridesFile),
|
|
]);
|
|
if (raw.schemaVersion !== 1 || !Array.isArray(raw.zones)) throw new Error("Unsupported all-instance forensic export.");
|
|
const rawByZone = new Map(raw.zones.map((zone) => [Number(zone.zoneId), zone]));
|
|
const entries = entrySpawns(entrySource);
|
|
const sourceHashes = {
|
|
backups: {
|
|
"../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak": await sha256(path.join(projectRoot, "../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak")),
|
|
"../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak": await sha256(path.join(projectRoot, "../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak")),
|
|
},
|
|
catalogs: Object.fromEntries(await Promise.all([
|
|
"Resource/data/npcobject.db",
|
|
"Resource/data/imageobject.db",
|
|
"Resource/data/string_enus.db",
|
|
].map(async (file) => [file, await sha256(path.join(sourceRoot, file))]))),
|
|
};
|
|
|
|
await Promise.all([mkdir(recipesDirectory, { recursive: true }), mkdir(snapshotsDirectory, { recursive: true })]);
|
|
const overrides = { ...existingOverrides };
|
|
const prepared = [];
|
|
for (const dungeon of config.dungeons ?? []) {
|
|
const slug = slugify(dungeon.id);
|
|
const zone = rawByZone.get(Number(dungeon.base_zone));
|
|
if (!zone) throw new Error("Forensic export is missing Zone " + dungeon.base_zone + ".");
|
|
const zoneRows = Array.isArray(zone.rows) ? zone.rows : [];
|
|
const wdb = normalizedWdb(zone.zoneObject?.mapFile);
|
|
if (!wdb) throw new Error("Zone " + dungeon.base_zone + " has no authoritative mapfile.");
|
|
overrides[dungeon.id] = {
|
|
wdb,
|
|
source: "ObjectEdit.dbo.ZoneObjectDB.mapfile",
|
|
zoneObjectTemplateId: Number(zone.zoneObjectTemplateId),
|
|
};
|
|
if (dungeon.id === "forsaken_abbey") {
|
|
prepared.push({ id: dungeon.id, zoneId: dungeon.base_zone, rows: zone.rowCount, wdb, status: "preserved-existing-pilot" });
|
|
continue;
|
|
}
|
|
const wdbFile = path.join(resourceRoot, "wdb", ...wdb.split("/"));
|
|
const workRoot = path.join(projectRoot, "runewaker-export-work", slug);
|
|
const inspection = await inspectWdb({ wdbFile, reportFile: path.join(workRoot, "inspection", "wdb-report.json") });
|
|
const hasDungeonModel = inspection.candidates.some((entry) => entry.isDungeonModel);
|
|
const candidate = inspection.candidates.find((entry) => entry.isDungeonModel && entry.placements === 1)
|
|
?? inspection.candidates.find((entry) => entry.isDungeonModel)
|
|
?? inspection.candidates[0];
|
|
if (!candidate) throw new Error(dungeon.name + " WDB contains no ROS environment candidates.");
|
|
const model = candidate.resource.replaceAll(String.fromCharCode(92), "/").replace(/^\/+/, "");
|
|
await stat(path.join(resourceRoot, ...model.split("/")));
|
|
const entry = entries.get(dungeon.id);
|
|
if (!entry) throw new Error("03246.lua has no entrance for " + dungeon.id + ".");
|
|
const normalizedModel = normalizeModelPath(model);
|
|
const modelPlacements = (inspection.report.descriptors ?? []).filter((descriptor) => (
|
|
normalizeModelPath(descriptor.resource) === normalizedModel
|
|
));
|
|
const selectedPlacement = [...modelPlacements].sort((left, right) => {
|
|
const distanceSquared = (descriptor) => descriptor.translation.reduce((sum, value, index) => (
|
|
sum + (Number(value) - entry.position[index]) ** 2
|
|
), 0);
|
|
return distanceSquared(left) - distanceSquared(right);
|
|
})[0];
|
|
if (!selectedPlacement) throw new Error(dungeon.name + " primary model has no WDB placement.");
|
|
const proceduralTerrainFallback = !hasDungeonModel ? (() => {
|
|
const extents = (inspection.report.descriptors ?? []).map((descriptor) => {
|
|
const center = descriptor.worldBounds?.center ?? descriptor.translation;
|
|
const radius = Number(descriptor.worldBounds?.radius ?? 0);
|
|
return { center: center.map(Number), radius };
|
|
});
|
|
const xValues = extents.flatMap(({ center, radius }) => [center[0] - radius, center[0] + radius]);
|
|
const zValues = extents.flatMap(({ center, radius }) => [center[2] - radius, center[2] + radius]);
|
|
const combatY = zoneRows.filter((row) => Number(row.nativeLevel) > 0).map((row) => Number(row.sourcePosition[1]));
|
|
return {
|
|
kind: "flat-source-evidence-arena",
|
|
reason: "The authoritative WDB contains placed props but no model/dungeon terrain ROS.",
|
|
sourceBounds: {
|
|
min: [Math.min(...xValues, entry.position[0]), 0, Math.min(...zValues, entry.position[2])],
|
|
max: [Math.max(...xValues, entry.position[0]), 0, Math.max(...zValues, entry.position[2])],
|
|
},
|
|
floorYSource: Math.min(Number(entry.position[1]), ...combatY),
|
|
paddingSourceUnits: 20,
|
|
thicknessSourceUnits: 4,
|
|
evidence: ["ObjectEdit.dbo.ZoneObjectDB.mapfile", "WDB descriptor world bounds", entry.evidence],
|
|
reviewRequired: true,
|
|
};
|
|
})() : null;
|
|
const assemblyPlacementTranslations = dungeon.id === "ystra_labyrinth_boss1"
|
|
? modelPlacements.map((placement) => placement.translation.map(Number))
|
|
: null;
|
|
const sourceAnchorSupport = SOURCE_ANCHOR_SUPPORT_DUNGEONS.has(dungeon.id) ? (() => {
|
|
const unique = new Map();
|
|
for (const position of [
|
|
entry.position,
|
|
...zoneRows.filter((row) => Number(row.nativeLevel) > 0).map((row) => row.sourcePosition),
|
|
]) {
|
|
const point = position.map(Number);
|
|
unique.set(point.map((value) => value.toFixed(3)).join(":"), point);
|
|
}
|
|
return {
|
|
kind: "source-anchor-support-pads",
|
|
reason: "The authoritative WDB is modular or its primary ROS collision has navigation gaps at source spawn anchors.",
|
|
points: [...unique.values()],
|
|
halfExtentSourceUnits: 60,
|
|
thicknessSourceUnits: 3,
|
|
evidence: ["ROM_Global.dbo.NPCData source coordinates", entry.evidence],
|
|
reviewRequired: true,
|
|
};
|
|
})() : null;
|
|
const camel = camelCase(slug);
|
|
const environmentRecipeFile = path.join(recipesDirectory, slug + ".json");
|
|
const populationRecipeFile = path.join(recipesDirectory, slug + "-population.json");
|
|
const environmentRecipe = {
|
|
schemaVersion: 2,
|
|
slug,
|
|
title: dungeon.name,
|
|
source: {
|
|
rootEnvironmentVariable: "RUNEWAKER_ROOT",
|
|
relativeDefault: "../rom-pvt-server-backups/Runewaker",
|
|
resourceRoot: "Resource",
|
|
wdb: "Resource/wdb/" + wdb,
|
|
model,
|
|
primaryPlacementTranslation: selectedPlacement.translation.map(Number),
|
|
...(assemblyPlacementTranslations ? { assemblyPlacementTranslations } : {}),
|
|
...(proceduralTerrainFallback ? { proceduralTerrainFallback } : {}),
|
|
...(sourceAnchorSupport ? { sourceAnchorSupport } : {}),
|
|
modelSelection: {
|
|
rule: proceduralTerrainFallback
|
|
? "largest WDB prop retained as the coordinate anchor; playable terrain generated from authoritative WDB bounds"
|
|
: candidate.placements === 1
|
|
? "largest unique model/dungeon ROS"
|
|
: "largest model/dungeon ROS placement nearest the authoritative entrance",
|
|
score: candidate.score,
|
|
placements: candidate.placements,
|
|
dominance: inspection.recommendation?.dominance ?? null,
|
|
},
|
|
dungeonConfig: "Tools/dungeon_config.json",
|
|
dungeonConfigId: dungeon.id,
|
|
entryCoordinates: { position: entry.position, direction: entry.direction, reviewRequired: false, evidence: entry.evidence },
|
|
},
|
|
coordinateSystem: {
|
|
source: "left-handed Y-up",
|
|
nativeBridge: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
|
|
metersPerSourceUnit: 0.1,
|
|
directionUnitsPerTurn: 360,
|
|
calibration: "model/character/pc/dummy/fake_pc.ros mesh height is 16.4977 source units",
|
|
},
|
|
navigation: {
|
|
cellSize: 0.3, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36,
|
|
agentMaxClimb: 0.5, agentMaxSlope: 50, tileSize: 64, retainTileIntermediates: false,
|
|
},
|
|
review: {
|
|
environment: proceduralTerrainFallback
|
|
? "authoritative-wdb-has-no-terrain-procedural-fallback-review-required"
|
|
: "authoritative-wdb-largest-model-selected",
|
|
collision: sourceAnchorSupport
|
|
? "primary-ros-plus-source-anchor-support-review-required"
|
|
: "generated-review-required",
|
|
entrance: "source-script",
|
|
bossAnchors: "source-population",
|
|
wdbPlacements: assemblyPlacementTranslations
|
|
? "all-repeated-primary-room-placements-assembled"
|
|
: candidate.placements === 1 ? "unique-primary-model" : "nearest-authoritative-entrance",
|
|
},
|
|
exceptions: [
|
|
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
|
"Collision initially includes decorative primary-ROS geometry.",
|
|
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
|
|
...(proceduralTerrainFallback ? [
|
|
"The source WDB has no terrain ROS; a flat playable arena is derived from source descriptor bounds and must be visually reviewed.",
|
|
] : []),
|
|
...(sourceAnchorSupport ? [
|
|
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up.",
|
|
] : []),
|
|
],
|
|
};
|
|
|
|
const rowsByTemplate = new Map();
|
|
for (const row of zoneRows) {
|
|
const bucket = rowsByTemplate.get(Number(row.templateId)) ?? [];
|
|
bucket.push(row);
|
|
rowsByTemplate.set(Number(row.templateId), bucket);
|
|
}
|
|
const actorsByModel = new Map();
|
|
const actorIds = new Map();
|
|
const templates = [];
|
|
for (const [templateId, templateRows] of [...rowsByTemplate].sort(([left], [right]) => left - right)) {
|
|
const sourceModel = preferredModel(templateRows);
|
|
let actor = null;
|
|
if (sourceModel) {
|
|
let definition = actorsByModel.get(sourceModel);
|
|
if (!definition) {
|
|
const id = actorIdFor(sourceModel, actorIds);
|
|
definition = { id, sourceModel, runtimeRotationY: 0 };
|
|
actorsByModel.set(sourceModel, definition);
|
|
actorIds.set(id, sourceModel);
|
|
}
|
|
actor = definition.id;
|
|
}
|
|
templates.push(templateDefinition(templateId, templateRows, sourceModel, actor));
|
|
}
|
|
const templateMap = new Map(templates.map((template) => [template.id, template]));
|
|
const snapshotRows = zoneRows.map((row) => {
|
|
const template = templateMap.get(Number(row.templateId));
|
|
const modelPath = normalizeModelPath(row.modelPath) || template.source.modelPath;
|
|
return {
|
|
spawnId: Number(row.spawnId),
|
|
templateId: Number(row.templateId),
|
|
name: template.name,
|
|
sourceName: clean(row.localizedName) || clean(row.roleName) || template.name,
|
|
classification: template.classification,
|
|
level: Number(row.nativeLevel),
|
|
sex: Number(row.sex),
|
|
imageId: Number(row.imageId ?? 0),
|
|
modelPath: modelPath || null,
|
|
sourcePosition: row.sourcePosition.map(Number),
|
|
direction: Number(row.direction),
|
|
source: {
|
|
roomId: Number(row.roomId), roleName: clean(row.roleName), autoPlot: clean(row.autoPlot),
|
|
plotClassName: clean(row.plotClassName),
|
|
templateScripts: Object.fromEntries(Object.entries(row.templateScripts ?? {}).map(([key, value]) => [key, clean(value)])),
|
|
spells: row.spells ?? [],
|
|
},
|
|
};
|
|
}).sort((left, right) => left.spawnId - right.spawnId);
|
|
const content = {
|
|
schemaVersion: 2, dungeonId: slug, zoneId: Number(dungeon.base_zone), queryVersion: raw.queryVersion,
|
|
rowCount: snapshotRows.length, sourceHashes, rows: snapshotRows,
|
|
};
|
|
const snapshot = { ...content, contentSha256: stableHash(content) };
|
|
const snapshotFile = path.join(snapshotsDirectory, slug + "-zone-" + dungeon.base_zone + ".json");
|
|
const bossSpawns = snapshotRows.filter((row) => row.classification === "boss").length;
|
|
const populationRecipe = {
|
|
schemaVersion: 2,
|
|
dungeonId: slug,
|
|
sourceDungeonConfigId: dungeon.id,
|
|
zoneId: Number(dungeon.base_zone),
|
|
expectedActiveRows: snapshotRows.length,
|
|
expectedBossSpawns: bossSpawns,
|
|
runtimeIdPrefix: "rw-" + slug,
|
|
coordinateSystem: { metersPerSourceUnit: 0.1, directionUnitsPerTurn: 360, maximumProjectionDistance: 4 },
|
|
files: {
|
|
environmentRecipe: projectPath(environmentRecipeFile),
|
|
snapshot: projectPath(snapshotFile),
|
|
generatedSource: "src/game/generated/" + camel + "Population.generated.ts",
|
|
report: "src/assets/game/dungeons/" + slug + "/population-import-report.json",
|
|
mechanicsReport: "src/assets/game/dungeons/" + slug + "/mechanics-audit.json",
|
|
mechanicsSource: "src/game/generated/" + camel + "Mechanics.generated.ts",
|
|
environmentMetadata: "src/assets/game/dungeons/" + slug + "/import-metadata.json",
|
|
navigation: "src/assets/game/dungeons/" + slug + "/" + slug + "-navigation.glb",
|
|
actorManifest: "public/assets/creatures/" + slug + "/manifest.json",
|
|
actorOutput: "public/assets/creatures/" + slug,
|
|
},
|
|
source: {
|
|
rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker",
|
|
resourceRoot: "Resource", dungeonConfig: "Tools/dungeon_config.json", dungeonConfigId: dungeon.id,
|
|
luaRoot: "Resource/luascript",
|
|
catalogs: ["Resource/data/npcobject.db", "Resource/data/imageobject.db", "Resource/data/string_enus.db"],
|
|
backups: {
|
|
global: "../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak",
|
|
objects: "../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak",
|
|
},
|
|
serverEnvironmentVariable: "RUNEWAKER_SQL_SERVER", serverDefault: ".\\HEALERMAN_RW",
|
|
},
|
|
actors: [...actorsByModel.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
|
templates,
|
|
deferred: [
|
|
"Doors, summons, patrols, phases, interactables, and loot require reviewed mechanics mappings.",
|
|
"Direct GR2 actors and exact paperdoll appearances use recovered native animation when validated; explicit pose-only/proxy exceptions retain procedural presentation.",
|
|
],
|
|
};
|
|
await Promise.all([
|
|
writeFile(environmentRecipeFile, JSON.stringify(environmentRecipe, null, 2) + "\n", "utf8"),
|
|
writeFile(populationRecipeFile, JSON.stringify(populationRecipe, null, 2) + "\n", "utf8"),
|
|
writeFile(snapshotFile, JSON.stringify(snapshot, null, 2) + "\n", "utf8"),
|
|
]);
|
|
prepared.push({
|
|
id: dungeon.id, slug, zoneId: dungeon.base_zone, rows: snapshotRows.length, bosses: bossSpawns,
|
|
actors: actorsByModel.size, wdb, model, modelPlacements: candidate.placements,
|
|
modelSelection: candidate.placements === 1 ? "automatic-unique" : "automatic-nearest-entrance",
|
|
});
|
|
}
|
|
|
|
await writeFile(overridesFile, JSON.stringify(overrides, null, 2) + "\n", "utf8");
|
|
const reportFile = path.join(projectRoot, "runewaker-export-work", "all-instances-preparation-report.json");
|
|
await writeFile(reportFile, JSON.stringify({
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
source: projectPath(rawFile),
|
|
counts: {
|
|
configured: prepared.length,
|
|
generated: prepared.filter((entry) => entry.status !== "preserved-existing-pilot").length,
|
|
sourceRows: prepared.reduce((sum, entry) => sum + entry.rows, 0),
|
|
bossSpawns: prepared.reduce((sum, entry) => sum + Number(entry.bosses ?? 0), 0),
|
|
},
|
|
dungeons: prepared,
|
|
}, null, 2) + "\n", "utf8");
|
|
console.log("[runewaker] prepared " + (prepared.length - 1) + " remaining instance recipes and portable snapshots.");
|
|
console.log("[runewaker] report: " + projectPath(reportFile));
|