Release Healer Man 0.1.39
This commit is contained in:
1 parent
b417b7c3c3
commit
ed0d2fea3d
51 files changed
+17811
-49
No files matched your search
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { activeDungeonCompletedObjectiveIds, subscribeDungeonEncounterSnapshots } from "./dungeonEncounterRuntime";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import type { DungeonRunMember } from "./dungeonRunTypes";
|
||||
|
||||
function currentRunParty(): readonly DungeonRunMember[] {
|
||||
const shell = useShellStore.getState();
|
||||
const character = shell.activeCharacter;
|
||||
if (!character) return [];
|
||||
const members: DungeonRunMember[] = [{ name: character.name, classId: character.classId,
|
||||
level: useCombatStore.getState().level, role: shell.activeDungeonRole, npc: false,
|
||||
...(character.secondaryClassId ? { secondaryClassId: character.secondaryClassId } : {}) }];
|
||||
const online = useOnlineSessionStore.getState().snapshot;
|
||||
for (const player of online?.players ?? []) {
|
||||
if (player.accountId === shell.session?.ownerId || !player.character) continue;
|
||||
members.push({ name: player.character.name, classId: player.character.classId,
|
||||
level: player.character.level, role: player.role ?? "damage", npc: false });
|
||||
}
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
members.push({ name: member.name, classId: member.classId, level: member.level, role: member.role, npc: true });
|
||||
}
|
||||
return members.slice(0, 5);
|
||||
}
|
||||
|
||||
/** Wall-clock run timing survives wipes, pauses, checkpoints, and renderer retries. */
|
||||
export function DungeonRunBridge() {
|
||||
useEffect(() => {
|
||||
let announcedId: string | null = null;
|
||||
const advance = () => {
|
||||
const game = useGameStore.getState();
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode
|
||||
|| !["ready", "proxy"].includes(game.assetStatus)) return;
|
||||
const run = useDungeonRunStore.getState();
|
||||
if (run.progress?.dungeonId !== game.activeDungeonId) return;
|
||||
const party = currentRunParty();
|
||||
const activity = useOnlineGroupStore.getState().activeActivity;
|
||||
if (activity?.type === "dungeon" && activity.contentId === game.activeDungeonId
|
||||
&& run.progress.finishedAt === null && run.progress.id !== activity.id) {
|
||||
useDungeonRunStore.setState({ progress: { ...run.progress, id: activity.id,
|
||||
startedAt: activity.startedAt, party } });
|
||||
}
|
||||
run.start(Date.now(), party);
|
||||
run.advance(activeDungeonCompletedObjectiveIds(game.activeDungeonId),
|
||||
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
|
||||
Date.now(), party);
|
||||
const progress = useDungeonRunStore.getState().progress;
|
||||
if (progress?.finishedAt !== null && progress && announcedId !== progress.id) {
|
||||
announcedId = progress.id;
|
||||
useGameStore.getState().openOverlay("dungeon-results");
|
||||
}
|
||||
};
|
||||
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(advance);
|
||||
const unsubscribeGame = useGameStore.subscribe(advance);
|
||||
const interval = setInterval(advance, 250);
|
||||
advance();
|
||||
return () => { unsubscribeEncounters(); unsubscribeGame(); clearInterval(interval); };
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint";
|
||||
import { useGameStore } from "./store";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
|
||||
function checkpointSignature(dungeonId: string): string {
|
||||
const game = useGameStore.getState();
|
||||
@@ -15,6 +16,7 @@ function checkpointSignature(dungeonId: string): string {
|
||||
difficultyId: game.activeDifficultyId,
|
||||
spawn: game.activeSpawn,
|
||||
encounters,
|
||||
run: useDungeonRunStore.getState().progress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,10 +43,13 @@ export function DungeonRunCheckpointBridge() {
|
||||
spawn: game.activeSpawn,
|
||||
encounters,
|
||||
savedAt: Date.now(),
|
||||
run: useDungeonRunStore.getState().progress ?? undefined,
|
||||
});
|
||||
};
|
||||
save();
|
||||
return subscribeDungeonEncounterSnapshots(save);
|
||||
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(save);
|
||||
const unsubscribeRun = useDungeonRunStore.subscribe(save);
|
||||
return () => { unsubscribeEncounters(); unsubscribeRun(); };
|
||||
}, [activeCharacterId, phase]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -3589,11 +3589,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
0,
|
||||
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
|
||||
);
|
||||
const attackDamage = existing && registration.damageMultiplier !== undefined
|
||||
const attackDamage = existing && level === existing.level && registration.damageMultiplier !== undefined
|
||||
? Math.max(1, Math.round(
|
||||
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
|
||||
))
|
||||
: existing?.attackDamage ?? Math.max(1, Math.round(
|
||||
: (existing && level === existing.level ? existing.attackDamage : undefined) ?? Math.max(1, Math.round(
|
||||
defaults.attackDamage * damageMultiplier,
|
||||
));
|
||||
const mob: MobCombatState = existing
|
||||
@@ -3621,7 +3621,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
leashRange,
|
||||
moveSpeed,
|
||||
attackDamage,
|
||||
health: Math.min(existing.health, maxHealth),
|
||||
health: existing.dead || existing.health <= 0
|
||||
? 0
|
||||
: clamp(Math.round(existing.health / Math.max(1, existing.maxHealth) * maxHealth), 1, maxHealth),
|
||||
xpReward,
|
||||
hasLoot,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../app/characterCatalog";
|
||||
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
|
||||
import { dungeonBossesForDifficulty, finalDungeonBoss } from "./dungeonRunStore";
|
||||
|
||||
describe("dungeon leaderboard catalog", () => {
|
||||
it("keeps the server's final bosses, boss lists, difficulties, and classes aligned with the game", () => {
|
||||
const catalog = {
|
||||
dungeons: DUNGEON_DEFINITIONS.map((dungeon) => ({ id: dungeon.id, title: dungeon.title,
|
||||
finalBossId: finalDungeonBoss(dungeon)?.id ?? "",
|
||||
bosses: dungeon.bosses.map((boss) => ({ id: boss.id, name: boss.name })),
|
||||
difficulties: dungeon.difficultyVariants.map((difficulty) => ({ id: difficulty.id, name: difficulty.difficulty,
|
||||
bossIds: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
|
||||
finalBossId: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "" })),
|
||||
defaultDifficultyId: dungeon.defaultDifficultyId,
|
||||
})).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
|
||||
};
|
||||
expect(catalog.dungeons).toHaveLength(84);
|
||||
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
|
||||
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(catalog);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||
|
||||
describe("normal dungeon level scaling", () => {
|
||||
it("scales opening-tier trash to the character and gives bosses a one-level edge", () => {
|
||||
@@ -13,4 +13,19 @@ describe("normal dungeon level scaling", () => {
|
||||
expect(scaledDungeonEnemyLevel(80, false)).toBe(80);
|
||||
expect(scaledDungeonEnemyLevel(80, true)).toBe(80);
|
||||
});
|
||||
|
||||
it("grows RoM health with ability ranks and clamps invalid levels", () => {
|
||||
expect(dungeonEnemyHealthBaseline(1, "runewaker")).toBeGreaterThan(1_000);
|
||||
expect(dungeonEnemyHealthBaseline(20, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(8, "runewaker")!);
|
||||
expect(dungeonEnemyHealthBaseline(70, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(20, "runewaker")! * 5);
|
||||
expect(dungeonEnemyHealthBaseline(0, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker"));
|
||||
expect(dungeonEnemyHealthBaseline(100, "runewaker")).toBe(dungeonEnemyHealthBaseline(80, "runewaker"));
|
||||
expect(dungeonEnemyHealthBaseline(Number.NaN, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker"));
|
||||
});
|
||||
|
||||
it("retains native WoW creature health across its three expansions", () => {
|
||||
for (const expansion of ["classic", "burning-crusade", "wrath"] as const) {
|
||||
expect(dungeonEnemyHealthBaseline(20, expansion)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clampPlayerLevel } from "./progression";
|
||||
import type { DungeonExpansion } from "./dungeonTypes";
|
||||
|
||||
/**
|
||||
* Normal campaign dungeons scale to the character instead of retaining the
|
||||
@@ -8,3 +9,15 @@ import { clampPlayerLevel } from "./progression";
|
||||
export function scaledDungeonEnemyLevel(characterLevel: number, boss: boolean): number {
|
||||
return clampPlayerLevel(characterLevel + (boss ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* RoM creatures do not have packaged native health tables. Give their dungeon
|
||||
* baseline enough growth for a full party's rank-scaled abilities; the old
|
||||
* 72 + level * 22 solo-target fallback was overwhelmed by the opening volley.
|
||||
* Authored creature and boss health multipliers are applied separately.
|
||||
*/
|
||||
export function dungeonEnemyHealthBaseline(level: number, expansion: DungeonExpansion): number | undefined {
|
||||
if (expansion !== "runewaker") return undefined;
|
||||
const safeLevel = clampPlayerLevel(level);
|
||||
return 1_000 + safeLevel * 100 + safeLevel * safeLevel * 8;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
|
||||
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
|
||||
import { withRunewakerCompletion } from "./runewakerCompletion";
|
||||
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
||||
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
|
||||
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
|
||||
@@ -19,6 +20,7 @@ import { withWowVerifiedNoOpInstanceContent } from "./wowVerifiedNoOpInstanceCat
|
||||
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
|
||||
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
|
||||
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
|
||||
import { withWowDungeonEnvironment } from "./wowDungeonEnvironment";
|
||||
|
||||
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
|
||||
|
||||
@@ -151,6 +153,7 @@ if (authoredWailingCaverns.id !== DEFAULT_DUNGEON_KEY) {
|
||||
const DUNGEON_CONTENT_ADAPTERS: readonly (
|
||||
(definition: DungeonDefinition) => DungeonDefinition
|
||||
)[] = Object.freeze([
|
||||
withWowDungeonEnvironment,
|
||||
withWowEncounterContent,
|
||||
withWowClassicEncounterContent,
|
||||
withWowClassicEncounterBatchTwo,
|
||||
@@ -164,6 +167,7 @@ const DUNGEON_CONTENT_ADAPTERS: readonly (
|
||||
withWowTbcEncounterBatchTwoContent,
|
||||
withAscensionCustomEncounterContent,
|
||||
withRunewakerEncounterContent,
|
||||
withRunewakerCompletion,
|
||||
]);
|
||||
|
||||
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
|
||||
|
||||
@@ -14,6 +14,14 @@ const definition: DungeonEncounterDefinition = {
|
||||
};
|
||||
|
||||
describe("dungeon run checkpoints", () => {
|
||||
it("round-trips a saved timer and rejects a timer belonging to another dungeon", () => {
|
||||
const input = { version: DUNGEON_RUN_CHECKPOINT_VERSION, dungeonId: "test", difficultyId: "normal",
|
||||
checkpointId: "entrance", spawn: { footPosition: [1, 2, 3], yaw: 0.5 }, encounters: [], savedAt: 123,
|
||||
run: { id: "checkpoint-timer", dungeonId: "test", difficultyId: "normal", startedAt: 100,
|
||||
finishedAt: null, durationMs: null, timed: true, killedBossIds: [], party: [] } };
|
||||
expect(normalizeDungeonRunCheckpoint(JSON.parse(JSON.stringify(input)))?.run).toEqual(input.run);
|
||||
expect(normalizeDungeonRunCheckpoint({ ...input, run: { ...input.run, dungeonId: "other" } })).toBeNull();
|
||||
});
|
||||
it("normalizes JSON-safe encounter progress and world spawn", () => {
|
||||
const result = normalizeDungeonRunCheckpoint({
|
||||
version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
|
||||
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
||||
import { normalizeDungeonRunProgress, type DungeonRunProgress } from "./dungeonRunTypes";
|
||||
|
||||
// Version 1 snapshots sorted consumed action ids and cannot be migrated
|
||||
// reliably when multiple durable actions write the same state slot. Failing
|
||||
@@ -15,6 +16,7 @@ export interface DungeonRunCheckpoint {
|
||||
readonly spawn: WorldSpawn;
|
||||
readonly encounters: readonly DungeonEncounterSnapshot[];
|
||||
readonly savedAt: number;
|
||||
readonly run?: DungeonRunProgress;
|
||||
}
|
||||
|
||||
function finiteVector(value: unknown): Vector3Tuple | null {
|
||||
@@ -115,6 +117,8 @@ export function normalizeDungeonRunCheckpoint(value: unknown): DungeonRunCheckpo
|
||||
) return null;
|
||||
const encounters = input.encounters.map(normalizeSnapshot);
|
||||
if (encounters.some((entry) => entry === null)) return null;
|
||||
const run = input.run === undefined ? undefined : normalizeDungeonRunProgress(input.run);
|
||||
if (run === null || (run && (run.dungeonId !== input.dungeonId || run.difficultyId !== input.difficultyId))) return null;
|
||||
return Object.freeze({
|
||||
version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
||||
dungeonId: input.dungeonId,
|
||||
@@ -123,5 +127,6 @@ export function normalizeDungeonRunCheckpoint(value: unknown): DungeonRunCheckpo
|
||||
spawn: { footPosition: position, yaw },
|
||||
encounters: Object.freeze(encounters as DungeonEncounterSnapshot[]),
|
||||
savedAt: input.savedAt,
|
||||
...(run ? { run } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
import { createCharacter } from "../app/accountRepository";
|
||||
import { randomAppearance } from "../app/characterCatalog";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { subscribeCombatEvents } from "./combatEvents";
|
||||
import { deriveDungeonEncounterCombatEvents, type DungeonEncounterMobCursor } from "./dungeonEncounterCombatEvents";
|
||||
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, activeDungeonRunIsComplete,
|
||||
advanceDungeonEncounters, dispatchActiveDungeonEntityEvent, exportDungeonEncounterCheckpointSnapshots, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||
import { completedDungeonRun, finalDungeonBoss, useDungeonRunStore } from "./dungeonRunStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
it("turns an actual RoM party's lethal damage to Anglo into one timed completion and a resumable result", () => {
|
||||
const dungeon = requireDungeonDefinition("barren-caves");
|
||||
const boss = finalDungeonBoss(dungeon)!;
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
const bossCombat = entity.combat;
|
||||
if (!bossCombat) throw new Error("Anglo must have a combat definition");
|
||||
const healthMultiplier = bossCombat.healthMultiplier ?? 1;
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
useGameStore.setState({ activeDungeonId: dungeon.id, activeDifficultyId: "normal", gameMode: "dungeon", gmTestMode: false });
|
||||
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? [], "normal");
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 1 });
|
||||
combat.setPlayerPosition([-18, 0, 0]);
|
||||
usePartyStore.getState().initializeParty("completion-run", 1, useCombatStore.getState().health, "healer", 5, "rom");
|
||||
combat.synchronizeActors();
|
||||
const bossLevel = scaledDungeonEnemyLevel(1, true);
|
||||
combat.registerMob(boss.id, { name: entity.name, boss: true, level: bossLevel, xpReward: 0,
|
||||
maxHealth: dungeonEnemyHealthBaseline(bossLevel, "runewaker")! * healthMultiplier,
|
||||
healthMultiplier, attacks: bossCombat.attacks });
|
||||
setMobPosition(boss.id, [0, 0, 0]);
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, boss.id, true);
|
||||
}
|
||||
const party = () => [{ name: "Runview", classId: "rom-priest", level: 1, role: "healer", npc: false },
|
||||
...usePartyStore.getState().members.map((member) => ({ name: member.name, classId: member.classId,
|
||||
level: member.level, role: member.role, npc: true }))];
|
||||
const epoch = Date.now() - 181000;
|
||||
const run = useDungeonRunStore.getState(); run.begin(dungeon.id, "normal"); run.start(epoch, party());
|
||||
combat.damageMob(boss.id, 1, epoch);
|
||||
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
|
||||
let cursor: DungeonEncounterMobCursor = {};
|
||||
let npcDamage = 0; let healing = 0; let deaths = 0;
|
||||
const stop = subscribeCombatEvents((event) => {
|
||||
if (event.kind === "damage" && event.targetActorId === boss.id && event.sourceActorId !== "player") npcDamage += event.effectiveAmount;
|
||||
if (event.kind === "healing") healing += event.effectiveAmount;
|
||||
});
|
||||
try {
|
||||
for (let now = epoch + 100; now <= epoch + 181000; now += 100) {
|
||||
combat.tick(.1, now); advanceDungeonPartyCombat(now); combat.advanceMobCombat(now);
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||
if (tank.health < tank.maxHealth * .65 && !useCombatStore.getState().activeCast) {
|
||||
usePartyStore.getState().selectMember(tank.id); combat.castAbility(heal.id, undefined, now);
|
||||
}
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
const events = deriveDungeonEncounterCombatEvents(cursor, mobs, now); cursor = events.cursor;
|
||||
for (const event of events.events) { dispatchActiveDungeonEntityEvent(event); if (event.kind === "entity-death") deaths += 1; }
|
||||
const dead = Object.values(mobs).filter((mob) => mob.dead).map((mob) => mob.id);
|
||||
advanceDungeonEncounters(dungeon.id, { kind: "tick", now, facts: { registeredEntityIds: Object.keys(mobs), deadEntityIds: dead } });
|
||||
run.advance(activeDungeonCompletedObjectiveIds(dungeon.id), dead, now, party());
|
||||
if (useDungeonRunStore.getState().progress?.finishedAt !== null) break;
|
||||
}
|
||||
const progress = useDungeonRunStore.getState().progress!;
|
||||
expect(useCombatStore.getState().mobs[boss.id].dead).toBe(true);
|
||||
expect(activeDungeonRunIsComplete(dungeon.id)).toBe(true);
|
||||
expect(npcDamage).toBeGreaterThan(0); expect(healing).toBeGreaterThan(0); expect(deaths).toBe(1);
|
||||
expect(completedDungeonRun(progress)).toMatchObject({ fullClear: true, killedBossIds: [boss.id] });
|
||||
expect(progress.durationMs).toBeGreaterThan(20000);
|
||||
expect(usePartyStore.getState().members.every((member) => member.health > 0)).toBe(true);
|
||||
const snapshots = exportDungeonEncounterCheckpointSnapshots(dungeon.id, 0);
|
||||
if (process.env.DUNGEON_RUN_REPORT === "1") {
|
||||
const values = new Map<string, string>();
|
||||
const character = createCharacter({ ownerId: "qa-owner", name: "Runview", raceId: "rom-human", classId: "rom-priest",
|
||||
categoryId: "rom", gender: "female", appearance: randomAppearance("rom-human", "female", () => 0) },
|
||||
{ getItem: (key) => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); },
|
||||
removeItem: (key) => { values.delete(key); } }).character!;
|
||||
mkdirSync("artifacts/dungeon-runs", { recursive: true });
|
||||
writeFileSync("artifacts/dungeon-runs/combat-completion.json", `${JSON.stringify({ npcDamage, healing, deaths, progress,
|
||||
character: { ...character, dungeonRunCheckpoint: { version: 2, dungeonId: dungeon.id, difficultyId: "normal",
|
||||
checkpointId: "entrance", spawn: { footPosition: dungeon.entrance.footPosition, yaw: dungeon.entrance.yaw },
|
||||
encounters: snapshots, savedAt: Date.now(), run: progress } } }, null, 2)}\n`);
|
||||
}
|
||||
} finally { stop(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); }
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { completedDungeonRun, finalDungeonBoss, useDungeonRunStore } from "./dungeonRunStore";
|
||||
import { normalizeDungeonRunProgress, formatDungeonDuration, type DungeonRunMember } from "./dungeonRunTypes";
|
||||
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, activeDungeonRunIsComplete,
|
||||
advanceDungeonEncounters, createDungeonEncounterSnapshot, exportDungeonEncounterCheckpointSnapshots,
|
||||
importDungeonEncounterSnapshots, reduceDungeonEncounter, resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||
|
||||
const party: readonly DungeonRunMember[] = [{ name: "Tester", classId: "rom-priest", level: 1, role: "healer", npc: false }];
|
||||
beforeEach(() => { useDungeonRunStore.setState({ progress: null }); });
|
||||
|
||||
describe("dungeon completion and run timing", () => {
|
||||
it("completes Barren Caves when Anglo dies, records its real spawn id, and survives checkpoint hydration", () => {
|
||||
const dungeon = requireDungeonDefinition("barren-caves");
|
||||
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? [], "normal");
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
const run = useDungeonRunStore.getState();
|
||||
run.begin(dungeon.id, "normal"); run.start(1000, party);
|
||||
advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 1000, facts: { registeredEntityIds: [], deadEntityIds: [] } });
|
||||
expect(activeDungeonRunIsComplete(dungeon.id)).toBe(false);
|
||||
const boss = finalDungeonBoss(dungeon)!;
|
||||
expect(boss.name).toBe("Anglo");
|
||||
advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 12456,
|
||||
facts: { registeredEntityIds: [boss.id], deadEntityIds: [boss.id] } });
|
||||
run.advance(activeDungeonCompletedObjectiveIds(dungeon.id), [boss.id], 12456, party);
|
||||
expect(activeDungeonRunIsComplete(dungeon.id)).toBe(true);
|
||||
expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toMatchObject({
|
||||
durationMs: 11456, fullClear: true, killedBossIds: [boss.id], party,
|
||||
});
|
||||
const snapshots = exportDungeonEncounterCheckpointSnapshots(dungeon.id, 0);
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
importDungeonEncounterSnapshots(dungeon.id, snapshots, { authoritative: true });
|
||||
expect(activeDungeonRunIsComplete(dungeon.id)).toBe(true);
|
||||
const result = useDungeonRunStore.getState().progress;
|
||||
run.advance([boss.id], [boss.id], 99999, []); run.start(99999, []);
|
||||
expect(useDungeonRunStore.getState().progress).toBe(result);
|
||||
});
|
||||
|
||||
it("adds durable completion bookkeeping to every RoM dungeon with configured bosses, without awarding missing bosses", () => {
|
||||
for (const dungeon of DUNGEON_DEFINITIONS.filter((entry) => entry.expansion === "runewaker" && entry.bosses.length > 0)) {
|
||||
const baseline = dungeon.encounters!.find((entry) => entry.id === `${dungeon.id}:completion-baseline`)!;
|
||||
expect(baseline, dungeon.id).toBeDefined();
|
||||
let snapshot = createDungeonEncounterSnapshot(baseline, 0);
|
||||
snapshot = reduceDungeonEncounter(baseline, snapshot, { kind: "tick", now: 10,
|
||||
facts: { registeredEntityIds: [], deadEntityIds: [] } }).snapshot;
|
||||
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(false);
|
||||
const ordinary = [...new Set([...dungeon.objectiveOrder, ...dungeon.bosses]
|
||||
.filter((boss) => !boss.eventOnly).map((boss) => boss.id))];
|
||||
snapshot = reduceDungeonEncounter(baseline, snapshot, { kind: "tick", now: 20,
|
||||
facts: { registeredEntityIds: ordinary, deadEntityIds: ordinary } }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds, dungeon.id).toEqual(expect.arrayContaining(ordinary));
|
||||
if (!dungeon.objectiveOrder.some((boss) => !boss.optional && boss.eventOnly)) {
|
||||
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("finishes a final boss clear even when earlier bosses were skipped, but keeps it out of full clears", () => {
|
||||
const dungeon = requireDungeonDefinition("deadmines");
|
||||
const final = finalDungeonBoss(dungeon)!;
|
||||
const run = useDungeonRunStore.getState();
|
||||
run.begin(dungeon.id, dungeon.defaultDifficultyId); run.start(1000, party);
|
||||
run.advance([], [final.id], 2000, party);
|
||||
expect(useDungeonRunStore.getState().progress?.finishedAt).toBeNull();
|
||||
run.advance([final.id], [final.id], 3000, party);
|
||||
expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toMatchObject({ durationMs: 2000, fullClear: false });
|
||||
});
|
||||
|
||||
it("keeps the original wall-clock timer and boss history through a wipe and a saved resume", () => {
|
||||
const dungeon = requireDungeonDefinition("deadmines");
|
||||
const run = useDungeonRunStore.getState();
|
||||
run.begin(dungeon.id, "normal"); run.start(1000, party);
|
||||
run.advance([], [dungeon.bosses[0].id], 2000, party);
|
||||
const saved = normalizeDungeonRunProgress(JSON.parse(JSON.stringify(useDungeonRunStore.getState().progress)))!;
|
||||
run.begin(dungeon.id, "normal", saved); run.start(5000, party);
|
||||
run.advance([finalDungeonBoss(dungeon)!.id], [], 9000, party);
|
||||
expect(useDungeonRunStore.getState().progress).toMatchObject({ id: saved.id, startedAt: 1000,
|
||||
durationMs: 8000, killedBossIds: expect.arrayContaining([dungeon.bosses[0].id]) });
|
||||
});
|
||||
|
||||
it("announces legacy resumed runs without inventing a leaderboard time", () => {
|
||||
const run = useDungeonRunStore.getState(); run.begin("barren-caves", "normal", undefined, true);
|
||||
run.start(1000, party); run.advance([finalDungeonBoss(requireDungeonDefinition("barren-caves"))!.id], [], 2000, party);
|
||||
expect(useDungeonRunStore.getState().progress?.finishedAt).toBe(2000);
|
||||
expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects corrupted saved timers and impossible party levels", () => {
|
||||
useDungeonRunStore.getState().begin("barren-caves", "normal");
|
||||
const progress = useDungeonRunStore.getState().progress!;
|
||||
expect(normalizeDungeonRunProgress({ ...progress, startedAt: NaN })).toBeNull();
|
||||
expect(normalizeDungeonRunProgress({ ...progress, startedAt: 100, finishedAt: 50, durationMs: 10 })).toBeNull();
|
||||
expect(normalizeDungeonRunProgress({ ...progress, party: [{ ...party[0], level: 81 }] })).toBeNull();
|
||||
expect(formatDungeonDuration(123456)).toBe("02:03.4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { create } from "zustand";
|
||||
import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type { DungeonRunMember, DungeonRunProgress, DungeonRunRecord } from "./dungeonRunTypes";
|
||||
|
||||
export function dungeonBossesForDifficulty(definition: DungeonDefinition, difficultyId = definition.defaultDifficultyId) {
|
||||
const difficulty = definition.difficultyVariants.find((variant) => variant.id === difficultyId);
|
||||
return definition.bosses.filter((boss) => {
|
||||
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id);
|
||||
return !spawn || (((spawn.spawnMask ?? 1) & (difficulty?.spawnMask ?? 1)) !== 0);
|
||||
});
|
||||
}
|
||||
|
||||
export function finalDungeonBoss(definition: DungeonDefinition, difficultyId = definition.defaultDifficultyId) {
|
||||
const bosses = dungeonBossesForDifficulty(definition, difficultyId);
|
||||
const bossIds = new Set(bosses.map((boss) => boss.id));
|
||||
return definition.objectiveOrder.filter((objective) => !objective.optional && bossIds.has(objective.id)).at(-1)
|
||||
?? bosses.filter((boss) => !boss.optional).at(-1);
|
||||
}
|
||||
|
||||
export function completedDungeonRun(progress: DungeonRunProgress): DungeonRunRecord | null {
|
||||
const definition = dungeonDefinitionById(progress.dungeonId);
|
||||
if (!definition || !progress.timed || progress.finishedAt === null || progress.durationMs === null) return null;
|
||||
return { id: progress.id, dungeonId: progress.dungeonId, difficultyId: progress.difficultyId,
|
||||
finishedAt: progress.finishedAt, durationMs: progress.durationMs, killedBossIds: progress.killedBossIds,
|
||||
party: progress.party, fullClear: dungeonBossesForDifficulty(definition, progress.difficultyId)
|
||||
.every((boss) => progress.killedBossIds.includes(boss.id)) };
|
||||
}
|
||||
|
||||
interface DungeonRunState {
|
||||
readonly progress: DungeonRunProgress | null;
|
||||
begin: (dungeonId: string, difficultyId: string, resume?: DungeonRunProgress, legacyResume?: boolean) => void;
|
||||
start: (now: number, party: readonly DungeonRunMember[]) => void;
|
||||
advance: (completedObjectiveIds: readonly string[], deadBossIds: readonly string[], now: number,
|
||||
party: readonly DungeonRunMember[]) => void;
|
||||
}
|
||||
|
||||
export const useDungeonRunStore = create<DungeonRunState>((set, get) => ({
|
||||
progress: null,
|
||||
begin: (dungeonId, difficultyId, resume, legacyResume = false) => set({
|
||||
progress: resume?.dungeonId === dungeonId && resume.difficultyId === difficultyId ? resume : {
|
||||
id: globalThis.crypto.randomUUID(), dungeonId, difficultyId, startedAt: null,
|
||||
finishedAt: null, durationMs: null, killedBossIds: [], party: [], timed: !legacyResume,
|
||||
},
|
||||
}),
|
||||
start: (now, party) => {
|
||||
const progress = get().progress;
|
||||
if (!progress || progress.startedAt !== null || party.length === 0) return;
|
||||
set({ progress: { ...progress, startedAt: now, party: [...party] } });
|
||||
},
|
||||
advance: (completedObjectiveIds, deadBossIds, now, party) => {
|
||||
const progress = get().progress;
|
||||
if (!progress || progress.startedAt === null || progress.finishedAt !== null) return;
|
||||
const definition = dungeonDefinitionById(progress.dungeonId);
|
||||
if (!definition) return;
|
||||
const bossIds = new Set(definition.bosses.map((boss) => boss.id));
|
||||
const killedBossIds = [...new Set([...progress.killedBossIds, ...completedObjectiveIds, ...deadBossIds])]
|
||||
.filter((id) => bossIds.has(id));
|
||||
const finalBoss = finalDungeonBoss(definition, progress.difficultyId);
|
||||
// A scripted final boss must award its objective; despawns and raw corpse states cannot complete it.
|
||||
const finished = finalBoss !== undefined && completedObjectiveIds.includes(finalBoss.id);
|
||||
if (!finished && killedBossIds.length === progress.killedBossIds.length) return;
|
||||
set({ progress: { ...progress, killedBossIds,
|
||||
...(finished ? { finishedAt: Math.max(now, progress.startedAt), durationMs: Math.max(1, Math.round(now - progress.startedAt)),
|
||||
party: [...party] } : {}) } });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,78 @@
|
||||
export interface DungeonRunMember {
|
||||
readonly name: string;
|
||||
readonly classId: string;
|
||||
readonly level: number;
|
||||
readonly secondaryClassId?: string;
|
||||
readonly role: string;
|
||||
readonly npc: boolean;
|
||||
}
|
||||
|
||||
export interface DungeonRunProgress {
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly difficultyId: string;
|
||||
readonly startedAt: number | null;
|
||||
readonly finishedAt: number | null;
|
||||
readonly durationMs: number | null;
|
||||
readonly timed: boolean;
|
||||
readonly killedBossIds: readonly string[];
|
||||
readonly party: readonly DungeonRunMember[];
|
||||
}
|
||||
|
||||
export interface DungeonRunRecord {
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly difficultyId: string;
|
||||
readonly durationMs: number;
|
||||
readonly finishedAt: number;
|
||||
readonly killedBossIds: readonly string[];
|
||||
readonly party: readonly DungeonRunMember[];
|
||||
readonly fullClear: boolean;
|
||||
}
|
||||
|
||||
export type DungeonClearFilter = "full" | "final";
|
||||
export interface RankedDungeonRun extends DungeonRunRecord { readonly rank: number }
|
||||
export interface DungeonLeaderboard {
|
||||
readonly entries: readonly RankedDungeonRun[];
|
||||
readonly total: number;
|
||||
}
|
||||
export interface DungeonRunPlacement {
|
||||
readonly finalRank: number;
|
||||
readonly finalTotal: number;
|
||||
readonly fullRank: number | null;
|
||||
readonly fullTotal: number;
|
||||
}
|
||||
|
||||
export function formatDungeonDuration(durationMs: number): string {
|
||||
const tenths = Math.floor(Math.max(0, durationMs) / 100);
|
||||
const seconds = Math.floor(tenths / 10);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}.${tenths % 10}`;
|
||||
}
|
||||
|
||||
/** Untrusted checkpoint/local-storage data must never reset or invent a ranked timer. */
|
||||
export function normalizeDungeonRunProgress(value: unknown): DungeonRunProgress | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const input = value as DungeonRunProgress;
|
||||
const text = (entry: unknown) => typeof entry === "string" && entry.length > 0 && entry.length <= 100;
|
||||
const time = (entry: unknown) => entry === null || (typeof entry === "number" && Number.isFinite(entry) && entry >= 0);
|
||||
if (!text(input.id) || !text(input.dungeonId) || !text(input.difficultyId)
|
||||
|| !time(input.startedAt) || !time(input.finishedAt) || !time(input.durationMs)
|
||||
|| typeof input.timed !== "boolean" || !Array.isArray(input.killedBossIds)
|
||||
|| input.killedBossIds.length > 100 || !input.killedBossIds.every(text)
|
||||
|| !Array.isArray(input.party) || input.party.length > 5
|
||||
|| !input.party.every((member) => member && text(member.name) && text(member.classId)
|
||||
&& Number.isInteger(member.level) && member.level >= 1 && member.level <= 80
|
||||
&& ["tank", "healer", "damage"].includes(member.role) && typeof member.npc === "boolean"
|
||||
&& (member.secondaryClassId === undefined || text(member.secondaryClassId)))
|
||||
|| (input.finishedAt !== null && (input.startedAt === null || input.finishedAt < input.startedAt))
|
||||
|| (input.durationMs !== null && (input.finishedAt === null || input.durationMs < 1))
|
||||
|| (input.finishedAt !== null && (input.durationMs === null || input.party.length === 0
|
||||
|| input.durationMs !== Math.max(1, Math.round(input.finishedAt - input.startedAt!))))) return null;
|
||||
return { id: input.id, dungeonId: input.dungeonId, difficultyId: input.difficultyId,
|
||||
startedAt: input.startedAt, finishedAt: input.finishedAt, durationMs: input.durationMs,
|
||||
timed: input.timed, killedBossIds: [...new Set(input.killedBossIds)],
|
||||
party: input.party.map((member) => ({ name: member.name, classId: member.classId,
|
||||
level: member.level, role: member.role, npc: member.npc,
|
||||
...(member.secondaryClassId ? { secondaryClassId: member.secondaryClassId } : {}) })) };
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import type { DungeonRunProgress } from "./dungeonRunTypes";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
@@ -28,6 +30,8 @@ export interface DungeonSessionOptions {
|
||||
readonly preserveCharacter?: boolean;
|
||||
/** Hydrates save-safe state after definitions have been activated and reset. */
|
||||
readonly encounterSnapshots?: readonly DungeonEncounterSnapshot[];
|
||||
readonly runProgress?: DungeonRunProgress;
|
||||
readonly legacyResume?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +51,10 @@ export function activateDungeonSession(
|
||||
|
||||
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
|
||||
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
|
||||
if (!options.preserveCharacter) {
|
||||
useDungeonRunStore.getState().begin(dungeonId, difficultyId ?? definition.defaultDifficultyId,
|
||||
options.runProgress, options.legacyResume);
|
||||
}
|
||||
activateDungeonEncounterDefinitions(
|
||||
dungeonId,
|
||||
definition.encounters ?? [],
|
||||
|
||||
@@ -37,8 +37,8 @@ describe("reviewed dungeon stair proxies", () => {
|
||||
it("bridges exactly the existing reviewed portal vertices and rejects stale asset coordinates", () => {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||
...RAMP.navigationBridge.startPortal.flat(),
|
||||
...RAMP.navigationBridge.endPortal.flat(),
|
||||
...RAMP.navigationBridge!.startPortal.flat(),
|
||||
...RAMP.navigationBridge!.endPortal.flat(),
|
||||
], 3));
|
||||
geometry.setIndex([]);
|
||||
smoothDungeonStairGeometry(geometry, [RAMP], 0.05);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { DungeonTraversalRamp, Vector3Tuple } from "./dungeonTypes";
|
||||
const MAX_STAIR_SURFACE_DELTA = 0.85;
|
||||
const PORTAL_VERTEX_TOLERANCE = 0.002;
|
||||
|
||||
function rampHeight(
|
||||
export function dungeonRampHeight(
|
||||
ramp: DungeonTraversalRamp,
|
||||
x: number,
|
||||
z: number,
|
||||
@@ -36,10 +36,38 @@ export function smoothDungeonStairGeometry(
|
||||
surfaceClearance = 0,
|
||||
): void {
|
||||
const position = geometry.getAttribute("position");
|
||||
// Fleet packages can contain many short flights and hundreds of thousands of
|
||||
// vertices. Index their footprints once rather than checking every flight
|
||||
// (and validating/recomputing its direction) for every collision vertex.
|
||||
const cellSize = 16;
|
||||
const cells = new Map<string, { ramp: DungeonTraversalRamp; dx: number; dz: number; length: number }[]>();
|
||||
for (const ramp of ramps) {
|
||||
dungeonRampHeight(ramp, ramp.start[0], ramp.start[2]);
|
||||
const dx = ramp.end[0] - ramp.start[0];
|
||||
const dz = ramp.end[2] - ramp.start[2];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const xPadding = Math.abs(dz) / length * ramp.width / 2;
|
||||
const zPadding = Math.abs(dx) / length * ramp.width / 2;
|
||||
for (let x = Math.floor((Math.min(ramp.start[0], ramp.end[0]) - xPadding) / cellSize);
|
||||
x <= Math.floor((Math.max(ramp.start[0], ramp.end[0]) + xPadding) / cellSize); x += 1) {
|
||||
for (let z = Math.floor((Math.min(ramp.start[2], ramp.end[2]) - zPadding) / cellSize);
|
||||
z <= Math.floor((Math.max(ramp.start[2], ramp.end[2]) + zPadding) / cellSize); z += 1) {
|
||||
const key = `${x}:${z}`;
|
||||
const bucket = cells.get(key) ?? [];
|
||||
bucket.push({ ramp, dx, dz, length });
|
||||
cells.set(key, bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let vertex = 0; vertex < position.count; vertex += 1) {
|
||||
for (const ramp of ramps) {
|
||||
const height = rampHeight(ramp, position.getX(vertex), position.getZ(vertex));
|
||||
if (height === null || Math.abs(position.getY(vertex) - height) > MAX_STAIR_SURFACE_DELTA) continue;
|
||||
const x = position.getX(vertex); const z = position.getZ(vertex);
|
||||
for (const { ramp, dx, dz, length } of cells.get(`${Math.floor(x / cellSize)}:${Math.floor(z / cellSize)}`) ?? []) {
|
||||
const offsetX = x - ramp.start[0]; const offsetZ = z - ramp.start[2];
|
||||
const along = (offsetX * dx + offsetZ * dz) / length;
|
||||
const across = (offsetX * dz - offsetZ * dx) / length;
|
||||
if (along < 0 || along > length || Math.abs(across) > ramp.width / 2) continue;
|
||||
const height = ramp.start[1] + (ramp.end[1] - ramp.start[1]) * along / length;
|
||||
if (Math.abs(position.getY(vertex) - height) > MAX_STAIR_SURFACE_DELTA) continue;
|
||||
position.setY(vertex, height + surfaceClearance);
|
||||
break;
|
||||
}
|
||||
@@ -57,7 +85,7 @@ export function bridgeDungeonStairNavigation(
|
||||
? Array.from(geometry.index.array)
|
||||
: Array.from({ length: position.count }, (_, vertex) => vertex);
|
||||
const portalVertex = (ramp: DungeonTraversalRamp, point: Vector3Tuple): number => {
|
||||
const height = rampHeight(ramp, point[0], point[2]);
|
||||
const height = dungeonRampHeight(ramp, point[0], point[2]);
|
||||
if (height === null) throw new Error(`${ramp.id}: navigation portal is outside the stair ramp.`);
|
||||
for (let vertex = 0; vertex < position.count; vertex += 1) {
|
||||
if (Math.hypot(
|
||||
@@ -69,9 +97,15 @@ export function bridgeDungeonStairNavigation(
|
||||
throw new Error(`${ramp.id}: reviewed navigation portal no longer matches the source GLB.`);
|
||||
};
|
||||
for (const ramp of ramps) {
|
||||
const [a, b] = ramp.navigationBridge.startPortal.map((point) => portalVertex(ramp, point));
|
||||
const [c, d] = ramp.navigationBridge.endPortal.map((point) => portalVertex(ramp, point));
|
||||
indices.push(a, b, d, a, d, c);
|
||||
for (const bridge of dungeonRampNavigationBridges(ramp)) {
|
||||
const [a, b] = bridge.startPortal.map((point) => portalVertex(ramp, point));
|
||||
const [c, d] = bridge.endPortal.map((point) => portalVertex(ramp, point));
|
||||
indices.push(a, b, d, a, d, c);
|
||||
}
|
||||
}
|
||||
geometry.setIndex(indices);
|
||||
}
|
||||
|
||||
export function dungeonRampNavigationBridges(ramp: DungeonTraversalRamp) {
|
||||
return [...(ramp.navigationBridge ? [ramp.navigationBridge] : []), ...(ramp.navigationBridges ?? [])];
|
||||
}
|
||||
@@ -162,11 +162,16 @@ export interface DungeonTraversalRamp {
|
||||
readonly start: Vector3Tuple;
|
||||
readonly end: Vector3Tuple;
|
||||
readonly width: number;
|
||||
readonly navigationBridge: {
|
||||
readonly navigationBridge?: {
|
||||
readonly linkId: string;
|
||||
readonly startPortal: readonly [Vector3Tuple, Vector3Tuple];
|
||||
readonly endPortal: readonly [Vector3Tuple, Vector3Tuple];
|
||||
};
|
||||
readonly navigationBridges?: readonly {
|
||||
readonly linkId: string;
|
||||
readonly startPortal: readonly [Vector3Tuple, Vector3Tuple];
|
||||
readonly endPortal: readonly [Vector3Tuple, Vector3Tuple];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface DungeonEncounterSpawnGroupDefinition {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
|
||||
import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes";
|
||||
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||
import { bridgeDungeonStairNavigation, dungeonRampNavigationBridges, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||
import {
|
||||
buildPartyNavigationGraph,
|
||||
type PartyNavigationGraph,
|
||||
@@ -77,7 +77,7 @@ export function preparePartyNavigationGraph(
|
||||
smoothDungeonStairGeometry(geometry, ramps, 0.05);
|
||||
bridgeDungeonStairNavigation(geometry, ramps, 0.05);
|
||||
}
|
||||
const bridgedLinks = new Set(ramps.map((ramp) => ramp.navigationBridge.linkId));
|
||||
const bridgedLinks = new Set(ramps.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId)));
|
||||
graph = buildPartyNavigationGraph(geometry, {
|
||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||
import { BARREN_CAVES_ENTITIES } from "./generated/barrenCavesPopulation.generated";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { subscribeCombatEvents } from "./combatEvents";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
|
||||
const report: unknown[] = [];
|
||||
const trash = BARREN_CAVES_ENTITIES["rw-barren-caves-101456"];
|
||||
const boss = BARREN_CAVES_ENTITIES["rw-barren-caves-101462"];
|
||||
|
||||
afterAll(() => {
|
||||
if (process.env.ROM_DUNGEON_HEALTH_REPORT !== "1") return;
|
||||
const directory = resolve("artifacts/rom-dungeon-health");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({ results: report }, null, 2)}\n`);
|
||||
});
|
||||
|
||||
function initializePull(level: number, seed: string, maxHealth: number, isBoss = false): void {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level });
|
||||
useCombatStore.getState().setPlayerPosition([-18, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, "healer", 5, "rom");
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const definition = isBoss ? boss : trash;
|
||||
useCombatStore.getState().registerMob("pull", {
|
||||
name: definition.name,
|
||||
boss: isBoss,
|
||||
level: scaledDungeonEnemyLevel(level, isBoss),
|
||||
maxHealth,
|
||||
healthMultiplier: definition.combat.healthMultiplier,
|
||||
attacks: definition.combat.attacks,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition("pull", [0, 0, 0]);
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, "pull", true);
|
||||
}
|
||||
useCombatStore.getState().damageMob("pull", 1, 100);
|
||||
}
|
||||
|
||||
function measurePull(level: number, seed: string, maxHealth: number, isBoss = false) {
|
||||
initializePull(level, seed, maxHealth, isBoss);
|
||||
let firstDamageAt: number | null = null;
|
||||
let diedAt: number | null = null;
|
||||
let openingHealth = maxHealth;
|
||||
const stop = subscribeCombatEvents((event) => {
|
||||
if (event.targetActorId !== "pull") return;
|
||||
if (event.kind === "damage" && event.effectiveAmount > 0) firstDamageAt ??= event.occurredAt;
|
||||
});
|
||||
try {
|
||||
for (let now = 1_000; now <= 121_000; now += 100) {
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
const mob = useCombatStore.getState().mobs.pull;
|
||||
if (firstDamageAt !== null && now <= firstDamageAt + 1_000) openingHealth = mob.health;
|
||||
if (mob.dead) {
|
||||
diedAt = mob.diedAt ?? now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { maxHealth, seconds: diedAt === null || firstDamageAt === null ? null : (diedAt - firstDamageAt) / 1_000,
|
||||
openingHealth, classes: usePartyStore.getState().members.map((member) => member.classId) };
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
describe("RoM dungeon enemy durability", () => {
|
||||
for (const level of [1, 2, 4, 8, 9, 20, 40, 70, 80]) {
|
||||
it(`Barren Caves trash survives a full RoM party's opening volley at level ${level}`, () => {
|
||||
for (let seed = 0; seed < 12; seed += 1) {
|
||||
const partySeed = `rom-health-${seed}`;
|
||||
const before = measurePull(level, partySeed, 72 + level * 22);
|
||||
const after = measurePull(level, partySeed, dungeonEnemyHealthBaseline(level, "runewaker")!);
|
||||
report.push({ level, seed: partySeed, before, after });
|
||||
expect(after.openingHealth, JSON.stringify({ level, before, after })).toBeGreaterThan(0);
|
||||
expect(after.seconds, JSON.stringify({ level, before, after })).not.toBeNull();
|
||||
expect(after.seconds!, JSON.stringify({ level, before, after })).toBeGreaterThanOrEqual(3);
|
||||
expect(after.seconds!, JSON.stringify({ level, before, after })).toBeLessThanOrEqual(30);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("retains Barren Caves' authored boss health multiplier", () => {
|
||||
const level = 8;
|
||||
const maxHealth = dungeonEnemyHealthBaseline(scaledDungeonEnemyLevel(level, true), "runewaker")!
|
||||
* boss.combat.healthMultiplier;
|
||||
const result = measurePull(level, "rom-health-0", maxHealth, true);
|
||||
report.push({ level, boss: boss.name, ...result });
|
||||
expect(result.seconds).not.toBeNull();
|
||||
expect(result.seconds!).toBeGreaterThanOrEqual(20);
|
||||
expect(result.seconds!).toBeLessThanOrEqual(120);
|
||||
});
|
||||
|
||||
it("allows enemy attacks and Priest healing before an opening pull ends", () => {
|
||||
const level = 1;
|
||||
initializePull(level, "rom-health-0", dungeonEnemyHealthBaseline(level, "runewaker")!);
|
||||
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
|
||||
let incomingDamage = 0;
|
||||
let healing = 0;
|
||||
const stop = subscribeCombatEvents((event) => {
|
||||
if (event.sourceActorId === "pull" && event.kind === "damage") incomingDamage += event.effectiveAmount;
|
||||
if (event.kind === "healing") healing += event.effectiveAmount;
|
||||
});
|
||||
try {
|
||||
for (let now = 1_000; now <= 31_000; now += 100) {
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
useCombatStore.getState().advanceMobCombat(now);
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||
if (tank.health < tank.maxHealth && !useCombatStore.getState().activeCast) {
|
||||
usePartyStore.getState().selectMember(tank.id);
|
||||
useCombatStore.getState().castAbility(heal.id, undefined, now);
|
||||
}
|
||||
if (useCombatStore.getState().mobs.pull.dead) break;
|
||||
}
|
||||
expect(useCombatStore.getState().mobs.pull.dead).toBe(true);
|
||||
expect(incomingDamage).toBeGreaterThan(0);
|
||||
expect(healing).toBeGreaterThan(0);
|
||||
expect(usePartyStore.getState().members.every((member) => member.health > 0)).toBe(true);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates loaded enemies on level-up while retaining wounds and fallen enemies", () => {
|
||||
const initial = dungeonEnemyHealthBaseline(1, "runewaker")!;
|
||||
const scaled = dungeonEnemyHealthBaseline(8, "runewaker")!;
|
||||
initializePull(1, "rom-health-level-up", initial);
|
||||
useCombatStore.getState().damageMob("pull", initial / 2 - 1, 1_000);
|
||||
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 8, maxHealth: scaled });
|
||||
expect(useCombatStore.getState().mobs.pull).toMatchObject({ level: 8, maxHealth: scaled, health: scaled / 2,
|
||||
attackDamage: 20 });
|
||||
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 8, maxHealth: scaled });
|
||||
expect(useCombatStore.getState().mobs.pull.health).toBe(scaled / 2);
|
||||
useCombatStore.getState().damageMob("pull", scaled, 2_000);
|
||||
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 20,
|
||||
maxHealth: dungeonEnemyHealthBaseline(20, "runewaker") });
|
||||
expect(useCombatStore.getState().mobs.pull).toMatchObject({ dead: true, health: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
|
||||
|
||||
/** Authored completion bookkeeping, independent of reviewed RoM encounter scripts. */
|
||||
export function withRunewakerCompletion(definition: DungeonDefinition): DungeonDefinition {
|
||||
if (definition.expansion !== "runewaker" || definition.bosses.length === 0) return definition;
|
||||
const objectives = [...new Map([...definition.objectiveOrder, ...definition.bosses]
|
||||
.map((objective) => [objective.id, objective] as const)).values()];
|
||||
const encounter: DungeonEncounterDefinition = {
|
||||
id: `${definition.id}:completion-baseline`,
|
||||
dungeonId: definition.id,
|
||||
initialPhase: "running",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
source: {
|
||||
fidelity: "healer-man-authored", scope: "instance-orchestration",
|
||||
files: ["src/game/runewakerCompletion.ts"],
|
||||
reviewedBehavior: "Records defeated dungeon bosses and completes the required objectives.",
|
||||
adaptations: ["Completion bookkeeping uses runtime spawn ids and persists across checkpoints."],
|
||||
remainingGaps: [],
|
||||
},
|
||||
rules: [
|
||||
...objectives.filter((objective) => !objective.eventOnly).map((objective) => ({
|
||||
id: `defeated:${objective.id}`,
|
||||
trigger: { kind: "entities-dead" as const, entityIds: [objective.id] },
|
||||
actions: [{ id: `record:${objective.id}`, kind: "complete-objective" as const,
|
||||
objectiveId: objective.id, persistence: "run" as const }],
|
||||
})),
|
||||
{
|
||||
id: "complete", trigger: { kind: "tick" },
|
||||
guards: objectives.filter((objective) => !objective.optional).map((objective) => ({
|
||||
kind: "objective-complete", objectiveId: objective.id, equals: true,
|
||||
})),
|
||||
actions: [{ id: "complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
return { ...definition, encounters: [...definition.encounters ?? [], encounter] };
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
|
||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||
export type InputMode = "keyboard" | "gamepad";
|
||||
export type GameMode = "dungeon" | "manastorm";
|
||||
export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | null;
|
||||
export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
|
||||
|
||||
interface PlayerSnapshot {
|
||||
position: Vector3Tuple;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey";
|
||||
import { WOW_DUNGEON_LIGHTING, withWowDungeonEnvironment, wowDungeonLightingProfile } from "./wowDungeonEnvironment";
|
||||
import stairs from "./generated/wowDungeonStairs.generated.json";
|
||||
import stockadesOverride from "../../dungeon-pipeline/runtime-overrides/stormwind-stockade.json";
|
||||
|
||||
describe("fleet-wide WoW dungeon environment policy", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
|
||||
|
||||
it("gives every WoW dungeon readable ambient and hemisphere fill without changing fog draw distance or material roles", () => {
|
||||
expect(wowDungeons).toHaveLength(54);
|
||||
for (const dungeon of wowDungeons) {
|
||||
const profile = WOW_DUNGEON_LIGHTING[wowDungeonLightingProfile(dungeon.id)];
|
||||
expect(dungeon.presentation.ambientLight).toEqual(profile.ambientLight);
|
||||
expect(dungeon.presentation.hemisphereLight).toEqual(profile.hemisphereLight);
|
||||
expect(dungeon.presentation.fog.far).toBeGreaterThan(dungeon.presentation.fog.near);
|
||||
const updated = withWowDungeonEnvironment(dungeon);
|
||||
expect(updated.presentation.materials).toBe(dungeon.presentation.materials);
|
||||
expect(updated.assets).toBe(dungeon.assets);
|
||||
expect(updated.validation).toBe(dungeon.validation);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Stockades' reviewed lighting, ramps and navigation links intact", () => {
|
||||
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||
// The existing campaign compiler stores package vectors at four decimals.
|
||||
const compiled = (value: unknown) => JSON.parse(JSON.stringify(value, (_key, item) => typeof item === "number" ? Number(item.toFixed(4)) : item));
|
||||
expect(stockades.traversalRamps).toEqual(compiled(stockadesOverride.traversalRamps));
|
||||
expect(stockades.navigationLinks).toEqual(compiled(stockadesOverride.navigationLinks));
|
||||
expect(stockades.presentation.directionalLight).toEqual(stockadesOverride.presentation.directionalLight);
|
||||
});
|
||||
|
||||
it("does not apply WoW changes to RuneWaker environments", () => {
|
||||
expect(withWowDungeonEnvironment(FORSAKEN_ABBEY_DEFINITION)).toBe(FORSAKEN_ABBEY_DEFINITION);
|
||||
});
|
||||
|
||||
it("records a scan for every shipped WoW dungeon and installs only package-matched proxies", () => {
|
||||
expect(Object.keys(stairs.dungeons).sort()).toEqual(wowDungeons.map((dungeon) => dungeon.id).sort());
|
||||
for (const dungeon of wowDungeons) {
|
||||
const record = (stairs.dungeons as Record<string, { ramps: unknown[] }>)[dungeon.id];
|
||||
if (record.ramps.length) expect(dungeon.traversalRamps).toEqual(record.ramps);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects stale ramp coordinates when collision content is replaced", () => {
|
||||
const dungeon = wowDungeons.find((entry) => entry.id !== "stormwind-stockade" && entry.traversalRamps?.length)!;
|
||||
expect(dungeon).toBeDefined();
|
||||
const replaced = { ...dungeon, traversalRamps: undefined, assets: { ...dungeon.assets, collision: dungeon.assets.collision.map((asset) => ({ ...asset, checksum: "replacement" })) } };
|
||||
expect(withWowDungeonEnvironment(replaced).traversalRamps).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { DungeonDefinition, DungeonPresentation, DungeonTraversalRamp } from "./dungeonTypes";
|
||||
import stairCatalog from "./generated/wowDungeonStairs.generated.json";
|
||||
|
||||
type LightingProfile = Pick<DungeonPresentation, "background" | "ambientLight" | "hemisphereLight" | "directionalLight"> & {
|
||||
readonly fog: Pick<DungeonPresentation["fog"], "color" | "near">;
|
||||
};
|
||||
|
||||
// Imported WoW environments do not carry the original client's baked lighting.
|
||||
// Stockades' reviewed fill levels are the baseline; color remains environment-specific.
|
||||
export const WOW_DUNGEON_LIGHTING: Readonly<Record<string, LightingProfile>> = {
|
||||
stone: {
|
||||
background: "#171815", fog: { color: "#1b1c18", near: 44 },
|
||||
ambientLight: { color: "#d8c9aa", intensity: 0.58 },
|
||||
hemisphereLight: { skyColor: "#cfc3a5", groundColor: "#292820", intensity: 0.86 },
|
||||
directionalLight: { color: "#ffe0a8", intensity: 1.1, offset: [10, 18, -5] },
|
||||
},
|
||||
cavern: {
|
||||
background: "#121914", fog: { color: "#172019", near: 44 },
|
||||
ambientLight: { color: "#bcccaf", intensity: 0.58 },
|
||||
hemisphereLight: { skyColor: "#b2c6b5", groundColor: "#242b22", intensity: 0.86 },
|
||||
directionalLight: { color: "#dce5bc", intensity: 1.1, offset: [10, 18, -5] },
|
||||
},
|
||||
fire: {
|
||||
background: "#211612", fog: { color: "#291c17", near: 44 },
|
||||
ambientLight: { color: "#d3bca9", intensity: 0.58 },
|
||||
hemisphereLight: { skyColor: "#d1bca6", groundColor: "#30251f", intensity: 0.86 },
|
||||
directionalLight: { color: "#ffd3a1", intensity: 1.1, offset: [10, 18, -5] },
|
||||
},
|
||||
arcane: {
|
||||
background: "#161827", fog: { color: "#1c2030", near: 44 },
|
||||
ambientLight: { color: "#c4c8de", intensity: 0.58 },
|
||||
hemisphereLight: { skyColor: "#bdc9e6", groundColor: "#25283a", intensity: 0.86 },
|
||||
directionalLight: { color: "#e0e9ff", intensity: 1.1, offset: [10, 18, -5] },
|
||||
},
|
||||
crypt: {
|
||||
background: "#171721", fog: { color: "#1d1d28", near: 44 },
|
||||
ambientLight: { color: "#c8c3d1", intensity: 0.58 },
|
||||
hemisphereLight: { skyColor: "#c0c2d3", groundColor: "#282530", intensity: 0.86 },
|
||||
directionalLight: { color: "#e7ddcf", intensity: 1.1, offset: [10, 18, -5] },
|
||||
},
|
||||
outdoor: {
|
||||
background: "#293330", fog: { color: "#35423e", near: 80 },
|
||||
ambientLight: { color: "#d4dccb", intensity: 0.58 },
|
||||
hemisphereLight: { skyColor: "#c9d9e5", groundColor: "#30382b", intensity: 0.86 },
|
||||
directionalLight: { color: "#fff0cd", intensity: 1.1, offset: [10, 18, -5] },
|
||||
},
|
||||
};
|
||||
|
||||
const PROFILE_DUNGEONS: Readonly<Record<string, readonly string[]>> = {
|
||||
cavern: ["wailing-caverns", "razorfen-kraul", "razorfen-downs", "blackfathom-deeps", "maraudon", "underbog", "slave-pens", "steamvault"],
|
||||
fire: ["ragefire-chasm", "blackrock-depths", "blackrock-spire", "hellfire-ramparts", "blood-furnace", "shattered-halls"],
|
||||
arcane: ["gnomeregan", "arcatraz", "botanica", "mechanar", "magisters-terrace", "the-nexus", "the-oculus", "violet-hold", "tinkertech-showdown"],
|
||||
crypt: ["shadowfang-keep", "scholomance", "stratholme", "sunken-temple", "auchenai-crypts", "mana-tombs", "sethekk-halls", "shadow-labyrinth", "azjol-nerub", "ahnkahet-old-kingdom", "forge-of-souls", "halls-of-reflection"],
|
||||
outdoor: ["zulfarrak", "black-morass", "escape-from-durnholde", "culling-of-stratholme", "trial-of-the-champion", "pit-of-saron", "frozen-reach"],
|
||||
};
|
||||
|
||||
export function wowDungeonLightingProfile(id: string): string {
|
||||
return Object.entries(PROFILE_DUNGEONS).find(([, ids]) => ids.includes(id))?.[0] ?? "stone";
|
||||
}
|
||||
|
||||
/** Shared adapter also covers authored Wailing Caverns and survives campaign regeneration. */
|
||||
export function withWowDungeonEnvironment(definition: DungeonDefinition): DungeonDefinition {
|
||||
if (definition.expansion === "runewaker") return definition;
|
||||
const profile = WOW_DUNGEON_LIGHTING[wowDungeonLightingProfile(definition.id)];
|
||||
const stairs = (stairCatalog.dungeons as unknown as Readonly<Record<string, {
|
||||
readonly collision: readonly { readonly url: string; readonly checksum: string }[];
|
||||
readonly navigation?: { readonly url: string; readonly checksum: string };
|
||||
readonly ramps: readonly DungeonTraversalRamp[];
|
||||
}>>)[definition.id];
|
||||
// Never apply package-space coordinates to a newly replaced environment.
|
||||
const matchingAssets = stairs && definition.assets.collision.length === stairs.collision.length
|
||||
&& definition.assets.collision.every((asset, index) => asset.url === stairs.collision[index].url
|
||||
&& asset.checksum === stairs.collision[index].checksum);
|
||||
const matchingNavigation = !stairs?.ramps.some((ramp) => ramp.navigationBridges?.length)
|
||||
|| (definition.assets.navigation?.url === stairs?.navigation?.url
|
||||
&& definition.assets.navigation?.checksum === stairs?.navigation?.checksum);
|
||||
return {
|
||||
...definition,
|
||||
presentation: definition.id === "stormwind-stockade" ? definition.presentation : {
|
||||
...definition.presentation, ...profile,
|
||||
fog: { ...definition.presentation.fog, ...profile.fog },
|
||||
},
|
||||
...(matchingAssets && matchingNavigation && stairs.ramps.length ? { traversalRamps: stairs.ramps } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import path from "node:path";
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { BufferGeometry, Group, Mesh } from "three";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import type { DungeonTraversalRamp } from "./dungeonTypes";
|
||||
import stairs from "./generated/wowDungeonStairs.generated.json";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||
import { DUNGEON_PHYSICAL_COLLISION_GROUPS } from "../scene/dungeonLineOfSight";
|
||||
import { createPartyDirectSegmentValidator } from "../scene/partyTraversal";
|
||||
|
||||
const pipelineModule = "../../scripts/dungeon-pipeline/wow-stair-geometry.ts";
|
||||
const { readDungeonGeometry } = await import(pipelineModule) as {
|
||||
readDungeonGeometry(file: string): Promise<BufferGeometry[]>;
|
||||
};
|
||||
const packages = Object.entries(stairs.dungeons).filter(([, entry]) => entry.ramps.length);
|
||||
|
||||
beforeAll(async () => { await RAPIER.init(); });
|
||||
|
||||
describe("shipping WoW dungeon stair proxies", () => {
|
||||
it.each(packages)("%s supports live NPC capsule sweeps uphill/downhill in three lanes", async (id) => {
|
||||
const definition = requireDungeonDefinition(id);
|
||||
const ramps = definition.traversalRamps!;
|
||||
const source = new Group();
|
||||
for (const asset of definition.assets.collision) {
|
||||
for (const geometry of await readDungeonGeometry(path.join(process.cwd(), "public", asset.url!))) {
|
||||
source.add(new Mesh(geometry));
|
||||
}
|
||||
}
|
||||
const prepared = prepareCollision(source, ramps);
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
try {
|
||||
for (const mesh of collisionTrimeshes(prepared)) {
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(Float32Array.from(mesh.vertices), Uint32Array.from(mesh.indices))
|
||||
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||
}
|
||||
world.step();
|
||||
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||
for (const ramp of ramps) {
|
||||
const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const count = Math.max(3, Math.ceil(length / 0.35) + 1);
|
||||
const lateral = Math.max(0, Math.min(ramp.width / 4, ramp.width / 2 - 0.45));
|
||||
for (const lane of [0, -lateral, lateral]) {
|
||||
const points = Array.from({ length: count }, (_, index): [number, number, number] => {
|
||||
const t = index / (count - 1);
|
||||
return [ramp.start[0] + dx * t - dz / length * lane,
|
||||
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t,
|
||||
ramp.start[2] + dz * t + dx / length * lane];
|
||||
});
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const detail = `${id}/${ramp.id}, lane ${lane}, sample ${index}`;
|
||||
expect(allowed(points[index - 1], points[index]), detail).toBe(true);
|
||||
expect(allowed(points[index], points[index - 1]), detail).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
world.free();
|
||||
for (const group of [source, prepared]) group.traverse((node) => { if ((node as Mesh).isMesh) (node as Mesh).geometry.dispose(); });
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("every generated seam matches actual shipping navigation vertices", async () => {
|
||||
for (const [id] of packages) {
|
||||
const definition = requireDungeonDefinition(id);
|
||||
const ramps = definition.traversalRamps as readonly DungeonTraversalRamp[];
|
||||
const bridges = ramps.reduce((count, ramp) => count + (ramp.navigationBridges?.length ?? 0), 0);
|
||||
if (!bridges) continue;
|
||||
const geometries = await readDungeonGeometry(path.join(process.cwd(), "public", definition.assets.navigation!.url!));
|
||||
const geometry = geometries.sort((a, b) => b.getAttribute("position").count - a.getAttribute("position").count)[0];
|
||||
try {
|
||||
const originalCount = geometry.index!.count;
|
||||
smoothDungeonStairGeometry(geometry, ramps, 0.05);
|
||||
expect(() => bridgeDungeonStairNavigation(geometry, ramps, 0.05)).not.toThrow();
|
||||
expect(geometry.index!.count).toBe(originalCount + bridges * 6);
|
||||
} finally { geometries.forEach((entry) => entry.dispose()); }
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BufferGeometry, Float32BufferAttribute } from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||
|
||||
// The offline Node 24 pipeline uses native .ts specifiers; load it at runtime,
|
||||
// without making those tool-only modules part of the browser TypeScript build.
|
||||
const pipelineModule = "../../scripts/dungeon-pipeline/wow-stair-geometry.ts";
|
||||
const { detectDungeonStairRamps, dungeonStairNavigationBridges, dungeonStairRisers } = await import(pipelineModule);
|
||||
|
||||
function staircase(steps: number, rotated = false) {
|
||||
const vertices: number[] = [];
|
||||
for (let step = 0; step < steps; step += 1) {
|
||||
const x = step;
|
||||
const y = step * 0.5;
|
||||
const points = [[x, y, -2], [x, y + 0.5, -2], [x, y + 0.5, 2], [x, y, -2], [x, y + 0.5, 2], [x, y, 2]];
|
||||
for (const point of points) vertices.push(...(rotated ? [-point[2], point[1], point[0]] : point));
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
describe("WoW shipped stair detection", () => {
|
||||
it.each([false, true])("detects repeated risers, including rotated stairs (%s)", (rotated) => {
|
||||
const geometry = staircase(7, rotated);
|
||||
const ramps = detectDungeonStairRamps(dungeonStairRisers(geometry));
|
||||
expect(ramps).toHaveLength(1);
|
||||
expect(ramps[0].width).toBeCloseTo(4.02);
|
||||
expect(ramps[0].end[1] - ramps[0].start[1]).toBeCloseTo(3.5);
|
||||
geometry.dispose();
|
||||
});
|
||||
|
||||
it("does not interpret a single ledge or tall wall as a staircase", () => {
|
||||
const ledge = staircase(1);
|
||||
expect(detectDungeonStairRamps(dungeonStairRisers(ledge))).toEqual([]);
|
||||
const wall = staircase(4);
|
||||
const p = wall.getAttribute("position");
|
||||
for (let index = 0; index < p.count; index += 1) p.setY(index, p.getY(index) * 10);
|
||||
expect(detectDungeonStairRamps(dungeonStairRisers(wall))).toEqual([]);
|
||||
ledge.dispose(); wall.dispose();
|
||||
});
|
||||
|
||||
it("fills separate stair portals using existing source vertices, without bridging an unrelated floor", () => {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||
-1, 0, 0, 1, 0, 0, -1, 0.5, 2, 1, 0.5, 2,
|
||||
-1, 0.75, 3, 1, 0.75, 3, -1, 1.5, 6, 1, 1.5, 6,
|
||||
-1, 8, 3, 1, 8, 3, -1, 8, 4,
|
||||
], 3));
|
||||
geometry.setIndex([0, 1, 3, 0, 3, 2, 4, 5, 7, 4, 7, 6, 8, 9, 10]);
|
||||
const ramps = dungeonStairNavigationBridges(geometry, [{ id: "flight", start: [0, 0, -0.01], end: [0, 1.5, 6.01], width: 3 }]);
|
||||
expect(ramps[0].navigationBridges).toHaveLength(1);
|
||||
smoothDungeonStairGeometry(geometry, ramps, 0.05);
|
||||
expect(() => bridgeDungeonStairNavigation(geometry, ramps, 0.05)).not.toThrow();
|
||||
expect(geometry.index!.count).toBe(21);
|
||||
expect(geometry.getAttribute("position").getY(8)).toBe(8);
|
||||
geometry.dispose();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user