Files
healer-man/src/game/runewakerEncounterCatalog.ts
T
2026-09-08 10:40:26 -04:00

407 lines
16 KiB
TypeScript

import type {
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
} from "./dungeonTypes";
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
import type { DungeonWorldObjectDefinition } from "./dungeonWorldObjects";
import type { PopulationEntityDefinition } from "./mobPopulation";
export type RunewakerEncounterContentFidelity = "source-exact" | "source-backed-adapted";
export interface RunewakerEncounterSourceFile {
readonly path: string;
readonly sha256: string;
}
export interface RunewakerEncounterEvidenceReview {
readonly id: string;
readonly fidelity: RunewakerEncounterContentFidelity;
readonly sourceRows: readonly number[];
readonly evidenceIds: readonly string[];
readonly sourceFiles: readonly RunewakerEncounterSourceFile[];
readonly reviewedBehavior: string;
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
}
interface RunewakerEncounterEntityAlias {
readonly id: string;
readonly sourceEntityId: string;
readonly name: string;
}
export interface RunewakerEncounterContentPackage {
readonly dungeonId: string;
readonly encounters: readonly DungeonEncounterDefinition[];
readonly spawnGroups: readonly DungeonEncounterSpawnGroupDefinition[];
readonly worldObjects: readonly DungeonWorldObjectDefinition[];
readonly entityAliases: readonly RunewakerEncounterEntityAlias[];
readonly reviews: readonly RunewakerEncounterEvidenceReview[];
}
const FORSAKEN_PATROL_GROUP_IDS = Object.freeze([
"forsaken-abbey:follower-a",
"forsaken-abbey:follower-b",
"forsaken-abbey:follower-c",
] as const);
const FORSAKEN_PATROL_ENCOUNTER = Object.freeze({
id: "forsaken-abbey:follower-patrols",
dungeonId: "forsaken-abbey",
initialPhase: "initializing",
resetPolicy: { bossWipePhase: "initializing", checkpointPhase: "initializing" },
rules: [
{
id: "create-source-formations",
trigger: { kind: "tick" },
guards: [{ kind: "phase", phases: ["initializing"] }],
actions: [
{ id: "enter-formation-hold", kind: "set-phase", phase: "formation-hold" },
...FORSAKEN_PATROL_GROUP_IDS.map((spawnGroupId, index) => ({
id: `activate-follower-${String.fromCharCode(97 + index)}`,
kind: "activate-spawn-group" as const,
spawnGroupId,
})),
],
},
{
id: "release-source-formations",
trigger: { kind: "elapsed", afterMs: 2_000 },
guards: [{ kind: "phase", phases: ["formation-hold"] }],
actions: [{ id: "enter-patrol-phase", kind: "set-phase", phase: "patrols-active" }],
},
],
} as const satisfies DungeonEncounterDefinition);
const FORSAKEN_PATROL_SPAWN_GROUPS = Object.freeze([
{
id: FORSAKEN_PATROL_GROUP_IDS[0],
spawns: [
{
id: "forsaken-abbey:follower-a:ghoul",
entityId: "rw-fa-100653",
position: [119.523032, 4.05115, 5.524107],
yaw: 4.865415815,
spawnMask: 1,
},
{
id: "forsaken-abbey:follower-a:spider",
entityId: "rw-fa-100393",
position: [121.104334, 4.05115, 5.767995],
yaw: 4.865415815,
spawnMask: 1,
},
],
},
{
id: FORSAKEN_PATROL_GROUP_IDS[1],
spawns: [
{
id: "forsaken-abbey:follower-b:ghoul",
entityId: "rw-fa-100653",
position: [76.438701, -6.138638, 17.204093],
yaw: 4.828148071,
spawnMask: 1,
},
{
id: "forsaken-abbey:follower-b:spider",
entityId: "rw-fa-100393",
position: [78.027993, -6.138638, 17.388895],
yaw: 4.828148071,
spawnMask: 1,
},
],
},
{
id: FORSAKEN_PATROL_GROUP_IDS[2],
spawns: [
{
id: "forsaken-abbey:follower-c:ghoul",
entityId: "rw-fa-100653",
position: [129.755498, -15.077516, -74.285004],
yaw: 1.132059978,
spawnMask: 1,
},
{
id: "forsaken-abbey:follower-c:spider",
entityId: "rw-fa-100393",
position: [128.307036, -15.077516, -73.60533],
yaw: 1.132059978,
spawnMask: 1,
},
],
},
] as const satisfies readonly DungeonEncounterSpawnGroupDefinition[]);
const TREASURE_TROVE_DRAWBRIDGE_ID = "treasure-trove:drawbridge";
const TREASURE_TROVE_SWITCH_ID = "treasure-trove:drawbridge-switch";
const TREASURE_TROVE_WORLD_OBJECTS = Object.freeze([
{
id: TREASURE_TROVE_DRAWBRIDGE_ID,
name: "Drawbridge",
kind: "bridge",
position: [588.054827, -4.084724, -6.244854],
yaw: 3.024162567,
halfExtents: [3.5, 0.35, 5.5],
initialState: "raised",
states: [
{ id: "raised", label: "Raised", visible: true, blocksMovement: true, blocksSight: false },
{ id: "lowered", label: "Lowered", visible: true, blocksMovement: false, blocksSight: false },
],
visual: { color: "#6b5540", emissive: "#1b1209" },
source: {
fidelity: "source-backed-adapted",
files: [
"scripts/runewaker-pipeline/snapshots/treasure-trove-zone-108.json",
"src/assets/game/dungeons/treasure-trove/population-import-report.json",
"preserved RuneWaker Resource/luascript/01159.lua (sha256 23314a25d9654ec34a7d25de588974453f415c235804cc64e7240bf4e03423b6)",
],
templateId: 112098,
spawnId: 5952428,
note: "Source placement and state transition are preserved; the collision box and procedural visual are HealerMan adapters.",
},
},
{
id: TREASURE_TROVE_SWITCH_ID,
name: "Drawbridge Switch",
kind: "switch",
position: [585.282218, -4.084724, -6.571043],
yaw: 1.470568359,
halfExtents: [0.55, 0.8, 0.55],
initialState: "locked",
states: [
{ id: "locked", label: "Locked by Boulder Sidaar", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "activated", label: "Activated", visible: true, blocksMovement: false, interactionEnabled: false },
],
interaction: {
prompt: "Lower the drawbridge",
range: 2.5,
signalId: "treasure-trove:drawbridge-activated",
allowedStates: ["ready"],
oneShot: true,
instructions: "Defeat Boulder Sidaar, then interact to lower the nearby drawbridge.",
},
visual: { color: "#a76e2a", emissive: "#3a2108" },
source: {
fidelity: "source-backed-adapted",
files: [
"scripts/runewaker-pipeline/snapshots/treasure-trove-zone-108.json",
"src/assets/game/dungeons/treasure-trove/mechanics-audit.json",
"preserved RuneWaker Resource/luascript/01159.lua (sha256 23314a25d9654ec34a7d25de588974453f415c235804cc64e7240bf4e03423b6)",
],
templateId: 112100,
spawnId: 5952438,
note: "Source placement and boss-gated interaction are preserved; interaction range and procedural visual are HealerMan adapters.",
},
},
] as const satisfies readonly DungeonWorldObjectDefinition[]);
const TREASURE_TROVE_DRAWBRIDGE_ENCOUNTER = Object.freeze({
id: "treasure-trove:drawbridge-sequence",
dungeonId: "treasure-trove",
initialPhase: "boss-active",
initialState: {
worldObjectStates: {
[TREASURE_TROVE_DRAWBRIDGE_ID]: "raised",
[TREASURE_TROVE_SWITCH_ID]: "locked",
},
},
resetPolicy: { bossWipePhase: "boss-active", checkpointPhase: "boss-active" },
rules: [
{
id: "boulder-sidaar-defeated",
trigger: { kind: "entity-death", entityId: "rw-treasure-trove-spawn-5952494" },
guards: [{ kind: "world-object-state", objectId: TREASURE_TROVE_SWITCH_ID, states: ["locked"] }],
actions: [
{ id: "arm-drawbridge-switch", kind: "set-phase", phase: "switch-ready", persistence: "run" },
{
id: "unlock-drawbridge-switch",
kind: "set-world-object-state",
objectId: TREASURE_TROVE_SWITCH_ID,
state: "ready",
persistence: "run",
},
],
},
{
id: "activate-drawbridge-switch",
trigger: { kind: "interact", objectId: TREASURE_TROVE_SWITCH_ID },
guards: [{ kind: "world-object-state", objectId: TREASURE_TROVE_SWITCH_ID, states: ["ready"] }],
actions: [
{ id: "complete-drawbridge-sequence", kind: "set-phase", phase: "complete", persistence: "run" },
{
id: "mark-drawbridge-switch-activated",
kind: "set-world-object-state",
objectId: TREASURE_TROVE_SWITCH_ID,
state: "activated",
persistence: "run",
},
{
id: "lower-treasure-trove-drawbridge",
kind: "set-world-object-state",
objectId: TREASURE_TROVE_DRAWBRIDGE_ID,
state: "lowered",
persistence: "run",
},
],
},
{
id: "restore-ready-phase-after-reset",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["boss-active"] },
{ kind: "world-object-state", objectId: TREASURE_TROVE_SWITCH_ID, states: ["ready"] },
],
actions: [{ id: "restore-switch-ready-phase", kind: "set-phase", phase: "switch-ready" }],
},
{
id: "restore-complete-phase-after-reset",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["boss-active", "switch-ready"] },
{ kind: "world-object-state", objectId: TREASURE_TROVE_DRAWBRIDGE_ID, states: ["lowered"] },
],
actions: [{ id: "restore-drawbridge-complete-phase", kind: "set-phase", phase: "complete" }],
},
],
} as const satisfies DungeonEncounterDefinition);
const FORSAKEN_REVIEW = Object.freeze({
id: "forsaken-abbey:follower-formations-review",
fidelity: "source-backed-adapted",
sourceRows: [12230, 12236, 12238],
evidenceIds: [
"signal:forsaken-abbey:template:110426:autoplot:f355bf45c2e5",
"signal:forsaken-abbey:template:110426:autoplot:191cb880e4a9",
"signal:forsaken-abbey:template:110426:autoplot:8cb0866b5187",
],
sourceFiles: [
{ path: "scripts/runewaker-pipeline/snapshots/forsaken-abbey-zone-102.json", sha256: "3d19cf799b376b2cb2e0eccc4b509da22821e55f0a0581ce6406643759d37874" },
{ path: "src/assets/game/dungeons/forsaken-abbey/mechanics-audit.json", sha256: "e6069c60e63de77b4f32731d8888a4025c1d5b916bfa699b206c18d16738beca" },
{ path: "preserved RuneWaker Resource/luascript/00571.lua", sha256: "110efc48ad7a915ca76d269ec577a56c0e9e7b2edced0f146720a446aba33cf7" },
{ path: "preserved RuneWaker Resource/luascript/00727.lua", sha256: "10e2e3e315e54dd0d7340fb314d88602a9692985ad7a67e79099a83bef0e387a" },
],
reviewedBehavior: "Each of three source initialization triggers creates a two-member Ghoul/Carrion Spider formation, holds it for two seconds, then hands it to an endless flag patrol.",
adaptations: [
"Source sleep units are converted from tenths of a second to a 2,000 ms encounter phase.",
"Formation offsets are transformed into packaged dungeon coordinates and use projected trigger heights.",
"The source-only level-scaled Ghoul 100653 reuses the packaged canonical Ghoul 100072 visual and combat definition.",
],
remainingGaps: [
"The flag-path patrol primitive is not represented by encounter spawn groups, so the formations remain at their source start positions after the hold phase.",
"Original reset/respawn cadence is not exposed by the preserved scripts and remains dungeon-reset scoped.",
],
} as const satisfies RunewakerEncounterEvidenceReview);
const TREASURE_TROVE_REVIEW = Object.freeze({
id: "treasure-trove:drawbridge-review",
fidelity: "source-backed-adapted",
sourceRows: [5952428, 5952438],
evidenceIds: [
"signal:treasure-trove:template:101533:autoplot:3a096d804fa4",
"signal:treasure-trove:template:112100:autoplot:9520ea9af0f5",
],
sourceFiles: [
{ path: "scripts/runewaker-pipeline/snapshots/treasure-trove-zone-108.json", sha256: "aca7a3e57cbe704a6624f722c8060dc8e7be5d2014240781bc4668b162225a1e" },
{ path: "src/assets/game/dungeons/treasure-trove/mechanics-audit.json", sha256: "a496185046e28657d76bd1d64353dcae070bee45c3e65a265f927a94f0c9fd86" },
{ path: "preserved RuneWaker Resource/luascript/01159.lua", sha256: "23314a25d9654ec34a7d25de588974453f415c235804cc64e7240bf4e03423b6" },
],
reviewedBehavior: "Boulder Sidaar's death arms source switch 112100; touching that switch activates both it and nearby drawbridge 112098 exactly once.",
adaptations: [
"Source placements use the package's navmesh-projected local coordinates.",
"The source motions are represented as explicit object states with procedural collision and visuals.",
"The switch prompt is hidden until the boss death instead of showing the source failure message before it is armed.",
],
remainingGaps: [
"Original ACT motion clips and object meshes are not packaged, so raised/lowered visuals use the generic world-object proxy.",
"Black Mary's key-gated door remains deferred because the encounter guard model does not expose inventory item 203550.",
],
} as const satisfies RunewakerEncounterEvidenceReview);
export const RUNEWAKER_ENCOUNTER_CONTENT_CATALOG = Object.freeze([
Object.freeze({
dungeonId: "forsaken-abbey",
encounters: [FORSAKEN_PATROL_ENCOUNTER],
spawnGroups: FORSAKEN_PATROL_SPAWN_GROUPS,
worldObjects: [],
entityAliases: [{ id: "rw-fa-100653", sourceEntityId: "rw-fa-100072", name: "Ghoul (source summon 100653)" }],
reviews: [FORSAKEN_REVIEW],
}),
Object.freeze({
dungeonId: "treasure-trove",
encounters: [TREASURE_TROVE_DRAWBRIDGE_ENCOUNTER],
spawnGroups: [],
worldObjects: TREASURE_TROVE_WORLD_OBJECTS,
entityAliases: [],
reviews: [TREASURE_TROVE_REVIEW],
}),
] as const satisfies readonly RunewakerEncounterContentPackage[]);
export const RUNEWAKER_ENCOUNTER_CONTENT_LEDGER = Object.freeze({
sourceReportDeferredRows: 4_113,
mappedDeferredRows: RUNEWAKER_ENCOUNTER_CONTENT_CATALOG.reduce(
(count, entry) => count + entry.reviews.reduce((subtotal, review) => subtotal + review.sourceRows.length, 0),
0,
),
remainingDeferredRows: 4_108,
sourceReportUnresolvedEvidence: 1_136,
reviewedEvidenceRecords: RUNEWAKER_ENCOUNTER_CONTENT_CATALOG.reduce(
(count, entry) => count + entry.reviews.reduce((subtotal, review) => subtotal + review.evidenceIds.length, 0),
0,
),
remainingUnresolvedEvidence: 1_131,
});
const CONTENT_BY_DUNGEON: ReadonlyMap<string, RunewakerEncounterContentPackage> = new Map(
RUNEWAKER_ENCOUNTER_CONTENT_CATALOG.map((entry) => [entry.dungeonId, entry] as const),
);
function mergeUniqueById<T extends { readonly id: string }>(
label: string,
existing: readonly T[],
additions: readonly T[],
): readonly T[] {
const merged = new Map(existing.map((entry) => [entry.id, entry] as const));
for (const addition of additions) {
if (merged.has(addition.id)) throw new Error(`Duplicate ${label} ${addition.id}`);
merged.set(addition.id, addition);
}
return Object.freeze([...merged.values()]);
}
function aliasedEntities(
definition: DungeonDefinition,
aliases: readonly RunewakerEncounterEntityAlias[],
): Readonly<Record<string, PopulationEntityDefinition>> {
const result: Record<string, PopulationEntityDefinition> = { ...definition.entities };
for (const alias of aliases) {
if (result[alias.id]) throw new Error(`Duplicate encounter entity ${alias.id}`);
const source = definition.entities[alias.sourceEntityId];
if (!source) throw new Error(`Encounter entity alias ${alias.id} references missing ${alias.sourceEntityId}`);
result[alias.id] = Object.freeze({ ...source, id: alias.id, name: alias.name });
}
return Object.freeze(result);
}
/** Adds only reviewed, source-linked encounter content; generated package files remain immutable. */
export function withRunewakerEncounterContent(definition: DungeonDefinition): DungeonDefinition {
const content = CONTENT_BY_DUNGEON.get(definition.id);
if (!content) return definition;
if (definition.expansion !== "runewaker") {
throw new Error(`RuneWaker encounter content cannot attach to ${definition.expansion} dungeon ${definition.id}`);
}
return Object.freeze({
...definition,
entities: aliasedEntities(definition, content.entityAliases),
encounters: mergeUniqueById("encounter", definition.encounters ?? [], content.encounters),
encounterSpawnGroups: mergeUniqueById(
"encounter spawn group",
definition.encounterSpawnGroups ?? [],
content.spawnGroups,
),
serverGameObjects: mergeUniqueById("world object", definition.serverGameObjects, content.worldObjects),
});
}