forked from phenom/healer-man
1430 lines
50 KiB
TypeScript
1430 lines
50 KiB
TypeScript
import type {
|
|
DungeonDefinition,
|
|
DungeonEncounterSpawnGroupDefinition,
|
|
Vector3Tuple,
|
|
} from "./dungeonTypes";
|
|
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
|
|
import type { DungeonWorldObjectDefinition } from "./dungeonWorldObjects";
|
|
import type { EnemyAttackDefinition, EnemyCombatDefinition } from "./enemyAttacks";
|
|
import type {
|
|
PopulationDefinitionMap,
|
|
PopulationEntityDefinition,
|
|
StaticMobSpawnDefinition,
|
|
} from "./mobPopulation";
|
|
|
|
const RECOVERY_MANIFEST = "dungeon-pipeline/ascension/ASCENSION_CUSTOM_DUNGEON_RECOVERY.json";
|
|
const RECOVERY_NOTES = "dungeon-pipeline/ascension/ASCENSION_CUSTOM_DUNGEON_RECOVERY.md";
|
|
const MANASTORM_CATALOG = "src/game/generated/manastormRuntimeCatalog.json";
|
|
const ASCENSION_SPELL_DBC = "../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc (sha256 2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce)";
|
|
|
|
const RECOVERY_FILES = Object.freeze([
|
|
RECOVERY_MANIFEST,
|
|
RECOVERY_NOTES,
|
|
MANASTORM_CATALOG,
|
|
ASCENSION_SPELL_DBC,
|
|
] as const);
|
|
|
|
export type AscensionCustomBindingConfidence =
|
|
| "exact"
|
|
| "spell-linked"
|
|
| "candidate-unbound"
|
|
| "authored";
|
|
|
|
export interface AscensionCustomEncounterEvidence {
|
|
readonly dungeonId: string;
|
|
readonly fidelity: "source-backed-adapted" | "healer-man-authored";
|
|
readonly exactCreatureIds: readonly number[];
|
|
readonly spellLinkedCreatureIds: readonly number[];
|
|
readonly spellLinkedGameObjectIds: readonly number[];
|
|
readonly exactSpellFamilies: Readonly<Record<string, readonly number[]>>;
|
|
readonly candidateUnboundSpellFamilies: Readonly<Record<string, readonly number[]>>;
|
|
readonly authoredDecisions: readonly string[];
|
|
readonly remainingGaps: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* Durable evidence boundary for the three Ascension-only packages. A spell id
|
|
* appearing in candidateUnboundSpellFamilies is never presented as an exact
|
|
* creature binding by the runtime adapter.
|
|
*/
|
|
export const ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE = Object.freeze([
|
|
Object.freeze({
|
|
dungeonId: "frozen-reach",
|
|
fidelity: "source-backed-adapted",
|
|
exactCreatureIds: Object.freeze([97_700, 97_701]),
|
|
spellLinkedCreatureIds: Object.freeze([97_703, 97_731, 97_770]),
|
|
spellLinkedGameObjectIds: Object.freeze([]),
|
|
exactSpellFamilies: Object.freeze({
|
|
alvaRanged: Object.freeze([255_347, 255_349, 255_352, 255_358, 255_401, 255_420, 255_421]),
|
|
arktos: Object.freeze([255_360, 255_374, 255_379, 255_380, 255_399, 255_417, 255_434]),
|
|
alvaVengeance: Object.freeze([255_389, 255_390, 255_393, 255_394, 255_397, 255_410, 255_419, 255_432]),
|
|
}),
|
|
candidateUnboundSpellFamilies: Object.freeze({}),
|
|
authoredDecisions: Object.freeze([
|
|
"Normal mode uses recovered D0 spell rows; the surviving mode ids do not establish public difficulty labels.",
|
|
"Cast cadence, target selection, Arktos placement, Alvajar formation, and the exact death transition are HealerMan adaptations.",
|
|
"Arktos is an attempt-scoped encounter actor so a boss wipe restores the duo and can replay his death transition.",
|
|
"The unsupported fifteen-creature procedural hallway population is removed.",
|
|
]),
|
|
remainingGaps: Object.freeze([
|
|
"The source server script, creature templates, display ids, and authoritative placements were not recovered.",
|
|
"Blast Arrow cannot currently retarget a friendly encounter creature, so the Set Ablaze interaction with Arktos remains documented but unexecutable.",
|
|
"Trap and area-trigger summons use bounded combat proxies until dedicated hazard actors are available.",
|
|
]),
|
|
}),
|
|
Object.freeze({
|
|
dungeonId: "forgotten-mine",
|
|
fidelity: "source-backed-adapted",
|
|
exactCreatureIds: Object.freeze([254_272]),
|
|
spellLinkedCreatureIds: Object.freeze([254_278]),
|
|
spellLinkedGameObjectIds: Object.freeze([254_051]),
|
|
exactSpellFamilies: Object.freeze({
|
|
stonebreaker: Object.freeze([354_000, 354_021, 354_022, 354_023, 354_024]),
|
|
quake: Object.freeze([354_001, 354_009, 354_010]),
|
|
stoneShield: Object.freeze([354_002, 354_011, 354_012]),
|
|
geospike: Object.freeze([354_004, 354_005, 354_006, 354_025, 354_026]),
|
|
resonance: Object.freeze([354_013, 354_014]),
|
|
geodeBarrage: Object.freeze([354_015, 354_016, 354_027, 354_028, 354_029, 354_030]),
|
|
rupturedEarth: Object.freeze([354_017, 354_018]),
|
|
stoneskin: Object.freeze([354_019, 354_020]),
|
|
resonite: Object.freeze([354_031, 354_032]),
|
|
}),
|
|
candidateUnboundSpellFamilies: Object.freeze({}),
|
|
authoredDecisions: Object.freeze([
|
|
"The fifteen existing route spawns remain as five conceptual three-creature packs with authored identities and cadence.",
|
|
"The 50 percent phase threshold, fixed Geospike proxy location, Resonite offsets, generous interaction bounds, and thirty-second recycle are HealerMan adaptations.",
|
|
"A consumed Resonite shard suppresses Resonance for eight seconds because the exact server-side cleanse rule did not survive.",
|
|
]),
|
|
remainingGaps: Object.freeze([
|
|
"Authoritative trash entries, creature stats, display ids, paths, and spawn coordinates were not recovered.",
|
|
"Geode Barrage is represented as one telegraphed cast rather than its unknown source projectile sequence.",
|
|
"Resonite Cleanse is source-constrained but its exact protection removal and prevention semantics remain unavailable.",
|
|
]),
|
|
}),
|
|
Object.freeze({
|
|
dungeonId: "tinkertech-showdown",
|
|
fidelity: "healer-man-authored",
|
|
exactCreatureIds: Object.freeze([663_012]),
|
|
spellLinkedCreatureIds: Object.freeze([]),
|
|
spellLinkedGameObjectIds: Object.freeze([]),
|
|
exactSpellFamilies: Object.freeze({}),
|
|
candidateUnboundSpellFamilies: Object.freeze({
|
|
rocket: Object.freeze([257_438]),
|
|
gatlingMissileBarrage: Object.freeze([257_459, 257_460, 257_461]),
|
|
incinerationBeam: Object.freeze([257_470]),
|
|
electrifiedZone: Object.freeze([257_472, 257_473]),
|
|
}),
|
|
authoredDecisions: Object.freeze([
|
|
"The machine spell cluster is a low-confidence client-side candidate and is deliberately classified as candidate-unbound, not exact Mechatron data.",
|
|
"All trash identities, positions, attacks, boss cadence, telegraphs, and the 35 percent reinforcement phase are HealerMan-authored.",
|
|
"The existing fifteen-spawn route is retained as five conceptual three-creature packs.",
|
|
]),
|
|
remainingGaps: Object.freeze([
|
|
"No server row links spells 257438 or 257459-257473 to Mechatron 663012.",
|
|
"No authoritative trash, gameobject, platform-destruction, display, loot, or placement data was recovered.",
|
|
"The provisional map anchors still require a live traversal and collision playtest.",
|
|
]),
|
|
}),
|
|
] as const satisfies readonly AscensionCustomEncounterEvidence[]);
|
|
|
|
const EVIDENCE_BY_DUNGEON: ReadonlyMap<string, AscensionCustomEncounterEvidence> = new Map(
|
|
ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.map((entry) => [entry.dungeonId, entry] as const),
|
|
);
|
|
|
|
function frozen<T>(value: T): Readonly<T> {
|
|
return Object.freeze(value);
|
|
}
|
|
|
|
function attack(value: EnemyAttackDefinition): EnemyAttackDefinition {
|
|
return frozen(value);
|
|
}
|
|
|
|
function encounterCondition(encounterId: string, states: readonly string[]) {
|
|
return frozen({ kind: "encounter-state" as const, encounterId, states: frozen([...states]) });
|
|
}
|
|
|
|
function combatWith(
|
|
source: PopulationEntityDefinition,
|
|
attacks: readonly EnemyAttackDefinition[],
|
|
mechanicIds: readonly string[],
|
|
overrides: Partial<EnemyCombatDefinition> = {},
|
|
): EnemyCombatDefinition {
|
|
return frozen({
|
|
...source.combat,
|
|
...overrides,
|
|
mechanicIds: frozen([...mechanicIds]),
|
|
attacks: frozen([...attacks]),
|
|
});
|
|
}
|
|
|
|
function appendWarnings(
|
|
definition: DungeonDefinition,
|
|
additions: readonly string[],
|
|
removeContaining: readonly string[] = [],
|
|
): DungeonDefinition["validation"] {
|
|
const warnings = definition.validation.warnings.filter(
|
|
(warning) => !removeContaining.some((part) => warning.includes(part)),
|
|
);
|
|
for (const addition of additions) if (!warnings.includes(addition)) warnings.push(addition);
|
|
return frozen({ ...definition.validation, warnings: frozen(warnings) });
|
|
}
|
|
|
|
function sourceReview(
|
|
evidence: AscensionCustomEncounterEvidence,
|
|
reviewedBehavior: string,
|
|
): NonNullable<DungeonEncounterDefinition["source"]> {
|
|
return frozen({
|
|
fidelity: evidence.fidelity,
|
|
scope: "instance-orchestration",
|
|
files: RECOVERY_FILES,
|
|
reviewedBehavior,
|
|
adaptations: evidence.authoredDecisions,
|
|
remainingGaps: evidence.remainingGaps,
|
|
});
|
|
}
|
|
|
|
function replaceBaselineReview(
|
|
definition: DungeonDefinition,
|
|
evidence: AscensionCustomEncounterEvidence,
|
|
): readonly DungeonEncounterDefinition[] {
|
|
return frozen((definition.encounters ?? []).map((encounter) => {
|
|
if (encounter.id !== `${definition.id}:boss-progression`) return encounter;
|
|
return frozen({
|
|
...encounter,
|
|
source: frozen({
|
|
fidelity: evidence.fidelity,
|
|
scope: "boss-progression" as const,
|
|
files: RECOVERY_FILES,
|
|
reviewedBehavior: evidence.fidelity === "healer-man-authored"
|
|
? "Mechatron's exact encounter identity gates a fully authored completion objective and checkpoint."
|
|
: "Recovered Ascension encounter identities gate persistent completion and a post-boss checkpoint.",
|
|
adaptations: frozen([
|
|
"HealerMan owns completion, checkpoint, loot, and reset semantics because the Ascension instance script was not recovered.",
|
|
]),
|
|
remainingGaps: frozen([
|
|
...evidence.remainingGaps,
|
|
"An end-to-end traversal, wipe, checkpoint reload, HUD, collision, and completion playtest is still required before validation.",
|
|
]),
|
|
}),
|
|
});
|
|
}));
|
|
}
|
|
|
|
function withoutKeys(
|
|
entities: PopulationDefinitionMap,
|
|
removedIds: ReadonlySet<string>,
|
|
): Record<string, PopulationEntityDefinition> {
|
|
return Object.fromEntries(Object.entries(entities).filter(([id]) => !removedIds.has(id)));
|
|
}
|
|
|
|
function offset(position: Vector3Tuple, x: number, z: number, y = 0): Vector3Tuple {
|
|
return frozen([position[0] + x, position[1] + y, position[2] + z]);
|
|
}
|
|
|
|
function requireEntity(definition: DungeonDefinition, id: string): PopulationEntityDefinition {
|
|
const entity = definition.entities[id];
|
|
if (!entity) throw new Error(`${definition.id} is missing required entity ${id}`);
|
|
return entity;
|
|
}
|
|
|
|
function requireSpawn(definition: DungeonDefinition, id: string): StaticMobSpawnDefinition {
|
|
const spawn = definition.staticSpawns.find((entry) => entry.id === id);
|
|
if (!spawn) throw new Error(`${definition.id} is missing required spawn ${id}`);
|
|
return spawn;
|
|
}
|
|
|
|
function buildFrozenReach(definition: DungeonDefinition): DungeonDefinition {
|
|
const markerId = "frozen-reach:alva-and-arktos";
|
|
if (definition.encounters?.some((encounter) => encounter.id === markerId)) return definition;
|
|
const evidence = EVIDENCE_BY_DUNGEON.get(definition.id)!;
|
|
const alvaSource = requireEntity(definition, "entry-97700");
|
|
const alvaSpawn = requireSpawn(definition, "encounter-2168");
|
|
const condition = (states: readonly string[]) => encounterCondition(markerId, states);
|
|
const alvaAttacks = frozen([
|
|
attack({
|
|
id: "auto-shot-255347",
|
|
name: "Auto Shot",
|
|
spellId: 255_347,
|
|
delivery: "projectile",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
range: 35,
|
|
cooldownMs: 2_200,
|
|
damageMultiplier: 0.8,
|
|
}),
|
|
attack({
|
|
id: "rain-of-arrows-255349",
|
|
name: "Rain of Arrows",
|
|
spellId: 255_349,
|
|
delivery: "area",
|
|
target: "random-party",
|
|
school: "physical",
|
|
animation: "cast",
|
|
castTimeMs: 2_000,
|
|
range: 35,
|
|
radius: 7,
|
|
cooldownMs: 12_000,
|
|
initialCooldownMs: [5_000, 7_000],
|
|
damageMultiplier: 0.85,
|
|
telegraph: { shape: "circle", anchor: "target", warningMs: 2_000, radius: 7 },
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "unbridled-arrows-255352",
|
|
name: "Unbridled Arrows",
|
|
spellId: 255_352,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "physical",
|
|
animation: "cast",
|
|
castTimeMs: 2_100,
|
|
range: 28,
|
|
cooldownMs: 20_000,
|
|
initialCooldownMs: [9_000, 11_000],
|
|
damageMultiplier: 0.72,
|
|
telegraph: { shape: "cone", anchor: "source", warningMs: 2_100, length: 28, angleDegrees: 75 },
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "blast-arrow-255358",
|
|
name: "Blast Arrow",
|
|
spellId: 255_358,
|
|
delivery: "area",
|
|
target: "random-party",
|
|
school: "fire",
|
|
animation: "cast",
|
|
castTimeMs: 1_750,
|
|
range: 38,
|
|
radius: 5.5,
|
|
cooldownMs: 15_000,
|
|
initialCooldownMs: [7_000, 9_000],
|
|
maximumCasts: 3,
|
|
damageMultiplier: 1.05,
|
|
telegraph: { shape: "circle", anchor: "target", warningMs: 1_750, radius: 5.5 },
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "blast-arrow-trigger-255363",
|
|
name: "Blast Arrow Ground Trigger",
|
|
spellId: 255_363,
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "fire",
|
|
animation: "cast",
|
|
range: 0,
|
|
cooldownMs: 15_000,
|
|
initialCooldownMs: [7_000, 9_000],
|
|
damageMultiplier: 0,
|
|
effect: {
|
|
kind: "summon",
|
|
creatureEntry: 97_703,
|
|
count: 1,
|
|
entityId: "frozen-reach:blast-arrow-trigger",
|
|
spawnGroupId: "frozen-reach:blast-arrow-summons",
|
|
},
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "cluster-shot-255401",
|
|
name: "Cluster Shot",
|
|
spellId: 255_401,
|
|
delivery: "area",
|
|
target: "random-party",
|
|
school: "physical",
|
|
animation: "cast",
|
|
castTimeMs: 1_800,
|
|
range: 38,
|
|
radius: 6.5,
|
|
cooldownMs: 18_000,
|
|
damageMultiplier: 0.95,
|
|
telegraph: { shape: "circle", anchor: "target", warningMs: 1_800, radius: 6.5 },
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "explosive-trap-255421",
|
|
name: "Throw Explosive Trap",
|
|
spellId: 255_421,
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "fire",
|
|
animation: "cast",
|
|
castTimeMs: 1_500,
|
|
range: 0,
|
|
cooldownMs: 16_000,
|
|
initialCooldownMs: [10_000, 12_000],
|
|
maximumCasts: 3,
|
|
damageMultiplier: 0,
|
|
effect: {
|
|
kind: "summon",
|
|
creatureEntry: 97_731,
|
|
count: 1,
|
|
entityId: "frozen-reach:explosive-trap",
|
|
spawnGroupId: "frozen-reach:trap-summons",
|
|
},
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "vengeful-bolt-255394",
|
|
name: "Vengeful Bolt",
|
|
spellId: 255_394,
|
|
delivery: "projectile",
|
|
target: "random-party",
|
|
school: "shadow",
|
|
animation: "cast",
|
|
castTimeMs: 1_400,
|
|
range: 40,
|
|
cooldownMs: 7_000,
|
|
damageMultiplier: 0.95,
|
|
condition: condition(["vengeance"]),
|
|
}),
|
|
attack({
|
|
id: "shadow-veil-lance-255397",
|
|
name: "Shadow Veil Lance",
|
|
spellId: 255_397,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "shadow",
|
|
animation: "cast",
|
|
castTimeMs: 2_000,
|
|
range: 34,
|
|
cooldownMs: 12_000,
|
|
damageMultiplier: 1.1,
|
|
telegraph: { shape: "line", anchor: "source", warningMs: 2_000, length: 34, width: 4 },
|
|
condition: condition(["vengeance"]),
|
|
}),
|
|
attack({
|
|
id: "sphere-grief-wrath-255393",
|
|
name: "Sphere of Grief and Wrath",
|
|
spellId: 255_393,
|
|
delivery: "area",
|
|
target: "random-party",
|
|
school: "shadow",
|
|
animation: "cast",
|
|
castTimeMs: 2_100,
|
|
range: 38,
|
|
radius: 8,
|
|
cooldownMs: 20_000,
|
|
damageMultiplier: 1.15,
|
|
telegraph: { shape: "circle", anchor: "target", warningMs: 2_100, radius: 8 },
|
|
condition: condition(["vengeance"]),
|
|
}),
|
|
attack({
|
|
id: "alvas-grief-255410",
|
|
name: "Alva's Grief",
|
|
spellId: 255_410,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "shadow",
|
|
animation: "cast",
|
|
range: 10,
|
|
radius: 10,
|
|
cooldownMs: 10_000,
|
|
damageMultiplier: 0.62,
|
|
condition: condition(["vengeance"]),
|
|
}),
|
|
attack({
|
|
id: "alvas-wrath-255419",
|
|
name: "Alva's Wrath",
|
|
spellId: 255_419,
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "shadow",
|
|
animation: "cast",
|
|
range: 0,
|
|
cooldownMs: 25_000,
|
|
damageMultiplier: 0,
|
|
effect: { kind: "aura", aura: "alvas-wrath", durationMs: 0, magnitude: 0.15 },
|
|
condition: condition(["vengeance"]),
|
|
}),
|
|
]);
|
|
const alva = frozen({
|
|
...alvaSource,
|
|
name: "Alva",
|
|
title: "Source-backed adapted encounter",
|
|
combat: combatWith(alvaSource, alvaAttacks, alvaAttacks.map((entry) => entry.id), {
|
|
serverEntry: 97_700,
|
|
level: 60,
|
|
healthMultiplier: 1.35,
|
|
damageMultiplier: 1.08,
|
|
}),
|
|
} as PopulationEntityDefinition);
|
|
const arktosAttacks = frozen([
|
|
attack({
|
|
id: "carnivorous-bite-255379",
|
|
name: "Carnivorous Bite",
|
|
spellId: 255_379,
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
range: 5,
|
|
cooldownMs: 2_200,
|
|
damageMultiplier: 1,
|
|
}),
|
|
attack({
|
|
id: "pact-of-the-hunt-255360",
|
|
name: "Pact of the Hunt",
|
|
spellId: 255_360,
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "frost",
|
|
animation: "cast",
|
|
range: 0,
|
|
cooldownMs: 60_000,
|
|
initialCooldownMs: [0, 0],
|
|
maximumCasts: 1,
|
|
damageMultiplier: 0,
|
|
effect: { kind: "aura", aura: "pact-of-the-hunt", durationMs: 0, magnitude: 0.1 },
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "ice-rock-255374",
|
|
name: "Ice Rock",
|
|
spellId: 255_374,
|
|
delivery: "projectile",
|
|
target: "random-party",
|
|
school: "frost",
|
|
animation: "cast",
|
|
castTimeMs: 1_600,
|
|
range: 32,
|
|
cooldownMs: 12_000,
|
|
damageMultiplier: 0.9,
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "frost-roar-255399",
|
|
name: "Frost Roar",
|
|
spellId: 255_399,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "frost",
|
|
animation: "cast",
|
|
castTimeMs: 1_800,
|
|
range: 15,
|
|
radius: 15,
|
|
cooldownMs: 18_000,
|
|
damageMultiplier: 0.75,
|
|
telegraph: { shape: "circle", anchor: "source", warningMs: 1_800, radius: 15 },
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "lacerate-255434",
|
|
name: "Lacerate",
|
|
spellId: 255_434,
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
range: 5,
|
|
cooldownMs: 8_000,
|
|
damageMultiplier: 0,
|
|
effect: { kind: "target-aura", aura: "lacerate", durationMs: 12_000 },
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
attack({
|
|
id: "frost-shield-255380",
|
|
name: "Frost Shield",
|
|
spellId: 255_380,
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "frost",
|
|
animation: "cast",
|
|
range: 0,
|
|
cooldownMs: 22_000,
|
|
damageMultiplier: 0,
|
|
effect: { kind: "aura", aura: "frost-shield", durationMs: 8_000, magnitude: 0.2 },
|
|
condition: condition(["duo-active"]),
|
|
}),
|
|
]);
|
|
const arktos: PopulationEntityDefinition = frozen({
|
|
...alvaSource,
|
|
id: "frozen-reach:arktos",
|
|
kind: "mob",
|
|
name: "Arktos",
|
|
hasLoot: false,
|
|
combat: combatWith(alvaSource, arktosAttacks, arktosAttacks.map((entry) => entry.id), {
|
|
serverEntry: 97_701,
|
|
level: 60,
|
|
healthMultiplier: 0.95,
|
|
damageMultiplier: 0.95,
|
|
}),
|
|
visual: frozen({
|
|
...alvaSource.visual,
|
|
primaryColor: "#6c8194",
|
|
accentColor: "#c8efff",
|
|
scale: Math.max(1, alvaSource.visual.scale * 0.95),
|
|
}),
|
|
});
|
|
const alvajar: PopulationEntityDefinition = frozen({
|
|
...alvaSource,
|
|
id: "frozen-reach:alvajar",
|
|
kind: "mob",
|
|
name: "Alvajar",
|
|
hasLoot: false,
|
|
combat: frozen({
|
|
serverEntry: 97_770,
|
|
level: 60,
|
|
healthMultiplier: 0.32,
|
|
damageMultiplier: 0.55,
|
|
moveSpeed: 3.5,
|
|
leashRange: 55,
|
|
mechanicIds: frozen(["authored-alvajar-strike"]),
|
|
attacks: frozen([
|
|
attack({
|
|
id: "authored-alvajar-strike",
|
|
name: "Alvajar Strike",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "frost",
|
|
animation: "attack",
|
|
range: 4.5,
|
|
cooldownMs: 2_100,
|
|
damageMultiplier: 0.75,
|
|
}),
|
|
]),
|
|
}),
|
|
visual: frozen({
|
|
...alvaSource.visual,
|
|
primaryColor: "#312d4b",
|
|
accentColor: "#a9d8ff",
|
|
scale: Math.max(0.78, alvaSource.visual.scale * 0.7),
|
|
}),
|
|
});
|
|
const removed = new Set([
|
|
"frozen-reach-guardian",
|
|
"frozen-reach-caster",
|
|
"frozen-reach-brute",
|
|
]);
|
|
const entities: PopulationDefinitionMap = frozen({
|
|
...withoutKeys(definition.entities, removed),
|
|
"entry-97700": alva,
|
|
[arktos.id]: arktos,
|
|
[alvajar.id]: alvajar,
|
|
});
|
|
const arktosSpawn: StaticMobSpawnDefinition = frozen({
|
|
id: "frozen-reach:arktos",
|
|
entityId: arktos.id,
|
|
position: offset(alvaSpawn.position, -6.5, 5.5),
|
|
yaw: Math.PI * 0.75,
|
|
spawnMask: alvaSpawn.spawnMask,
|
|
});
|
|
const arktosGroup: DungeonEncounterSpawnGroupDefinition = frozen({
|
|
id: "frozen-reach:arktos-attempt",
|
|
spawns: frozen([arktosSpawn]),
|
|
});
|
|
const alvajarSpawns = frozen([
|
|
[-8, -7],
|
|
[8, -7],
|
|
[-8, 7],
|
|
[8, 7],
|
|
].map(([x, z], index): StaticMobSpawnDefinition => frozen({
|
|
id: `frozen-reach:alvajar-${index + 1}`,
|
|
entityId: alvajar.id,
|
|
position: offset(alvaSpawn.position, x, z),
|
|
yaw: Math.atan2(-x, -z),
|
|
spawnMask: alvaSpawn.spawnMask,
|
|
})));
|
|
const alvajarGroup: DungeonEncounterSpawnGroupDefinition = frozen({
|
|
id: "frozen-reach:rebirth-alvajar",
|
|
spawns: alvajarSpawns,
|
|
});
|
|
const encounter: DungeonEncounterDefinition = frozen({
|
|
id: markerId,
|
|
dungeonId: definition.id,
|
|
initialPhase: "duo-active",
|
|
initialState: { flags: { "arktos-defeated": false } },
|
|
resetPolicy: { bossWipePhase: "duo-active", checkpointPhase: "duo-active" },
|
|
source: sourceReview(
|
|
evidence,
|
|
"Alva and an attempt-scoped Arktos begin together with recovered D0 ability families. Arktos's death moves Alva to the recovered vengeance family and activates four spell-linked Alvajar proxies; a wipe restores the duo.",
|
|
),
|
|
rules: frozen([
|
|
frozen({
|
|
id: "spawn-arktos-attempt",
|
|
trigger: { kind: "tick" },
|
|
guards: [
|
|
{ kind: "phase", phases: ["duo-active"] },
|
|
{ kind: "spawn-group-active", spawnGroupId: arktosGroup.id, equals: false },
|
|
],
|
|
actions: [
|
|
{ id: "activate-arktos-attempt", kind: "activate-spawn-group", spawnGroupId: arktosGroup.id },
|
|
],
|
|
}),
|
|
frozen({
|
|
id: "arktos-defeated",
|
|
trigger: { kind: "entity-death", entityId: arktosSpawn.id },
|
|
guards: [{ kind: "phase", phases: ["duo-active"] }],
|
|
actions: [
|
|
{ id: "mark-arktos-defeated", kind: "set-flag", flagId: "arktos-defeated", value: true },
|
|
{ id: "deactivate-arktos-attempt", kind: "deactivate-spawn-group", spawnGroupId: arktosGroup.id },
|
|
{ id: "activate-rebirth-alvajar", kind: "activate-spawn-group", spawnGroupId: alvajarGroup.id },
|
|
{ id: "enter-alva-vengeance", kind: "set-phase", phase: "vengeance" },
|
|
],
|
|
}),
|
|
frozen({
|
|
id: "alvajar-defeated",
|
|
trigger: { kind: "entities-dead", entityIds: alvajarSpawns.map((spawn) => spawn.id) },
|
|
guards: [{ kind: "phase", phases: ["vengeance"] }],
|
|
actions: [{ id: "deactivate-rebirth-alvajar", kind: "deactivate-spawn-group", spawnGroupId: alvajarGroup.id }],
|
|
}),
|
|
frozen({
|
|
id: "alva-defeated",
|
|
trigger: { kind: "entity-death", entityId: alvaSpawn.id },
|
|
actions: [
|
|
{ id: "cleanup-rebirth-alvajar", kind: "deactivate-spawn-group", spawnGroupId: alvajarGroup.id },
|
|
{ id: "complete-alva-encounter", kind: "set-phase", phase: "complete", persistence: "run" },
|
|
],
|
|
}),
|
|
]),
|
|
});
|
|
const staticSpawns = frozen([
|
|
frozen({ ...alvaSpawn, entityId: alva.id }),
|
|
]);
|
|
return frozen({
|
|
...definition,
|
|
entities,
|
|
staticSpawns,
|
|
roamingPacks: frozen([]),
|
|
encounters: frozen([...replaceBaselineReview(definition, evidence), encounter]),
|
|
encounterSpawnGroups: frozen([
|
|
...(definition.encounterSpawnGroups ?? []).filter((group) => (
|
|
group.id !== arktosGroup.id && group.id !== alvajarGroup.id
|
|
)),
|
|
arktosGroup,
|
|
alvajarGroup,
|
|
]),
|
|
validation: appendWarnings(definition, [
|
|
"Frozen Reach now uses the recovered Alva/Arktos encounter families; all cadence, placement, and hazard behavior remains source-backed adaptation pending live playtest.",
|
|
], ["Trash identities and placements are procedural"]),
|
|
});
|
|
}
|
|
|
|
function tinkerAttackKit(phase: "active" | "overdrive", cooldownScale: number): readonly EnemyAttackDefinition[] {
|
|
const condition = encounterCondition("tinkertech-showdown:mechatron", [phase]);
|
|
const scaled = (milliseconds: number) => Math.round(milliseconds * cooldownScale);
|
|
const suffix = phase === "active" ? "normal" : "overdrive";
|
|
return frozen([
|
|
attack({
|
|
id: `rocket-257438-${suffix}`,
|
|
name: "Rocket",
|
|
spellId: 257_438,
|
|
delivery: "area",
|
|
target: "random-party",
|
|
school: "fire",
|
|
animation: "cast",
|
|
castTimeMs: 2_000,
|
|
range: 42,
|
|
radius: 7,
|
|
cooldownMs: scaled(8_000),
|
|
damageMultiplier: 1.05,
|
|
telegraph: { shape: "circle", anchor: "target", warningMs: 2_000, radius: 7 },
|
|
condition,
|
|
}),
|
|
attack({
|
|
id: `gatling-missile-barrage-257459-${suffix}`,
|
|
name: "Gatling Missile Barrage",
|
|
spellId: 257_459,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "fire",
|
|
animation: "cast",
|
|
castTimeMs: 3_000,
|
|
range: 38,
|
|
cooldownMs: scaled(14_000),
|
|
damageMultiplier: 0.92,
|
|
telegraph: { shape: "line", anchor: "source", warningMs: 3_000, length: 38, width: 6 },
|
|
condition,
|
|
}),
|
|
attack({
|
|
id: `incineration-beam-257470-${suffix}`,
|
|
name: "Incineration Beam",
|
|
spellId: 257_470,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "fire",
|
|
animation: "cast",
|
|
castTimeMs: 2_500,
|
|
range: 45,
|
|
cooldownMs: scaled(20_000),
|
|
damageMultiplier: 1.25,
|
|
telegraph: { shape: "line", anchor: "source", warningMs: 2_500, length: 45, width: 4.5 },
|
|
condition,
|
|
}),
|
|
attack({
|
|
id: `electrified-zone-257472-${suffix}`,
|
|
name: "Electrified Zone",
|
|
spellId: 257_472,
|
|
delivery: "area",
|
|
target: "random-party",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 1_500,
|
|
range: 40,
|
|
radius: 8,
|
|
cooldownMs: scaled(16_000),
|
|
damageMultiplier: 0,
|
|
telegraph: { shape: "circle", anchor: "target", warningMs: 1_500, radius: 8 },
|
|
effect: { kind: "control", mechanic: "stun", durationMs: 1_500 },
|
|
condition,
|
|
}),
|
|
]);
|
|
}
|
|
|
|
function buildTinkertechShowdown(definition: DungeonDefinition): DungeonDefinition {
|
|
const markerId = "tinkertech-showdown:mechatron";
|
|
if (definition.encounters?.some((encounter) => encounter.id === markerId)) return definition;
|
|
const evidence = EVIDENCE_BY_DUNGEON.get(definition.id)!;
|
|
const bossSource = requireEntity(definition, "entry-663012");
|
|
const bossSpawn = requireSpawn(definition, "encounter-9013");
|
|
const guardbotSource = requireEntity(definition, "tinkertech-showdown-guardian");
|
|
const coilcasterSource = requireEntity(definition, "tinkertech-showdown-caster");
|
|
const demolisherSource = requireEntity(definition, "tinkertech-showdown-brute");
|
|
const normalAttacks = tinkerAttackKit("active", 1);
|
|
const overdriveAttacks = tinkerAttackKit("overdrive", 0.8);
|
|
const mechatron = frozen({
|
|
...bossSource,
|
|
name: "Mechatron",
|
|
title: "HealerMan-authored machine encounter",
|
|
combat: combatWith(
|
|
bossSource,
|
|
frozen([
|
|
attack({
|
|
id: "basic-attack",
|
|
name: "Piston Slam",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
range: 5,
|
|
cooldownMs: 2_000,
|
|
damageMultiplier: 1,
|
|
}),
|
|
...normalAttacks,
|
|
...overdriveAttacks,
|
|
]),
|
|
[...normalAttacks, ...overdriveAttacks].map((entry) => entry.id),
|
|
{ serverEntry: 663_012, healthMultiplier: 1.4, damageMultiplier: 1.08 },
|
|
),
|
|
} as PopulationEntityDefinition);
|
|
const guardbotAttacks = frozen([
|
|
attack({
|
|
id: "servo-strike",
|
|
name: "Servo Strike",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
range: 4.5,
|
|
cooldownMs: 1_900,
|
|
damageMultiplier: 1,
|
|
}),
|
|
attack({
|
|
id: "authored-clamping-field",
|
|
name: "Clamping Field",
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 1_500,
|
|
range: 8,
|
|
radius: 8,
|
|
cooldownMs: 11_000,
|
|
damageMultiplier: 0,
|
|
effect: { kind: "control", mechanic: "root", durationMs: 1_500 },
|
|
}),
|
|
]);
|
|
const coilcasterAttacks = frozen([
|
|
attack({
|
|
id: "coil-discharge-257473",
|
|
name: "Coil Discharge",
|
|
spellId: 257_473,
|
|
delivery: "projectile",
|
|
target: "random-party",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 1_400,
|
|
range: 30,
|
|
cooldownMs: 5_500,
|
|
damageMultiplier: 0.9,
|
|
}),
|
|
]);
|
|
const demolisherAttacks = frozen([
|
|
attack({
|
|
id: "demolition-rocket-257438",
|
|
name: "Demolition Rocket",
|
|
spellId: 257_438,
|
|
delivery: "area",
|
|
target: "random-party",
|
|
school: "fire",
|
|
animation: "cast",
|
|
castTimeMs: 2_000,
|
|
range: 32,
|
|
radius: 5,
|
|
cooldownMs: 8_000,
|
|
damageMultiplier: 1,
|
|
telegraph: { shape: "circle", anchor: "target", warningMs: 2_000, radius: 5 },
|
|
}),
|
|
]);
|
|
const guardbot = frozen({
|
|
...guardbotSource,
|
|
name: "Tinkertech Guardbot",
|
|
combat: combatWith(guardbotSource, guardbotAttacks, guardbotAttacks.map((entry) => entry.id)),
|
|
} as PopulationEntityDefinition);
|
|
const coilcaster = frozen({
|
|
...coilcasterSource,
|
|
name: "Tinkertech Coilcaster",
|
|
combat: combatWith(coilcasterSource, coilcasterAttacks, coilcasterAttacks.map((entry) => entry.id)),
|
|
} as PopulationEntityDefinition);
|
|
const demolisher = frozen({
|
|
...demolisherSource,
|
|
name: "Tinkertech Demolisher",
|
|
combat: combatWith(demolisherSource, demolisherAttacks, demolisherAttacks.map((entry) => entry.id)),
|
|
} as PopulationEntityDefinition);
|
|
const entities: PopulationDefinitionMap = frozen({
|
|
...definition.entities,
|
|
"entry-663012": mechatron,
|
|
"tinkertech-showdown-guardian": guardbot,
|
|
"tinkertech-showdown-caster": coilcaster,
|
|
"tinkertech-showdown-brute": demolisher,
|
|
});
|
|
const reinforcementGroup: DungeonEncounterSpawnGroupDefinition = frozen({
|
|
id: "tinkertech-showdown:overdrive-reinforcements",
|
|
spawns: frozen([
|
|
frozen({
|
|
id: "tinkertech-showdown:reinforcement-a",
|
|
entityId: coilcaster.id,
|
|
position: offset(bossSpawn.position, -9, -14),
|
|
yaw: 0,
|
|
spawnMask: bossSpawn.spawnMask,
|
|
}),
|
|
frozen({
|
|
id: "tinkertech-showdown:reinforcement-b",
|
|
entityId: coilcaster.id,
|
|
position: offset(bossSpawn.position, 9, -14),
|
|
yaw: 0,
|
|
spawnMask: bossSpawn.spawnMask,
|
|
}),
|
|
]),
|
|
});
|
|
const encounter: DungeonEncounterDefinition = frozen({
|
|
id: markerId,
|
|
dungeonId: definition.id,
|
|
initialPhase: "active",
|
|
initialState: { flags: { overdrive: false } },
|
|
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
|
|
source: sourceReview(
|
|
evidence,
|
|
"Mechatron keeps its exact recovered identity but uses an explicitly authored machine kit. At 35 percent health it enters a 20-percent-faster overdrive kit and calls two authored Coilcaster reinforcements.",
|
|
),
|
|
rules: frozen([
|
|
frozen({
|
|
id: "mechatron-overdrive",
|
|
trigger: { kind: "entity-health", entityId: bossSpawn.id, atOrBelowPercent: 35 },
|
|
guards: [{ kind: "phase", phases: ["active"] }],
|
|
actions: [
|
|
{ id: "mark-mechatron-overdrive", kind: "set-flag", flagId: "overdrive", value: true },
|
|
{ id: "activate-mechatron-reinforcements", kind: "activate-spawn-group", spawnGroupId: reinforcementGroup.id },
|
|
{ id: "enter-mechatron-overdrive", kind: "set-phase", phase: "overdrive" },
|
|
],
|
|
}),
|
|
frozen({
|
|
id: "mechatron-defeated",
|
|
trigger: { kind: "entity-death", entityId: bossSpawn.id },
|
|
actions: [
|
|
{ id: "cleanup-mechatron-reinforcements", kind: "deactivate-spawn-group", spawnGroupId: reinforcementGroup.id },
|
|
{ id: "complete-mechatron-encounter", kind: "set-phase", phase: "complete", persistence: "run" },
|
|
],
|
|
}),
|
|
]),
|
|
});
|
|
return frozen({
|
|
...definition,
|
|
entities,
|
|
encounters: frozen([...replaceBaselineReview(definition, evidence), encounter]),
|
|
encounterSpawnGroups: frozen([
|
|
...(definition.encounterSpawnGroups ?? []).filter((group) => group.id !== reinforcementGroup.id),
|
|
reinforcementGroup,
|
|
]),
|
|
validation: appendWarnings(definition, [
|
|
"Tinkertech population and Mechatron mechanics are explicitly HealerMan-authored; candidate spell ids are not authoritative creature bindings.",
|
|
], ["Trash identities and placements are procedural"]),
|
|
});
|
|
}
|
|
|
|
const FORGOTTEN_MINE_MARKER_ID = "forgotten-mine:geolord-vek";
|
|
const FORGOTTEN_MINE_GEOSPIKE_GROUP_ID = "forgotten-mine:geospike";
|
|
const FORGOTTEN_MINE_SHARD_IDS = Object.freeze([
|
|
"forgotten-mine:resonite-shard-1",
|
|
"forgotten-mine:resonite-shard-2",
|
|
"forgotten-mine:resonite-shard-3",
|
|
] as const);
|
|
const FORGOTTEN_MINE_SHARD_ID_SET: ReadonlySet<string> = new Set(FORGOTTEN_MINE_SHARD_IDS);
|
|
|
|
function forgottenMineWorldObjects(bossPosition: Vector3Tuple): readonly DungeonWorldObjectDefinition[] {
|
|
const offsets = [
|
|
[-12, 0],
|
|
[6, 10.392],
|
|
[6, -10.392],
|
|
] as const;
|
|
return frozen(offsets.map(([x, z], index): DungeonWorldObjectDefinition => frozen({
|
|
id: FORGOTTEN_MINE_SHARD_IDS[index],
|
|
name: "Resonite Shard",
|
|
kind: "resource",
|
|
position: offset(bossPosition, x, z),
|
|
halfExtents: [2.25, 2.4, 2.25],
|
|
initialState: "dormant",
|
|
states: frozen([
|
|
frozen({ id: "dormant", label: "Dormant", visible: false, blocksMovement: false, interactionEnabled: false }),
|
|
frozen({ id: "ready", label: "Charged", visible: true, blocksMovement: false, interactionEnabled: true }),
|
|
frozen({ id: "consumed", label: "Consumed", visible: false, blocksMovement: false, interactionEnabled: false }),
|
|
]),
|
|
interaction: frozen({
|
|
prompt: "Consume Resonite Shard",
|
|
range: 4.5,
|
|
signalId: `forgotten-mine:consume-resonite-${index + 1}`,
|
|
allowedStates: ["ready"],
|
|
oneShot: false,
|
|
instructions: "Consume a shard to suppress Geolord Vek's next Resonance window.",
|
|
}),
|
|
visual: frozen({ color: "#76dbe8", emissive: "#176778", scale: [1.35, 2.2, 1.35] }),
|
|
source: frozen({
|
|
fidelity: "source-backed-adapted",
|
|
files: RECOVERY_FILES,
|
|
templateId: 254_051,
|
|
note: "Template 254051 is spell-linked exactly by Create Resonite Shard 354032. Placement, bounds, procedural visual, state cycle, and cleanse behavior are authored.",
|
|
}),
|
|
})));
|
|
}
|
|
|
|
function buildForgottenMine(definition: DungeonDefinition): DungeonDefinition {
|
|
if (definition.encounters?.some((encounter) => encounter.id === FORGOTTEN_MINE_MARKER_ID)) return definition;
|
|
const evidence = EVIDENCE_BY_DUNGEON.get(definition.id)!;
|
|
const bossSource = requireEntity(definition, "entry-254272");
|
|
const bossSpawn = requireSpawn(definition, "encounter-5069");
|
|
const condition = (states: readonly string[]) => encounterCondition(FORGOTTEN_MINE_MARKER_ID, states);
|
|
const allCombatPhases = [
|
|
"boss-active",
|
|
"pre-resonance-geospike",
|
|
"resonance-active",
|
|
"resonance-cleansed",
|
|
"resonance-recharge",
|
|
"resonance-geospike",
|
|
] as const;
|
|
const phaseTwoDamagePhases = ["resonance-active", "resonance-recharge"] as const;
|
|
const vekAttacks = frozen([
|
|
attack({
|
|
id: "basic-attack",
|
|
name: "Stone Fist",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
range: 5,
|
|
cooldownMs: 2_000,
|
|
damageMultiplier: 1,
|
|
}),
|
|
attack({
|
|
id: "stone-shield-354002",
|
|
name: "Stone Shield",
|
|
spellId: 354_002,
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "nature",
|
|
animation: "cast",
|
|
range: 0,
|
|
cooldownMs: 30_000,
|
|
initialCooldownMs: [0, 0],
|
|
damageMultiplier: 0,
|
|
effect: { kind: "aura", aura: "stone-shield", durationMs: 10_000, magnitude: 0.2 },
|
|
condition: condition(allCombatPhases),
|
|
}),
|
|
attack({
|
|
id: "stonebreaker-354000",
|
|
name: "Stonebreaker",
|
|
spellId: 354_000,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "physical",
|
|
animation: "cast",
|
|
castTimeMs: 1_700,
|
|
range: 12,
|
|
cooldownMs: 6_500,
|
|
damageMultiplier: 1.15,
|
|
telegraph: { shape: "cone", anchor: "source", warningMs: 1_700, length: 12, angleDegrees: 85 },
|
|
condition: condition(allCombatPhases),
|
|
}),
|
|
attack({
|
|
id: "quake-354001",
|
|
name: "Quake",
|
|
spellId: 354_001,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 1_800,
|
|
range: 18,
|
|
radius: 18,
|
|
cooldownMs: 18_000,
|
|
damageMultiplier: 0.82,
|
|
telegraph: { shape: "circle", anchor: "source", warningMs: 1_800, radius: 18 },
|
|
condition: condition(allCombatPhases),
|
|
}),
|
|
attack({
|
|
id: "geode-barrage-354015",
|
|
name: "Geode Barrage",
|
|
spellId: 354_015,
|
|
delivery: "projectile",
|
|
target: "random-party",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 1_600,
|
|
range: 38,
|
|
cooldownMs: 11_000,
|
|
damageMultiplier: 1.02,
|
|
condition: condition(allCombatPhases),
|
|
}),
|
|
attack({
|
|
id: "raise-geospike-354004",
|
|
name: "Geospike",
|
|
spellId: 354_004,
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 1_200,
|
|
range: 0,
|
|
cooldownMs: 15_000,
|
|
initialCooldownMs: [8_000, 10_000],
|
|
damageMultiplier: 0,
|
|
effect: { kind: "encounter-event", eventId: "forgotten-mine:geospike-cast" },
|
|
condition: condition(["boss-active", ...phaseTwoDamagePhases]),
|
|
}),
|
|
attack({
|
|
id: "resonance-354013",
|
|
name: "Resonance",
|
|
spellId: 354_013,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 2_000,
|
|
range: 28,
|
|
radius: 28,
|
|
cooldownMs: 14_000,
|
|
damageMultiplier: 1.15,
|
|
telegraph: { shape: "circle", anchor: "source", warningMs: 2_000, radius: 28 },
|
|
condition: condition(phaseTwoDamagePhases),
|
|
}),
|
|
attack({
|
|
id: "ruptured-earth-354017",
|
|
name: "Ruptured Earth",
|
|
spellId: 354_017,
|
|
delivery: "area",
|
|
target: "random-party",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 2_200,
|
|
range: 38,
|
|
radius: 7,
|
|
cooldownMs: 22_000,
|
|
damageMultiplier: 1.25,
|
|
telegraph: { shape: "circle", anchor: "target", warningMs: 2_200, radius: 7 },
|
|
condition: condition(["resonance-active", "resonance-cleansed", "resonance-recharge"]),
|
|
}),
|
|
]);
|
|
const vek: PopulationEntityDefinition = frozen({
|
|
...bossSource,
|
|
name: "Geolord Vek",
|
|
title: "Source-backed adapted encounter",
|
|
combat: combatWith(bossSource, vekAttacks, vekAttacks.map((entry) => entry.id), {
|
|
serverEntry: 254_272,
|
|
healthMultiplier: 1.4,
|
|
damageMultiplier: 1.08,
|
|
}),
|
|
});
|
|
const stoneguardSource = requireEntity(definition, "forgotten-mine-guardian");
|
|
const geomancerSource = requireEntity(definition, "forgotten-mine-caster");
|
|
const bruteSource = requireEntity(definition, "forgotten-mine-brute");
|
|
const stoneguard = frozen({
|
|
...stoneguardSource,
|
|
name: "Forgotten Mine Stoneguard",
|
|
combat: combatWith(stoneguardSource, frozen([
|
|
attack({
|
|
id: "stoneguard-strike",
|
|
name: "Stoneguard Strike",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
range: 4.5,
|
|
cooldownMs: 1_900,
|
|
damageMultiplier: 1,
|
|
}),
|
|
attack({
|
|
id: "authored-stone-guard",
|
|
name: "Stone Guard",
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "nature",
|
|
animation: "cast",
|
|
range: 0,
|
|
cooldownMs: 16_000,
|
|
damageMultiplier: 0,
|
|
effect: { kind: "aura", aura: "authored-stone-guard", durationMs: 5_000, magnitude: 0.15 },
|
|
}),
|
|
]), ["stoneguard-strike", "authored-stone-guard"]),
|
|
} as PopulationEntityDefinition);
|
|
const geomancer = frozen({
|
|
...geomancerSource,
|
|
name: "Forgotten Mine Geomancer",
|
|
combat: combatWith(geomancerSource, frozen([
|
|
attack({
|
|
id: "authored-geode-bolt",
|
|
name: "Geode Bolt",
|
|
delivery: "projectile",
|
|
target: "random-party",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 1_500,
|
|
range: 30,
|
|
cooldownMs: 5_500,
|
|
damageMultiplier: 0.9,
|
|
}),
|
|
]), ["authored-geode-bolt"]),
|
|
} as PopulationEntityDefinition);
|
|
const quarryBrute = frozen({
|
|
...bruteSource,
|
|
name: "Forgotten Mine Quarry Brute",
|
|
combat: combatWith(bruteSource, frozen([
|
|
attack({
|
|
id: "authored-quarry-cleave",
|
|
name: "Quarry Cleave",
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "physical",
|
|
animation: "attack",
|
|
range: 9,
|
|
cooldownMs: 6_000,
|
|
damageMultiplier: 1.08,
|
|
telegraph: { shape: "cone", anchor: "source", warningMs: 1_300, length: 9, angleDegrees: 90 },
|
|
}),
|
|
]), ["authored-quarry-cleave"]),
|
|
} as PopulationEntityDefinition);
|
|
const geospike: PopulationEntityDefinition = frozen({
|
|
...bossSource,
|
|
id: "forgotten-mine:geospike",
|
|
kind: "mob",
|
|
name: "Geospike",
|
|
hasLoot: false,
|
|
combat: frozen({
|
|
serverEntry: 254_278,
|
|
level: bossSource.combat?.level,
|
|
healthMultiplier: 0.5,
|
|
damageMultiplier: 0.8,
|
|
moveSpeed: 0.1,
|
|
leashRange: 6,
|
|
mechanicIds: frozen(["geospike-eruption-354005"]),
|
|
attacks: frozen([
|
|
attack({
|
|
id: "geospike-eruption-354005",
|
|
name: "Geospike Eruption",
|
|
spellId: 354_005,
|
|
delivery: "area",
|
|
target: "nearby-party",
|
|
school: "nature",
|
|
animation: "cast",
|
|
castTimeMs: 1_800,
|
|
range: 7,
|
|
radius: 7,
|
|
cooldownMs: 60_000,
|
|
initialCooldownMs: [0, 0],
|
|
maximumCasts: 1,
|
|
damageMultiplier: 1.15,
|
|
telegraph: { shape: "circle", anchor: "source", warningMs: 1_800, radius: 7 },
|
|
}),
|
|
]),
|
|
}),
|
|
visual: frozen({
|
|
...bossSource.visual,
|
|
primaryColor: "#705a40",
|
|
accentColor: "#f7c867",
|
|
scale: Math.max(0.9, bossSource.visual.scale * 0.82),
|
|
}),
|
|
});
|
|
const entities: PopulationDefinitionMap = frozen({
|
|
...definition.entities,
|
|
"entry-254272": vek,
|
|
"forgotten-mine-guardian": stoneguard,
|
|
"forgotten-mine-caster": geomancer,
|
|
"forgotten-mine-brute": quarryBrute,
|
|
[geospike.id]: geospike,
|
|
});
|
|
const geospikeSpawn: StaticMobSpawnDefinition = frozen({
|
|
id: "forgotten-mine:geospike",
|
|
entityId: geospike.id,
|
|
position: offset(bossSpawn.position, 0, -7),
|
|
yaw: 0,
|
|
spawnMask: bossSpawn.spawnMask,
|
|
});
|
|
const geospikeGroup: DungeonEncounterSpawnGroupDefinition = frozen({
|
|
id: FORGOTTEN_MINE_GEOSPIKE_GROUP_ID,
|
|
spawns: frozen([geospikeSpawn]),
|
|
});
|
|
const worldObjects = forgottenMineWorldObjects(bossSpawn.position);
|
|
const revealShardActions = worldObjects.map((object, index) => ({
|
|
id: `reveal-resonite-${index + 1}`,
|
|
kind: "set-world-object-state" as const,
|
|
objectId: object.id,
|
|
state: "ready",
|
|
}));
|
|
const encounter: DungeonEncounterDefinition = frozen({
|
|
id: FORGOTTEN_MINE_MARKER_ID,
|
|
dungeonId: definition.id,
|
|
initialPhase: "boss-active",
|
|
initialState: {
|
|
counters: { "resonite-cleanses": 0 },
|
|
worldObjectStates: Object.fromEntries(worldObjects.map((object) => [object.id, "dormant"])),
|
|
},
|
|
resetPolicy: { bossWipePhase: "boss-active", checkpointPhase: "boss-active" },
|
|
source: sourceReview(
|
|
evidence,
|
|
"Geolord Vek uses the recovered earth family, enters Resonance at 50 percent, creates a short-lived spell-linked Geospike proxy, and exposes three spell-linked Resonite Shards with authored cleanse/recharge semantics.",
|
|
),
|
|
rules: frozen([
|
|
frozen({
|
|
id: "enter-resonance-phase",
|
|
trigger: { kind: "entity-health", entityId: bossSpawn.id, atOrBelowPercent: 50 },
|
|
actions: [
|
|
{ id: "cleanup-geospike-for-resonance", kind: "deactivate-spawn-group", spawnGroupId: geospikeGroup.id },
|
|
...revealShardActions,
|
|
{ id: "enter-vek-resonance", kind: "set-phase", phase: "resonance-active" },
|
|
],
|
|
}),
|
|
frozen({
|
|
id: "raise-geospike-before-resonance",
|
|
trigger: { kind: "signal", signalId: "forgotten-mine:geospike-cast" },
|
|
guards: [{ kind: "phase", phases: ["boss-active"] }],
|
|
actions: [
|
|
{ id: "activate-pre-resonance-geospike", kind: "activate-spawn-group", spawnGroupId: geospikeGroup.id },
|
|
{ id: "enter-pre-resonance-geospike", kind: "set-phase", phase: "pre-resonance-geospike" },
|
|
],
|
|
once: false,
|
|
}),
|
|
frozen({
|
|
id: "clear-geospike-before-resonance",
|
|
trigger: { kind: "elapsed", afterMs: 3_500 },
|
|
guards: [{ kind: "phase", phases: ["pre-resonance-geospike"] }],
|
|
actions: [
|
|
{ id: "deactivate-pre-resonance-geospike", kind: "deactivate-spawn-group", spawnGroupId: geospikeGroup.id },
|
|
{ id: "resume-vek-before-resonance", kind: "set-phase", phase: "boss-active" },
|
|
],
|
|
once: false,
|
|
}),
|
|
frozen({
|
|
id: "raise-geospike-during-resonance",
|
|
trigger: { kind: "signal", signalId: "forgotten-mine:geospike-cast" },
|
|
guards: [{ kind: "phase", phases: ["resonance-active", "resonance-recharge"] }],
|
|
actions: [
|
|
{ id: "activate-resonance-geospike", kind: "activate-spawn-group", spawnGroupId: geospikeGroup.id },
|
|
{ id: "enter-resonance-geospike", kind: "set-phase", phase: "resonance-geospike" },
|
|
],
|
|
once: false,
|
|
}),
|
|
frozen({
|
|
id: "clear-geospike-during-resonance",
|
|
trigger: { kind: "elapsed", afterMs: 3_500 },
|
|
guards: [{ kind: "phase", phases: ["resonance-geospike"] }],
|
|
actions: [
|
|
{ id: "deactivate-resonance-geospike", kind: "deactivate-spawn-group", spawnGroupId: geospikeGroup.id },
|
|
{ id: "resume-vek-resonance", kind: "set-phase", phase: "resonance-recharge" },
|
|
],
|
|
once: false,
|
|
}),
|
|
...worldObjects.map((object, index) => frozen({
|
|
id: `consume-resonite-shard-${index + 1}`,
|
|
trigger: { kind: "interact" as const, objectId: object.id },
|
|
guards: [
|
|
{ kind: "phase" as const, phases: ["resonance-active", "resonance-cleansed", "resonance-recharge"] },
|
|
{ kind: "world-object-state" as const, objectId: object.id, states: ["ready"] },
|
|
],
|
|
actions: [
|
|
{
|
|
id: `consume-resonite-object-${index + 1}`,
|
|
kind: "set-world-object-state" as const,
|
|
objectId: object.id,
|
|
state: "consumed",
|
|
},
|
|
{ id: `count-resonite-cleanse-${index + 1}`, kind: "increment-counter" as const, counterId: "resonite-cleanses" },
|
|
{ id: `apply-resonite-cleanse-${index + 1}`, kind: "set-phase" as const, phase: "resonance-cleansed" },
|
|
],
|
|
once: false,
|
|
})),
|
|
frozen({
|
|
id: "expire-resonite-cleanse",
|
|
trigger: { kind: "elapsed", afterMs: 8_000 },
|
|
guards: [{ kind: "phase", phases: ["resonance-cleansed"] }],
|
|
actions: [{ id: "enter-resonite-recharge", kind: "set-phase", phase: "resonance-recharge" }],
|
|
once: false,
|
|
}),
|
|
frozen({
|
|
id: "recharge-resonite-shards",
|
|
trigger: { kind: "elapsed", afterMs: 22_000 },
|
|
guards: [{ kind: "phase", phases: ["resonance-recharge"] }],
|
|
actions: [
|
|
...worldObjects.map((object, index) => ({
|
|
id: `recharge-resonite-${index + 1}`,
|
|
kind: "set-world-object-state" as const,
|
|
objectId: object.id,
|
|
state: "ready",
|
|
})),
|
|
{ id: "complete-resonite-recharge", kind: "set-phase", phase: "resonance-active" },
|
|
],
|
|
once: false,
|
|
}),
|
|
frozen({
|
|
id: "geolord-vek-defeated",
|
|
trigger: { kind: "entity-death", entityId: bossSpawn.id },
|
|
actions: [
|
|
{ id: "cleanup-final-geospike", kind: "deactivate-spawn-group", spawnGroupId: geospikeGroup.id },
|
|
...worldObjects.map((object, index) => ({
|
|
id: `hide-final-resonite-${index + 1}`,
|
|
kind: "set-world-object-state" as const,
|
|
objectId: object.id,
|
|
state: "consumed",
|
|
})),
|
|
{ id: "complete-geolord-vek-encounter", kind: "set-phase", phase: "complete", persistence: "run" },
|
|
],
|
|
}),
|
|
]),
|
|
});
|
|
return frozen({
|
|
...definition,
|
|
entities,
|
|
encounters: frozen([...replaceBaselineReview(definition, evidence), encounter]),
|
|
encounterSpawnGroups: frozen([
|
|
...(definition.encounterSpawnGroups ?? []).filter((group) => group.id !== geospikeGroup.id),
|
|
geospikeGroup,
|
|
]),
|
|
serverGameObjects: frozen([
|
|
...definition.serverGameObjects.filter((object) => !FORGOTTEN_MINE_SHARD_ID_SET.has(object.id)),
|
|
...worldObjects,
|
|
]),
|
|
validation: appendWarnings(definition, [
|
|
"Forgotten Mine uses recovered Vek, Geospike, and Resonite families; trash, cadence, placement, and cleanse behavior remain explicitly authored pending live playtest.",
|
|
], ["Trash identities and placements are procedural"]),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Applies only the three reviewed Ascension custom-dungeon packages. Generated
|
|
* campaign data remains immutable and unrelated definitions are returned by
|
|
* identity so this can be composed safely with the WoW and RuneWaker adapters.
|
|
*/
|
|
export function withAscensionCustomEncounterContent(definition: DungeonDefinition): DungeonDefinition {
|
|
if (definition.id === "frozen-reach") return buildFrozenReach(definition);
|
|
if (definition.id === "forgotten-mine") return buildForgottenMine(definition);
|
|
if (definition.id === "tinkertech-showdown") return buildTinkertechShowdown(definition);
|
|
return definition;
|
|
}
|