Files
healer-man/src/game/bossTesting.ts
T
2026-09-16 14:30:58 -04:00

135 lines
7.7 KiB
TypeScript

import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
import type { StaticMobSpawnDefinition } from "./mobPopulation";
import type { BossTestSession } from "./store";
export interface TestableBoss {
readonly key: string;
readonly name: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly entityId: string;
readonly category: "wow" | "rom";
readonly spawn: StaticMobSpawnDefinition;
}
export function buildBossTestingCatalog(definitions: readonly DungeonDefinition[]): readonly TestableBoss[] {
return definitions.flatMap((dungeon) => Object.values(dungeon.entities).flatMap((entity): TestableBoss[] => {
if (entity.kind !== "boss") return [];
const spawn = dungeon.staticSpawns.find((candidate) => candidate.entityId === entity.id)
?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((candidate) => candidate.entityId === entity.id);
const objective = dungeon.bosses.find((boss) => boss.id === spawn?.id || boss.name === entity.name);
const pack = dungeon.roamingPacks.find((candidate) => candidate.members.some((member) => member.entityId === entity.id));
const member = pack?.members.find((candidate) => candidate.entityId === entity.id);
const position = spawn?.position ?? objective?.position ?? pack?.waypoints[0];
if (!position) return [];
return [{
key: `${dungeon.id}/${entity.id}`, name: entity.name, dungeonId: dungeon.id,
dungeonTitle: dungeon.title, entityId: entity.id,
category: dungeon.expansion === "runewaker" ? "rom" : "wow",
spawn: { ...spawn, id: spawn?.id ?? (pack && member ? `${pack.id}:${member.id}` : objective?.id ?? entity.id),
entityId: entity.id, position, spawnChance: 1 },
}];
})).sort((a, b) => a.name.localeCompare(b.name) || a.dungeonTitle.localeCompare(b.dungeonTitle));
}
export const BOSS_TESTING_CATALOG = buildBossTestingCatalog(DUNGEON_DEFINITIONS);
export function randomTestingBoss(bosses: readonly TestableBoss[], random: () => number = Math.random): TestableBoss | null {
if (!bosses.length) return null;
const draw = random();
const index = Math.floor((Number.isFinite(draw) ? Math.max(0, Math.min(0.999999999, draw)) : 0) * bosses.length);
return bosses[index];
}
export function anotherRandomTestingBoss(
bosses: readonly TestableBoss[],
currentKey: string,
random: () => number = Math.random,
): TestableBoss | null {
return randomTestingBoss(bosses.filter((boss) => boss.key !== currentKey), random);
}
function references(value: unknown, ids: ReadonlySet<string>): boolean {
if (typeof value === "string") return ids.has(value);
if (Array.isArray(value)) return value.some((entry) => references(entry, ids));
return Boolean(value && typeof value === "object" && Object.values(value).some((entry) => references(entry, ids)));
}
function remapEncounterReferences<T>(value: T, ids: ReadonlyMap<string, string>): T {
if (typeof value === "string") return (ids.get(value) ?? value) as T;
if (Array.isArray(value)) return value.map((entry) => remapEncounterReferences(entry, ids)) as T;
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
.map(([key, entry]) => [key, remapEncounterReferences(entry, ids)])) as T;
return value;
}
/** Keep original mechanic coordinates and spawn IDs while replacing the environment with a clear floor. */
export function createBossTestingRoom(source: DungeonDefinition, boss: TestableBoss): {
readonly definition: DungeonDefinition;
readonly session: BossTestSession;
} {
const entity = source.entities[boss.entityId];
if (source.id !== boss.dungeonId || entity?.kind !== "boss") throw new Error("That boss is unavailable.");
const id = `boss-testing:${source.id}:${boss.entityId}`;
const ids = new Set([boss.entityId, boss.spawn.id]);
const sourceEncounters = (source.encounters ?? []).filter((encounter) =>
references(encounter.rules.map((rule) => rule.trigger), ids)
|| references(entity.combat, new Set([encounter.id]))
);
const encounterIds = new Map(sourceEncounters.map((encounter) => [encounter.id, `${id}:${encounter.id}`]));
const encounters = sourceEncounters.map((encounter) => {
const isolated = remapEncounterReferences(encounter, encounterIds);
return { ...isolated, id: encounterIds.get(encounter.id)!, dungeonId: id,
rules: isolated.rules.map((rule) => ({ ...rule, actions: rule.actions.filter((action) =>
action.kind !== "grant-loot" && action.kind !== "set-checkpoint"
) })),
};
});
const groups = (source.encounterSpawnGroups ?? []).filter((group) => references(encounters, new Set([group.id])));
const objects = source.serverGameObjects.filter((object) => references(encounters, new Set([object.id, object.interaction?.signalId ?? ""])));
const entityIds = new Set([boss.entityId, ...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId))]);
// Summoned creature definitions are needed even though they are absent from the initial room.
for (const entityId of entityIds) {
for (const attack of source.entities[entityId]?.combat?.attacks ?? []) {
const effects = attack.effect?.kind === "composite"
? attack.effect.effects
: attack.effect ? [attack.effect] : [];
for (const effect of effects) {
if (effect.kind !== "summon") continue;
for (const candidate of Object.values(source.entities)) {
if (candidate.id === effect.entityId || candidate.combat?.serverEntry === effect.creatureEntry) entityIds.add(candidate.id);
}
}
}
}
const center = boss.spawn.position;
const radius = Math.max(40, ...groups.flatMap((group) => group.spawns.map((spawn) =>
Math.hypot(spawn.position[0] - center[0], spawn.position[2] - center[2]) + 8
)), ...objects.map((object) => Math.hypot(object.position[0] - center[0], object.position[2] - center[2]) + 8));
const spawnDistance = Math.max(14, (entity.combat?.aggroRange ?? 12) + 5);
const entrance: Vector3Tuple = [center[0], center[1], center[2] + Math.min(radius - 5, spawnDistance)];
const spawn = { ...boss.spawn, spawnMask: undefined, spawnChance: 1 };
const objective = { id: spawn.id, name: boss.name, position: center };
return {
session: { key: boss.key, name: boss.name, sourceDungeonId: source.id, entityId: boss.entityId, runtimeIds: [spawn.id] },
definition: {
...source, id, title: `${boss.name} — Boss Testing`, availability: "available",
assets: { visual: [], collision: [] },
bounds: { min: [center[0] - radius, center[1] - 2, center[2] - radius], max: [center[0] + radius, center[1] + 24, center[2] + radius] },
entrance: { name: "Testing room", footPosition: entrance, yaw: Math.PI, forward: [0, 0, -1] },
areas: [{ id: "testing-room", name: "Boss Testing", center, radius: radius * 2 }],
entities: Object.fromEntries([...entityIds].filter((entityId) => source.entities[entityId])
.map((entityId) => [entityId, remapEncounterReferences(source.entities[entityId], encounterIds)])),
staticSpawns: [spawn], roamingPacks: [], bosses: [objective], objectiveOrder: [objective],
navigationLinks: [], traversalRamps: [], encounters, encounterSpawnGroups: groups, serverGameObjects: objects,
presentation: { ...source.presentation, location: "Boss Testing", summary: "An isolated room for testing boss mechanics.",
loadingMessage: `Preparing ${boss.name}…`, background: "#111923",
fog: { color: "#111923", near: radius, far: radius * 3 },
ambientLight: { color: "#ffffff", intensity: 1.2 },
hemisphereLight: { skyColor: "#c8ddf0", groundColor: "#444c57", intensity: 1.4 },
map: { width: 600, height: 600, padding: 20, contours: [] } },
},
};
}