407 lines
16 KiB
TypeScript
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),
|
|
});
|
|
}
|