Release Healer Man 0.1.59
This commit is contained in:
1 parent
11934a3979
commit
ab1434a3b8
44 files changed
+1242
-103
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user