186 lines
9.0 KiB
TypeScript
186 lines
9.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
activeDungeonCompletedObjectiveIds,
|
||
advanceDungeonEncounters,
|
||
createDungeonEncounterSnapshot,
|
||
reduceDungeonEncounter,
|
||
resetDungeonEncounters,
|
||
validateDungeonEncounterDefinition,
|
||
} from "./dungeonEncounterRuntime";
|
||
import { BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource";
|
||
import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry";
|
||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||
import { completedDungeonRun, useDungeonRunStore } from "./dungeonRunStore";
|
||
import {
|
||
BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
|
||
createBiteSizedStockadesDefinition,
|
||
} from "./dungeons/biteSizedStockades";
|
||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||
import { dungeonEnemyScaling } from "./dungeonBossScaling";
|
||
|
||
const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID];
|
||
|
||
describe("bite-sized Stormwind Stockades", () => {
|
||
it("creates a separate central-corridor dungeon without changing Stockades", () => {
|
||
const originalBossIds = source.bosses.map((boss) => boss.id);
|
||
const originalSpawnCount = source.staticSpawns.length;
|
||
const biteSized = createBiteSizedStockadesDefinition(source, "run-a");
|
||
|
||
expect(biteSized.id).toBe(BITE_SIZED_STOCKADES_DUNGEON_ID);
|
||
expect(biteSized).not.toBe(source);
|
||
expect(source.bosses.map((boss) => boss.id)).toEqual(originalBossIds);
|
||
expect(source.staticSpawns).toHaveLength(originalSpawnCount);
|
||
expect(source.bosses).toHaveLength(5);
|
||
expect(source.serverGameObjects.some((object) => object.id.startsWith("bite-sized-"))).toBe(false);
|
||
|
||
const bossId = biteSized.bosses[0].id;
|
||
expect(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.sourceSpawnId)).toContain(bossId);
|
||
expect(biteSized.objectiveOrder).toEqual(biteSized.bosses);
|
||
expect(biteSized.finalBossDurability).toBe("standard");
|
||
expect(biteSized.staticSpawns.filter((spawn) => spawn.id === bossId)).toHaveLength(1);
|
||
expect(biteSized.staticSpawns.every((spawn) => Math.abs(spawn.position[2]) <= 15)).toBe(true);
|
||
expect(biteSized.navigationLinks).toEqual(source.navigationLinks);
|
||
expect(biteSized.traversalRamps).toEqual(source.traversalRamps);
|
||
expect(biteSized.roamingPacks).toEqual([]);
|
||
});
|
||
|
||
it("seals both side wings with visible movement and sight blockers", () => {
|
||
const biteSized = createBiteSizedStockadesDefinition(source, "run-b");
|
||
const walls = biteSized.serverGameObjects.filter((object) => object.id.startsWith("bite-sized-stockades-"));
|
||
|
||
expect(walls.map((wall) => wall.id).sort()).toEqual([
|
||
"bite-sized-stockades-north-wall",
|
||
"bite-sized-stockades-south-wall",
|
||
]);
|
||
for (const wall of walls) {
|
||
expect(() => validateDungeonWorldObject(wall)).not.toThrow();
|
||
expect(wall.states).toEqual([expect.objectContaining({
|
||
id: "sealed",
|
||
visible: true,
|
||
blocksMovement: true,
|
||
blocksSight: true,
|
||
})]);
|
||
}
|
||
});
|
||
|
||
it("chooses all five source bosses deterministically across fresh run ids", () => {
|
||
const selected = new Set<string>();
|
||
for (let index = 0; index < 1_000; index += 1) {
|
||
selected.add(createBiteSizedStockadesDefinition(source, `fresh-run-${index}`).bosses[0].name);
|
||
}
|
||
expect([...selected].sort()).toEqual(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.name).sort());
|
||
const first = createBiteSizedStockadesDefinition(source, "same-run");
|
||
const second = createBiteSizedStockadesDefinition(source, "same-run");
|
||
expect(second.bosses[0]).toEqual(first.bosses[0]);
|
||
expect(second.staticSpawns.find((spawn) => spawn.id === second.bosses[0].id)?.entityId)
|
||
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
|
||
});
|
||
|
||
it("retains the shorter Bite-Sized durability curve for every level-17 roll", () => {
|
||
const variants = new Map<string, ReturnType<typeof createBiteSizedStockadesDefinition>>();
|
||
for (let index = 0; index < 1_000 && variants.size < BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length; index += 1) {
|
||
const definition = createBiteSizedStockadesDefinition(source, `level-17-run-${index}`);
|
||
variants.set(definition.bosses[0].id, definition);
|
||
}
|
||
expect(variants.size).toBe(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length);
|
||
|
||
const ordinaryBoss = source.bosses[0];
|
||
const ordinarySpawn = source.staticSpawns.find((spawn) => spawn.id === ordinaryBoss.id)!;
|
||
const ordinaryEntity = source.entities[ordinarySpawn.entityId];
|
||
for (const characterSource of ["wow", "rom", "coa"] as const) {
|
||
const ordinaryHealth = dungeonEnemyScaling(
|
||
source, ordinaryEntity, ordinaryBoss.id, 17, "normal", 1, characterSource,
|
||
).maxHealth;
|
||
for (const definition of variants.values()) {
|
||
const boss = definition.bosses[0];
|
||
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||
const entity = definition.entities[spawn.entityId];
|
||
const health = dungeonEnemyScaling(
|
||
definition, entity, boss.id, 17, "normal", 1, characterSource,
|
||
).maxHealth;
|
||
expect(health, `${characterSource}:${boss.name}`).toBeGreaterThan(0);
|
||
expect(health, `${characterSource}:${boss.name}`).toBeLessThan(ordinaryHealth);
|
||
// The established leveling curve interpolates a 0.685–0.835 WoW
|
||
// floor/ceiling at level 17; editor work must not restore campaign HP.
|
||
if (characterSource === "wow") {
|
||
expect(health).toBeGreaterThanOrEqual(ordinaryHealth * 0.685 - 0.001);
|
||
expect(health).toBeLessThanOrEqual(ordinaryHealth * 0.835 + 0.001);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
it("uses the run identity for fresh, restarted, and shared sessions", () => {
|
||
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-a" })).toBe(true);
|
||
const firstBoss = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name;
|
||
expect(useDungeonRunStore.getState().progress?.id).toBe("run-a");
|
||
|
||
expect(restartActiveDungeonSession()).toBe(true);
|
||
expect(dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name).toBe(firstBoss);
|
||
|
||
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-b" })).toBe(true);
|
||
expect(useDungeonRunStore.getState().progress?.id).toBe("run-b");
|
||
expect(dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name).not.toBe(firstBoss);
|
||
});
|
||
|
||
it("completes the one-boss run only through its stable objective", () => {
|
||
const biteSized = createBiteSizedStockadesDefinition(source, "completion-run");
|
||
const bossId = biteSized.bosses[0].id;
|
||
const encounter = biteSized.encounters![0];
|
||
expect(() => validateDungeonEncounterDefinition(encounter)).not.toThrow();
|
||
const snapshot = createDungeonEncounterSnapshot(encounter, 1_000);
|
||
const reduced = reduceDungeonEncounter(encounter, snapshot, {
|
||
kind: "tick",
|
||
now: 2_000,
|
||
facts: {
|
||
registeredEntityIds: [bossId],
|
||
deadEntityIds: [bossId],
|
||
},
|
||
}).snapshot;
|
||
|
||
expect(reduced.completedObjectiveIds).toEqual([bossId]);
|
||
expect(reduced.flags["dungeon-complete"]).toBe(true);
|
||
expect(reduced.phase).toBe("complete");
|
||
});
|
||
|
||
it("rebuilds seeded boss rules and records the original timer after a wipe and retry", () => {
|
||
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "stale-provider-a" })).toBe(true);
|
||
const firstBossId = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].id;
|
||
let nextRunId = "stale-provider-b";
|
||
for (let index = 0; index < 1_000; index += 1) {
|
||
const candidate = `stale-provider-${index}`;
|
||
if (createBiteSizedStockadesDefinition(source, candidate).bosses[0].id !== firstBossId) {
|
||
nextRunId = candidate;
|
||
break;
|
||
}
|
||
}
|
||
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: nextRunId })).toBe(true);
|
||
const rerolledBossId = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].id;
|
||
expect(rerolledBossId).not.toBe(firstBossId);
|
||
|
||
const party = [{ name: "Tester", classId: "priest", level: 17, role: "healer", npc: false }] as const;
|
||
const run = useDungeonRunStore.getState();
|
||
run.start(1_000, party);
|
||
advanceDungeonEncounters(BITE_SIZED_STOCKADES_DUNGEON_ID, {
|
||
kind: "tick",
|
||
now: 2_000,
|
||
facts: { registeredEntityIds: [rerolledBossId], deadEntityIds: [] },
|
||
});
|
||
resetDungeonEncounters("boss-wipe", BITE_SIZED_STOCKADES_DUNGEON_ID, 3_000);
|
||
expect(useDungeonRunStore.getState().progress?.startedAt).toBe(1_000);
|
||
|
||
advanceDungeonEncounters(BITE_SIZED_STOCKADES_DUNGEON_ID, {
|
||
kind: "tick",
|
||
now: 9_000,
|
||
facts: { registeredEntityIds: [rerolledBossId], deadEntityIds: [rerolledBossId] },
|
||
});
|
||
run.advance(activeDungeonCompletedObjectiveIds(BITE_SIZED_STOCKADES_DUNGEON_ID), [rerolledBossId], 9_000, party);
|
||
expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toMatchObject({
|
||
id: nextRunId,
|
||
durationMs: 8_000,
|
||
killedBossIds: [rerolledBossId],
|
||
fullClear: true,
|
||
});
|
||
});
|
||
});
|