Release Healer Man 0.1.59

This commit is contained in:
phenom committed 2026-09-27 10:16:42 -04:00
1 parent 11934a3979
commit ab1434a3b8
44 files changed
+1242 -103

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { consumeMemoryReset } from "./memoryReset";
const HANDOFF_KEY = "healer-man.memory-reset.v1";
function storageWith(value: unknown) {
const entries = new Map([[HANDOFF_KEY, JSON.stringify(value)]]);
return {
getItem: (key: string) => entries.get(key) ?? null,
setItem: (key: string, item: string) => { entries.set(key, item); },
removeItem: (key: string) => { entries.delete(key); },
};
}
describe("memory reset handoff", () => {
it("restores the selected character and destination once", () => {
const storage = storageWith({ ownerId: "owner", characterId: "rom-rogue", phase: "main-menu" });
expect(consumeMemoryReset("owner", ["rom-rogue"], storage)).toEqual({
ownerId: "owner", characterId: "rom-rogue", phase: "main-menu",
});
expect(consumeMemoryReset("owner", ["rom-rogue"], storage)).toBeNull();
});
it("does not navigate to another account or a deleted character", () => {
const otherOwner = storageWith({ ownerId: "other", characterId: "rom-rogue", phase: "main-menu" });
expect(consumeMemoryReset("owner", ["rom-rogue"], otherOwner)).toBeNull();
const deleted = storageWith({ ownerId: "owner", characterId: "deleted", phase: "class-hall" });
expect(consumeMemoryReset("owner", ["rom-rogue"], deleted)).toBeNull();
});
it("rejects a destination outside the safe shell screens", () => {
const storage = storageWith({ ownerId: "owner", characterId: "rom-rogue", phase: "game" });
expect(consumeMemoryReset("owner", ["rom-rogue"], storage)).toBeNull();
});
});
+63
View File
@@ -0,0 +1,63 @@
import type { ShellPhase } from "./types";
const HANDOFF_KEY = "healer-man.memory-reset.v1";
export interface MemoryResetHandoff {
readonly ownerId: string;
readonly characterId: string;
readonly phase: Extract<ShellPhase, "characters" | "main-menu" | "class-hall">;
}
interface HandoffStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
function browserStorage(): HandoffStorage | null {
try {
return typeof window === "undefined" ? null : window.sessionStorage;
} catch {
return null;
}
}
/** A full page reload is the browser's boundary for releasing imported modules. */
export function requestMemoryReset(handoff: MemoryResetHandoff): boolean {
if (import.meta.env.MODE === "test") return false;
const storage = browserStorage();
if (!storage) return false;
try {
storage.setItem(HANDOFF_KEY, JSON.stringify(handoff));
window.location.reload();
return true;
} catch {
try { storage.removeItem(HANDOFF_KEY); } catch { /* Storage may be disabled. */ }
return false;
}
}
/** Consume once so a later ordinary reload never replays an old navigation. */
export function consumeMemoryReset(
ownerId: string,
characterIds: readonly string[],
storage: HandoffStorage | null = browserStorage(),
): MemoryResetHandoff | null {
if (!storage) return null;
let raw: string | null;
try {
raw = storage.getItem(HANDOFF_KEY);
storage.removeItem(HANDOFF_KEY);
} catch {
return null;
}
if (!raw) return null;
try {
const value = JSON.parse(raw) as Partial<MemoryResetHandoff>;
if (value.ownerId !== ownerId || !value.characterId || !characterIds.includes(value.characterId)) return null;
if (value.phase !== "characters" && value.phase !== "main-menu" && value.phase !== "class-hall") return null;
return value as MemoryResetHandoff;
} catch {
return null;
}
}
+34 -6
View File
@@ -25,6 +25,7 @@ import {
clampAppearance,
classById,
classesForRace,
contentCategoryForClass,
createDefaultAppearance,
normalizeCharacterName,
randomAppearance,
@@ -53,6 +54,8 @@ import type { ManastormPartySize, ManastormProgress } from "../game/manastormPro
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import { dungeonShellRuntime, type DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { manastormShellRuntime } from "./manastormShellRuntimeBridge";
import { consumeMemoryReset, requestMemoryReset } from "./memoryReset";
import { useOnlineGroupStore } from "./onlineGroupStore";
import {
activateOnlineSession,
hydrateOnlineSession,
@@ -152,17 +155,27 @@ function restoredState(): Pick<ShellState, "phase" | "session" | "characters" |
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
};
const characters = listCharacters(session.ownerId);
const handoff = consumeMemoryReset(session.ownerId, characters.map((character) => character.id));
return {
phase: "characters",
phase: handoff?.phase ?? "characters",
session,
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null,
selectedCharacterId: handoff?.characterId ?? characters[0]?.id ?? null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
};
}
const restored = restoredState();
let enteredCharacterId: string | null = restored.phase === "main-menu" || restored.phase === "class-hall"
? restored.selectedCharacterId
: null;
function canResetMemory(): boolean {
// A reload while a group activity is live would forget its consumed ID and
// could re-enter the same run when the group snapshot reconnects.
return !useOnlineGroupStore.getState().snapshot.group?.activity;
}
export const useShellStore = create<ShellState>((set, get) => ({
...restored,
@@ -172,6 +185,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
draft: defaultDraft(),
notice: "",
beginSession: (session) => {
enteredCharacterId = null;
activateOnlineSession(session);
saveSession(session);
const characters = listCharacters(session.ownerId);
@@ -187,6 +201,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
});
},
signOut: () => {
enteredCharacterId = null;
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
const session = get().session;
void logoutOnlineSession(session);
@@ -313,7 +328,13 @@ export const useShellStore = create<ShellState>((set, get) => ({
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
const categoryId = contentCategoryForClass(character.classId);
const nextPhase = categoryId !== "rom" && !character.specializationId ? "class-hall" : "main-menu";
if (enteredCharacterId && enteredCharacterId !== character.id && canResetMemory()) {
if (requestMemoryReset({ ownerId: session.ownerId, characterId: character.id, phase: nextPhase })) return true;
}
enteredCharacterId = character.id;
if (categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." });
return true;
}
@@ -447,6 +468,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
return true;
},
returnToMainMenu: () => {
const wasInGame = get().phase === "game";
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
const { session, characters, activeCharacter, selectedCharacterId } = get();
@@ -460,6 +482,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
return;
}
set({ phase: "main-menu", selectedCharacterId: currentId, activeCharacter: null, notice: "" });
if (wasInGame && canResetMemory()) requestMemoryReset({ ownerId: session.ownerId, characterId: currentId, phase: "main-menu" });
},
enterDungeon: (dungeonId, requestedRole, settings) => {
const runtime = dungeonShellRuntime();
@@ -486,6 +509,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
returnToCharacters: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
const wasInGame = get().phase === "game";
const session = get().session;
if (!session) {
set({ phase: "login", activeCharacter: null });
@@ -493,17 +517,21 @@ export const useShellStore = create<ShellState>((set, get) => ({
}
const characters = listCharacters(session.ownerId);
const currentId = get().activeCharacter?.id ?? get().selectedCharacterId;
const selectedCharacterId = characters.some((character) => character.id === currentId)
? currentId
: characters[0]?.id ?? null;
set({
phase: "characters",
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters.some((character) => character.id === currentId)
? currentId
: characters[0]?.id ?? null,
selectedCharacterId,
activeCharacter: null,
notice: "",
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
});
if (wasInGame && selectedCharacterId && canResetMemory()) requestMemoryReset({
ownerId: session.ownerId, characterId: selectedCharacterId, phase: "characters",
});
},
saveActiveProgression: (level, experience, talentRanks) => {
const { session, activeCharacter } = get();