Files
healer-man/src/game/dungeons/biteSizedStockades.ts
2026-10-06 22:25:34 -04:00

187 lines
7.6 KiB
TypeScript

import type { DungeonDefinition, DungeonBossObjective } from "../dungeonTypes";
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "../mobPopulation";
import type { DungeonWorldObjectDefinition } from "../dungeonWorldObjects";
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;
export interface BiteSizedStockadesBossCandidate {
readonly sourceSpawnId: string;
readonly name: string;
}
export const BITE_SIZED_STOCKADES_BOSS_CANDIDATES: readonly BiteSizedStockadesBossCandidate[] = Object.freeze([
{ sourceSpawnId: "creature-84027", name: "Targorr the Dread" },
{ sourceSpawnId: "creature-79088", name: "Kam Deepfury" },
{ sourceSpawnId: "creature-79098", name: "Hamhock" },
{ sourceSpawnId: "creature-79124", name: "Bazil Thredd" },
{ sourceSpawnId: "creature-79116", name: "Dextren Ward" },
]);
export function biteSizedStockadesBossIndex(seed: string): number {
return seededBossIndex(seed, BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length);
}
function branchWall(id: string, name: string, z: number): DungeonWorldObjectDefinition {
return {
id,
name,
kind: "gate",
position: [-129.17, -22.1, z],
halfExtents: [6, 5.2, 0.65],
initialState: "sealed",
states: [{
id: "sealed",
label: "Sealed",
visible: true,
blocksMovement: true,
blocksSight: true,
interactionEnabled: false,
}],
visual: { color: "#332f2b" },
source: {
fidelity: "healer-man-authored",
files: ["src/game/dungeons/biteSizedStockades.ts"],
note: "Permanent wall for the bite-sized route; the source Stockades geometry remains unchanged.",
},
};
}
function sourceBoss(
source: DungeonDefinition,
candidate: BiteSizedStockadesBossCandidate,
): { readonly spawn: StaticMobSpawnDefinition; readonly entity: PopulationEntityDefinition } {
const spawn = source.staticSpawns.find((entry) => entry.id === candidate.sourceSpawnId);
const entity = spawn ? source.entities[spawn.entityId] : undefined;
if (!spawn || !entity || entity.kind !== "boss") {
throw new Error(`Stockades boss source is missing: ${candidate.sourceSpawnId}`);
}
return { spawn, entity };
}
/**
* Builds a one-boss Stockades variant without mutating the generated source definition.
* The seed controls only boss identity, so saves and online party members can reconstruct
* the same run while a fresh run receives a new random selection.
*/
export function createBiteSizedStockadesDefinition(
source: DungeonDefinition,
seed: string,
): DungeonDefinition {
if (source.id !== STORMWIND_STOCKADE_DUNGEON_ID) {
throw new Error(`Bite-sized Stockades requires ${STORMWIND_STOCKADE_DUNGEON_ID}.`);
}
const candidate = BITE_SIZED_STOCKADES_BOSS_CANDIDATES[biteSizedStockadesBossIndex(seed)];
const selected = sourceBoss(source, candidate);
const sourceBossIds = new Set(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((entry) => entry.sourceSpawnId));
const corridorSpawns = source.staticSpawns.filter((spawn) => (
!sourceBossIds.has(spawn.id)
&& Math.abs(spawn.position[2]) <= CENTRAL_CORRIDOR_HALF_WIDTH
));
const usedEntityIds = new Set(corridorSpawns.map((spawn) => spawn.entityId));
const entities = Object.fromEntries([
...Object.entries(source.entities).filter(([entityId]) => usedEntityIds.has(entityId)),
[selected.spawn.entityId, selected.entity],
]);
const boss: DungeonBossObjective = {
id: candidate.sourceSpawnId,
name: candidate.name,
position: BOSS_ROOM_POSITION,
};
return {
...source,
id: BITE_SIZED_STOCKADES_DUNGEON_ID,
title: "Stormwind Stockade: Cellblock Rush",
lfgDungeonIds: [],
presentation: {
...source.presentation,
location: "Stormwind Stockade · Central Cellblock",
theme: "Bite-Sized Dungeon",
summary: "Race through the central cellblock and defeat one surprise warden chosen from the five Stockades bosses.",
loadingMessage: "Sealing the side wings and choosing a cellblock warden...",
unchartedAreaName: "Central Cellblock",
},
areas: [
{ ...source.entrance, id: "entrance", center: source.entrance.footPosition, radius: 35 },
{ id: "bite-sized-boss-room", name: `${candidate.name}'s Cell`, center: BOSS_ROOM_POSITION, radius: 32 },
],
entities,
staticSpawns: [
...corridorSpawns,
{
id: candidate.sourceSpawnId,
entityId: selected.spawn.entityId,
sourceCreatureGuid: selected.spawn.sourceCreatureGuid,
position: BOSS_ROOM_POSITION,
yaw: selected.spawn.yaw,
spawnMask: selected.spawn.spawnMask,
},
],
roamingPacks: [],
bosses: [boss],
objectiveOrder: [boss],
finalBossDurability: "standard",
// Keep the complete reviewed Stockades navigation graph connected. The
// physical branch walls, not navmesh pruning, close the unused wings.
navigationLinks: source.navigationLinks,
traversalRamps: source.traversalRamps,
encounters: [{
id: `${BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`,
dungeonId: BITE_SIZED_STOCKADES_DUNGEON_ID,
initialPhase: "progression",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "progression", checkpointPhase: "progression" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedStockades.ts"],
reviewedBehavior: "A single seeded Stockades boss completes the bite-sized run in the first boss room.",
adaptations: [
"Each seeded run retains the selected source boss objective ID so saves, loot, guides, and leaderboards report the correct variant.",
"The one-boss run uses standard boss durability instead of the campaign-finale health floor.",
"The side-wing stairs retain their navigation bridges so the central route stays on the retained navmesh component; physical walls seal both unused wings.",
],
remainingGaps: [],
},
rules: [
{
id: "boss-defeated",
trigger: { kind: "entities-dead", entityIds: [candidate.sourceSpawnId] },
actions: [
{ id: "record-boss", kind: "complete-objective", objectiveId: candidate.sourceSpawnId, persistence: "run" },
{ id: "checkpoint-boss", kind: "set-checkpoint", checkpointId: candidate.sourceSpawnId, persistence: "checkpoint" },
],
},
{
id: "complete-run",
trigger: { kind: "tick" },
guards: [{ kind: "objective-complete", objectiveId: candidate.sourceSpawnId, equals: true }],
actions: [
{ id: "mark-dungeon-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "enter-complete-phase", kind: "set-phase", phase: "complete", persistence: "run" },
],
},
],
}],
encounterSpawnGroups: [],
serverGameObjects: [
...source.serverGameObjects.filter((object) => Math.abs(object.position[2]) <= CENTRAL_CORRIDOR_HALF_WIDTH),
branchWall("bite-sized-stockades-north-wall", "Sealed North Wing", 16.4),
branchWall("bite-sized-stockades-south-wall", "Sealed South Wing", -16.4),
],
validation: {
...source.validation,
warnings: [
...source.validation.warnings,
"Bite-sized layout and branch walls are Healer Man-authored; environment and combat assets are shared with Stormwind Stockade.",
],
},
};
}