forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
8b9c95b24f
commit
14e105642c
13 files changed
+355
-12
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OnlineGroupActivity } from "./onlineGroupTypes";
|
||||
import { enterOnlineGroupActivity } from "./OnlineGroupCoordinator";
|
||||
import { enterOnlineGroupActivity, onlineGroupActivityNeedsEntry } from "./OnlineGroupCoordinator";
|
||||
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
|
||||
const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it;
|
||||
@@ -34,6 +34,12 @@ function entryDependencies(enterDungeon: () => boolean) {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const dependencies = entryDependencies(() => true);
|
||||
await enterOnlineGroupActivity({ ...DUNGEON_ACTIVITY, difficultyId: "heroic", bossDifficulty: 9 },
|
||||
|
||||
@@ -66,11 +66,22 @@ export async function enterOnlineGroupActivity(
|
||||
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() {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const character = useShellStore(selectedCharacter);
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const snapshot = useOnlineGroupStore((state) => state.snapshot);
|
||||
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||
const consumedActivityIds = useOnlineGroupStore((state) => state.consumedActivityIds);
|
||||
const refresh = useOnlineGroupStore((state) => state.refresh);
|
||||
const updateMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
@@ -113,8 +124,11 @@ export function OnlineGroupCoordinator() {
|
||||
|
||||
useEffect(() => {
|
||||
const activity = snapshot.group?.activity;
|
||||
if (!session || !activity || phase === "game" || consumedActivityIds.includes(activity.id)) return;
|
||||
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
|
||||
if (!session || !activity || !onlineGroupActivityNeedsEntry(
|
||||
activity,
|
||||
activeActivity,
|
||||
consumedActivityIds,
|
||||
)) return;
|
||||
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
|
||||
if (!member?.character || !member.role) return;
|
||||
if (pendingActivityIds.current.has(activity.id)) return;
|
||||
@@ -126,7 +140,8 @@ export function OnlineGroupCoordinator() {
|
||||
.finally(() => {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
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 { useDungeonRunStore } from "./dungeonRunStore";
|
||||
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];
|
||||
|
||||
@@ -32,6 +36,7 @@ describe("bite-sized Stormwind Stockades", () => {
|
||||
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);
|
||||
@@ -71,6 +76,32 @@ describe("bite-sized Stormwind Stockades", () => {
|
||||
.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", () => {
|
||||
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-a" })).toBe(true);
|
||||
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.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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,8 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
|
||||
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
||||
// Match durability to the character's game, including when crossing dungeon catalogs.
|
||||
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
|
||||
// finale. A reduced floor still prevents high-level source creatures with
|
||||
// tiny extrapolated level-two health from dying before mechanics can run.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DUNGEON_CATEGORIES,
|
||||
dungeonById,
|
||||
dungeonsByCategory,
|
||||
randomDungeonByCategory,
|
||||
} from "./dungeonCatalog";
|
||||
import { DUNGEON_MANIFEST } from "./dungeonManifest";
|
||||
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
||||
@@ -51,6 +52,13 @@ describe("dungeon catalog", () => {
|
||||
.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", () => {
|
||||
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
|
||||
@@ -63,3 +63,19 @@ export function dungeonById(id: string): DungeonCatalogEntry | null {
|
||||
export function dungeonsByCategory(category: DungeonCategoryId): readonly DungeonCatalogEntry[] {
|
||||
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];
|
||||
}
|
||||
@@ -396,6 +396,8 @@ export interface DungeonEncounterProvider {
|
||||
readonly definition: DungeonEncounterDefinition;
|
||||
readonly encounterId: string;
|
||||
readonly dungeonId: string;
|
||||
/** Framework-created providers may be rebuilt when a seeded run changes its definition. */
|
||||
readonly runtimeManaged?: boolean;
|
||||
readonly getSnapshot: () => DungeonEncounterSnapshot;
|
||||
readonly dispatch: (event: DungeonEncounterEvent) => readonly DungeonEncounterEffect[];
|
||||
readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void;
|
||||
@@ -564,8 +566,14 @@ export function activateDungeonEncounterDefinitions(
|
||||
const existing = providers.get(definition.id);
|
||||
if (existing) {
|
||||
if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`);
|
||||
existing.setContext?.({ difficultyId });
|
||||
continue;
|
||||
if (!existing.runtimeManaged || existing.definition === definition) {
|
||||
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 context: DungeonEncounterRuntimeContext = { difficultyId };
|
||||
@@ -573,6 +581,7 @@ export function activateDungeonEncounterDefinitions(
|
||||
definition,
|
||||
encounterId: definition.id,
|
||||
dungeonId,
|
||||
runtimeManaged: true,
|
||||
getSnapshot: () => snapshot,
|
||||
dispatch: (event) => {
|
||||
const result = reduceDungeonEncounter(definition, snapshot, event, context);
|
||||
|
||||
@@ -258,6 +258,8 @@ export interface DungeonDefinition {
|
||||
readonly roamingPacks: readonly MobPackDefinition[];
|
||||
readonly bosses: 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 traversalRamps?: readonly DungeonTraversalRamp[];
|
||||
/** Serializable dungeon-wide orchestration kept separate from render state. */
|
||||
|
||||
@@ -133,6 +133,7 @@ export function createBiteSizedStockadesDefinition(
|
||||
roamingPacks: [],
|
||||
bosses: [boss],
|
||||
objectiveOrder: [boss],
|
||||
finalBossDurability: "standard",
|
||||
// Keep the complete reviewed Stockades navigation graph connected. The
|
||||
// physical branch walls, not navmesh pruning, close the unused wings.
|
||||
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.",
|
||||
adaptations: [
|
||||
"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.",
|
||||
],
|
||||
remainingGaps: [],
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { awaitSharedDungeonRunPlacement, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun, type LeaderboardScope } from "../app/dungeonLeaderboardClient";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { startAnotherBiteSizedDungeon } from "../app/biteSizedDungeonRuntime";
|
||||
import { dungeonById } from "../game/dungeonCatalog";
|
||||
import { completedDungeonRun, useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { formatDungeonDuration, type DungeonRunPlacement, type DungeonRunProgress } from "../game/dungeonRunTypes";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
@@ -18,20 +21,25 @@ export interface RunPlacementState {
|
||||
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 ranking: RunPlacementState;
|
||||
readonly onFinish: () => void;
|
||||
readonly onLoot: () => void;
|
||||
readonly onRetry: () => void;
|
||||
readonly onAnother?: () => void;
|
||||
readonly startingAnother?: boolean;
|
||||
readonly anotherMessage?: string;
|
||||
}) {
|
||||
const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId);
|
||||
const difficulty = definition?.difficulties.find((entry) => entry.id === progress.difficultyId);
|
||||
const fullClear = difficulty ? isLeaderboardFullClear(difficulty, progress.killedBossIds) : false;
|
||||
const controller = useMenuController([
|
||||
...(onAnother ? [{ id: "run-another", run: onAnother, enabled: !startingAnother }] : []),
|
||||
{ id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish },
|
||||
...(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 panel = useRef<HTMLElement>(null);
|
||||
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}
|
||||
{!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}
|
||||
{anotherMessage ? <p className="dungeon-results__note" role="status">{anotherMessage}</p> : null}
|
||||
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
|
||||
difficultyId={progress.difficultyId} bossDifficulty={progress.bossDifficulty} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
|
||||
<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-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}
|
||||
</footer>
|
||||
</section>
|
||||
@@ -80,6 +93,12 @@ export function DungeonRunResults() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const record = useMemo(() => progress ? completedDungeonRun(progress) : null, [progress]);
|
||||
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 });
|
||||
useEffect(() => {
|
||||
if (!record) { setRanking({ scope: "world", placement: null, loading: false }); return; }
|
||||
@@ -95,7 +114,22 @@ export function DungeonRunResults() {
|
||||
return () => { live = false; };
|
||||
}, [record, retry]);
|
||||
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)}
|
||||
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={() => {
|
||||
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
|
||||
useGameStore.getState().closeOverlay();
|
||||
|
||||
@@ -7,6 +7,7 @@ import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json"
|
||||
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
|
||||
|
||||
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,
|
||||
startedAt: 1000, finishedAt: 13345, durationMs: 12345, killedBossIds: [caves.finalBossId],
|
||||
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 }));
|
||||
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", () => {
|
||||
const html = renderToStaticMarkup(createElement(DungeonLeaderboard, { dungeonTitle: caves.title,
|
||||
data: { entries: [{ ...progress, durationMs: progress.durationMs!, finishedAt: progress.finishedAt!, fullClear: true, rank: 1 }], total: 1 },
|
||||
|
||||
Reference in new issue
Block a user