forked from phenom/healer-man
Release Healer Man 0.1.40
This commit is contained in:
1 parent
ed0d2fea3d
commit
197a64117c
215 files changed
+5131
-181
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user