Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 15:05:14 -04:00
1 parent 8b9c95b24f
commit 14e105642c
13 files changed
+355 -12

No files matched your search

+7 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { OnlineGroupActivity } from "./onlineGroupTypes"; import type { OnlineGroupActivity } from "./onlineGroupTypes";
import { enterOnlineGroupActivity } from "./OnlineGroupCoordinator"; import { enterOnlineGroupActivity, onlineGroupActivityNeedsEntry } from "./OnlineGroupCoordinator";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures"; import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it; const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it;
@@ -34,6 +34,12 @@ function entryDependencies(enterDungeon: () => boolean) {
} }
describe("online group activity entry", () => { describe("online group activity entry", () => {
it("recognizes a newly published activity while the previous activity is still active", () => {
const next = { ...DUNGEON_ACTIVITY, id: "activity-dungeon-2", startedAt: 10_000 };
expect(onlineGroupActivityNeedsEntry(next, DUNGEON_ACTIVITY, [DUNGEON_ACTIVITY.id], 10_100)).toBe(true);
expect(onlineGroupActivityNeedsEntry(DUNGEON_ACTIVITY, DUNGEON_ACTIVITY, [], 10_100)).toBe(false);
});
it("enters with the leader's source variant and boss difficulty as a fresh shared run", async () => { it("enters with the leader's source variant and boss difficulty as a fresh shared run", async () => {
const dependencies = entryDependencies(() => true); const dependencies = entryDependencies(() => true);
await enterOnlineGroupActivity({ ...DUNGEON_ACTIVITY, difficultyId: "heroic", bossDifficulty: 9 }, await enterOnlineGroupActivity({ ...DUNGEON_ACTIVITY, difficultyId: "heroic", bossDifficulty: 9 },
+19 -4
View File
@@ -66,11 +66,22 @@ export async function enterOnlineGroupActivity(
return entered; return entered;
} }
export function onlineGroupActivityNeedsEntry(
activity: OnlineGroupActivity,
activeActivity: OnlineGroupActivity | null,
consumedActivityIds: readonly string[],
now = Date.now(),
): boolean {
return activity.id !== activeActivity?.id
&& !consumedActivityIds.includes(activity.id)
&& now - activity.startedAt <= ACTIVITY_JOIN_WINDOW_MS;
}
export function OnlineGroupCoordinator() { export function OnlineGroupCoordinator() {
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter); const character = useShellStore(selectedCharacter);
const phase = useShellStore((state) => state.phase);
const snapshot = useOnlineGroupStore((state) => state.snapshot); const snapshot = useOnlineGroupStore((state) => state.snapshot);
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
const consumedActivityIds = useOnlineGroupStore((state) => state.consumedActivityIds); const consumedActivityIds = useOnlineGroupStore((state) => state.consumedActivityIds);
const refresh = useOnlineGroupStore((state) => state.refresh); const refresh = useOnlineGroupStore((state) => state.refresh);
const updateMember = useOnlineGroupStore((state) => state.updateMember); const updateMember = useOnlineGroupStore((state) => state.updateMember);
@@ -113,8 +124,11 @@ export function OnlineGroupCoordinator() {
useEffect(() => { useEffect(() => {
const activity = snapshot.group?.activity; const activity = snapshot.group?.activity;
if (!session || !activity || phase === "game" || consumedActivityIds.includes(activity.id)) return; if (!session || !activity || !onlineGroupActivityNeedsEntry(
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return; activity,
activeActivity,
consumedActivityIds,
)) return;
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId); const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
if (!member?.character || !member.role) return; if (!member?.character || !member.role) return;
if (pendingActivityIds.current.has(activity.id)) return; if (pendingActivityIds.current.has(activity.id)) return;
@@ -126,7 +140,8 @@ export function OnlineGroupCoordinator() {
.finally(() => { .finally(() => {
pendingActivityIds.current.delete(activity.id); pendingActivityIds.current.delete(activity.id);
}); });
}, [consumeActivity, consumedActivityIds, installedRevision, phase, session?.ownerId, snapshot.group?.activity?.id]); }, [activeActivity?.id, consumeActivity, consumedActivityIds, installedRevision, session?.ownerId,
snapshot.group?.activity?.id]);
return null; return null;
} }
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { canStartAnotherBiteSizedDungeon, startAnotherBiteSizedDungeon } from "./biteSizedDungeonRuntime";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore";
import { useShellStore } from "./shellStore";
const progress: DungeonRunProgress = {
id: "completed-bite-sized-run",
dungeonId: BITE_SIZED_STOCKADES_DUNGEON_ID,
difficultyId: "normal",
bossDifficulty: 1,
startedAt: 1_000,
finishedAt: 9_000,
durationMs: 8_000,
timed: true,
killedBossIds: ["creature-84027"],
party: [{ name: "Tester", classId: "priest", level: 17, role: "healer", npc: false }],
};
const originalShell = useShellStore.getState();
const originalGroup = useOnlineGroupStore.getState();
const originalOnline = useOnlineSessionStore.getState();
beforeEach(() => {
useOnlineGroupStore.getState().reset();
useOnlineSessionStore.getState().reset();
});
afterEach(() => {
useShellStore.setState(originalShell, true);
useOnlineGroupStore.setState(originalGroup, true);
useOnlineSessionStore.setState(originalOnline, true);
});
describe("bite-sized replay", () => {
it("starts a fresh local run and treats the local player as party leader", async () => {
const enterDungeon = vi.fn(() => true);
const saveCheckpoint = vi.fn();
useShellStore.setState({
session: { kind: "offline", ownerId: "local-owner", displayName: "Tester" },
activeDungeonRole: "healer",
enterDungeon,
saveActiveDungeonRunCheckpoint: saveCheckpoint,
});
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(saveCheckpoint).toHaveBeenCalledWith(null);
expect(enterDungeon).toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", {
difficultyId: "normal",
bossDifficulty: 1,
freshRun: true,
});
});
it("lets only the online group leader publish the next shared activity", async () => {
const startActivity = vi.fn(async () => true);
const saveCheckpoint = vi.fn();
const activity = {
id: "shared-bite-sized-run",
type: "dungeon" as const,
contentId: BITE_SIZED_STOCKADES_DUNGEON_ID,
fillWithAi: true,
partySize: 4,
startingLevel: null,
startedAt: 1_000,
};
useShellStore.setState({
session: { kind: "account", ownerId: "leader", displayName: "Leader", accessToken: "test-token" },
saveActiveDungeonRunCheckpoint: saveCheckpoint,
});
useOnlineGroupStore.setState({
startActivity,
snapshot: { invitations: [], group: { id: "group", leaderAccountId: "leader", members: [], activity,
activityRevision: 1, createdAt: 1, updatedAt: 1 } },
});
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(startActivity).toHaveBeenCalledWith(expect.objectContaining({ ownerId: "leader" }), {
type: "dungeon",
contentId: BITE_SIZED_STOCKADES_DUNGEON_ID,
difficultyId: "normal",
bossDifficulty: 1,
partySize: 4,
});
expect(saveCheckpoint).toHaveBeenCalledWith(null);
useShellStore.setState({ session: { kind: "account", ownerId: "follower", displayName: "Follower", accessToken: "test-token" } });
expect(canStartAnotherBiteSizedDungeon()).toBe(false);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { dungeonContentIsUsable } from "../content/contentManager";
import { randomDungeonByCategory } from "../game/dungeonCatalog";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore";
import { useShellStore } from "./shellStore";
export function canStartAnotherBiteSizedDungeon(): boolean {
const shell = useShellStore.getState();
const group = useOnlineGroupStore.getState().snapshot.group;
if (group) return group.leaderAccountId === shell.session?.ownerId;
const online = useOnlineSessionStore.getState().snapshot;
return !online || online.authority;
}
/** Starts a fresh random run locally, or publishes one shared activity for the whole online party. */
export async function startAnotherBiteSizedDungeon(progress: DungeonRunProgress): Promise<boolean> {
const shell = useShellStore.getState();
const groupState = useOnlineGroupStore.getState();
const group = groupState.snapshot.group;
const online = useOnlineSessionStore.getState().snapshot;
if (!canStartAnotherBiteSizedDungeon()) {
shell.setNotice("Only the party leader can start the next Bite-Sized dungeon.");
return false;
}
const next = randomDungeonByCategory(
"bite-sized",
progress.dungeonId,
Math.random,
(dungeon) => dungeonContentIsUsable(dungeon.id),
);
if (!next) {
shell.setNotice("No installed Bite-Sized dungeon is ready to enter.");
return false;
}
const definition = dungeonDefinitionById(next.id);
const difficultyId = definition?.difficultyVariants.some((difficulty) => difficulty.id === progress.difficultyId)
? progress.difficultyId
: undefined;
if (group) {
const session = shell.session;
if (!session || session.kind !== "account" || !session.accessToken) {
shell.setNotice("Reconnect before starting the next shared dungeon.");
return false;
}
const started = await groupState.startActivity(session, {
type: "dungeon",
contentId: next.id,
difficultyId,
bossDifficulty: progress.bossDifficulty,
partySize: group.activity?.partySize ?? 5,
});
if (!started) {
shell.setNotice(useOnlineGroupStore.getState().error || "The next shared dungeon could not be started.");
return false;
}
shell.saveActiveDungeonRunCheckpoint(null);
return true;
}
if (online) {
shell.setNotice("Reconnect to the party before starting the next shared dungeon.");
return false;
}
shell.saveActiveDungeonRunCheckpoint(null);
const entered = shell.enterDungeon(next.id, shell.activeDungeonRole, {
difficultyId,
bossDifficulty: progress.bossDifficulty,
freshRun: true,
});
return entered;
}
+72 -1
View File
@@ -1,18 +1,22 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
activeDungeonCompletedObjectiveIds,
advanceDungeonEncounters,
createDungeonEncounterSnapshot, createDungeonEncounterSnapshot,
reduceDungeonEncounter, reduceDungeonEncounter,
resetDungeonEncounters,
validateDungeonEncounterDefinition, validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime"; } from "./dungeonEncounterRuntime";
import { BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource"; import { BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource";
import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry"; import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry";
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession"; import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
import { useDungeonRunStore } from "./dungeonRunStore"; import { completedDungeonRun, useDungeonRunStore } from "./dungeonRunStore";
import { import {
BITE_SIZED_STOCKADES_BOSS_CANDIDATES, BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
createBiteSizedStockadesDefinition, createBiteSizedStockadesDefinition,
} from "./dungeons/biteSizedStockades"; } from "./dungeons/biteSizedStockades";
import { validateDungeonWorldObject } from "./dungeonWorldObjects"; import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID]; const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID];
@@ -32,6 +36,7 @@ describe("bite-sized Stormwind Stockades", () => {
const bossId = biteSized.bosses[0].id; const bossId = biteSized.bosses[0].id;
expect(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.sourceSpawnId)).toContain(bossId); expect(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.sourceSpawnId)).toContain(bossId);
expect(biteSized.objectiveOrder).toEqual(biteSized.bosses); expect(biteSized.objectiveOrder).toEqual(biteSized.bosses);
expect(biteSized.finalBossDurability).toBe("standard");
expect(biteSized.staticSpawns.filter((spawn) => spawn.id === bossId)).toHaveLength(1); 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.staticSpawns.every((spawn) => Math.abs(spawn.position[2]) <= 15)).toBe(true);
expect(biteSized.navigationLinks).toEqual(source.navigationLinks); expect(biteSized.navigationLinks).toEqual(source.navigationLinks);
@@ -71,6 +76,32 @@ describe("bite-sized Stormwind Stockades", () => {
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId); .toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
}); });
it("uses ordinary boss durability 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];
expect(dungeonEnemyScaling(
definition, entity, boss.id, 17, "normal", 1, characterSource,
).maxHealth, `${characterSource}:${boss.name}`).toBeCloseTo(ordinaryHealth);
}
}
});
it("uses the run identity for fresh, restarted, and shared sessions", () => { it("uses the run identity for fresh, restarted, and shared sessions", () => {
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-a" })).toBe(true); expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-a" })).toBe(true);
const firstBoss = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name; const firstBoss = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name;
@@ -103,4 +134,44 @@ describe("bite-sized Stormwind Stockades", () => {
expect(reduced.flags["dungeon-complete"]).toBe(true); expect(reduced.flags["dungeon-complete"]).toBe(true);
expect(reduced.phase).toBe("complete"); 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,
});
});
}); });
+2 -1
View File
@@ -81,7 +81,8 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId; const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
// Match durability to the character's game, including when crossing dungeon catalogs. // Match durability to the character's game, including when crossing dungeon catalogs.
const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing); const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing);
const bossFloor = partyHealthFloor * (finalBoss ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER : 1); const finaleFloor = finalBoss && dungeon.finalBossDurability !== "standard";
const bossFloor = partyHealthFloor * (finaleFloor ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER : 1);
// A test room's sole objective must not promote every boss to a campaign // A test room's sole objective must not promote every boss to a campaign
// finale. A reduced floor still prevents high-level source creatures with // finale. A reduced floor still prevents high-level source creatures with
// tiny extrapolated level-two health from dying before mechanics can run. // tiny extrapolated level-two health from dying before mechanics can run.
+8
View File
@@ -4,6 +4,7 @@ import {
DUNGEON_CATEGORIES, DUNGEON_CATEGORIES,
dungeonById, dungeonById,
dungeonsByCategory, dungeonsByCategory,
randomDungeonByCategory,
} from "./dungeonCatalog"; } from "./dungeonCatalog";
import { DUNGEON_MANIFEST } from "./dungeonManifest"; import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated"; import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
@@ -51,6 +52,13 @@ describe("dungeon catalog", () => {
.toEqual(["bite-sized-stormwind-stockade"]); .toEqual(["bite-sized-stormwind-stockade"]);
}); });
it("selects another available category dungeon and falls back to the sole bite-sized entry", () => {
expect(randomDungeonByCategory("bite-sized", "bite-sized-stormwind-stockade", () => 0)?.id)
.toBe("bite-sized-stormwind-stockade");
expect(randomDungeonByCategory("wow", "stormwind-stockade", () => 0)?.id)
.not.toBe("stormwind-stockade");
});
it("keeps stable unique ids and resolves only catalogued dungeons", () => { it("keeps stable unique ids and resolves only catalogued dungeons", () => {
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id); const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
expect(new Set(ids).size).toBe(ids.length); expect(new Set(ids).size).toBe(ids.length);
+16
View File
@@ -63,3 +63,19 @@ export function dungeonById(id: string): DungeonCatalogEntry | null {
export function dungeonsByCategory(category: DungeonCategoryId): readonly DungeonCatalogEntry[] { export function dungeonsByCategory(category: DungeonCategoryId): readonly DungeonCatalogEntry[] {
return DUNGEON_CATALOG.filter((dungeon) => dungeon.category === category); return DUNGEON_CATALOG.filter((dungeon) => dungeon.category === category);
} }
/** Selects from enterable category content and avoids an immediate repeat when alternatives exist. */
export function randomDungeonByCategory(
category: DungeonCategoryId,
currentDungeonId?: string,
random: () => number = Math.random,
eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true,
): DungeonCatalogEntry | null {
const available = dungeonsByCategory(category).filter((dungeon) => dungeon.available && eligible(dungeon));
const candidates = available.length > 1
? available.filter((dungeon) => dungeon.id !== currentDungeonId)
: available;
if (candidates.length === 0) return null;
const roll = Math.max(0, Math.min(0.9999999999999999, random()));
return candidates[Math.floor(roll * candidates.length)] ?? candidates[0];
}
+11 -2
View File
@@ -396,6 +396,8 @@ export interface DungeonEncounterProvider {
readonly definition: DungeonEncounterDefinition; readonly definition: DungeonEncounterDefinition;
readonly encounterId: string; readonly encounterId: string;
readonly dungeonId: string; readonly dungeonId: string;
/** Framework-created providers may be rebuilt when a seeded run changes its definition. */
readonly runtimeManaged?: boolean;
readonly getSnapshot: () => DungeonEncounterSnapshot; readonly getSnapshot: () => DungeonEncounterSnapshot;
readonly dispatch: (event: DungeonEncounterEvent) => readonly DungeonEncounterEffect[]; readonly dispatch: (event: DungeonEncounterEvent) => readonly DungeonEncounterEffect[];
readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void; readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void;
@@ -564,8 +566,14 @@ export function activateDungeonEncounterDefinitions(
const existing = providers.get(definition.id); const existing = providers.get(definition.id);
if (existing) { if (existing) {
if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`); if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`);
existing.setContext?.({ difficultyId }); if (!existing.runtimeManaged || existing.definition === definition) {
continue; existing.setContext?.({ difficultyId });
continue;
}
// Seeded variants can keep a stable encounter id while changing the
// boss/objective rules. Reusing that provider would leave completion
// listening for the previous run's boss.
providers.delete(definition.id);
} }
let snapshot = createDungeonEncounterSnapshot(definition); let snapshot = createDungeonEncounterSnapshot(definition);
let context: DungeonEncounterRuntimeContext = { difficultyId }; let context: DungeonEncounterRuntimeContext = { difficultyId };
@@ -573,6 +581,7 @@ export function activateDungeonEncounterDefinitions(
definition, definition,
encounterId: definition.id, encounterId: definition.id,
dungeonId, dungeonId,
runtimeManaged: true,
getSnapshot: () => snapshot, getSnapshot: () => snapshot,
dispatch: (event) => { dispatch: (event) => {
const result = reduceDungeonEncounter(definition, snapshot, event, context); const result = reduceDungeonEncounter(definition, snapshot, event, context);
+2
View File
@@ -258,6 +258,8 @@ export interface DungeonDefinition {
readonly roamingPacks: readonly MobPackDefinition[]; readonly roamingPacks: readonly MobPackDefinition[];
readonly bosses: readonly DungeonBossObjective[]; readonly bosses: readonly DungeonBossObjective[];
readonly objectiveOrder: readonly DungeonBossObjective[]; readonly objectiveOrder: readonly DungeonBossObjective[];
/** Defaults to finale; short-form dungeons can keep their last objective at standard boss durability. */
readonly finalBossDurability?: "standard" | "finale";
readonly navigationLinks: readonly DungeonNavigationLink[]; readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps?: readonly DungeonTraversalRamp[]; readonly traversalRamps?: readonly DungeonTraversalRamp[];
/** Serializable dungeon-wide orchestration kept separate from render state. */ /** Serializable dungeon-wide orchestration kept separate from render state. */
+2
View File
@@ -133,6 +133,7 @@ export function createBiteSizedStockadesDefinition(
roamingPacks: [], roamingPacks: [],
bosses: [boss], bosses: [boss],
objectiveOrder: [boss], objectiveOrder: [boss],
finalBossDurability: "standard",
// Keep the complete reviewed Stockades navigation graph connected. The // Keep the complete reviewed Stockades navigation graph connected. The
// physical branch walls, not navmesh pruning, close the unused wings. // physical branch walls, not navmesh pruning, close the unused wings.
navigationLinks: source.navigationLinks, navigationLinks: source.navigationLinks,
@@ -150,6 +151,7 @@ export function createBiteSizedStockadesDefinition(
reviewedBehavior: "A single seeded Stockades boss completes the bite-sized run in the first boss room.", reviewedBehavior: "A single seeded Stockades boss completes the bite-sized run in the first boss room.",
adaptations: [ adaptations: [
"Each seeded run retains the selected source boss objective ID so saves, loot, guides, and leaderboards report the correct variant.", "Each seeded run retains the selected source boss objective ID so saves, loot, guides, and leaderboards report the correct variant.",
"The one-boss run uses standard boss durability instead of the campaign-finale health floor.",
"The side-wing stairs retain their navigation bridges so the central route stays on the retained navmesh component; physical walls seal both unused wings.", "The side-wing stairs retain their navigation bridges so the central route stays on the retained navmesh component; physical walls seal both unused wings.",
], ],
remainingGaps: [], remainingGaps: [],
+37 -3
View File
@@ -1,7 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { awaitSharedDungeonRunPlacement, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun, type LeaderboardScope } from "../app/dungeonLeaderboardClient"; import { awaitSharedDungeonRunPlacement, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun, type LeaderboardScope } from "../app/dungeonLeaderboardClient";
import { useOnlineSessionStore } from "../app/onlineSessionStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { startAnotherBiteSizedDungeon } from "../app/biteSizedDungeonRuntime";
import { dungeonById } from "../game/dungeonCatalog";
import { completedDungeonRun, useDungeonRunStore } from "../game/dungeonRunStore"; import { completedDungeonRun, useDungeonRunStore } from "../game/dungeonRunStore";
import { formatDungeonDuration, type DungeonRunPlacement, type DungeonRunProgress } from "../game/dungeonRunTypes"; import { formatDungeonDuration, type DungeonRunPlacement, type DungeonRunProgress } from "../game/dungeonRunTypes";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json"; import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
@@ -18,20 +21,25 @@ export interface RunPlacementState {
readonly loading: boolean; readonly loading: boolean;
} }
export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry }: { export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry, onAnother,
startingAnother = false, anotherMessage }: {
readonly progress: DungeonRunProgress; readonly progress: DungeonRunProgress;
readonly ranking: RunPlacementState; readonly ranking: RunPlacementState;
readonly onFinish: () => void; readonly onFinish: () => void;
readonly onLoot: () => void; readonly onLoot: () => void;
readonly onRetry: () => void; readonly onRetry: () => void;
readonly onAnother?: () => void;
readonly startingAnother?: boolean;
readonly anotherMessage?: string;
}) { }) {
const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId); const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId);
const difficulty = definition?.difficulties.find((entry) => entry.id === progress.difficultyId); const difficulty = definition?.difficulties.find((entry) => entry.id === progress.difficultyId);
const fullClear = difficulty ? isLeaderboardFullClear(difficulty, progress.killedBossIds) : false; const fullClear = difficulty ? isLeaderboardFullClear(difficulty, progress.killedBossIds) : false;
const controller = useMenuController([ const controller = useMenuController([
...(onAnother ? [{ id: "run-another", run: onAnother, enabled: !startingAnother }] : []),
{ id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish }, { id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish },
...(ranking.scope === "device" ? [{ id: "run-retry", run: onRetry }] : []), ...(ranking.scope === "device" ? [{ id: "run-retry", run: onRetry }] : []),
], { initialId: "run-loot", onBack: onLoot }); ], { initialId: onAnother ? "run-another" : "run-loot", onBack: onLoot });
const controls = { selectedId: controller.selectedId, select: controller.select }; const controls = { selectedId: controller.selectedId, select: controller.select };
const panel = useRef<HTMLElement>(null); const panel = useRef<HTMLElement>(null);
useEffect(() => { useEffect(() => {
@@ -64,11 +72,16 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
{ranking.placement?.fullRank ? <p className="dungeon-results__full-rank">Full dungeon clears: <strong>#{ranking.placement.fullRank}</strong> of {ranking.placement.fullTotal}</p> : null} {ranking.placement?.fullRank ? <p className="dungeon-results__full-rank">Full dungeon clears: <strong>#{ranking.placement.fullRank}</strong> of {ranking.placement.fullTotal}</p> : null}
{!progress.timed ? <p className="dungeon-results__note">This resumed run has no saved timer. Start a new expedition to record a leaderboard time.</p> : null} {!progress.timed ? <p className="dungeon-results__note">This resumed run has no saved timer. Start a new expedition to record a leaderboard time.</p> : null}
{ranking.scope === "device" ? <p className="dungeon-results__note">Your time is saved on this device. Reconnect to submit it to the world leaderboard.</p> : null} {ranking.scope === "device" ? <p className="dungeon-results__note">Your time is saved on this device. Reconnect to submit it to the world leaderboard.</p> : null}
{anotherMessage ? <p className="dungeon-results__note" role="status">{anotherMessage}</p> : null}
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId} <div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
difficultyId={progress.difficultyId} bossDifficulty={progress.bossDifficulty} party={progress.party} killedBossIds={progress.killedBossIds} /></div> difficultyId={progress.difficultyId} bossDifficulty={progress.bossDifficulty} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
<footer> <footer>
{onAnother ? <ControllerButton {...controls} controlId="run-another" className="button button--primary"
disabled={startingAnother} onClick={onAnother}>
{startingAnother ? "Starting next dungeon…" : "Another random Bite-Sized dungeon"}
</ControllerButton> : null}
<ControllerButton {...controls} controlId="run-loot" className="button" onClick={onLoot}>Stay and loot</ControllerButton> <ControllerButton {...controls} controlId="run-loot" className="button" onClick={onLoot}>Stay and loot</ControllerButton>
<ControllerButton {...controls} controlId="run-finish" className="button button--primary" onClick={onFinish}>Return to main menu</ControllerButton> <ControllerButton {...controls} controlId="run-finish" className={`button ${onAnother ? "" : "button--primary"}`} onClick={onFinish}>Return to main menu</ControllerButton>
{ranking.scope === "device" ? <ControllerButton {...controls} controlId="run-retry" className="button" onClick={onRetry}>Retry submission</ControllerButton> : null} {ranking.scope === "device" ? <ControllerButton {...controls} controlId="run-retry" className="button" onClick={onRetry}>Retry submission</ControllerButton> : null}
</footer> </footer>
</section> </section>
@@ -80,6 +93,12 @@ export function DungeonRunResults() {
const overlay = useGameStore((state) => state.overlay); const overlay = useGameStore((state) => state.overlay);
const record = useMemo(() => progress ? completedDungeonRun(progress) : null, [progress]); const record = useMemo(() => progress ? completedDungeonRun(progress) : null, [progress]);
const [retry, setRetry] = useState(0); const [retry, setRetry] = useState(0);
const [startingAnother, setStartingAnother] = useState(false);
const [anotherError, setAnotherError] = useState("");
const group = useOnlineGroupStore((state) => state.snapshot.group);
const groupBusy = useOnlineGroupStore((state) => state.busy);
const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null);
const online = useOnlineSessionStore((state) => state.snapshot);
const [ranking, setRanking] = useState<RunPlacementState>({ scope: "world", placement: null, loading: false }); const [ranking, setRanking] = useState<RunPlacementState>({ scope: "world", placement: null, loading: false });
useEffect(() => { useEffect(() => {
if (!record) { setRanking({ scope: "world", placement: null, loading: false }); return; } if (!record) { setRanking({ scope: "world", placement: null, loading: false }); return; }
@@ -95,7 +114,22 @@ export function DungeonRunResults() {
return () => { live = false; }; return () => { live = false; };
}, [record, retry]); }, [record, retry]);
if (overlay !== "dungeon-results" || !progress || progress.finishedAt === null) return null; if (overlay !== "dungeon-results" || !progress || progress.finishedAt === null) return null;
const biteSized = dungeonById(progress.dungeonId)?.category === "bite-sized";
const partyLeader = group ? group.leaderAccountId === sessionOwnerId : !online || online.authority;
const canStartAnother = biteSized && partyLeader;
const leaderMessage = biteSized && !partyLeader
? "The party leader can start the next random Bite-Sized dungeon."
: "";
return <DungeonRunResultsDialog progress={progress} ranking={ranking} onRetry={() => setRetry((value) => value + 1)} return <DungeonRunResultsDialog progress={progress} ranking={ranking} onRetry={() => setRetry((value) => value + 1)}
onAnother={canStartAnother ? () => {
setStartingAnother(true);
setAnotherError("");
void startAnotherBiteSizedDungeon(progress).then((started) => {
if (!started) setAnotherError(useShellStore.getState().notice || "The next dungeon could not be started.");
}).finally(() => setStartingAnother(false));
} : undefined}
startingAnother={startingAnother || groupBusy}
anotherMessage={anotherError || leaderMessage}
onLoot={() => useGameStore.getState().closeOverlay()} onFinish={() => { onLoot={() => useGameStore.getState().closeOverlay()} onFinish={() => {
useShellStore.getState().saveActiveDungeonRunCheckpoint(null); useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
useGameStore.getState().closeOverlay(); useGameStore.getState().closeOverlay();
+10
View File
@@ -7,6 +7,7 @@ import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json"
import type { DungeonRunProgress } from "../game/dungeonRunTypes"; import type { DungeonRunProgress } from "../game/dungeonRunTypes";
const caves = catalog.dungeons.find((entry) => entry.id === "barren-caves")!; const caves = catalog.dungeons.find((entry) => entry.id === "barren-caves")!;
const biteSized = catalog.dungeons.find((entry) => entry.id === "bite-sized-stormwind-stockade")!;
const progress: DungeonRunProgress = { id: "ui-caves-test", dungeonId: caves.id, difficultyId: "normal", timed: true, const progress: DungeonRunProgress = { id: "ui-caves-test", dungeonId: caves.id, difficultyId: "normal", timed: true,
startedAt: 1000, finishedAt: 13345, durationMs: 12345, killedBossIds: [caves.finalBossId], startedAt: 1000, finishedAt: 13345, durationMs: 12345, killedBossIds: [caves.finalBossId],
party: [{ name: "Tester", classId: "rom-priest", level: 3, role: "healer", npc: false }, party: [{ name: "Tester", classId: "rom-priest", level: 3, role: "healer", npc: false },
@@ -27,6 +28,15 @@ describe("dungeon results and leaderboard", () => {
onFinish: noop, onLoot: noop, onRetry: noop })); onFinish: noop, onLoot: noop, onRetry: noop }));
expect(html).toContain("This device leaderboard"); expect(html).toContain("Retry submission"); expect(html).toContain("This device leaderboard"); expect(html).toContain("Retry submission");
}); });
it("offers the party leader another random bite-sized run without removing the menu exit", () => {
const biteProgress = { ...progress, id: "bite-sized-ui-test", dungeonId: biteSized.id,
killedBossIds: [biteSized.finalBossId] };
const html = renderToStaticMarkup(createElement(DungeonRunResultsDialog, { progress: biteProgress,
ranking: { scope: "world", loading: false, placement: null },
onFinish: noop, onLoot: noop, onRetry: noop, onAnother: noop }));
expect(html).toContain("Another random Bite-Sized dungeon");
expect(html).toContain("Return to main menu");
});
it("offers both clear filters and expandable party/boss records in the dungeon picker", () => { it("offers both clear filters and expandable party/boss records in the dungeon picker", () => {
const html = renderToStaticMarkup(createElement(DungeonLeaderboard, { dungeonTitle: caves.title, const html = renderToStaticMarkup(createElement(DungeonLeaderboard, { dungeonTitle: caves.title,
data: { entries: [{ ...progress, durationMs: progress.durationMs!, finishedAt: progress.finishedAt!, fullClear: true, rank: 1 }], total: 1 }, data: { entries: [{ ...progress, durationMs: progress.durationMs!, finishedAt: progress.finishedAt!, fullClear: true, rank: 1 }], total: 1 },