Release Healer Man 0.1.40

This commit is contained in:
phenom committed 2026-09-13 13:21:27 -04:00
1 parent ed0d2fea3d
commit 197a64117c
215 files changed
+5131 -181

No files matched your search

+8
View File
@@ -34,6 +34,14 @@ function entryDependencies(enterDungeon: () => boolean) {
}
describe("online group activity entry", () => {
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 },
"damage", vi.fn(), dependencies);
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "damage", {
difficultyId: "heroic", bossDifficulty: 9, freshRun: true,
});
});
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => false);
+8 -3
View File
@@ -10,6 +10,7 @@ import type { ManastormPartySize } from "../game/manastormProgress";
import type { OnlineGroupActivity } from "./onlineGroupTypes";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
const GROUP_POLL_MS = 2_500;
@@ -17,7 +18,7 @@ const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
interface OnlineGroupActivityEntryDependencies {
readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole) => boolean;
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
readonly installManastormRuntime: () => Promise<void>;
readonly enterManastorm: (role?: PartyRole, partySize?: ManastormPartySize, startingLevel?: number) => boolean;
}
@@ -27,7 +28,7 @@ const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
},
enterDungeon: (dungeonId, role) => useShellStore.getState().enterDungeon(dungeonId, role),
enterDungeon: (dungeonId, role, settings) => useShellStore.getState().enterDungeon(dungeonId, role, settings),
installManastormRuntime: async () => {
await installShelvedOnlineGroupRuntime();
},
@@ -47,7 +48,11 @@ export async function enterOnlineGroupActivity(
let entered: boolean;
if (activity.type === "dungeon") {
await dependencies.installDungeonRuntime();
entered = dependencies.enterDungeon(activity.contentId, role);
entered = activity.bossDifficulty === undefined && activity.difficultyId === undefined
? dependencies.enterDungeon(activity.contentId, role)
: dependencies.enterDungeon(activity.contentId, role, {
difficultyId: activity.difficultyId, bossDifficulty: activity.bossDifficulty, freshRun: true,
});
} else {
if (!SHELVED_MODES_ENABLED) return false;
await dependencies.installManastormRuntime();
+15
View File
@@ -12,6 +12,7 @@ import {
updateCharacterEquipment,
updateCharacterDungeonRunCheckpoint,
updateCharacterInventory,
updateCharacterBossCards,
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterSettings,
@@ -381,6 +382,20 @@ describe("local profile repository", () => {
});
});
it("persists card copies and claim receipts per character across roster reloads", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
const character = createCharacter(draft, storage).character!;
const other = createCharacter({ ...draft, name: "Other" }, storage).character!;
const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } };
expect(updateCharacterBossCards(offline.ownerId, character.id, bossCards, storage)?.bossCards).toEqual(bossCards);
const roster = listCharacters(offline.ownerId, storage);
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toEqual(bossCards);
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toEqual({ counts: {}, lastClaims: {} });
expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]);
});
it("persists threat-meter visibility and position per character", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
+20
View File
@@ -1,4 +1,5 @@
import type { CharacterProfile, PlayerSession } from "./types";
import { normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
import {
contentCategoryForClass,
@@ -190,6 +191,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
actionLoadouts,
inventory,
equipment,
bossCards: normalizeBossCardCollection(value.bossCards),
manastormProgress,
dungeonRunCheckpoint,
settings,
@@ -728,6 +730,24 @@ export function updateCharacterEquipment(
return updated;
}
export function updateCharacterBossCards(
ownerId: string,
characterId: string,
bossCards: BossCardCollection,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage) return null;
const database = readDatabase(storage);
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId) return character;
updated = normalizeCharacterProfile({ ...character, bossCards });
return updated;
});
if (updated) writeDatabase(database, storage);
return updated;
}
export function updateCharacterSettings(
ownerId: string,
characterId: string,
+11
View File
@@ -17,6 +17,17 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals());
describe("offline dungeon leaderboard", () => {
it("isolates all ten difficulty levels, ranks, pages, and legacy records", () => {
saveLocalDungeonRun(record("legacy-fastest", 1));
for (let difficulty = 1; difficulty <= 10; difficulty += 1) {
const run = { ...record(`slider-run-${difficulty}`, 10000 + difficulty), bossDifficulty: difficulty };
saveLocalDungeonRun(run);
expect(localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final", 0, difficulty).entries
.map((entry) => entry.id)).toEqual([run.id]);
expect(localDungeonRunPlacement(run)).toEqual({ finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 });
}
expect(localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final").total).toBe(1);
});
it("persists full/final filters, ties, pages, and the placement of a run outside the first page", () => {
for (let index = 0; index < 25; index += 1) saveLocalDungeonRun(record(`offline-${index}`, 1000 + index * 100));
const fastest = record("offline-fastest", 500, false);
+8 -6
View File
@@ -41,8 +41,9 @@ export function saveLocalDungeonRun(record: DungeonRunRecord): void {
}
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset = 0): DungeonLeaderboard {
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0): DungeonLeaderboard {
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
&& (entry.bossDifficulty ?? 0) === bossDifficulty
&& (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs
|| a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
return { total: runs.length, entries: runs.slice(offset, offset + DUNGEON_LEADERBOARD_PAGE_SIZE)
@@ -50,18 +51,19 @@ export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
}
export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPlacement {
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final");
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full");
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId);
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0);
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0);
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId
&& (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0));
return { finalRank: 1 + runs.filter((entry) => entry.durationMs < record.durationMs).length,
finalTotal: final.total, fullTotal: full.total,
fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null };
}
export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset: number, signal?: AbortSignal): Promise<DungeonLeaderboard> {
filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0): Promise<DungeonLeaderboard> {
const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter,
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE) });
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE), bossDifficulty: String(bossDifficulty) });
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
}
+10 -5
View File
@@ -8,7 +8,7 @@ import {
type PartyRole,
} from "../game/partyRoles";
import { useShellStore } from "./shellStore";
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { registerDungeonShellRuntime, type DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { touchCharacter } from "./accountRepository";
import { specializationById } from "../game/specializationCatalog";
import { dungeonContentIsUsable } from "../content/contentManager";
@@ -17,7 +17,7 @@ export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon });
}
function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: DungeonEntrySettings): boolean {
const dungeon = dungeonById(dungeonId);
if (!dungeon || !dungeon.available) {
useShellStore.setState({ notice: "That dungeon is not available yet." });
@@ -55,14 +55,19 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
});
return false;
}
const resume = activeCharacter.dungeonRunCheckpoint?.dungeonId === dungeonId
const checkpoint = activeCharacter.dungeonRunCheckpoint;
const resume = !settings?.freshRun && checkpoint?.dungeonId === dungeonId
&& checkpoint.run?.finishedAt == null
&& (settings?.difficultyId === undefined || checkpoint.difficultyId === settings.difficultyId)
&& (settings?.bossDifficulty === undefined || checkpoint.run?.bossDifficulty === settings.bossDifficulty)
? activeCharacter.dungeonRunCheckpoint
: null;
if (!activateDungeonSession(dungeonId, resume?.difficultyId, {
if (!activateDungeonSession(dungeonId, settings?.difficultyId ?? resume?.difficultyId, {
bossDifficulty: settings?.bossDifficulty ?? resume?.run?.bossDifficulty,
spawn: resume?.spawn,
encounterSnapshots: resume?.encounters,
runProgress: resume?.run,
legacyResume: Boolean(resume && !resume.run),
legacyResume: Boolean(resume && resume.run?.bossDifficulty === undefined),
})) {
useShellStore.setState({
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
+7 -2
View File
@@ -1,7 +1,13 @@
import type { PartyRole } from "../game/partyRoles";
export interface DungeonShellRuntime {
enterDungeon: (dungeonId: string, playerRole?: PartyRole) => boolean;
enterDungeon: (dungeonId: string, playerRole?: PartyRole, settings?: DungeonEntrySettings) => boolean;
}
export interface DungeonEntrySettings {
readonly difficultyId?: string;
readonly bossDifficulty?: number;
readonly freshRun?: boolean;
}
let installedRuntime: DungeonShellRuntime | null = null;
@@ -13,4 +19,3 @@ export function registerDungeonShellRuntime(runtime: DungeonShellRuntime): void
export function dungeonShellRuntime(): DungeonShellRuntime | null {
return installedRuntime;
}
+4
View File
@@ -22,6 +22,8 @@ export interface OnlineGroupMember {
}
export interface OnlineGroupActivity {
readonly difficultyId?: string;
readonly bossDifficulty?: number;
readonly id: string;
readonly type: "dungeon" | "manastorm";
readonly contentId: string;
@@ -56,6 +58,8 @@ export interface OnlineGroupSnapshot {
}
export interface StartOnlineGroupActivityInput {
readonly difficultyId?: string;
readonly bossDifficulty?: number;
readonly type: OnlineGroupActivity["type"];
readonly contentId: string;
readonly fillWithAi: boolean;
+10 -1
View File
@@ -13,7 +13,16 @@ describe("on-demand shell runtime delegation", () => {
registerDungeonShellRuntime({ enterDungeon });
expect(useShellStore.getState().enterDungeon("wailing-caverns", "healer")).toBe(true);
expect(enterDungeon).toHaveBeenCalledWith("wailing-caverns", "healer");
expect(enterDungeon).toHaveBeenCalledWith("wailing-caverns", "healer", undefined);
});
it("delegates dungeon entry settings to its phase runtime", () => {
const enterDungeon = vi.fn(() => true);
const settings = { difficultyId: "heroic", bossDifficulty: 5, freshRun: true };
registerDungeonShellRuntime({ enterDungeon });
expect(useShellStore.getState().enterDungeon("wailing-caverns", "healer", settings)).toBe(true);
expect(enterDungeon).toHaveBeenCalledWith("wailing-caverns", "healer", settings);
});
normalReleaseIt("ignores registered Manastorm delegates in a normal release", () => {
+17 -4
View File
@@ -11,6 +11,7 @@ import {
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterInventory,
updateCharacterBossCards,
updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint,
updateCharacterProgression,
@@ -38,6 +39,7 @@ import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from
import type { DungeonId } from "../game/dungeonRegistry";
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes";
import type { BossCardCollection } from "../game/bossCards";
import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import {
@@ -48,7 +50,7 @@ import {
import type { PartyRole } from "../game/partyRoles";
import type { ManastormPartySize, ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import { dungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { dungeonShellRuntime, type DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { manastormShellRuntime } from "./manastormShellRuntimeBridge";
import {
activateOnlineSession,
@@ -95,7 +97,7 @@ export interface ShellState {
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
swapSelectedRomClasses: () => boolean;
returnToMainMenu: () => void;
enterDungeon: (dungeonId: string, playerRole?: PartyRole) => boolean;
enterDungeon: (dungeonId: string, playerRole?: PartyRole, settings?: DungeonEntrySettings) => boolean;
enterManastorm: (
playerRole?: PartyRole,
partySize?: ManastormPartySize,
@@ -109,6 +111,7 @@ export interface ShellState {
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveActiveBossCards: (bossCards: BossCardCollection) => void;
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
saveActiveSettings: (settings: GameplaySettings) => void;
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
@@ -450,9 +453,9 @@ export const useShellStore = create<ShellState>((set, get) => ({
}
set({ phase: "main-menu", selectedCharacterId: currentId, activeCharacter: null, notice: "" });
},
enterDungeon: (dungeonId, requestedRole) => {
enterDungeon: (dungeonId, requestedRole, settings) => {
const runtime = dungeonShellRuntime();
if (runtime) return runtime.enterDungeon(dungeonId, requestedRole);
if (runtime) return runtime.enterDungeon(dungeonId, requestedRole, settings);
set({ notice: "Dungeon data is still loading. Try again in a moment." });
return false;
},
@@ -535,6 +538,16 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveBossCards: (bossCards) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterBossCards(session.ownerId, activeCharacter.id, bossCards);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveEquipment: (equipment) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
+2
View File
@@ -5,6 +5,7 @@ import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import type { ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import type { BossCardCollection } from "../game/bossCards";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game";
export type SessionKind = "account" | "offline";
@@ -41,6 +42,7 @@ export interface CharacterProfile {
actionLoadouts?: Record<string, Record<string, string | null>>;
inventory: InventoryItem[];
equipment: EquipmentAssignments;
bossCards?: BossCardCollection;
/** Present on normalized and newly created profiles; optional for legacy callers. */
manastormProgress?: ManastormProgress;
/** Safe resume point only; active combat, timers, and temporary waves are never persisted. */