Release Healer Man 0.1.21

This commit is contained in:
phenom committed 2026-09-08 10:40:26 -04:00
1 parent 699fc858c9
commit f6f2375145
210 files changed
+976039 -6247

No files matched your search

+31 -1
View File
@@ -10,6 +10,7 @@ import {
restoreSession,
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterDungeonRunCheckpoint,
updateCharacterInventory,
updateCharacterManastormProgress,
updateCharacterProgression,
@@ -31,6 +32,7 @@ import {
setManastormLoadout,
} from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "../game/dungeonRunCheckpoint";
class MemoryStorage implements StorageLike {
private values = new Map<string, string>();
@@ -40,6 +42,34 @@ class MemoryStorage implements StorageLike {
}
describe("local profile repository", () => {
it("persists a safe dungeon checkpoint per character", () => {
const storage = new MemoryStorage();
const character = createCharacter({
ownerId: "offline-roster",
name: "Delver",
categoryId: "wow",
raceId: "human",
classId: "priest",
specializationId: "priest:holy",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const updated = updateCharacterDungeonRunCheckpoint("offline-roster", character.id, {
version: DUNGEON_RUN_CHECKPOINT_VERSION,
dungeonId: "wailing-caverns",
difficultyId: "normal",
checkpointId: "entrance",
spawn: { footPosition: [-158, -82, 123], yaw: 0 },
encounters: [],
savedAt: 42,
}, storage);
expect(updated?.dungeonRunCheckpoint).toMatchObject({
dungeonId: "wailing-caverns",
checkpointId: "entrance",
});
expect(listCharacters("offline-roster", storage)[0]?.dungeonRunCheckpoint?.savedAt).toBe(42);
});
it("persists specializations and supports Class Hall keep/reset choices", () => {
const storage = new MemoryStorage();
const talentAbility = abilitiesForClass("stormbringer").find((ability) => ability.talentEntryId !== undefined)!;
@@ -182,7 +212,7 @@ describe("local profile repository", () => {
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(5);
}
const legacyV3Storage = new MemoryStorage();
+35 -5
View File
@@ -27,6 +27,10 @@ import {
normalizeSpecializationId,
specializationById,
} from "../game/specializationCatalog";
import {
normalizeDungeonRunCheckpoint,
type DungeonRunCheckpoint,
} from "../game/dungeonRunCheckpoint";
const DATABASE_KEY = "healer-man.profile-database.v1";
const SESSION_KEY = "healer-man.session.v1";
@@ -56,7 +60,7 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 4;
version: 5;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
@@ -98,7 +102,7 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 4, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
return { version: 5, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -126,6 +130,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
secondaryClassId?: unknown;
actionLoadouts?: unknown;
specializationId?: unknown;
dungeonRunCheckpoint?: unknown;
};
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
? Math.max(1, Math.min(80, Math.floor(legacy.level)))
@@ -170,6 +175,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
const settings = legacy.settings && typeof legacy.settings === "object" && !Array.isArray(legacy.settings)
? { ...(legacy.settings as Partial<GameplaySettings>) }
: undefined;
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
return {
...value,
level,
@@ -183,6 +189,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
inventory,
equipment,
manastormProgress,
dungeonRunCheckpoint,
settings,
};
}
@@ -196,7 +203,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
rosters?: unknown;
presentationSettingsByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3, 4].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -236,8 +243,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
return [ownerId, normalized];
}),
);
const database: ProfileDatabase = { version: 4, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 4 || presentationSettingsMigrated) writeDatabase(database, storage);
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
return database;
} catch {
return emptyDatabase();
@@ -762,6 +769,29 @@ export function updateCharacterManastormProgress(
return updated;
}
export function updateCharacterDungeonRunCheckpoint(
ownerId: string,
characterId: string,
checkpoint: DungeonRunCheckpoint | null,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage) return null;
const database = readDatabase(storage);
const normalized = checkpoint === null ? null : normalizeDungeonRunCheckpoint(checkpoint);
if (checkpoint !== null && !normalized) return null;
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId) return character;
updated = normalizeCharacterProfile({
...character,
dungeonRunCheckpoint: normalized,
});
return updated;
});
if (updated) writeDatabase(database, storage);
return updated;
}
export function deleteCharacter(
ownerId: string,
characterId: string,
+7 -1
View File
@@ -50,7 +50,13 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
});
return false;
}
if (!activateDungeonSession(dungeonId)) {
const resume = activeCharacter.dungeonRunCheckpoint?.dungeonId === dungeonId
? activeCharacter.dungeonRunCheckpoint
: null;
if (!activateDungeonSession(dungeonId, resume?.difficultyId, {
spawn: resume?.spawn,
encounterSnapshots: resume?.encounters,
})) {
useShellStore.setState({
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
});
+6 -1
View File
@@ -5,6 +5,7 @@ import type { InventoryItem } from "../game/lootTypes";
import type { PartyMember } from "../game/partyStore";
import type { OnlineGroupActivity, OnlineGroupCharacter } from "./onlineGroupTypes";
import type { PartyRole } from "../game/partyRoles";
import type { DungeonEncounterSnapshot } from "../game/dungeonEncounterTypes";
export type OnlineSessionVector = readonly [number, number, number];
@@ -34,6 +35,9 @@ export interface OnlineMobTransform {
}
export interface OnlineSharedWorld {
/** Identifies the package that owns encounter snapshots and prevents cross-dungeon hydration. */
readonly dungeonId?: string;
readonly encounters?: readonly DungeonEncounterSnapshot[];
readonly mobs: Readonly<Record<string, MobCombatState>>;
readonly mobTransforms: Readonly<Record<string, OnlineMobTransform>>;
readonly partyMembers: readonly PartyMember[];
@@ -50,7 +54,7 @@ export interface OnlineSharedWorld {
readonly publishedBy?: string;
}
export type OnlineRelayEventKind = "damage" | "healing" | "resurrection" | "loot" | "portal";
export type OnlineRelayEventKind = "damage" | "healing" | "resurrection" | "loot" | "portal" | "interact" | "party-teleport";
export interface OnlineRelayEventInput {
readonly clientEventId: string;
@@ -63,6 +67,7 @@ export interface OnlineRelayEventInput {
readonly effectiveAmount?: number;
readonly critical?: boolean;
readonly occurredAt?: number;
readonly position?: OnlineSessionVector;
}
export interface OnlineRelayEvent extends OnlineRelayEventInput {
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(5);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+17
View File
@@ -12,6 +12,7 @@ import {
updateCharacterEquipment,
updateCharacterInventory,
updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint,
updateCharacterProgression,
updateCharacterSettings,
updateCharacterSpecialization,
@@ -46,6 +47,7 @@ import {
} from "../game/combatPresentation";
import type { PartyRole } from "../game/partyRoles";
import type { ManastormPartySize, ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import { dungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { manastormShellRuntime } from "./manastormShellRuntimeBridge";
import {
@@ -112,6 +114,7 @@ export interface ShellState {
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
resetCombatPresentationSettings: () => void;
saveActiveManastormProgress: (progress: ManastormProgress) => void;
saveActiveDungeonRunCheckpoint: (checkpoint: DungeonRunCheckpoint | null) => void;
saveSelectedManastormLoadout: (loadout: readonly string[]) => boolean;
setNotice: (notice: string) => void;
}
@@ -576,6 +579,20 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveDungeonRunCheckpoint: (checkpoint) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterDungeonRunCheckpoint(
session.ownerId,
activeCharacter.id,
checkpoint,
);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveSelectedManastormLoadout: (loadout) => {
const runtime = manastormShellRuntime();
if (runtime) return runtime.saveSelectedLoadout(loadout);
+3
View File
@@ -4,6 +4,7 @@ import type { InventoryItem } from "../game/lootTypes";
import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import type { ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game";
export type SessionKind = "account" | "offline";
@@ -42,6 +43,8 @@ export interface CharacterProfile {
equipment: EquipmentAssignments;
/** 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. */
dungeonRunCheckpoint?: DungeonRunCheckpoint | null;
settings?: Partial<GameplaySettings>;
location: string;
createdAt: number;