Release Healer Man 0.1.21
This commit is contained in:
1 parent
699fc858c9
commit
f6f2375145
210 files changed
+976039
-6247
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user