251 lines
9.8 KiB
JavaScript
251 lines
9.8 KiB
JavaScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
camelCase,
|
|
exists,
|
|
hasFlag,
|
|
optionValue,
|
|
pipelineDirectory,
|
|
projectPath,
|
|
projectRoot,
|
|
readJson,
|
|
slugify,
|
|
} from "./lib/recipe.mjs";
|
|
import { inspectWdb } from "./lib/wdb.mjs";
|
|
|
|
const argv = process.argv.slice(2);
|
|
const dungeonId = optionValue(argv, "--id") ?? argv.find((value) => !value.startsWith("--"));
|
|
if (!dungeonId) {
|
|
throw new Error("Usage: npm run runewaker:instance -- scaffold --id forsaken_abbey [--wdb file.wdb]");
|
|
}
|
|
|
|
const sourceRoot = path.resolve(
|
|
process.env.RUNEWAKER_ROOT
|
|
?? path.join(projectRoot, "../rom-pvt-server-backups/Runewaker"),
|
|
);
|
|
const dungeonConfigFile = path.join(sourceRoot, "Tools", "dungeon_config.json");
|
|
const config = await readJson(dungeonConfigFile);
|
|
const dungeon = (config.dungeons ?? []).find((entry) => entry.id === dungeonId);
|
|
if (!dungeon) throw new Error("Unknown dungeon_config id: " + dungeonId);
|
|
|
|
const overrides = await readJson(path.join(pipelineDirectory, "discovery-overrides.json"));
|
|
const override = overrides[dungeonId] ?? {};
|
|
const slug = slugify(optionValue(argv, "--slug", dungeon.id));
|
|
const requestedWdb = optionValue(argv, "--wdb", override.wdb);
|
|
if (!requestedWdb) {
|
|
throw new Error(
|
|
"No unambiguous WDB is known for " + dungeonId
|
|
+ ". Run discovery, review its candidates, then pass --wdb <file>.",
|
|
);
|
|
}
|
|
const wdbFile = path.isAbsolute(requestedWdb)
|
|
? requestedWdb
|
|
: path.join(sourceRoot, "Resource", "wdb", ...requestedWdb.replaceAll("\\", "/").split("/"));
|
|
if (!await exists(wdbFile)) throw new Error("WDB does not exist: " + wdbFile);
|
|
|
|
const workRoot = path.join(projectRoot, "runewaker-export-work", slug);
|
|
const inspection = await inspectWdb({
|
|
wdbFile,
|
|
reportFile: path.join(workRoot, "inspection", "wdb-report.json"),
|
|
});
|
|
const explicitModel = optionValue(argv, "--model");
|
|
const recommended = inspection.recommendation;
|
|
const model = explicitModel ?? (recommended?.confidence === "high" ? recommended.resource : null);
|
|
if (!model) {
|
|
throw new Error(
|
|
"The WDB primary environment is ambiguous. Review "
|
|
+ projectPath(path.join(workRoot, "inspection", "wdb-report.json"))
|
|
+ " and rerun with --model <resource.ros>.",
|
|
);
|
|
}
|
|
|
|
function luaNumber(source, name) {
|
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const match = source.match(new RegExp("^\\s*" + escaped + "\\s*=\\s*(-?\\d+(?:\\.\\d+)?)", "m"));
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
async function discoverEntry() {
|
|
const candidates = [
|
|
dungeon.entry_lua_prefix ? dungeon.entry_lua_prefix + "_Z" + dungeon.base_zone : null,
|
|
dungeon.entry_lua_prefix || null,
|
|
"DQ_Z" + dungeon.base_zone,
|
|
].filter(Boolean);
|
|
for (const fileName of dungeon.lua_files ?? []) {
|
|
const absolute = path.join(sourceRoot, "Resource", "luascript", fileName);
|
|
if (!await exists(absolute)) continue;
|
|
const source = await readFile(absolute, "utf8");
|
|
for (const prefix of candidates) {
|
|
const values = ["X", "Y", "Z", "DIR"].map((suffix) => luaNumber(source, prefix + "_" + suffix));
|
|
if (values.every(Number.isFinite)) {
|
|
return {
|
|
entryLua: "Resource/luascript/" + fileName,
|
|
entryConstantPrefix: prefix,
|
|
values,
|
|
reviewRequired: false,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
const spawn = dungeon.player_spawn ?? {};
|
|
return {
|
|
entryLua: null,
|
|
entryConstantPrefix: null,
|
|
values: [spawn.x, spawn.y, spawn.z, spawn.dir].map((value) => Number(value ?? 0)),
|
|
reviewRequired: true,
|
|
};
|
|
}
|
|
|
|
const entry = await discoverEntry();
|
|
const camel = camelCase(slug);
|
|
const environmentRecipeFile = path.join(pipelineDirectory, "recipes", slug + ".json");
|
|
const populationRecipeFile = path.join(pipelineDirectory, "recipes", slug + "-population.json");
|
|
for (const file of [environmentRecipeFile, populationRecipeFile]) {
|
|
if (await exists(file) && !hasFlag(argv, "--force")) {
|
|
throw new Error("Recipe already exists; refusing to overwrite without --force: " + projectPath(file));
|
|
}
|
|
}
|
|
|
|
const environmentRecipe = {
|
|
schemaVersion: 2,
|
|
slug,
|
|
title: dungeon.name,
|
|
source: {
|
|
rootEnvironmentVariable: "RUNEWAKER_ROOT",
|
|
relativeDefault: "../rom-pvt-server-backups/Runewaker",
|
|
resourceRoot: "Resource",
|
|
wdb: "Resource/wdb/" + path.relative(path.join(sourceRoot, "Resource", "wdb"), wdbFile).split(path.sep).join("/"),
|
|
model: model.replaceAll("\\", "/"),
|
|
dungeonConfig: "Tools/dungeon_config.json",
|
|
dungeonConfigId: dungeon.id,
|
|
...(entry.entryLua ? {
|
|
entryLua: entry.entryLua,
|
|
entryConstantPrefix: entry.entryConstantPrefix,
|
|
} : {
|
|
entryCoordinates: {
|
|
position: entry.values.slice(0, 3),
|
|
direction: entry.values[3],
|
|
reviewRequired: true,
|
|
},
|
|
}),
|
|
},
|
|
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: "source-selected-review-required",
|
|
collision: "required",
|
|
entrance: entry.reviewRequired ? "required" : "source-found-review-required",
|
|
bossAnchors: "required",
|
|
wdbPlacements: recommended?.confidence === "high" ? "source-selected" : "manual-selection",
|
|
},
|
|
exceptions: [
|
|
"Secondary WDB doodads/effects are audited but the first environment pass exports the primary ROS hierarchy.",
|
|
"Collision initially includes decorative primary-ROS geometry and requires visual/navmesh review.",
|
|
"Mechanics candidates are extracted from Lua/AutoPlot metadata but require explicit HealerMan mappings.",
|
|
],
|
|
};
|
|
|
|
const templateIds = [...new Set(dungeon.mob_guids ?? [])].sort((left, right) => left - right);
|
|
const populationRecipe = {
|
|
schemaVersion: 2,
|
|
dungeonId: slug,
|
|
sourceDungeonConfigId: dungeon.id,
|
|
zoneId: dungeon.base_zone,
|
|
expectedActiveRows: null,
|
|
expectedBossSpawns: null,
|
|
runtimeIdPrefix: "rw-" + slug,
|
|
coordinateSystem: {
|
|
metersPerSourceUnit: 0.1,
|
|
directionUnitsPerTurn: 360,
|
|
maximumProjectionDistance: 2,
|
|
},
|
|
files: {
|
|
environmentRecipe: projectPath(environmentRecipeFile),
|
|
snapshot: "scripts/runewaker-pipeline/snapshots/" + slug + "-zone-" + dungeon.base_zone + ".json",
|
|
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: [],
|
|
templates: templateIds.map((id) => ({
|
|
id,
|
|
name: "REVIEW template " + id,
|
|
level: 0,
|
|
classification: "unclassified",
|
|
actor: null,
|
|
review: "Run forensic refresh --update-recipe, then review classification and model mapping.",
|
|
})),
|
|
deferred: [
|
|
"Review Lua/AutoPlot mechanics audit before enabling the dungeon.",
|
|
"Review doors, summons, patrols, phases, interactables, loot, and original animations.",
|
|
],
|
|
};
|
|
|
|
const checklist = `# ${dungeon.name} import checklist
|
|
|
|
- [ ] Review primary environment ROS: '${environmentRecipe.source.model}'
|
|
- [ ] Review entrance${entry.reviewRequired ? " (no authoritative Lua constants were found)" : ""}
|
|
- [ ] Run the forensic refresh with '--update-recipe'
|
|
- [ ] Review every generated template classification and actor model
|
|
- [ ] Build and visually validate environment, collision, and navmesh
|
|
- [ ] Build actors and static population with SQL stopped
|
|
- [ ] Review 'mechanics-audit.json'; explicitly map doors, phases, summons, patrols, spells, and loot
|
|
- [ ] Register the reviewed DungeonDefinition and add catalog/tests
|
|
- [ ] Run Khronos validation, tests, production build, and a browser playtest
|
|
|
|
The runtime package must remain independent of SQL Server and the preserved RuneWaker folders.
|
|
`;
|
|
|
|
if (!hasFlag(argv, "--dry-run")) {
|
|
await mkdir(path.dirname(environmentRecipeFile), { recursive: true });
|
|
await mkdir(workRoot, { recursive: true });
|
|
await writeFile(environmentRecipeFile, JSON.stringify(environmentRecipe, null, 2) + "\n", "utf8");
|
|
await writeFile(populationRecipeFile, JSON.stringify(populationRecipe, null, 2) + "\n", "utf8");
|
|
await writeFile(path.join(workRoot, "IMPORT_CHECKLIST.md"), checklist, "utf8");
|
|
}
|
|
|
|
console.log("\n[runewaker] scaffolded " + dungeon.name + ":");
|
|
console.log(" environment: " + projectPath(environmentRecipeFile));
|
|
console.log(" population: " + projectPath(populationRecipeFile));
|
|
console.log(" checklist: " + projectPath(path.join(workRoot, "IMPORT_CHECKLIST.md")));
|
|
console.log("[runewaker] recipes remain review-required until template/model and mechanics audits are complete.");
|