forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
8a98797fcf
commit
11934a3979
44 files changed
+3183
-329
No files matched your search
@@ -4,7 +4,7 @@ import { NodeIO } from "@gltf-transform/core";
|
||||
import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dungeonContentPackId, dungeonContentSourceId } from "../dungeonContentSource";
|
||||
import { DUNGEON_REGISTRY } from "../dungeonRegistry";
|
||||
import { DUNGEON_REGISTRY, prepareDungeonSessionDefinition } from "../dungeonRegistry";
|
||||
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime";
|
||||
import { buildPartyNavigationGraph, findNearestPartyNavigationSurface, findPartyNavigationPath } from "../partyNavigation";
|
||||
|
||||
@@ -15,6 +15,27 @@ const sliceIds = [
|
||||
] as const;
|
||||
|
||||
describe("RoM bite-sized finales", () => {
|
||||
it.each(sliceIds)("selects every %s source boss from stable run seeds at the same finale anchor", (id) => {
|
||||
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
|
||||
const anchor = DUNGEON_REGISTRY[id].bosses[0].position;
|
||||
const selected = new Set<string>();
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
const definition = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
|
||||
const again = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
|
||||
const boss = definition.bosses[0];
|
||||
selected.add(boss.id);
|
||||
expect(again.bosses[0].id).toBe(boss.id);
|
||||
expect(boss.position).toEqual(anchor);
|
||||
expect(boss.optional).toBe(false);
|
||||
expect(definition.encounterSpawnGroups![0].spawns[0].position).toEqual(anchor);
|
||||
for (const difficulty of definition.difficultyVariants) {
|
||||
expect((definition.encounterSpawnGroups![0].spawns[0].spawnMask! & difficulty.spawnMask)).not.toBe(0);
|
||||
}
|
||||
expect(source.bosses.map((candidate) => candidate.id)).toContain(boss.id);
|
||||
}
|
||||
expect(selected).toEqual(new Set(source.bosses.map((boss) => boss.id)));
|
||||
});
|
||||
|
||||
it.each(sliceIds)("gates %s behind its retained mobs and completes on its boss", (id) => {
|
||||
const slice = DUNGEON_REGISTRY[id];
|
||||
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
|
||||
@@ -45,6 +66,8 @@ describe("RoM bite-sized finales", () => {
|
||||
expect(completed.phase).toBe("complete");
|
||||
// The complete source remains available with its original population and objectives.
|
||||
expect(source.bosses.length).toBeGreaterThan(1);
|
||||
expect(slice.presentation.ambientLight.intensity).toBeGreaterThan(source.presentation.ambientLight.intensity);
|
||||
expect(slice.presentation.hemisphereLight.intensity).toBeGreaterThan(source.presentation.hemisphereLight.intensity);
|
||||
});
|
||||
|
||||
it.each(sliceIds)("keeps the %s start and boss reachable on shipped navigation", async (id) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
|
||||
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
|
||||
} from "../dungeonContentSource";
|
||||
import { seededBossIndex } from "./seededBossSelection";
|
||||
|
||||
interface RoMSlice {
|
||||
readonly sourceId: string;
|
||||
@@ -19,7 +20,7 @@ interface RoMSlice {
|
||||
readonly entranceName: string;
|
||||
readonly start: Vector3Tuple;
|
||||
readonly forward: Vector3Tuple;
|
||||
readonly bossName: string;
|
||||
readonly finaleBossName: string;
|
||||
readonly mobSpawnIds: readonly string[];
|
||||
readonly summary: string;
|
||||
readonly adaptation: string;
|
||||
@@ -29,65 +30,71 @@ const ROM_SLICES: readonly RoMSlice[] = [
|
||||
{
|
||||
sourceId: "forsaken-abbey",
|
||||
id: BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
|
||||
title: "Forsaken Abbey: Ancalon's Approach",
|
||||
title: "Forsaken Abbey: Final Passage",
|
||||
location: "Forsaken Abbey · Ancalon's Passage",
|
||||
entranceName: "Ancalon's Passage",
|
||||
// The omitted source bat occupied this floor-projected point before the last packs.
|
||||
start: [330.022888, -19.847076, -34.424903],
|
||||
forward: [1, 0, 0],
|
||||
bossName: "Demon Witch Ancalon",
|
||||
finaleBossName: "Demon Witch Ancalon",
|
||||
mobSpawnIds: [
|
||||
"rw-fa-spawn-7855", "rw-fa-spawn-7856",
|
||||
"rw-fa-spawn-12074", "rw-fa-spawn-12081",
|
||||
"rw-fa-spawn-12071", "rw-fa-spawn-12072",
|
||||
],
|
||||
summary: "Clear the shadows and followers in the final passage, then defeat Demon Witch Ancalon.",
|
||||
summary: "Clear the shadows and followers in the final passage, then defeat one surprise Abbey boss.",
|
||||
adaptation: "The earlier keepers, side rooms, patrol script, and deferred keeper doors are outside this short run.",
|
||||
},
|
||||
{
|
||||
sourceId: "paspers-shrine",
|
||||
id: BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
|
||||
title: "Paspers' Shrine: Hafiz's Hall",
|
||||
title: "Paspers' Shrine: Final Hall",
|
||||
location: "Paspers' Shrine · Hafiz's Hall",
|
||||
entranceName: "Hafiz's Hall",
|
||||
// The omitted source axe soldier occupied this projected point before the final corridor.
|
||||
start: [95.618457, 0.348065, -285.473267],
|
||||
forward: [1, 0, 0],
|
||||
bossName: "Blackhorn Hafiz",
|
||||
finaleBossName: "Blackhorn Hafiz",
|
||||
mobSpawnIds: [
|
||||
"rw-paspers-shrine-spawn-5943704", "rw-paspers-shrine-spawn-5943709",
|
||||
"rw-paspers-shrine-spawn-5943711", "rw-paspers-shrine-spawn-5943712",
|
||||
"rw-paspers-shrine-spawn-5943715", "rw-paspers-shrine-spawn-5943716",
|
||||
],
|
||||
summary: "Defeat the Blackhorn guards along the last corridor, then confront Blackhorn Hafiz.",
|
||||
summary: "Defeat the Blackhorn guards along the last corridor, then confront one surprise Shrine boss.",
|
||||
adaptation: "The other Blackhorn bosses and noncombat source interactions are outside this short run.",
|
||||
},
|
||||
{
|
||||
sourceId: "mystic-altar",
|
||||
id: BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
|
||||
title: "Mystic Altar: Golem's Antechamber",
|
||||
title: "Mystic Altar: Final Antechamber",
|
||||
location: "Mystic Altar · Golem's Antechamber",
|
||||
entranceName: "Golem's Antechamber",
|
||||
start: [-392.475647, -17.665024, 329],
|
||||
forward: [-1, 0, 0],
|
||||
bossName: "Cursed lron Golem",
|
||||
finaleBossName: "Cursed lron Golem",
|
||||
mobSpawnIds: [
|
||||
"rw-mystic-altar-spawn-13463", "rw-mystic-altar-spawn-13465",
|
||||
"rw-mystic-altar-spawn-13244", "rw-mystic-altar-spawn-13245",
|
||||
"rw-mystic-altar-spawn-13248", "rw-mystic-altar-spawn-13332",
|
||||
],
|
||||
summary: "Clear the guards, beetles, and Morcus beasts before the Cursed Iron Golem.",
|
||||
summary: "Clear the guards, beetles, and Morcus beasts before one surprise Altar boss.",
|
||||
adaptation: "Dorlos, the earlier altar route, and the remaining beetle swarm are outside this short run.",
|
||||
},
|
||||
];
|
||||
|
||||
function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefinition {
|
||||
function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string): DungeonDefinition {
|
||||
if (source.id !== slice.sourceId) throw new Error(`${slice.id} requires ${slice.sourceId}.`);
|
||||
const boss = source.objectiveOrder.find((objective) => objective.name === slice.bossName);
|
||||
const bossSpawn = boss && source.staticSpawns.find((spawn) => spawn.id === boss.id);
|
||||
if (!boss || !bossSpawn || source.entities[bossSpawn.entityId]?.kind !== "boss") {
|
||||
throw new Error(`${slice.sourceId} is missing ${slice.bossName}.`);
|
||||
const finale = source.objectiveOrder.find((objective) => objective.name === slice.finaleBossName);
|
||||
if (!finale || source.bosses.length === 0) {
|
||||
throw new Error(`${slice.sourceId} is missing ${slice.finaleBossName} or its boss list.`);
|
||||
}
|
||||
const boss = source.bosses[seededBossIndex(seed, source.bosses.length)];
|
||||
const sourceBossSpawn = source.staticSpawns.find((spawn) => spawn.id === boss.id);
|
||||
if (!sourceBossSpawn || source.entities[sourceBossSpawn.entityId]?.kind !== "boss") {
|
||||
throw new Error(`${slice.sourceId} is missing source boss ${boss.name}.`);
|
||||
}
|
||||
const bossSpawn = { ...sourceBossSpawn, position: finale.position,
|
||||
spawnMask: source.difficultyVariants.reduce((mask, difficulty) => mask | difficulty.spawnMask, 0) };
|
||||
const mobSpawns = slice.mobSpawnIds.map((id) => {
|
||||
const spawn = source.staticSpawns.find((candidate) => candidate.id === id);
|
||||
if (!spawn || source.entities[spawn.entityId]?.kind === "boss" || !source.entities[spawn.entityId]) {
|
||||
@@ -110,7 +117,7 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
|
||||
scope: "boss-progression",
|
||||
files: ["src/game/dungeons/biteSizedRoMDungeons.ts"],
|
||||
reviewedBehavior: slice.summary,
|
||||
adaptations: [slice.adaptation, "The boss appears after the retained source mobs are defeated."],
|
||||
adaptations: [slice.adaptation, "One source boss is selected from the run id and appears in the finale room after the retained source mobs are defeated."],
|
||||
remainingGaps: ["The start, party route, combat, boss activation, and completion need an in-game pass."],
|
||||
},
|
||||
rules: [
|
||||
@@ -136,12 +143,12 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
|
||||
},
|
||||
],
|
||||
};
|
||||
const objective: DungeonBossObjective = { ...boss, eventOnly: true };
|
||||
const objective: DungeonBossObjective = { ...boss, position: finale.position, optional: false, eventOnly: true };
|
||||
const entityIds = new Set([...mobSpawns, bossSpawn].map((spawn) => spawn.entityId));
|
||||
const entities = Object.fromEntries(Object.entries(source.entities).filter(([id]) => entityIds.has(id)));
|
||||
const areas = [
|
||||
{ id: "entrance", name: slice.entranceName, center: slice.start, radius: 22 },
|
||||
{ id: "boss-room", name: slice.bossName, center: boss.position, radius: 32 },
|
||||
{ id: "boss-room", name: `${boss.name}'s Room`, center: finale.position, radius: 32 },
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -153,7 +160,10 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
|
||||
yaw: Math.atan2(slice.forward[0], slice.forward[2]) },
|
||||
presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon",
|
||||
summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`,
|
||||
unchartedAreaName: slice.entranceName },
|
||||
unchartedAreaName: slice.entranceName,
|
||||
ambientLight: { color: "#e2dccc", intensity: 0.9 },
|
||||
hemisphereLight: { skyColor: "#ded9d0", groundColor: "#5a5350", intensity: 1.2 },
|
||||
directionalLight: { ...source.presentation.directionalLight, color: "#fff0d9", intensity: 1.35 } },
|
||||
areas,
|
||||
entities,
|
||||
staticSpawns: mobSpawns,
|
||||
@@ -169,14 +179,14 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
|
||||
};
|
||||
}
|
||||
|
||||
export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition): DungeonDefinition {
|
||||
return createRoMSlice(source, ROM_SLICES[0]);
|
||||
export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
|
||||
return createRoMSlice(source, ROM_SLICES[0], seed);
|
||||
}
|
||||
|
||||
export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition): DungeonDefinition {
|
||||
return createRoMSlice(source, ROM_SLICES[1]);
|
||||
export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
|
||||
return createRoMSlice(source, ROM_SLICES[1], seed);
|
||||
}
|
||||
|
||||
export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition): DungeonDefinition {
|
||||
return createRoMSlice(source, ROM_SLICES[2]);
|
||||
export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
|
||||
return createRoMSlice(source, ROM_SLICES[2], seed);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
STORMWIND_STOCKADE_DUNGEON_ID,
|
||||
} from "../dungeonContentSource";
|
||||
import { seededBossIndex } from "./seededBossSelection";
|
||||
|
||||
const BOSS_ROOM_POSITION = [-159.582, -25.6062, 1.2531] as const;
|
||||
const CENTRAL_CORRIDOR_HALF_WIDTH = 15;
|
||||
@@ -22,17 +23,8 @@ export const BITE_SIZED_STOCKADES_BOSS_CANDIDATES: readonly BiteSizedStockadesBo
|
||||
{ sourceSpawnId: "creature-79116", name: "Dextren Ward" },
|
||||
]);
|
||||
|
||||
function seedHash(seed: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash ^= seed.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function biteSizedStockadesBossIndex(seed: string): number {
|
||||
return seedHash(seed) % BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length;
|
||||
return seededBossIndex(seed, BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length);
|
||||
}
|
||||
|
||||
function branchWall(id: string, name: string, z: number): DungeonWorldObjectDefinition {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dungeonContentSourceId } from "../dungeonContentSource";
|
||||
import { DUNGEON_REGISTRY, prepareDungeonSessionDefinition } from "../dungeonRegistry";
|
||||
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime";
|
||||
import { finalDungeonBoss } from "../dungeonRunStore";
|
||||
|
||||
const ids = ["bite-sized-wailing-caverns", "bite-sized-deadmines-ship",
|
||||
"bite-sized-utgarde-pinnacle-skadi"] as const;
|
||||
|
||||
describe("WoW bite-sized seeded bosses", () => {
|
||||
it.each(ids)("selects every %s source boss at a stable finale anchor", (id) => {
|
||||
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
|
||||
const anchor = DUNGEON_REGISTRY[id].bosses[0].position;
|
||||
const selected = new Set<string>();
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
const seed = `wow-slice-${index}`;
|
||||
const definition = prepareDungeonSessionDefinition(id, seed)!;
|
||||
const boss = definition.bosses[0];
|
||||
const spawn = definition.encounterSpawnGroups![0].spawns[0];
|
||||
selected.add(boss.id);
|
||||
expect(prepareDungeonSessionDefinition(id, seed)!.bosses[0].id).toBe(boss.id);
|
||||
expect(boss.position).toEqual(anchor);
|
||||
expect(boss.eventOnly).toBe(true);
|
||||
expect(boss.optional).toBe(false);
|
||||
expect(spawn.id).toBe(boss.id);
|
||||
expect(spawn.position).toEqual(anchor);
|
||||
expect(definition.entities[spawn.entityId].kind).toBe("boss");
|
||||
expect(finalDungeonBoss(definition)?.id).toBe(boss.id);
|
||||
expect(definition.staticSpawns.every((entry) => definition.entities[entry.entityId].kind !== "boss")).toBe(true);
|
||||
}
|
||||
expect(selected).toEqual(new Set(source.bosses.map((boss) => boss.id)));
|
||||
});
|
||||
|
||||
it.each(ids)("gates the %s boss on retained mobs and completes on its death", (id) => {
|
||||
const definition = prepareDungeonSessionDefinition(id, "encounter-run")!;
|
||||
const encounter = definition.encounters![0];
|
||||
const mobs = definition.staticSpawns.map((spawn) => spawn.id);
|
||||
expect(mobs.length).toBeGreaterThan(0);
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 100,
|
||||
facts: { registeredEntityIds: mobs, deadEntityIds: mobs.slice(0, -1) } }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 200,
|
||||
facts: { registeredEntityIds: mobs, deadEntityIds: mobs } }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([definition.encounterSpawnGroups![0].id]);
|
||||
const bossId = definition.bosses[0].id;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 300,
|
||||
facts: { registeredEntityIds: [bossId], deadEntityIds: [bossId] } }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain(bossId);
|
||||
expect(snapshot.flags["dungeon-complete"]).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
|
||||
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
|
||||
} from "../dungeonContentSource";
|
||||
import { finaleBossSpawn, seededSourceBoss } from "./sourceBossCandidates";
|
||||
const DEADMINES_ROUTE_WIDTH = 34;
|
||||
|
||||
function normalizedName(value: string): string {
|
||||
@@ -39,30 +40,6 @@ function distanceToRoute(point: Vector3Tuple, route: readonly Vector3Tuple[]): n
|
||||
return Math.min(...route.slice(1).map((end, index) => distanceToSegment(point, route[index], end)));
|
||||
}
|
||||
|
||||
function routeSpawns(
|
||||
source: DungeonDefinition,
|
||||
route: readonly Vector3Tuple[],
|
||||
width: number,
|
||||
): readonly StaticMobSpawnDefinition[] {
|
||||
return source.staticSpawns.filter((spawn) => (
|
||||
source.entities[spawn.entityId]?.kind !== "boss"
|
||||
&& distanceToRoute(spawn.position, route) <= width
|
||||
));
|
||||
}
|
||||
|
||||
function routeRoamingPacks(
|
||||
source: DungeonDefinition,
|
||||
route: readonly Vector3Tuple[],
|
||||
width: number,
|
||||
): readonly MobPackDefinition[] {
|
||||
return source.roamingPacks.flatMap((pack) => {
|
||||
const members = pack.members.filter((member) => source.entities[member.entityId]?.kind !== "boss");
|
||||
return members.length > 0 && pack.waypoints.every((point) => distanceToRoute(point, route) <= width * 1.7)
|
||||
? [{ ...pack, members }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function entitiesFor(
|
||||
source: DungeonDefinition,
|
||||
staticSpawns: readonly StaticMobSpawnDefinition[],
|
||||
@@ -120,9 +97,11 @@ function appendPrototypeWarning(source: DungeonDefinition, note: string): Dungeo
|
||||
};
|
||||
}
|
||||
|
||||
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition): DungeonDefinition {
|
||||
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
|
||||
if (source.id !== "wailing-caverns") throw new Error("The Wailing Caverns slice requires its source dungeon.");
|
||||
const verdan = bossByName(source, "Verdan the Everliving");
|
||||
const selected = seededSourceBoss(source, seed);
|
||||
const bossSpawn = finaleBossSpawn(source, selected, verdan.objective.position);
|
||||
const startPosition: Vector3Tuple = [82, -30.92, 8];
|
||||
// These points sit on the shipped Wailing Caverns navigation surface in Verdan's chamber.
|
||||
const staticSpawns: readonly StaticMobSpawnDefinition[] = [
|
||||
@@ -133,7 +112,7 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
|
||||
const verdanGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan`;
|
||||
const verdanGroup: DungeonEncounterSpawnGroupDefinition = {
|
||||
id: verdanGroupId,
|
||||
spawns: [verdan.spawn],
|
||||
spawns: [bossSpawn],
|
||||
};
|
||||
const encounter: DungeonEncounterDefinition = {
|
||||
id: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan-room`,
|
||||
@@ -145,11 +124,11 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
|
||||
fidelity: "healer-man-authored",
|
||||
scope: "boss-progression",
|
||||
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/dungeons/wailingCaverns.ts"],
|
||||
reviewedBehavior: "Clear three mobs in Verdan's chamber, then defeat Verdan the Everliving.",
|
||||
reviewedBehavior: "Clear three mobs in Verdan's chamber, then defeat one source Wailing Caverns boss selected by the run ID.",
|
||||
adaptations: [
|
||||
"The Fanglords, Naralex escort, and Mutanus event are outside this short run.",
|
||||
"The room mobs use source Wailing Caverns creatures at floor-checked chamber positions.",
|
||||
"Verdan is held until the room mobs are defeated.",
|
||||
"The selected boss is held at Verdan's finale anchor until the room mobs are defeated.",
|
||||
],
|
||||
remainingGaps: ["The room encounter still needs an in-game combat and completion pass."],
|
||||
},
|
||||
@@ -165,18 +144,19 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
|
||||
},
|
||||
{
|
||||
id: "verdan-room-boss-defeated",
|
||||
trigger: { kind: "entities-dead", entityIds: [verdan.spawn.id] },
|
||||
trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
|
||||
guards: [{ kind: "phase", phases: ["verdan"] }],
|
||||
actions: runCompletionActions("verdan-room", verdan.objective.id),
|
||||
actions: runCompletionActions("verdan-room", selected.objective.id),
|
||||
},
|
||||
],
|
||||
};
|
||||
const finalObjective: DungeonBossObjective = { ...verdan.objective, eventOnly: true };
|
||||
const finalObjective: DungeonBossObjective = { ...selected.objective, position: verdan.objective.position,
|
||||
optional: false, eventOnly: true };
|
||||
|
||||
return {
|
||||
...source,
|
||||
id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
|
||||
title: "Wailing Caverns: Verdan’s Lair",
|
||||
title: "Wailing Caverns: Everliving Chamber",
|
||||
lfgDungeonIds: [],
|
||||
entrance: {
|
||||
...source.entrance,
|
||||
@@ -189,7 +169,7 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
|
||||
...source.presentation,
|
||||
location: "Wailing Caverns · Verdan’s Chamber",
|
||||
theme: "Bite-Sized Dungeon",
|
||||
summary: "Clear the creatures in Verdan’s chamber, then defeat Verdan the Everliving.",
|
||||
summary: "Clear the chamber creatures, then defeat one surprise Wailing Caverns boss.",
|
||||
loadingMessage: "Entering Verdan’s chamber...",
|
||||
unchartedAreaName: "Crag of the Everliving",
|
||||
},
|
||||
@@ -212,76 +192,72 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
|
||||
};
|
||||
}
|
||||
|
||||
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): DungeonDefinition {
|
||||
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
|
||||
if (source.id !== "deadmines") throw new Error("The Deadmines slice requires its source dungeon.");
|
||||
const smite = bossByName(source, "Mr. Smite");
|
||||
const captain = bossByName(source, "Captain Greenskin");
|
||||
const finalBoss = bossByName(source, "Edwin VanCleef");
|
||||
const startPosition = smite.spawn.position;
|
||||
const selected = seededSourceBoss(source, seed);
|
||||
const startPosition = captain.spawn.position;
|
||||
const route: readonly Vector3Tuple[] = [
|
||||
startPosition,
|
||||
[-807, 30, -42],
|
||||
captain.objective.position,
|
||||
finalBoss.objective.position,
|
||||
];
|
||||
const routeTrash = routeSpawns(source, route, DEADMINES_ROUTE_WIDTH);
|
||||
const roamingPacks = routeRoamingPacks(source, route, DEADMINES_ROUTE_WIDTH);
|
||||
const vanCleefSpawn: StaticMobSpawnDefinition = {
|
||||
...finalBoss.spawn,
|
||||
position: finalBoss.objective.position,
|
||||
};
|
||||
const routeTrash = ["creature-79334", "creature-79335", "creature-79369", "creature-79370"].map((id) => {
|
||||
const spawn = source.staticSpawns.find((entry) => entry.id === id);
|
||||
if (!spawn || source.entities[spawn.entityId]?.kind !== "mob") {
|
||||
throw new Error(`The Deadmines final deck is missing source mob ${id}.`);
|
||||
}
|
||||
return spawn;
|
||||
});
|
||||
const bossSpawn = finaleBossSpawn(source, selected, finalBoss.objective.position);
|
||||
const vanCleefGroupId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:van-cleef`;
|
||||
const vanCleefGroup: DungeonEncounterSpawnGroupDefinition = {
|
||||
id: vanCleefGroupId,
|
||||
spawns: [vanCleefSpawn],
|
||||
spawns: [bossSpawn],
|
||||
};
|
||||
const encounterId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:ship-progression`;
|
||||
const encounter: DungeonEncounterDefinition = {
|
||||
id: encounterId,
|
||||
dungeonId: BITE_SIZED_DEADMINES_DUNGEON_ID,
|
||||
initialPhase: "greenskin",
|
||||
initialPhase: "ship-mobs",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
resetPolicy: { bossWipePhase: "greenskin", checkpointPhase: "greenskin" },
|
||||
resetPolicy: { bossWipePhase: "ship-mobs", checkpointPhase: "ship-mobs" },
|
||||
source: {
|
||||
fidelity: "healer-man-authored",
|
||||
scope: "boss-progression",
|
||||
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/generated/dungeonCampaignCatalog.compressed.ts"],
|
||||
reviewedBehavior: "Start on Mr. Smite's floor-snapped deck position, clear the Defias crew, defeat Captain Greenskin, then confront Edwin VanCleef in his cabin.",
|
||||
reviewedBehavior: "Start on Greenskin's deck, clear two Defias guard pairs, then confront one seeded Deadmines boss in VanCleef's cabin.",
|
||||
adaptations: [
|
||||
"The mine, foundry, Cookie branch, and other Deadmines bosses are omitted from this run.",
|
||||
"VanCleef is held in an encounter spawn group until Greenskin is defeated.",
|
||||
"Source boss IDs are retained for objective records and loot lookup.",
|
||||
"The mine, foundry, lower ship decks, and Cookie branch are omitted from this run.",
|
||||
"One boss from the complete Deadmines boss list appears in the cabin after the retained crew die.",
|
||||
"The selected source boss ID is retained for objective records and loot lookup.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"The ship route boundary and the optional Cookie branch need live traversal review; this first pass filters population by a spatial corridor.",
|
||||
"Original boss-specific scripted actions beyond their imported combat profiles are not included in this two-boss slice.",
|
||||
"The final-deck approach and party traversal still need a live combat pass.",
|
||||
"Original boss-specific scripted actions beyond their imported combat profiles are not included in this short slice.",
|
||||
],
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
id: "ship-greenskin-defeated",
|
||||
trigger: { kind: "entities-dead", entityIds: [captain.objective.id] },
|
||||
guards: [{ kind: "phase", phases: ["greenskin"] }],
|
||||
id: "ship-crew-defeated",
|
||||
trigger: { kind: "entities-dead", entityIds: routeTrash.map((spawn) => spawn.id) },
|
||||
guards: [{ kind: "phase", phases: ["ship-mobs"] }],
|
||||
actions: [
|
||||
{ id: "ship-complete-greenskin", kind: "complete-objective", objectiveId: captain.objective.id, persistence: "run" },
|
||||
{ id: "ship-checkpoint-greenskin", kind: "set-checkpoint", checkpointId: captain.objective.id, persistence: "checkpoint" },
|
||||
{ id: "ship-activate-vancleef", kind: "activate-spawn-group", spawnGroupId: vanCleefGroupId },
|
||||
{ id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "van-cleef" },
|
||||
{ id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "boss" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ship-vancleef-defeated",
|
||||
trigger: { kind: "entities-dead", entityIds: [vanCleefSpawn.id] },
|
||||
guards: [{ kind: "phase", phases: ["van-cleef"] }],
|
||||
actions: runCompletionActions("ship-complete-vancleef", finalBoss.objective.id),
|
||||
trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
|
||||
guards: [{ kind: "phase", phases: ["boss"] }],
|
||||
actions: runCompletionActions("ship-complete-vancleef", selected.objective.id),
|
||||
},
|
||||
],
|
||||
};
|
||||
const encounterGroups = [vanCleefGroup];
|
||||
const objectives: readonly DungeonBossObjective[] = [
|
||||
captain.objective,
|
||||
{ ...finalBoss.objective, eventOnly: true },
|
||||
];
|
||||
const objective: DungeonBossObjective = { ...selected.objective, position: finalBoss.objective.position,
|
||||
optional: false, eventOnly: true };
|
||||
return {
|
||||
...source,
|
||||
id: BITE_SIZED_DEADMINES_DUNGEON_ID,
|
||||
@@ -289,29 +265,28 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
|
||||
lfgDungeonIds: [],
|
||||
entrance: {
|
||||
...source.entrance,
|
||||
name: "Mr. Smite’s Deck",
|
||||
name: "Greenskin’s Deck",
|
||||
footPosition: startPosition,
|
||||
forward: [-0.5, 0, -0.866],
|
||||
yaw: -2.618,
|
||||
forward: [0, 0, -1],
|
||||
yaw: Math.PI,
|
||||
},
|
||||
presentation: {
|
||||
...source.presentation,
|
||||
location: "The Deadmines · The Defias Ship",
|
||||
theme: "Bite-Sized Dungeon",
|
||||
summary: "Board the Defias ship, push through the crew, defeat Captain Greenskin, and finish VanCleef in the cabin.",
|
||||
summary: "Clear two Defias guard pairs on the final deck, then face one surprise Deadmines boss in the cabin.",
|
||||
loadingMessage: "Boarding the Defias ship...",
|
||||
unchartedAreaName: "Defias Ship",
|
||||
},
|
||||
areas: [
|
||||
routeArea("entrance", "Mr. Smite’s Deck", startPosition, 25),
|
||||
routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 32),
|
||||
routeArea("entrance", "Greenskin’s Deck", startPosition, 32),
|
||||
routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28),
|
||||
],
|
||||
entities: entitiesFor(source, routeTrash, roamingPacks, encounterGroups, [captain.spawn.entityId]),
|
||||
staticSpawns: [...routeTrash, captain.spawn],
|
||||
roamingPacks,
|
||||
bosses: objectives,
|
||||
objectiveOrder: objectives,
|
||||
entities: entitiesFor(source, routeTrash, [], encounterGroups),
|
||||
staticSpawns: routeTrash,
|
||||
roamingPacks: [],
|
||||
bosses: [objective],
|
||||
objectiveOrder: [objective],
|
||||
finalBossDurability: "standard",
|
||||
encounters: [encounter],
|
||||
encounterSpawnGroups: encounterGroups,
|
||||
@@ -320,12 +295,12 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
|
||||
)),
|
||||
validation: appendPrototypeWarning(
|
||||
source,
|
||||
"The Deadmines ship route and two-boss progression are Healer Man-authored; source ship geometry, crew, Greenskin, and VanCleef assets are shared with The Deadmines.",
|
||||
"The Deadmines ship route and one-boss progression are Healer Man-authored; source ship geometry, crew, and boss assets are shared with The Deadmines.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition): DungeonDefinition {
|
||||
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
|
||||
if (source.id !== "utgarde-pinnacle") throw new Error("The Skadi slice requires Utgarde Pinnacle.");
|
||||
const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless");
|
||||
const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground");
|
||||
@@ -333,6 +308,7 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
|
||||
if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[7]) {
|
||||
throw new Error("Utgarde Pinnacle is missing the Skadi room content.");
|
||||
}
|
||||
const selected = seededSourceBoss(source, seed);
|
||||
// The source start object is across a collision gap from the Skadi corridor.
|
||||
const startPosition: Vector3Tuple = [-511, 104.8, 370];
|
||||
const bossPosition = firstWave.spawns[0].position;
|
||||
@@ -341,14 +317,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
|
||||
firstWave.spawns[6],
|
||||
firstWave.spawns[5],
|
||||
];
|
||||
const skadiSpawn: StaticMobSpawnDefinition = {
|
||||
...sourceSkadiGroup.spawns[0],
|
||||
id: skadi.id,
|
||||
position: bossPosition,
|
||||
};
|
||||
const bossSpawn = finaleBossSpawn(source, selected, bossPosition);
|
||||
const skadiGroup: DungeonEncounterSpawnGroupDefinition = {
|
||||
...sourceSkadiGroup,
|
||||
spawns: [skadiSpawn],
|
||||
spawns: [bossSpawn],
|
||||
};
|
||||
const encounter: DungeonEncounterDefinition = {
|
||||
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-room`,
|
||||
@@ -360,10 +332,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
|
||||
fidelity: "healer-man-authored",
|
||||
scope: "boss-progression",
|
||||
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/wowWrathEncounterBatchTwoCatalog.ts"],
|
||||
reviewedBehavior: "Clear three source wave guards along the walkable Skadi corridor, then confront Skadi at its end.",
|
||||
reviewedBehavior: "Clear three source wave guards along the walkable Skadi corridor, then confront one seeded Utgarde Pinnacle boss at its end.",
|
||||
adaptations: [
|
||||
"The start interaction, flying phase, harpoons, and long wave sequence are omitted from this short run.",
|
||||
"Skadi uses the source boss combat data and objective ID at a floor-snapped source wave position at the corridor's end.",
|
||||
"The selected source boss uses its combat data and objective ID at a floor-snapped source wave position at the corridor's end.",
|
||||
],
|
||||
remainingGaps: ["The shortened encounter still needs an in-game combat and completion pass."],
|
||||
},
|
||||
@@ -379,18 +351,19 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
|
||||
},
|
||||
{
|
||||
id: "skadi-room-boss-defeated",
|
||||
trigger: { kind: "entities-dead", entityIds: [skadiSpawn.id] },
|
||||
trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
|
||||
guards: [{ kind: "phase", phases: ["skadi"] }],
|
||||
actions: runCompletionActions("skadi-room", skadi.id),
|
||||
actions: runCompletionActions("skadi-room", selected.objective.id),
|
||||
},
|
||||
],
|
||||
};
|
||||
const objective: DungeonBossObjective = { ...skadi, position: bossPosition, eventOnly: true };
|
||||
const objective: DungeonBossObjective = { ...selected.objective, position: bossPosition,
|
||||
optional: false, eventOnly: true };
|
||||
|
||||
return {
|
||||
...source,
|
||||
id: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
|
||||
title: "Utgarde Pinnacle: Skadi’s Gauntlet",
|
||||
title: "Utgarde Pinnacle: Eagle’s Eye Gauntlet",
|
||||
lfgDungeonIds: [],
|
||||
entrance: {
|
||||
...source.entrance,
|
||||
@@ -403,7 +376,7 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
|
||||
...source.presentation,
|
||||
location: "Utgarde Pinnacle · Eagle’s Eye",
|
||||
theme: "Bite-Sized Dungeon",
|
||||
summary: "Defeat the three guards on Eagle’s Eye, then fight Skadi on the approach.",
|
||||
summary: "Defeat the three guards on Eagle’s Eye, then fight one surprise Utgarde Pinnacle boss.",
|
||||
loadingMessage: "Entering Skadi’s gauntlet...",
|
||||
unchartedAreaName: "Eagle’s Eye",
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DungeonDefinition } from "../dungeonTypes";
|
||||
import type { PopulationEntityDefinition } from "../mobPopulation";
|
||||
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../dungeonContentSource";
|
||||
import { createBiteSizedStockadesDefinition } from "./biteSizedStockades";
|
||||
import { seededBossIndex } from "./seededBossSelection";
|
||||
|
||||
export type RandomizedCreatureKind = "mob" | "boss";
|
||||
|
||||
@@ -114,11 +115,7 @@ function dealMobs(
|
||||
}
|
||||
|
||||
function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntityDefinition {
|
||||
// Boss source ids remain intact so their existing loot table and collection
|
||||
// card can still resolve. Trash ids are namespaced away from the location.
|
||||
const id = candidate.kind === "boss"
|
||||
? candidate.entityId
|
||||
: `randomized:${candidate.key}`;
|
||||
const id = `randomized:${candidate.key}`;
|
||||
const combat = candidate.definition.combat ? {
|
||||
...candidate.definition.combat,
|
||||
// The identity and attacks are randomized, while durability and pressure
|
||||
@@ -130,58 +127,59 @@ function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntity
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the reviewed Stockades geometry and navigation, then deals a different
|
||||
* cross-dungeon creature to every corridor slot and rolls the boss separately.
|
||||
* Keeps the reviewed Stockades geometry and navigation, then deals creatures
|
||||
* and a boss from the selected cross-dungeon pool.
|
||||
*/
|
||||
export function createRandomizedBiteSizedStockadesDefinition(
|
||||
source: DungeonDefinition,
|
||||
seed: string,
|
||||
catalog: readonly RandomizedBiteSizedCreature[],
|
||||
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
): DungeonDefinition {
|
||||
const base = createBiteSizedStockadesDefinition(source, `${seed}:objective-slot`);
|
||||
const bossSpawnId = base.bosses[0]?.id;
|
||||
if (!bossSpawnId) throw new Error("Randomized Bite-Sized Stockades has no final boss slot.");
|
||||
const bossSpawn = base.staticSpawns.find((spawn) => spawn.id === bossSpawnId);
|
||||
if (!bossSpawn) throw new Error("Randomized Bite-Sized Stockades has no final boss slot.");
|
||||
const trashSpawns = base.staticSpawns.filter((spawn) => spawn.id !== bossSpawnId);
|
||||
const mobPool = catalog.filter((candidate) => candidate.kind === "mob");
|
||||
if (mobPool.length < trashSpawns.length) {
|
||||
throw new Error(`Randomized Bite-Sized Stockades needs ${trashSpawns.length} distinct mob candidates.`);
|
||||
}
|
||||
const mobCandidates = dealMobs(source, trashSpawns, mobPool, `${seed}:trash`);
|
||||
const originalBossSpawn = source.staticSpawns.find((spawn) => spawn.id === bossSpawnId);
|
||||
const originalBoss = originalBossSpawn ? source.entities[originalBossSpawn.entityId] : undefined;
|
||||
const originalBossKey = originalBoss ? creatureKey(source, originalBoss) : null;
|
||||
const bossCandidate = seededShuffle(
|
||||
catalog.filter((candidate) => candidate.kind === "boss"),
|
||||
`${seed}:boss`,
|
||||
).find((candidate) => candidate.key !== originalBossKey);
|
||||
if (!bossCandidate) throw new Error("Randomized Bite-Sized Stockades has no boss candidates.");
|
||||
|
||||
const bossPool = catalog.filter((candidate) => candidate.kind === "boss");
|
||||
if (!bossPool.length) throw new Error("Randomized Bite-Sized Stockades needs a boss candidate.");
|
||||
const bossCandidate = bossPool[seededBossIndex(`${seed}:boss`, bossPool.length)];
|
||||
const selectedBoss = { ...runtimeEntity(bossCandidate), id: bossCandidate.entityId };
|
||||
const selectedBossId = `randomized:${bossCandidate.key}`;
|
||||
const selectedMobs = new Map(trashSpawns.map((spawn, index) => [spawn.id, mobCandidates[index]]));
|
||||
const bossEntity = runtimeEntity(bossCandidate);
|
||||
const randomizedSpawns = base.staticSpawns.map((spawn) => {
|
||||
const candidate = selectedMobs.get(spawn.id);
|
||||
return { ...spawn, entityId: candidate ? runtimeEntity(candidate).id : bossEntity.id };
|
||||
return candidate ? { ...spawn, entityId: runtimeEntity(candidate).id }
|
||||
: spawn.id === bossSpawnId ? { ...spawn, id: selectedBossId, entityId: selectedBoss.id } : spawn;
|
||||
});
|
||||
const selectedCandidates = [...selectedMobs.values(), bossCandidate];
|
||||
const entities = Object.fromEntries(selectedCandidates.map((candidate) => {
|
||||
const randomizedEntities = Object.fromEntries([...selectedMobs.values()].map((candidate) => {
|
||||
const entity = runtimeEntity(candidate);
|
||||
return [entity.id, entity];
|
||||
})) as Readonly<Record<string, PopulationEntityDefinition>>;
|
||||
const boss = { ...base.bosses[0], name: bossCandidate.name };
|
||||
const entities = { ...randomizedEntities, [selectedBoss.id]: selectedBoss };
|
||||
const boss = { ...base.bosses[0], id: selectedBossId, name: selectedBoss.name };
|
||||
|
||||
return {
|
||||
...base,
|
||||
id: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
title: "Randomized Bite-Sized: Cellblock Shuffle",
|
||||
id: dungeonId,
|
||||
title: dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
|
||||
? "Randomized Bite-Sized: All Available"
|
||||
: "Randomized Bite-Sized: Bite-Sized Sources",
|
||||
presentation: {
|
||||
...base.presentation,
|
||||
location: "Stormwind Stockade · Shifting Cellblock",
|
||||
theme: "Randomized Bite-Sized",
|
||||
summary: "Every enemy slot is dealt a distinct cross-dungeon creature, followed by an independently randomized boss.",
|
||||
summary: "Every corridor slot and the final boss are selected from the chosen cross-dungeon creature pool.",
|
||||
loadingMessage: "Choosing a location and shuffling every creature slot...",
|
||||
},
|
||||
areas: base.areas.map((area) => area.id === "bite-sized-boss-room"
|
||||
? { ...area, name: `${bossCandidate.name}'s Cell` }
|
||||
? { ...area, name: `${boss.name}'s Cell` }
|
||||
: area),
|
||||
entities,
|
||||
staticSpawns: randomizedSpawns,
|
||||
@@ -189,16 +187,26 @@ export function createRandomizedBiteSizedStockadesDefinition(
|
||||
objectiveOrder: [boss],
|
||||
encounters: (base.encounters ?? []).map((encounter) => ({
|
||||
...encounter,
|
||||
id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`,
|
||||
dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
id: `${dungeonId}:boss-progression`,
|
||||
dungeonId,
|
||||
rules: encounter.rules.map((rule) => ({
|
||||
...rule,
|
||||
trigger: rule.trigger.kind === "entities-dead"
|
||||
? { ...rule.trigger, entityIds: [selectedBossId] } : rule.trigger,
|
||||
guards: rule.guards?.map((guard) => guard.kind === "objective-complete"
|
||||
? { ...guard, objectiveId: selectedBossId } : guard),
|
||||
actions: rule.actions.map((action) => action.kind === "complete-objective"
|
||||
? { ...action, objectiveId: selectedBossId }
|
||||
: action.kind === "set-checkpoint" ? { ...action, checkpointId: selectedBossId } : action),
|
||||
})),
|
||||
source: encounter.source ? {
|
||||
...encounter.source,
|
||||
files: ["src/game/dungeons/randomizedBiteSizedStockades.ts"],
|
||||
reviewedBehavior: "Every corridor slot receives a distinct seeded creature and the final boss uses an independent seeded roll.",
|
||||
reviewedBehavior: "Every corridor slot and the final boss receive seeded creatures from the selected cross-dungeon pool.",
|
||||
adaptations: [
|
||||
...encounter.source.adaptations,
|
||||
"The trash shuffle deals without replacement from the complete model-backed cross-dungeon mob catalog, so one run cannot repeat a mob identity.",
|
||||
"The final boss is rolled independently from the complete model-backed cross-dungeon boss catalog while retaining the location's stable completion objective.",
|
||||
"The final boss uses a namespaced creature key as its objective and leaderboard identity, with source combat and visuals.",
|
||||
"Randomized combat multipliers are normalized before party-level scaling so source-dungeon tuning cannot overwhelm a short leveling run.",
|
||||
],
|
||||
} : encounter.source,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** A run id selects one stable boss for saves and every member of a shared party. */
|
||||
export function seededBossIndex(seed: string, candidateCount: number): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash ^= seed.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0) % candidateCount;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { DungeonBossObjective, DungeonDefinition } from "../dungeonTypes";
|
||||
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "../mobPopulation";
|
||||
import { seededBossIndex } from "./seededBossSelection";
|
||||
|
||||
export interface SourceBossCandidate {
|
||||
readonly objective: DungeonBossObjective;
|
||||
readonly spawn: StaticMobSpawnDefinition;
|
||||
readonly entity: PopulationEntityDefinition;
|
||||
}
|
||||
|
||||
function normalizedName(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
/** Resolves ordinary, event, and roaming source bosses into a reusable one-boss spawn. */
|
||||
export function sourceBossCandidates(source: DungeonDefinition): readonly SourceBossCandidate[] {
|
||||
return source.bosses.map((objective) => {
|
||||
const namedBoss = (entityId: string) => {
|
||||
const entity = source.entities[entityId];
|
||||
return entity?.kind === "boss" && normalizedName(entity.name) === normalizedName(objective.name);
|
||||
};
|
||||
const staticSpawn = source.staticSpawns.find((spawn) => spawn.id === objective.id
|
||||
&& source.entities[spawn.entityId]?.kind === "boss");
|
||||
const groupSpawn = source.encounterSpawnGroups?.flatMap((group) => group.spawns)
|
||||
.filter((spawn) => namedBoss(spawn.entityId)).at(-1);
|
||||
const roamingMember = source.roamingPacks.flatMap((pack) => pack.members)
|
||||
.find((member) => namedBoss(member.entityId));
|
||||
const spawn = staticSpawn ?? groupSpawn ?? (roamingMember ? {
|
||||
id: objective.id, entityId: roamingMember.entityId, position: objective.position, yaw: 0,
|
||||
} : undefined);
|
||||
const entity = spawn && source.entities[spawn.entityId];
|
||||
if (!spawn || !entity || entity.kind !== "boss") {
|
||||
throw new Error(`${source.id} is missing a usable source spawn for ${objective.name}.`);
|
||||
}
|
||||
return { objective, spawn, entity };
|
||||
});
|
||||
}
|
||||
|
||||
export function seededSourceBoss(source: DungeonDefinition, seed: string): SourceBossCandidate {
|
||||
const candidates = sourceBossCandidates(source);
|
||||
if (!candidates.length) throw new Error(`${source.id} has no source bosses to select.`);
|
||||
return candidates[seededBossIndex(seed, candidates.length)];
|
||||
}
|
||||
|
||||
export function finaleBossSpawn(source: DungeonDefinition, candidate: SourceBossCandidate,
|
||||
position: DungeonBossObjective["position"]): StaticMobSpawnDefinition {
|
||||
return { ...candidate.spawn, id: candidate.objective.id, position,
|
||||
spawnMask: source.difficultyVariants.reduce((mask, difficulty) => mask | difficulty.spawnMask, 0) };
|
||||
}
|
||||
Reference in new issue
Block a user