Files
healer-man/src/game/biteSizedStockades.test.ts
2026-09-27 21:48:00 -04:00

186 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
});
});
});