From 14e105642c19acd6fe7a84b55e89515308e796e8 Mon Sep 17 00:00:00 2001 From: phenom Date: Sun, 20 Sep 2026 15:05:14 -0400 Subject: [PATCH] Update Healer Man server --- src/app/OnlineGroupCoordinator.test.ts | 8 ++- src/app/OnlineGroupCoordinator.tsx | 23 ++++-- src/app/biteSizedDungeonRuntime.test.ts | 94 +++++++++++++++++++++++++ src/app/biteSizedDungeonRuntime.ts | 75 ++++++++++++++++++++ src/game/biteSizedStockades.test.ts | 73 ++++++++++++++++++- src/game/dungeonBossScaling.ts | 3 +- src/game/dungeonCatalog.test.ts | 8 +++ src/game/dungeonCatalog.ts | 16 +++++ src/game/dungeonEncounterRuntime.ts | 13 +++- src/game/dungeonTypes.ts | 2 + src/game/dungeons/biteSizedStockades.ts | 2 + src/ui/DungeonRunResults.tsx | 40 ++++++++++- src/ui/dungeonRuns.test.ts | 10 +++ 13 files changed, 355 insertions(+), 12 deletions(-) create mode 100644 src/app/biteSizedDungeonRuntime.test.ts create mode 100644 src/app/biteSizedDungeonRuntime.ts diff --git a/src/app/OnlineGroupCoordinator.test.ts b/src/app/OnlineGroupCoordinator.test.ts index 54c184f9..53eb6595 100644 --- a/src/app/OnlineGroupCoordinator.test.ts +++ b/src/app/OnlineGroupCoordinator.test.ts @@ -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 }, diff --git a/src/app/OnlineGroupCoordinator.tsx b/src/app/OnlineGroupCoordinator.tsx index 691136d9..d4908918 100644 --- a/src/app/OnlineGroupCoordinator.tsx +++ b/src/app/OnlineGroupCoordinator.tsx @@ -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; } diff --git a/src/app/biteSizedDungeonRuntime.test.ts b/src/app/biteSizedDungeonRuntime.test.ts new file mode 100644 index 00000000..cf8a4857 --- /dev/null +++ b/src/app/biteSizedDungeonRuntime.test.ts @@ -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); + }); +}); diff --git a/src/app/biteSizedDungeonRuntime.ts b/src/app/biteSizedDungeonRuntime.ts new file mode 100644 index 00000000..75df5ef0 --- /dev/null +++ b/src/app/biteSizedDungeonRuntime.ts @@ -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 { + 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; +} diff --git a/src/game/biteSizedStockades.test.ts b/src/game/biteSizedStockades.test.ts index 2b36c8a0..96107e3f 100644 --- a/src/game/biteSizedStockades.test.ts +++ b/src/game/biteSizedStockades.test.ts @@ -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>(); + 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, + }); + }); }); diff --git a/src/game/dungeonBossScaling.ts b/src/game/dungeonBossScaling.ts index 9fee1cd8..4d7459a2 100644 --- a/src/game/dungeonBossScaling.ts +++ b/src/game/dungeonBossScaling.ts @@ -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. diff --git a/src/game/dungeonCatalog.test.ts b/src/game/dungeonCatalog.test.ts index 3cdbbd85..dad12a1a 100644 --- a/src/game/dungeonCatalog.test.ts +++ b/src/game/dungeonCatalog.test.ts @@ -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); diff --git a/src/game/dungeonCatalog.ts b/src/game/dungeonCatalog.ts index d7082050..e5702b7c 100644 --- a/src/game/dungeonCatalog.ts +++ b/src/game/dungeonCatalog.ts @@ -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]; +} diff --git a/src/game/dungeonEncounterRuntime.ts b/src/game/dungeonEncounterRuntime.ts index b7987ba2..98ace882 100644 --- a/src/game/dungeonEncounterRuntime.ts +++ b/src/game/dungeonEncounterRuntime.ts @@ -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); diff --git a/src/game/dungeonTypes.ts b/src/game/dungeonTypes.ts index e4a21937..f0e30593 100644 --- a/src/game/dungeonTypes.ts +++ b/src/game/dungeonTypes.ts @@ -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. */ diff --git a/src/game/dungeons/biteSizedStockades.ts b/src/game/dungeons/biteSizedStockades.ts index 30c112f1..dfbbfe45 100644 --- a/src/game/dungeons/biteSizedStockades.ts +++ b/src/game/dungeons/biteSizedStockades.ts @@ -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: [], diff --git a/src/ui/DungeonRunResults.tsx b/src/ui/DungeonRunResults.tsx index de2f81f7..ed57b1e9 100644 --- a/src/ui/DungeonRunResults.tsx +++ b/src/ui/DungeonRunResults.tsx @@ -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(null); useEffect(() => { @@ -64,11 +72,16 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o {ranking.placement?.fullRank ?

Full dungeon clears: #{ranking.placement.fullRank} of {ranking.placement.fullTotal}

: null} {!progress.timed ?

This resumed run has no saved timer. Start a new expedition to record a leaderboard time.

: null} {ranking.scope === "device" ?

Your time is saved on this device. Reconnect to submit it to the world leaderboard.

: null} + {anotherMessage ?

{anotherMessage}

: null}
+ {onAnother ? + {startingAnother ? "Starting next dungeon…" : "Another random Bite-Sized dungeon"} + : null} Stay and loot - Return to main menu + Return to main menu {ranking.scope === "device" ? Retry submission : null}
@@ -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({ 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 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(); diff --git a/src/ui/dungeonRuns.test.ts b/src/ui/dungeonRuns.test.ts index 795337cb..5668709a 100644 --- a/src/ui/dungeonRuns.test.ts +++ b/src/ui/dungeonRuns.test.ts @@ -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 },