new rpg mode
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createDefaultHealerAppearance } from "../game/healerVisuals";
|
||||
import { hasPendingSaveSync } from "./saveSync";
|
||||
import { getFrontendSnapshot, useFrontendStore } from "./store";
|
||||
|
||||
const originalState = useFrontendStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useFrontendStore.getState().deleteSlot(3);
|
||||
useFrontendStore.setState({
|
||||
activeSlotId: originalState.activeSlotId,
|
||||
selectedSlotId: originalState.selectedSlotId,
|
||||
screen: originalState.screen,
|
||||
appearanceClassId: originalState.appearanceClassId,
|
||||
appearanceDrafts: structuredClone(originalState.appearanceDrafts),
|
||||
previewMode: originalState.previewMode,
|
||||
previewAnimation: originalState.previewAnimation,
|
||||
notice: originalState.notice,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Appearance Lab frontend state", () => {
|
||||
it("opens from saved looks, previews drafts, saves explicitly, and cancels unsaved edits", () => {
|
||||
const frontend = useFrontendStore.getState();
|
||||
frontend.deleteSlot(3);
|
||||
expect(frontend.createSlot(3, "Wardrobe Tester")).toBe(true);
|
||||
frontend.playSlot(3);
|
||||
|
||||
useFrontendStore.getState().openAppearanceLab();
|
||||
let state = useFrontendStore.getState();
|
||||
const savedPriest = state.slots[2].local!.healers.priest.appearance;
|
||||
expect(state.screen).toBe("appearance");
|
||||
expect(state.appearanceClassId).toBe("priest");
|
||||
expect(state.appearanceDrafts.priest).toEqual(savedPriest);
|
||||
expect(state.appearanceDrafts.priest).not.toBe(savedPriest);
|
||||
expect(state.previewMode).toBe("modular");
|
||||
expect(state.previewAnimation).toBe("idle");
|
||||
|
||||
const mixedPriest = {
|
||||
...state.appearanceDrafts.priest,
|
||||
headPartId: "rogue-head" as const,
|
||||
};
|
||||
state.updateAppearanceDraft(mixedPriest);
|
||||
state.setAppearancePreviewMode("legacy");
|
||||
state.setAppearancePreviewAnimation("cast");
|
||||
state = useFrontendStore.getState();
|
||||
expect(state.appearanceDrafts.priest.headPartId).toBe("rogue-head");
|
||||
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("mage-head");
|
||||
expect(state.previewMode).toBe("legacy");
|
||||
expect(state.previewAnimation).toBe("cast");
|
||||
|
||||
expect(state.saveAppearanceDraft()).toBe(true);
|
||||
state = useFrontendStore.getState();
|
||||
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("rogue-head");
|
||||
expect(hasPendingSaveSync(3)).toBe(true);
|
||||
|
||||
state.updateAppearanceDraft({
|
||||
...state.appearanceDrafts.priest,
|
||||
headPartId: "ranger-head",
|
||||
});
|
||||
useFrontendStore.getState().closeAppearanceLab();
|
||||
state = useFrontendStore.getState();
|
||||
expect(state.screen).toBe("home");
|
||||
expect(state.appearanceDrafts.priest.headPartId).toBe("rogue-head");
|
||||
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("rogue-head");
|
||||
});
|
||||
|
||||
it("resets only the selected class and includes state but no actions in snapshots", () => {
|
||||
useFrontendStore.setState((state) => ({
|
||||
appearanceClassId: "paladin",
|
||||
appearanceDrafts: {
|
||||
...state.appearanceDrafts,
|
||||
paladin: { ...state.appearanceDrafts.paladin, headPartId: "rogue-head" },
|
||||
},
|
||||
previewMode: "legacy",
|
||||
previewAnimation: "walk",
|
||||
}));
|
||||
|
||||
useFrontendStore.getState().resetAppearanceDraft();
|
||||
const snapshot = getFrontendSnapshot();
|
||||
expect(snapshot.appearanceClassId).toBe("paladin");
|
||||
expect(snapshot.appearanceDrafts.paladin).toEqual(createDefaultHealerAppearance("paladin"));
|
||||
expect(snapshot.previewMode).toBe("legacy");
|
||||
expect(snapshot.previewAnimation).toBe("walk");
|
||||
expect("openAppearanceLab" in snapshot).toBe(false);
|
||||
expect("updateAppearanceDraft" in snapshot).toBe(false);
|
||||
expect("saveAppearanceDraft" in snapshot).toBe(false);
|
||||
expect("setAppearancePreviewAnimation" in snapshot).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCollections, MODE_COPY, selectRandomBoss } from "./data";
|
||||
import { buildCollections, createHunterSave, MODE_COPY, selectRandomBoss } from "./data";
|
||||
import { HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import { HEALER_VISUAL_PROFILES } from "../game/healerVisuals";
|
||||
import { selectRandomBossPair } from "../game/roguelike";
|
||||
import { GROUP_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
|
||||
|
||||
describe("game mode configuration", () => {
|
||||
it("separates randomized PVE from selectable Dungeons", () => {
|
||||
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
|
||||
it("initializes progression for every playable healer", () => {
|
||||
const save = createHunterSave(1, "2026-07-16T00:00:00.000Z", "Aelia");
|
||||
expect(Object.keys(save.healers)).toEqual(HEALER_CLASS_ORDER);
|
||||
expect(save.healers.paladin.inventory.length).toBeGreaterThan(0);
|
||||
expect(save.healers.chronomancer.inventory.length).toBeGreaterThan(0);
|
||||
for (const classId of HEALER_CLASS_ORDER) {
|
||||
expect(save.healers[classId].appearance).toEqual(HEALER_VISUAL_PROFILES[classId].appearance);
|
||||
expect(save.healers[classId].appearance).not.toBe(HEALER_VISUAL_PROFILES[classId].appearance);
|
||||
}
|
||||
});
|
||||
|
||||
it("separates RPG Roguelike from Rogue Trials and selectable Dungeons", () => {
|
||||
expect(MODE_COPY["roguelike-pve"].title).toBe("RPG Roguelike");
|
||||
expect(MODE_COPY["roguelike-pve"].description).toContain("Draft");
|
||||
expect(MODE_COPY["rogue-trials"].detail).toContain("Endless");
|
||||
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
|
||||
});
|
||||
|
||||
+53
-12
@@ -1,9 +1,11 @@
|
||||
import type { BossGroupCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "../game/bossCatalog";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import { createClassInventory, HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import type { BossId } from "../game/types";
|
||||
import { createDefaultGearProgress } from "../game/progression/gear";
|
||||
import { BOSS_PET_DROPS, GROUP_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type LootRarity, type MaterialStack } from "../game/progression/loot";
|
||||
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
|
||||
import { createDefaultHealerAppearance } from "../game/healerVisuals";
|
||||
|
||||
export const DEFAULT_SETTINGS: GameSettings = {
|
||||
masterVolume: 80,
|
||||
@@ -66,10 +68,10 @@ export const DEFAULT_COLLECTIONS: BossGroupCollection[] = buildCollections(DEFAU
|
||||
|
||||
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
|
||||
"roguelike-pve": {
|
||||
eyebrow: "1–4 hunters · randomized PVE",
|
||||
title: "PVE",
|
||||
description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.",
|
||||
detail: "Two bosses selected when the run begins",
|
||||
eyebrow: "Solo healer · drafted RPG expedition",
|
||||
title: "RPG Roguelike",
|
||||
description: "Draft a random four-companion party and mixed healing spellbook, clear escalating arcade hallways, defeat ten bosses, and build a run-only loadout.",
|
||||
detail: "Three acts, three shops, and a final guardian",
|
||||
status: "Playable now",
|
||||
},
|
||||
"rogue-trials": {
|
||||
@@ -86,11 +88,39 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
|
||||
detail: `${AVAILABLE_BOSS_IDS.length} animated guardians available`,
|
||||
status: "Playable now",
|
||||
},
|
||||
"hockey-healing": {
|
||||
eyebrow: "1–4 hunters · endless healer pressure",
|
||||
title: "Hockey Healing",
|
||||
description: "Defend a wide goal while healing through two active bosses. Aim returns past a moving Pong paddle that strikes back.",
|
||||
detail: "Every fallen boss drops loot, rolls its pet chance, then receives a replacement",
|
||||
status: "Playable now",
|
||||
},
|
||||
"hockey-healing-pvp": {
|
||||
eyebrow: "1v1 healer duel · online queue",
|
||||
title: "Healing Hockey PVP",
|
||||
description: "Defend your net, keep your party alive, and race a rival through the same endless boss order in a mirrored hockey arena.",
|
||||
detail: "Goals deal 45 partywide damage · 5% Dampening per boss · normalized base gear",
|
||||
status: "Playable now",
|
||||
},
|
||||
blockbreaker: {
|
||||
eyebrow: "1–4 hunters · endless color-break PVE",
|
||||
title: "Blockbreaker",
|
||||
description: "Heal through two endless bosses while aiming a puck into advancing rows of linked color bricks.",
|
||||
detail: `Breaches deal ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member · survive while four allies stand`,
|
||||
status: "Playable now",
|
||||
},
|
||||
"aether-assault": {
|
||||
eyebrow: "1–4 hunters · endless movement-only arcade PVE",
|
||||
title: "Aether Assault",
|
||||
description: "Move freely and heal through endless bosses while automatic spellfire cuts through arcane ship formations.",
|
||||
detail: "No extra buttons · fixed-forward auto-fire · ship strikes damage only the healer",
|
||||
status: "Playable now",
|
||||
},
|
||||
"roguelike-pvp": {
|
||||
eyebrow: "3v3 · mirrored expeditions",
|
||||
title: "Roguelike PvP",
|
||||
description: "Race a rival squad through shifting rooms. Send hazards across the veil while keeping your own formation alive.",
|
||||
detail: "Draft order, rival pressure, and sudden-death rules",
|
||||
detail: "Draft order, normalized base gear, rival pressure, and sudden-death rules",
|
||||
status: "Mode shell ready",
|
||||
},
|
||||
"stadium-pvp": {
|
||||
@@ -120,15 +150,15 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
||||
const normalizedName = normalizeHunterName(hunterName);
|
||||
if (!normalizedName) throw new Error("Hunter name is required.");
|
||||
return {
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 6,
|
||||
slotId,
|
||||
hunterName: normalizedName,
|
||||
activeClassId: "priest",
|
||||
healers: {
|
||||
priest: { level: 1, inventory: createClassInventory("priest") },
|
||||
druid: { level: 1, inventory: createClassInventory("druid") },
|
||||
shaman: { level: 1, inventory: createClassInventory("shaman") },
|
||||
},
|
||||
healers: Object.fromEntries(HEALER_CLASS_ORDER.map((classId) => [classId, {
|
||||
level: 1,
|
||||
inventory: createClassInventory(classId),
|
||||
appearance: createDefaultHealerAppearance(classId),
|
||||
}])) as HunterSave["healers"],
|
||||
location: "Ember Vault Approach",
|
||||
playSeconds: 0,
|
||||
updatedAt: now,
|
||||
@@ -140,6 +170,17 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
||||
bossKills: {},
|
||||
highestRoguelikeRound: 0,
|
||||
highestRogueTrialsEndlessKills: 0,
|
||||
highestHockeyHealingReturns: 0,
|
||||
longestHockeyHealingSecondsAtBest: 0,
|
||||
hockeyHealingPvpWins: 0,
|
||||
hockeyHealingPvpLosses: 0,
|
||||
hockeyHealingPvpBossKills: 0,
|
||||
highestBlockbreakerBricks: 0,
|
||||
longestBlockbreakerSeconds: 0,
|
||||
highestBlockbreakerScore: 0,
|
||||
highestAetherAssaultScore: 0,
|
||||
highestAetherAssaultWaveAtBest: 0,
|
||||
longestAetherAssaultSecondsAtBest: 0,
|
||||
},
|
||||
materials: [] as MaterialStack[],
|
||||
collectionLog: createEmptyCollectionLog(),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LeaderboardCache } from "./leaderboardCache";
|
||||
import type { LeaderboardResult } from "./onlineRepository";
|
||||
|
||||
function memoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
values,
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { values.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
||||
const result: LeaderboardResult = {
|
||||
kind: "roguelike",
|
||||
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12 }],
|
||||
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12 },
|
||||
};
|
||||
|
||||
describe("leaderboard cache", () => {
|
||||
it("persists rankings for signed-in and offline reads", () => {
|
||||
const storage = memoryStorage();
|
||||
const cache = new LeaderboardCache(storage, () => "2026-07-14T00:00:00.000Z");
|
||||
cache.write("hunter", "Aelia", 1, "roguelike", result);
|
||||
|
||||
expect(cache.read(1, "roguelike", "Aelia", "hunter")?.result).toEqual(result);
|
||||
expect(cache.read(1, "roguelike", "Aelia", null)?.accountId).toBe("hunter");
|
||||
expect(cache.read(1, "roguelike", "Other", null)).toBeNull();
|
||||
expect(cache.read(1, "roguelike", "Aelia", "different-account")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores corrupt persisted responses", () => {
|
||||
const storage = memoryStorage();
|
||||
storage.setItem("i-want-to-heal:leaderboards:cache:v1", JSON.stringify({
|
||||
"1:roguelike": { accountId: "hunter", hunterName: "Aelia", slotId: 1, statId: "roguelike", updatedAt: "today", result: {} },
|
||||
}));
|
||||
expect(new LeaderboardCache(storage).read(1, "roguelike", "Aelia", "hunter")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves Hockey Healing duration tiebreakers", () => {
|
||||
const cache = new LeaderboardCache(memoryStorage());
|
||||
const hockeyResult: LeaderboardResult = {
|
||||
kind: "hockey-healing",
|
||||
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 20, secondaryValue: 95.5 }],
|
||||
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 20, secondaryValue: 95.5 },
|
||||
};
|
||||
|
||||
cache.write("hunter", "Aelia", 1, "hockey-healing", hockeyResult);
|
||||
expect(cache.read(1, "hockey-healing", "Aelia", "hunter")?.result).toEqual(hockeyResult);
|
||||
});
|
||||
|
||||
it("validates Blockbreaker metric boards for offline reads", () => {
|
||||
const cache = new LeaderboardCache(memoryStorage());
|
||||
const blockbreakerResult: LeaderboardResult = {
|
||||
kind: "blockbreaker-score",
|
||||
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12_500 }],
|
||||
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12_500 },
|
||||
};
|
||||
cache.write("hunter", "Aelia", 1, "blockbreaker-score", blockbreakerResult);
|
||||
expect(cache.read(1, "blockbreaker-score", "Aelia", "hunter")?.result).toEqual(blockbreakerResult);
|
||||
});
|
||||
|
||||
it("preserves Aether Assault wave tiebreakers for offline reads", () => {
|
||||
const cache = new LeaderboardCache(memoryStorage());
|
||||
const aetherResult: LeaderboardResult = {
|
||||
kind: "aether-assault",
|
||||
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 48_500, secondaryValue: 9 }],
|
||||
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 48_500, secondaryValue: 9 },
|
||||
};
|
||||
cache.write("hunter", "Aelia", 1, "aether-assault", aetherResult);
|
||||
expect(cache.read(1, "aether-assault", "Aelia", "hunter")?.result).toEqual(aetherResult);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { LeaderboardEntry, LeaderboardResult } from "./onlineRepository";
|
||||
import type { ProfileStatId, SaveSlotId } from "./types";
|
||||
|
||||
interface StorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface CachedLeaderboard {
|
||||
accountId: string;
|
||||
hunterName: string;
|
||||
slotId: SaveSlotId;
|
||||
statId: ProfileStatId;
|
||||
updatedAt: string;
|
||||
result: LeaderboardResult;
|
||||
}
|
||||
|
||||
const CACHE_KEY = "i-want-to-heal:leaderboards:cache:v1";
|
||||
const fallbackMemory = new Map<string, string>();
|
||||
const fallbackStorage: StorageAdapter = {
|
||||
getItem: (key) => fallbackMemory.get(key) ?? null,
|
||||
setItem: (key, value) => { fallbackMemory.set(key, value); },
|
||||
};
|
||||
|
||||
function browserStorage(): StorageAdapter {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") return localStorage;
|
||||
} catch {
|
||||
// Android WebView can deny storage before its host is ready.
|
||||
}
|
||||
return fallbackStorage;
|
||||
}
|
||||
|
||||
function cacheId(slotId: SaveSlotId, statId: ProfileStatId) {
|
||||
return `${slotId}:${statId}`;
|
||||
}
|
||||
|
||||
function leaderboardEntry(value: unknown): LeaderboardEntry | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<LeaderboardEntry>;
|
||||
if (!Number.isInteger(candidate.rank) || Number(candidate.rank) < 1) return null;
|
||||
if (typeof candidate.username !== "string" || typeof candidate.hunterName !== "string") return null;
|
||||
if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null;
|
||||
if (!Number.isFinite(candidate.value) || Number(candidate.value) < 0) return null;
|
||||
if (candidate.secondaryValue !== undefined && (!Number.isFinite(candidate.secondaryValue) || Number(candidate.secondaryValue) < 0)) return null;
|
||||
return {
|
||||
rank: Number(candidate.rank),
|
||||
username: candidate.username,
|
||||
hunterName: candidate.hunterName,
|
||||
slotId: candidate.slotId,
|
||||
value: Number(candidate.value),
|
||||
...(candidate.secondaryValue === undefined ? {} : { secondaryValue: Number(candidate.secondaryValue) }),
|
||||
};
|
||||
}
|
||||
|
||||
function leaderboardResult(value: unknown): LeaderboardResult | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<LeaderboardResult>;
|
||||
if (candidate.kind !== "boss"
|
||||
&& candidate.kind !== "roguelike"
|
||||
&& candidate.kind !== "rogue-trials-endless"
|
||||
&& candidate.kind !== "hockey-healing"
|
||||
&& candidate.kind !== "hockey-pvp-wins"
|
||||
&& candidate.kind !== "hockey-pvp-boss-kills"
|
||||
&& candidate.kind !== "blockbreaker-bricks"
|
||||
&& candidate.kind !== "blockbreaker-time"
|
||||
&& candidate.kind !== "blockbreaker-score"
|
||||
&& candidate.kind !== "aether-assault") return null;
|
||||
if (!Array.isArray(candidate.top)) return null;
|
||||
const top = candidate.top.map(leaderboardEntry);
|
||||
if (top.some((entry) => !entry)) return null;
|
||||
const current = candidate.current === null ? null : leaderboardEntry(candidate.current);
|
||||
if (candidate.current !== null && !current) return null;
|
||||
return {
|
||||
kind: candidate.kind,
|
||||
...(candidate.kind === "boss" && typeof candidate.bossId === "string" ? { bossId: candidate.bossId } : {}),
|
||||
top: top as LeaderboardEntry[],
|
||||
current,
|
||||
};
|
||||
}
|
||||
|
||||
function cachedLeaderboard(value: unknown): CachedLeaderboard | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<CachedLeaderboard>;
|
||||
if (typeof candidate.accountId !== "string" || typeof candidate.hunterName !== "string") return null;
|
||||
if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null;
|
||||
if (typeof candidate.statId !== "string" || typeof candidate.updatedAt !== "string") return null;
|
||||
if (Number.isNaN(Date.parse(candidate.updatedAt))) return null;
|
||||
const result = leaderboardResult(candidate.result);
|
||||
if (!result) return null;
|
||||
return { ...candidate, result } as CachedLeaderboard;
|
||||
}
|
||||
|
||||
export class LeaderboardCache {
|
||||
constructor(
|
||||
private readonly storage: StorageAdapter = browserStorage(),
|
||||
private readonly now: () => string = () => new Date().toISOString(),
|
||||
) {}
|
||||
|
||||
read(slotId: SaveSlotId, statId: ProfileStatId, hunterName: string, accountId: string | null): CachedLeaderboard | null {
|
||||
const entry = this.readAll()[cacheId(slotId, statId)];
|
||||
if (!entry || entry.slotId !== slotId || entry.statId !== statId || entry.hunterName !== hunterName) return null;
|
||||
if (accountId && entry.accountId !== accountId) return null;
|
||||
return structuredClone(entry);
|
||||
}
|
||||
|
||||
write(accountId: string, hunterName: string, slotId: SaveSlotId, statId: ProfileStatId, result: LeaderboardResult): CachedLeaderboard {
|
||||
const entries = this.readAll();
|
||||
const entry: CachedLeaderboard = {
|
||||
accountId,
|
||||
hunterName,
|
||||
slotId,
|
||||
statId,
|
||||
updatedAt: this.now(),
|
||||
result: structuredClone(result),
|
||||
};
|
||||
entries[cacheId(slotId, statId)] = entry;
|
||||
this.storage.setItem(CACHE_KEY, JSON.stringify(entries));
|
||||
return structuredClone(entry);
|
||||
}
|
||||
|
||||
clearSlot(slotId: SaveSlotId) {
|
||||
const entries = this.readAll();
|
||||
for (const key of Object.keys(entries)) {
|
||||
if (entries[key].slotId === slotId) delete entries[key];
|
||||
}
|
||||
this.storage.setItem(CACHE_KEY, JSON.stringify(entries));
|
||||
}
|
||||
|
||||
private readAll(): Record<string, CachedLeaderboard> {
|
||||
try {
|
||||
const raw = this.storage.getItem(CACHE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) as Record<string, unknown> : {};
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
return Object.fromEntries(Object.entries(parsed).flatMap(([key, value]) => {
|
||||
const entry = cachedLeaderboard(value);
|
||||
return entry ? [[key, entry]] : [];
|
||||
}));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const leaderboardCache = new LeaderboardCache();
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { onlineRepository } from "./onlineRepository";
|
||||
import { hasPendingSaveSync } from "./saveSync";
|
||||
import { useFrontendStore } from "./store";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("offline leaderboard publishing", () => {
|
||||
it("keeps earned stats pending without attempting an offline upload", async () => {
|
||||
const original = useFrontendStore.getState();
|
||||
original.deleteSlot(3);
|
||||
expect(original.createSlot(3, "Offline Hunter")).toBe(true);
|
||||
original.playSlot(3);
|
||||
useFrontendStore.setState({ accountId: "offline-account" });
|
||||
useFrontendStore.getState().recordBossVictory("bulldrome", "initiate");
|
||||
useFrontendStore.getState().recordAetherAssaultDefeat(18_750, 6, 214.5);
|
||||
expect(hasPendingSaveSync(3)).toBe(true);
|
||||
|
||||
vi.stubGlobal("navigator", { onLine: false });
|
||||
const write = vi.spyOn(onlineRepository, "writeSave");
|
||||
expect(await useFrontendStore.getState().uploadSlot(3)).toBe(false);
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(useFrontendStore.getState().slots[2].local?.stats.bossKills.bulldrome).toBe(1);
|
||||
expect(useFrontendStore.getState().slots[2].local?.stats).toMatchObject({
|
||||
highestAetherAssaultScore: 18_750,
|
||||
highestAetherAssaultWaveAtBest: 6,
|
||||
longestAetherAssaultSecondsAtBest: 214.5,
|
||||
});
|
||||
|
||||
useFrontendStore.getState().deleteSlot(3);
|
||||
useFrontendStore.setState({
|
||||
accountId: original.accountId,
|
||||
activeSlotId: original.activeSlotId,
|
||||
selectedSlotId: original.selectedSlotId,
|
||||
screen: original.screen,
|
||||
notice: original.notice,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { HunterSave, SaveSlotId } from "./types";
|
||||
import type { BossId } from "../game/types";
|
||||
import type { HockeyPvpRemoteSnapshot, HockeyPvpRole } from "../game/hockeyHealingPvp";
|
||||
|
||||
export interface OnlineAccount {
|
||||
id: number;
|
||||
@@ -19,15 +20,32 @@ export interface LeaderboardEntry {
|
||||
hunterName: string;
|
||||
slotId: SaveSlotId;
|
||||
value: number;
|
||||
secondaryValue?: number;
|
||||
}
|
||||
|
||||
export interface LeaderboardResult {
|
||||
kind: "boss" | "roguelike" | "rogue-trials-endless";
|
||||
kind: "boss" | "roguelike" | "rogue-trials-endless" | "hockey-healing" | "hockey-pvp-wins" | "hockey-pvp-boss-kills" | "blockbreaker-bricks" | "blockbreaker-time" | "blockbreaker-score" | "aether-assault";
|
||||
bossId?: BossId;
|
||||
top: LeaderboardEntry[];
|
||||
current: LeaderboardEntry | null;
|
||||
}
|
||||
|
||||
export interface HockeyPvpQueueResult {
|
||||
ticketId: string;
|
||||
status: "waiting" | "matched";
|
||||
match?: {
|
||||
id: string;
|
||||
seed: number;
|
||||
opponentName: string;
|
||||
role: Exclude<HockeyPvpRole, "cpu">;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HockeyPvpExchangeResult {
|
||||
opponentSnapshot: HockeyPvpRemoteSnapshot | null;
|
||||
hostSnapshot: HockeyPvpRemoteSnapshot | null;
|
||||
}
|
||||
|
||||
interface TokenStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
@@ -151,6 +169,58 @@ export class OnlineRepository {
|
||||
rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`);
|
||||
}
|
||||
|
||||
hockeyHealingLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/hockey-healing?slot=${slotId}`);
|
||||
}
|
||||
|
||||
hockeyPvpWinsLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/hockey-pvp-wins?slot=${slotId}`);
|
||||
}
|
||||
|
||||
hockeyPvpBossKillsLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/hockey-pvp-boss-kills?slot=${slotId}`);
|
||||
}
|
||||
|
||||
blockbreakerBricksLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/blockbreaker-bricks?slot=${slotId}`);
|
||||
}
|
||||
|
||||
blockbreakerTimeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/blockbreaker-time?slot=${slotId}`);
|
||||
}
|
||||
|
||||
blockbreakerScoreLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/blockbreaker-score?slot=${slotId}`);
|
||||
}
|
||||
|
||||
aetherAssaultLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/aether-assault?slot=${slotId}`);
|
||||
}
|
||||
|
||||
joinHockeyPvpQueue(slotId: SaveSlotId, hunterName: string): Promise<HockeyPvpQueueResult> {
|
||||
return this.request("/api/hockey-pvp/queue", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slotId, hunterName }),
|
||||
});
|
||||
}
|
||||
|
||||
pollHockeyPvpQueue(ticketId: string): Promise<HockeyPvpQueueResult> {
|
||||
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`);
|
||||
}
|
||||
|
||||
cancelHockeyPvpQueue(ticketId: string): Promise<void> {
|
||||
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
exchangeHockeyPvpState(matchId: string, snapshot: HockeyPvpRemoteSnapshot): Promise<HockeyPvpExchangeResult> {
|
||||
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/state`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const onlineRepository = new OnlineRepository();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCollections } from "./data";
|
||||
import { alphabeticalBosses, defaultStatForSection, profileSectionForStat } from "./profileSections";
|
||||
|
||||
describe("hunter profile sections", () => {
|
||||
const bosses = alphabeticalBosses(buildCollections({ dropsFound: {}, petsFound: {} }, {}));
|
||||
|
||||
it("groups related records under one section", () => {
|
||||
expect(profileSectionForStat("blockbreaker-bricks")).toBe("blockbreaker");
|
||||
expect(profileSectionForStat("blockbreaker-time")).toBe("blockbreaker");
|
||||
expect(profileSectionForStat("hockey-pvp-boss-kills")).toBe("hockey-pvp");
|
||||
expect(profileSectionForStat("aether-assault")).toBe("aether-assault");
|
||||
expect(profileSectionForStat("bulldrome")).toBe("bosses");
|
||||
});
|
||||
|
||||
it("builds one alphabetical boss index across mechanic groups", () => {
|
||||
const names = bosses.map((boss) => boss.bossName);
|
||||
expect(names).toEqual([...names].sort((left, right) => left.localeCompare(right)));
|
||||
expect(new Set(bosses.map((boss) => boss.bossId)).size).toBe(bosses.length);
|
||||
});
|
||||
|
||||
it("opens each section on its primary record", () => {
|
||||
expect(defaultStatForSection("hockey-pvp", bosses)).toBe("hockey-pvp-wins");
|
||||
expect(defaultStatForSection("blockbreaker", bosses)).toBe("blockbreaker-score");
|
||||
expect(defaultStatForSection("aether-assault", bosses)).toBe("aether-assault");
|
||||
expect(defaultStatForSection("bosses", bosses)).toBe(bosses[0].bossId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AVAILABLE_BOSS_IDS } from "../game/bossCatalog";
|
||||
import type { BossId } from "../game/types";
|
||||
import type { GroupBossCollection, ProfileStatId } from "./types";
|
||||
|
||||
export type ProfileSectionId = "roguelike" | "rogue-trials" | "hockey" | "hockey-pvp" | "blockbreaker" | "aether-assault" | "bosses";
|
||||
|
||||
export interface ProfileSectionDefinition {
|
||||
id: ProfileSectionId;
|
||||
label: string;
|
||||
copy: string;
|
||||
icon: string;
|
||||
statIds: readonly ProfileStatId[];
|
||||
}
|
||||
|
||||
export const PROFILE_SECTIONS: readonly ProfileSectionDefinition[] = [
|
||||
{ id: "roguelike", label: "Roguelike", copy: "Highest completed round", icon: "∞", statIds: ["roguelike"] },
|
||||
{ id: "rogue-trials", label: "Trials Endless", copy: "Best endless boss run", icon: "Ⅲ", statIds: ["rogue-trials-endless"] },
|
||||
{ id: "hockey", label: "Hockey", copy: "Returns and survival", icon: "◌", statIds: ["hockey-healing"] },
|
||||
{ id: "hockey-pvp", label: "Hockey PVP", copy: "Record and race kills", icon: "◇", statIds: ["hockey-pvp-wins", "hockey-pvp-boss-kills"] },
|
||||
{ id: "blockbreaker", label: "Blockbreaker", copy: "Score, bricks, survival", icon: "▦", statIds: ["blockbreaker-score", "blockbreaker-bricks", "blockbreaker-time"] },
|
||||
{ id: "aether-assault", label: "Aether Assault", copy: "Score, wave, survival", icon: "⌁", statIds: ["aether-assault"] },
|
||||
{ id: "bosses", label: "Bosses", copy: "Kills and boss pets", icon: "♛", statIds: [] },
|
||||
] as const;
|
||||
|
||||
const BOSS_IDS = new Set<string>(AVAILABLE_BOSS_IDS);
|
||||
|
||||
export function isBossProfileStat(statId: ProfileStatId): statId is BossId {
|
||||
return BOSS_IDS.has(statId);
|
||||
}
|
||||
|
||||
export function profileSectionForStat(statId: ProfileStatId): ProfileSectionId {
|
||||
if (isBossProfileStat(statId)) return "bosses";
|
||||
return PROFILE_SECTIONS.find((section) => section.statIds.includes(statId))?.id ?? "roguelike";
|
||||
}
|
||||
|
||||
export function alphabeticalBosses(groups: readonly { bosses: readonly GroupBossCollection[] }[]): GroupBossCollection[] {
|
||||
return groups
|
||||
.flatMap((group) => group.bosses)
|
||||
.sort((left, right) => left.bossName.localeCompare(right.bossName));
|
||||
}
|
||||
|
||||
export function defaultStatForSection(sectionId: ProfileSectionId, bosses: readonly GroupBossCollection[]): ProfileStatId {
|
||||
if (sectionId === "bosses") return bosses[0]?.bossId ?? "roguelike";
|
||||
return PROFILE_SECTIONS.find((section) => section.id === sectionId)?.statIds[0] ?? "roguelike";
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
||||
import { groupDrop } from "../game/progression/loot";
|
||||
import { RUN_BUFF_ORDER } from "../game/roguelike";
|
||||
import { HEALER_VISUAL_PROFILES } from "../game/healerVisuals";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
@@ -45,10 +46,22 @@ describe("SaveRepository", () => {
|
||||
const repository = new SaveRepository(memoryStorage(), () => now);
|
||||
const serverSave = repository.create(1, "Aelia");
|
||||
serverSave.healers.priest.level = 40;
|
||||
const legacyStats = structuredClone(serverSave.stats) as unknown as Record<string, unknown>;
|
||||
delete legacyStats.highestAetherAssaultScore;
|
||||
delete legacyStats.highestAetherAssaultWaveAtBest;
|
||||
delete legacyStats.longestAetherAssaultSecondsAtBest;
|
||||
now = "2026-07-10T14:00:00.000Z";
|
||||
repository.replaceLocal(serverSave);
|
||||
repository.replaceLocal({ ...serverSave, schemaVersion: 5, stats: legacyStats } as never);
|
||||
expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40);
|
||||
expect(repository.listLocal()[0].local?.updatedAt).toBe(now);
|
||||
expect(repository.listLocal()[0].local).toMatchObject({
|
||||
schemaVersion: 6,
|
||||
stats: {
|
||||
highestAetherAssaultScore: 0,
|
||||
highestAetherAssaultWaveAtBest: 0,
|
||||
longestAetherAssaultSecondsAtBest: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes the local copy without inventing an online record", () => {
|
||||
@@ -68,7 +81,11 @@ describe("SaveRepository", () => {
|
||||
activeClassId: "druid",
|
||||
healers: {
|
||||
...save.healers,
|
||||
druid: { level: 8, inventory: [...save.healers.druid.inventory, { ...save.healers.druid.inventory[0], id: "druid-drop" }] },
|
||||
druid: {
|
||||
...save.healers.druid,
|
||||
level: 8,
|
||||
inventory: [...save.healers.druid.inventory, { ...save.healers.druid.inventory[0], id: "druid-drop" }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -81,7 +98,95 @@ describe("SaveRepository", () => {
|
||||
expect(save.healers.priest.inventory).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("resets every legacy save into fresh v5 progression while preserving identity and timestamp", () => {
|
||||
it("adds default appearances to existing v6 saves without resetting progression or timestamps", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-16T12:00:00.000Z");
|
||||
const created = repository.create(1, "Veteran");
|
||||
const legacy = structuredClone(created) as unknown as {
|
||||
updatedAt: string;
|
||||
healers: Record<string, { level: number; inventory: unknown[]; appearance?: unknown }>;
|
||||
};
|
||||
legacy.healers.priest.level = 37;
|
||||
legacy.updatedAt = "2026-07-15T09:30:00.000Z";
|
||||
for (const healer of Object.values(legacy.healers)) delete healer.appearance;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
|
||||
const migrated = repository.listLocal()[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(6);
|
||||
expect(migrated.updatedAt).toBe("2026-07-15T09:30:00.000Z");
|
||||
expect(migrated.healers.priest.level).toBe(37);
|
||||
for (const [classId, profile] of Object.entries(HEALER_VISUAL_PROFILES)) {
|
||||
expect(migrated.healers[classId as keyof typeof migrated.healers].appearance).toEqual(profile.appearance);
|
||||
}
|
||||
const persisted = JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}") as Record<string, typeof migrated>;
|
||||
expect(persisted["1"].updatedAt).toBe("2026-07-15T09:30:00.000Z");
|
||||
expect(persisted["1"].healers.paladin.appearance).toEqual(HEALER_VISUAL_PROFILES.paladin.appearance);
|
||||
});
|
||||
|
||||
it("repairs corrupt appearance fields without resetting safe choices or another healer", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-16T12:00:00.000Z");
|
||||
const created = repository.create(1, "Mixer");
|
||||
const druidAppearance = structuredClone(created.healers.druid.appearance);
|
||||
const corrupt = structuredClone(created) as unknown as Record<string, unknown>;
|
||||
const corruptHealers = (corrupt.healers as Record<string, Record<string, unknown>>);
|
||||
corruptHealers.priest.level = 22;
|
||||
corruptHealers.priest.appearance = {
|
||||
version: 1,
|
||||
rigId: "medium",
|
||||
scaleSourceMemberId: "unknown-member",
|
||||
headPartId: "knight-upper",
|
||||
upperBodyPartId: "knight-upper",
|
||||
lowerBodyPartId: "unknown-lower",
|
||||
headwearPartId: "druid-backpack",
|
||||
backPartId: "ranger-cape",
|
||||
mainHand: { modelId: "sword", grip: "staff" },
|
||||
offHand: { modelId: "unknown-weapon", grip: "prop" },
|
||||
};
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: corrupt }));
|
||||
|
||||
const normalized = repository.listLocal()[0].local!;
|
||||
expect(normalized.healers.priest.level).toBe(22);
|
||||
expect(normalized.healers.priest.appearance).toEqual({
|
||||
...HEALER_VISUAL_PROFILES.priest.appearance,
|
||||
upperBodyPartId: "knight-upper",
|
||||
backPartId: "ranger-cape",
|
||||
mainHand: { modelId: "cc/adv_sword_1handed", grip: "upright" },
|
||||
});
|
||||
expect(normalized.healers.priest.appearance.offHand).toBeUndefined();
|
||||
expect(normalized.healers.druid.appearance).toEqual(druidAppearance);
|
||||
});
|
||||
|
||||
it("round-trips and deep-copies a valid mixed appearance", () => {
|
||||
const repository = new SaveRepository(memoryStorage(), () => "2026-07-16T12:00:00.000Z");
|
||||
repository.create(1, "Mixer");
|
||||
repository.updateLocal(1, (save) => ({
|
||||
...save,
|
||||
healers: {
|
||||
...save.healers,
|
||||
priest: {
|
||||
...save.healers.priest,
|
||||
appearance: {
|
||||
...save.healers.priest.appearance,
|
||||
headPartId: "rogue-head",
|
||||
upperBodyPartId: "knight-upper",
|
||||
lowerBodyPartId: "ranger-lower",
|
||||
headwearPartId: "mage-hat",
|
||||
backPartId: "druid-backpack",
|
||||
mainHand: { modelId: "cc/wand_b", grip: "wand" },
|
||||
offHand: { modelId: "cc/spellbook_open", grip: "prop" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
repository.copyLocal(1, 2);
|
||||
const [source, copy] = repository.listLocal().map((slot) => slot.local);
|
||||
expect(copy?.healers.priest.appearance).toEqual(source?.healers.priest.appearance);
|
||||
expect(copy?.healers.priest.appearance).not.toBe(source?.healers.priest.appearance);
|
||||
expect(copy?.healers.priest.appearance.mainHand).not.toBe(source?.healers.priest.appearance.mainHand);
|
||||
});
|
||||
|
||||
it("resets every legacy save into fresh v6 progression while preserving identity and timestamp", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Legacy");
|
||||
@@ -103,16 +208,35 @@ describe("SaveRepository", () => {
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
|
||||
const migrated = repository.listLocal()[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.schemaVersion).toBe(6);
|
||||
expect(migrated.hunterName).toBe("Legacy");
|
||||
expect(migrated.activeClassId).toBe("priest");
|
||||
expect(migrated.playSeconds).toBe(0);
|
||||
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
|
||||
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0 });
|
||||
expect(migrated.stats).toEqual({
|
||||
totalBossKills: 0,
|
||||
flawlessClears: 0,
|
||||
alliesSaved: 0,
|
||||
healingDone: 0,
|
||||
bossKills: {},
|
||||
highestRoguelikeRound: 0,
|
||||
highestRogueTrialsEndlessKills: 0,
|
||||
highestHockeyHealingReturns: 0,
|
||||
longestHockeyHealingSecondsAtBest: 0,
|
||||
hockeyHealingPvpWins: 0,
|
||||
hockeyHealingPvpLosses: 0,
|
||||
hockeyHealingPvpBossKills: 0,
|
||||
highestBlockbreakerBricks: 0,
|
||||
longestBlockbreakerSeconds: 0,
|
||||
highestBlockbreakerScore: 0,
|
||||
highestAetherAssaultScore: 0,
|
||||
highestAetherAssaultWaveAtBest: 0,
|
||||
longestAetherAssaultSecondsAtBest: 0,
|
||||
});
|
||||
expect(migrated.materials).toEqual([]);
|
||||
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
|
||||
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
|
||||
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5);
|
||||
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(6);
|
||||
});
|
||||
|
||||
it("preserves valid v5 progression and group-drop inventory", () => {
|
||||
@@ -123,15 +247,31 @@ describe("SaveRepository", () => {
|
||||
created.healers.priest.level = 8;
|
||||
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
|
||||
created.stats.highestRogueTrialsEndlessKills = 14;
|
||||
created.stats.highestHockeyHealingReturns = 31;
|
||||
created.stats.longestHockeyHealingSecondsAtBest = 188.5;
|
||||
created.stats.highestBlockbreakerBricks = 52;
|
||||
created.stats.longestBlockbreakerSeconds = 245.25;
|
||||
created.stats.highestBlockbreakerScore = 9_800;
|
||||
created.stats.highestAetherAssaultScore = 12_400;
|
||||
created.stats.highestAetherAssaultWaveAtBest = 7;
|
||||
created.stats.longestAetherAssaultSecondsAtBest = 191.5;
|
||||
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
|
||||
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } }));
|
||||
|
||||
const migrated = repository.listLocal()[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.schemaVersion).toBe(6);
|
||||
expect(migrated.healers.priest.level).toBe(8);
|
||||
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
|
||||
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14);
|
||||
expect(migrated.stats.highestHockeyHealingReturns).toBe(31);
|
||||
expect(migrated.stats.longestHockeyHealingSecondsAtBest).toBe(188.5);
|
||||
expect(migrated.stats.highestBlockbreakerBricks).toBe(52);
|
||||
expect(migrated.stats.longestBlockbreakerSeconds).toBe(245.25);
|
||||
expect(migrated.stats.highestBlockbreakerScore).toBe(9_800);
|
||||
expect(migrated.stats.highestAetherAssaultScore).toBe(12_400);
|
||||
expect(migrated.stats.highestAetherAssaultWaveAtBest).toBe(7);
|
||||
expect(migrated.stats.longestAetherAssaultSecondsAtBest).toBe(191.5);
|
||||
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
|
||||
expect(migrated.collectionLog).toEqual(created.collectionLog);
|
||||
});
|
||||
@@ -145,10 +285,10 @@ describe("SaveRepository", () => {
|
||||
created.gearProgress.druid.passiveInfusionId = "mend-echo";
|
||||
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
|
||||
created.gearProgress.brann.passiveInfusionId = "deep-wells" as never;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } }));
|
||||
|
||||
const migrated = repository.listLocal()[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.schemaVersion).toBe(6);
|
||||
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
|
||||
expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull();
|
||||
expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo");
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createHunterSave } from "./data";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import { createClassInventory, HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear";
|
||||
import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions";
|
||||
import { GROUP_DROP_TABLES, type CollectionLog, type MaterialStack } from "../game/progression/loot";
|
||||
import type { BossId, HealerClassId } from "../game/types";
|
||||
import { normalizeHealerAppearance } from "../game/healerVisuals";
|
||||
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
|
||||
export interface StorageAdapter {
|
||||
@@ -48,7 +49,7 @@ interface LegacyHunterSave {
|
||||
gearProgress?: GearProgress;
|
||||
}
|
||||
|
||||
const HEALER_IDS: HealerClassId[] = ["priest", "druid", "shaman"];
|
||||
const HEALER_IDS: readonly HealerClassId[] = HEALER_CLASS_ORDER;
|
||||
|
||||
function positiveCounts(value: unknown): Record<string, number> {
|
||||
if (!value || typeof value !== "object") return {};
|
||||
@@ -121,7 +122,7 @@ function normalizeSave(value: unknown): HunterSave | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as LegacyHunterSave;
|
||||
if (!candidate.slotId || !candidate.hunterName) return null;
|
||||
if (candidate.schemaVersion !== 5) {
|
||||
if (candidate.schemaVersion !== 5 && candidate.schemaVersion !== 6) {
|
||||
try {
|
||||
return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName);
|
||||
} catch {
|
||||
@@ -132,13 +133,14 @@ function normalizeSave(value: unknown): HunterSave | null {
|
||||
const bossKills = normalizeBossKills(candidate.stats?.bossKills);
|
||||
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
|
||||
return {
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 6,
|
||||
slotId: candidate.slotId,
|
||||
hunterName: candidate.hunterName,
|
||||
activeClassId,
|
||||
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
|
||||
level: Math.max(1, candidate.healers?.[classId]?.level ?? (classId === "priest" ? candidate.level ?? 1 : 1)),
|
||||
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
|
||||
appearance: normalizeHealerAppearance(classId, candidate.healers?.[classId]?.appearance),
|
||||
}])) as HunterSave["healers"],
|
||||
location: candidate.location ?? "Ember Vault Approach",
|
||||
playSeconds: Math.max(0, candidate.playSeconds ?? 0),
|
||||
@@ -151,6 +153,17 @@ function normalizeSave(value: unknown): HunterSave | null {
|
||||
bossKills,
|
||||
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
|
||||
highestRogueTrialsEndlessKills: Math.max(0, Math.floor(candidate.stats?.highestRogueTrialsEndlessKills ?? 0)),
|
||||
highestHockeyHealingReturns: Math.max(0, Math.floor(candidate.stats?.highestHockeyHealingReturns ?? 0)),
|
||||
longestHockeyHealingSecondsAtBest: Math.max(0, Number(candidate.stats?.longestHockeyHealingSecondsAtBest) || 0),
|
||||
hockeyHealingPvpWins: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpWins ?? 0)),
|
||||
hockeyHealingPvpLosses: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpLosses ?? 0)),
|
||||
hockeyHealingPvpBossKills: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpBossKills ?? 0)),
|
||||
highestBlockbreakerBricks: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerBricks ?? 0)),
|
||||
longestBlockbreakerSeconds: Math.max(0, Number(candidate.stats?.longestBlockbreakerSeconds) || 0),
|
||||
highestBlockbreakerScore: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerScore ?? 0)),
|
||||
highestAetherAssaultScore: Math.max(0, Math.floor(candidate.stats?.highestAetherAssaultScore ?? 0)),
|
||||
highestAetherAssaultWaveAtBest: Math.max(0, Math.floor(candidate.stats?.highestAetherAssaultWaveAtBest ?? 0)),
|
||||
longestAetherAssaultSecondsAtBest: Math.max(0, Number(candidate.stats?.longestAetherAssaultSecondsAtBest) || 0),
|
||||
},
|
||||
materials: normalizeMaterials(candidate.materials, collectionLog),
|
||||
collectionLog,
|
||||
@@ -224,7 +237,7 @@ export class SaveRepository {
|
||||
}
|
||||
|
||||
replaceLocal(save: HunterSave): HunterSave {
|
||||
const normalized = { ...cloneSave(save), updatedAt: this.now() };
|
||||
const normalized = { ...(normalizeSave(save) ?? cloneSave(save)), updatedAt: this.now() };
|
||||
this.setLocal(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PendingSaveSyncRepository, SAVE_SYNC_RETRY_DELAYS_MS, SaveSyncRetryCoordinator } from "./saveSync";
|
||||
|
||||
function memoryStorage(initial = "[]") {
|
||||
const values = new Map([["i-want-to-heal:saves:pending-sync:v1", initial]]);
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { values.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
||||
describe("pending save sync", () => {
|
||||
it("persists unique dirty slots and tolerates corrupt state", () => {
|
||||
const storage = memoryStorage();
|
||||
const pending = new PendingSaveSyncRepository(storage);
|
||||
pending.mark(2);
|
||||
pending.mark(2);
|
||||
pending.mark(1);
|
||||
expect(pending.list()).toEqual([2, 1]);
|
||||
pending.clear(2);
|
||||
expect(pending.list()).toEqual([1]);
|
||||
expect(new PendingSaveSyncRepository(memoryStorage("not-json")).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits offline and caps automatic retries", async () => {
|
||||
const pending = new PendingSaveSyncRepository(memoryStorage());
|
||||
pending.mark(1);
|
||||
let online = false;
|
||||
const uploads: number[] = [];
|
||||
const tasks: Array<{ run: () => void; delay: number }> = [];
|
||||
const coordinator = new SaveSyncRetryCoordinator(
|
||||
pending,
|
||||
async (slotId) => { uploads.push(slotId); return false; },
|
||||
() => online,
|
||||
(run, delay) => {
|
||||
tasks.push({ run, delay });
|
||||
return tasks.length as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
coordinator.flush();
|
||||
expect(uploads).toEqual([]);
|
||||
online = true;
|
||||
coordinator.flush(true);
|
||||
await Promise.resolve();
|
||||
expect(uploads).toEqual([1]);
|
||||
|
||||
for (const delay of SAVE_SYNC_RETRY_DELAYS_MS) {
|
||||
coordinator.failed(1);
|
||||
expect(tasks[0]?.delay).toBe(delay);
|
||||
tasks.shift()!.run();
|
||||
await Promise.resolve();
|
||||
}
|
||||
coordinator.failed(1);
|
||||
expect(tasks).toEqual([]);
|
||||
expect(uploads).toHaveLength(1 + SAVE_SYNC_RETRY_DELAYS_MS.length);
|
||||
coordinator.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { SaveSlotId } from "./types";
|
||||
|
||||
interface StorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
type UploadSave = (slotId: SaveSlotId) => Promise<boolean>;
|
||||
|
||||
const PENDING_KEY = "i-want-to-heal:saves:pending-sync:v1";
|
||||
export const SAVE_SYNC_RETRY_DELAYS_MS = [2_000, 10_000, 30_000, 120_000] as const;
|
||||
const fallbackMemory = new Map<string, string>();
|
||||
const fallbackStorage: StorageAdapter = {
|
||||
getItem: (key) => fallbackMemory.get(key) ?? null,
|
||||
setItem: (key, value) => { fallbackMemory.set(key, value); },
|
||||
};
|
||||
|
||||
function browserStorage(): StorageAdapter {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") return localStorage;
|
||||
} catch {
|
||||
// Android WebView can deny storage before its host is ready.
|
||||
}
|
||||
return fallbackStorage;
|
||||
}
|
||||
|
||||
function isSlotId(value: number): value is SaveSlotId {
|
||||
return value === 1 || value === 2 || value === 3;
|
||||
}
|
||||
|
||||
export function networkAppearsOnline() {
|
||||
return typeof navigator === "undefined" || navigator.onLine !== false;
|
||||
}
|
||||
|
||||
export class PendingSaveSyncRepository {
|
||||
constructor(private readonly storage: StorageAdapter = browserStorage()) {}
|
||||
|
||||
list(): SaveSlotId[] {
|
||||
try {
|
||||
const parsed = JSON.parse(this.storage.getItem(PENDING_KEY) ?? "[]") as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return [...new Set(parsed.map(Number).filter(isSlotId))];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
has(slotId: SaveSlotId) {
|
||||
return this.list().includes(slotId);
|
||||
}
|
||||
|
||||
mark(slotId: SaveSlotId) {
|
||||
const slots = this.list();
|
||||
if (!slots.includes(slotId)) this.write([...slots, slotId]);
|
||||
}
|
||||
|
||||
clear(slotId: SaveSlotId) {
|
||||
this.write(this.list().filter((candidate) => candidate !== slotId));
|
||||
}
|
||||
|
||||
private write(slots: readonly SaveSlotId[]) {
|
||||
this.storage.setItem(PENDING_KEY, JSON.stringify(slots));
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveSyncRetryCoordinator {
|
||||
private readonly attempts = new Map<SaveSlotId, number>();
|
||||
private readonly timers = new Map<SaveSlotId, TimerHandle>();
|
||||
private readonly active = new Set<SaveSlotId>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly pending: PendingSaveSyncRepository,
|
||||
private readonly upload: UploadSave,
|
||||
private readonly isOnline: () => boolean = networkAppearsOnline,
|
||||
private readonly schedule: (run: () => void, delay: number) => TimerHandle = setTimeout,
|
||||
private readonly cancel: (timer: TimerHandle) => void = clearTimeout,
|
||||
) {}
|
||||
|
||||
flush(resetAttempts = false) {
|
||||
if (resetAttempts) this.attempts.clear();
|
||||
if (!this.isOnline()) return;
|
||||
for (const slotId of this.pending.list()) this.run(slotId);
|
||||
}
|
||||
|
||||
failed(slotId: SaveSlotId) {
|
||||
if (this.disposed || !this.pending.has(slotId) || !this.isOnline() || this.timers.has(slotId)) return;
|
||||
const attempt = this.attempts.get(slotId) ?? 0;
|
||||
const delay = SAVE_SYNC_RETRY_DELAYS_MS[attempt];
|
||||
if (delay === undefined) return;
|
||||
this.attempts.set(slotId, attempt + 1);
|
||||
const timer = this.schedule(() => {
|
||||
this.timers.delete(slotId);
|
||||
this.run(slotId);
|
||||
}, delay);
|
||||
this.timers.set(slotId, timer);
|
||||
}
|
||||
|
||||
succeeded(slotId: SaveSlotId) {
|
||||
this.attempts.delete(slotId);
|
||||
const timer = this.timers.get(slotId);
|
||||
if (timer !== undefined) this.cancel(timer);
|
||||
this.timers.delete(slotId);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
for (const timer of this.timers.values()) this.cancel(timer);
|
||||
this.timers.clear();
|
||||
this.active.clear();
|
||||
}
|
||||
|
||||
private run(slotId: SaveSlotId) {
|
||||
if (this.disposed || this.active.has(slotId) || this.timers.has(slotId) || !this.pending.has(slotId) || !this.isOnline()) return;
|
||||
this.active.add(slotId);
|
||||
void this.upload(slotId).finally(() => this.active.delete(slotId));
|
||||
}
|
||||
}
|
||||
|
||||
const pendingSaveSync = new PendingSaveSyncRepository();
|
||||
let activeCoordinator: SaveSyncRetryCoordinator | null = null;
|
||||
|
||||
export function markSaveSyncPending(slotId: SaveSlotId) {
|
||||
pendingSaveSync.mark(slotId);
|
||||
}
|
||||
|
||||
export function hasPendingSaveSync(slotId: SaveSlotId) {
|
||||
return pendingSaveSync.has(slotId);
|
||||
}
|
||||
|
||||
export function clearSaveSyncPending(slotId: SaveSlotId) {
|
||||
pendingSaveSync.clear(slotId);
|
||||
activeCoordinator?.succeeded(slotId);
|
||||
}
|
||||
|
||||
export function scheduleSaveSyncRetry(slotId: SaveSlotId) {
|
||||
activeCoordinator?.failed(slotId);
|
||||
}
|
||||
|
||||
export function startSaveSyncCoordinator(upload: UploadSave) {
|
||||
const coordinator = new SaveSyncRetryCoordinator(pendingSaveSync, upload);
|
||||
activeCoordinator?.dispose();
|
||||
activeCoordinator = coordinator;
|
||||
const onOnline = () => coordinator.flush(true);
|
||||
window.addEventListener("online", onOnline);
|
||||
coordinator.flush();
|
||||
return () => {
|
||||
window.removeEventListener("online", onOnline);
|
||||
coordinator.dispose();
|
||||
if (activeCoordinator === coordinator) activeCoordinator = null;
|
||||
};
|
||||
}
|
||||
+332
-18
@@ -2,9 +2,27 @@ import { create } from "zustand";
|
||||
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
|
||||
import { SaveRepository } from "./saveRepository";
|
||||
import { AccountRepository, type AccountResult } from "./accountRepository";
|
||||
import { onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
|
||||
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
|
||||
import { OnlineApiError, onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
|
||||
import { leaderboardCache } from "./leaderboardCache";
|
||||
import {
|
||||
clearSaveSyncPending,
|
||||
markSaveSyncPending,
|
||||
networkAppearsOnline,
|
||||
scheduleSaveSyncRetry,
|
||||
} from "./saveSync";
|
||||
import type {
|
||||
AppScreen,
|
||||
GameModeId,
|
||||
GameSettings,
|
||||
HunterSave,
|
||||
ProfileCollectionView,
|
||||
ProfileStatId,
|
||||
SaveSlotId,
|
||||
SaveSlotState,
|
||||
} from "./types";
|
||||
import type { BossGroupId } from "../game/bossCatalog";
|
||||
import type { AbilitySlotId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
|
||||
import { HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike";
|
||||
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
|
||||
import {
|
||||
@@ -13,20 +31,27 @@ import {
|
||||
infusionsForOwner,
|
||||
} from "../game/progression/infusions";
|
||||
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
||||
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
|
||||
import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "../game/progression/hunterStats";
|
||||
import {
|
||||
cloneCharacterAppearance,
|
||||
type CharacterAppearanceV1,
|
||||
type CharacterModelMode,
|
||||
} from "../game/characterAppearance";
|
||||
import { createDefaultHealerAppearance, normalizeHealerAppearance } from "../game/healerVisuals";
|
||||
|
||||
const repository = new SaveRepository();
|
||||
const accounts = new AccountRepository();
|
||||
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
|
||||
const onlineSaveQueues = new Map<SaveSlotId, Promise<HunterSave>>();
|
||||
const onlineSaveQueues = new Map<SaveSlotId, { updatedAt: string; promise: Promise<HunterSave> }>();
|
||||
|
||||
function writeServerSaveSerially(save: HunterSave): Promise<HunterSave> {
|
||||
const previous = onlineSaveQueues.get(save.slotId);
|
||||
const next = (previous ? previous.catch(() => save) : Promise.resolve(save))
|
||||
if (previous?.updatedAt === save.updatedAt) return previous.promise;
|
||||
const next = (previous ? previous.promise.catch(() => save) : Promise.resolve(save))
|
||||
.then(() => onlineRepository.writeSave(save));
|
||||
onlineSaveQueues.set(save.slotId, next);
|
||||
onlineSaveQueues.set(save.slotId, { updatedAt: save.updatedAt, promise: next });
|
||||
void next.finally(() => {
|
||||
if (onlineSaveQueues.get(save.slotId) === next) onlineSaveQueues.delete(save.slotId);
|
||||
if (onlineSaveQueues.get(save.slotId)?.promise === next) onlineSaveQueues.delete(save.slotId);
|
||||
}).catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
@@ -76,6 +101,16 @@ function replaceOnlineSlot(current: readonly SaveSlotState[], save: HunterSave):
|
||||
return refreshLocalSlots(current).map((slot) => slot.id === save.slotId ? { ...slot, online: save } : slot);
|
||||
}
|
||||
|
||||
export type AppearancePreviewAnimation = "idle" | "walk" | "cast";
|
||||
export type AppearanceDrafts = Record<HealerClassId, CharacterAppearanceV1>;
|
||||
|
||||
function appearanceDraftsFor(save: HunterSave | null): AppearanceDrafts {
|
||||
return Object.fromEntries(HEALER_CLASS_ORDER.map((classId) => [
|
||||
classId,
|
||||
cloneCharacterAppearance(save?.healers[classId].appearance ?? createDefaultHealerAppearance(classId)),
|
||||
])) as AppearanceDrafts;
|
||||
}
|
||||
|
||||
export interface FrontendState {
|
||||
screen: AppScreen;
|
||||
accountId: string | null;
|
||||
@@ -89,8 +124,15 @@ export interface FrontendState {
|
||||
selectedGearSlotId: GearSlotId;
|
||||
gearWorkshopMode: "upgrade" | "infusion";
|
||||
selectedInfusionId: string;
|
||||
selectedPassiveAbilityId: AbilityId;
|
||||
selectedPassiveAbilityId: AbilitySlotId;
|
||||
selectedPassiveInfusionId: RunBuffId;
|
||||
profileCollectionView: ProfileCollectionView;
|
||||
selectedProfileGroupId: BossGroupId;
|
||||
selectedProfileStatId: ProfileStatId;
|
||||
appearanceClassId: HealerClassId;
|
||||
appearanceDrafts: AppearanceDrafts;
|
||||
previewMode: CharacterModelMode;
|
||||
previewAnimation: AppearancePreviewAnimation;
|
||||
recentRewards: BossRewardAward[];
|
||||
settings: GameSettings;
|
||||
notice: string;
|
||||
@@ -105,7 +147,7 @@ export interface FrontendState {
|
||||
playSlot: (slotId: SaveSlotId) => void;
|
||||
deleteSlot: (slotId: SaveSlotId) => void;
|
||||
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
|
||||
uploadSlot: (slotId: SaveSlotId) => Promise<void>;
|
||||
uploadSlot: (slotId: SaveSlotId) => Promise<boolean>;
|
||||
downloadSlot: (slotId: SaveSlotId) => Promise<void>;
|
||||
selectMode: (mode: GameModeId) => void;
|
||||
selectBoss: (bossId: BossId) => void;
|
||||
@@ -114,8 +156,19 @@ export interface FrontendState {
|
||||
selectGearSlot: (slotId: GearSlotId) => void;
|
||||
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
|
||||
selectInfusion: (infusionId: string) => void;
|
||||
selectPassiveAbility: (abilityId: AbilityId) => void;
|
||||
selectPassiveAbility: (abilityId: AbilitySlotId) => void;
|
||||
selectPassiveInfusion: (passiveId: RunBuffId) => void;
|
||||
selectProfileCollectionView: (view: ProfileCollectionView) => void;
|
||||
selectProfileGroup: (groupId: BossGroupId) => void;
|
||||
selectProfileStat: (statId: ProfileStatId) => void;
|
||||
openAppearanceLab: () => void;
|
||||
selectAppearanceClass: (classId: HealerClassId) => void;
|
||||
updateAppearanceDraft: (appearance: CharacterAppearanceV1) => void;
|
||||
resetAppearanceDraft: () => void;
|
||||
saveAppearanceDraft: () => boolean;
|
||||
closeAppearanceLab: () => void;
|
||||
setAppearancePreviewMode: (mode: CharacterModelMode) => void;
|
||||
setAppearancePreviewAnimation: (animation: AppearancePreviewAnimation) => void;
|
||||
upgradeSelectedGear: () => boolean;
|
||||
equipSelectedInfusion: () => boolean;
|
||||
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
|
||||
@@ -126,6 +179,11 @@ export interface FrontendState {
|
||||
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
||||
recordRoguelikeDefeat: (round: number) => void;
|
||||
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
|
||||
recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void;
|
||||
recordHockeyPvpResult: (won: boolean) => void;
|
||||
recordHockeyPvpBossKill: () => void;
|
||||
recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void;
|
||||
recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void;
|
||||
clearRecentRewards: () => void;
|
||||
clearNotice: () => void;
|
||||
}
|
||||
@@ -147,8 +205,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
selectedGearSlotId: "weapon",
|
||||
gearWorkshopMode: "upgrade",
|
||||
selectedInfusionId: infusionsForOwner("priest")[0].id,
|
||||
selectedPassiveAbilityId: "mend",
|
||||
selectedPassiveAbilityId: "ability1",
|
||||
selectedPassiveInfusionId: "mend-echo",
|
||||
profileCollectionView: "stats",
|
||||
selectedProfileGroupId: "charge",
|
||||
selectedProfileStatId: "roguelike",
|
||||
appearanceClassId: "priest",
|
||||
appearanceDrafts: appearanceDraftsFor(null),
|
||||
previewMode: "modular",
|
||||
previewAnimation: "idle",
|
||||
recentRewards: [],
|
||||
settings: loadSettings(),
|
||||
notice: "",
|
||||
@@ -200,7 +265,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
void accounts.logout();
|
||||
set({ accountId: null, slots: repository.listLocal(), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." });
|
||||
},
|
||||
navigate: (screen) => set({ screen, notice: "" }),
|
||||
navigate: (screen) => set(screen === "profile"
|
||||
? {
|
||||
screen,
|
||||
profileCollectionView: "stats",
|
||||
selectedProfileGroupId: "charge",
|
||||
selectedProfileStatId: "roguelike",
|
||||
notice: "",
|
||||
}
|
||||
: { screen, notice: "" }),
|
||||
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
|
||||
createSlot: (slotId, rawHunterName) => {
|
||||
const hunterName = normalizeHunterName(rawHunterName);
|
||||
@@ -209,6 +282,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
repository.create(slotId, hunterName);
|
||||
leaderboardCache.clearSlot(slotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
|
||||
return true;
|
||||
},
|
||||
@@ -219,6 +293,8 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
},
|
||||
deleteSlot: (slotId) => {
|
||||
repository.deleteLocal(slotId);
|
||||
leaderboardCache.clearSlot(slotId);
|
||||
clearSaveSyncPending(slotId);
|
||||
set((state) => ({
|
||||
slots: refreshLocalSlots(state.slots),
|
||||
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
|
||||
@@ -228,30 +304,48 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
copySlot: (sourceId, targetId) => {
|
||||
const copy = repository.copyLocal(sourceId, targetId);
|
||||
if (!copy) return;
|
||||
leaderboardCache.clearSlot(targetId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
|
||||
},
|
||||
uploadSlot: async (slotId) => {
|
||||
const { accountId } = get();
|
||||
if (!accountId) return set({ notice: "Sign in before syncing online." });
|
||||
if (!accountId) {
|
||||
set({ notice: "Sign in before syncing online." });
|
||||
return false;
|
||||
}
|
||||
const local = repository.listLocal().find((slot) => slot.id === slotId)?.local;
|
||||
if (!local) return set({ notice: "No offline save to sync." });
|
||||
if (!local) {
|
||||
set({ notice: "No offline save to sync." });
|
||||
return false;
|
||||
}
|
||||
markSaveSyncPending(slotId);
|
||||
if (!networkAppearsOnline()) {
|
||||
set({ notice: `Slot ${slotId} saved locally. Online sync waits for connection.` });
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const uploaded = await writeServerSaveSerially(local);
|
||||
if (get().accountId === accountId) {
|
||||
set((state) => ({ slots: replaceOnlineSlot(state.slots, uploaded), notice: `Slot ${slotId} synced to TrueNAS.` }));
|
||||
}
|
||||
clearSaveSyncPending(slotId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const retryable = !(error instanceof OnlineApiError) || error.status === 0 || error.status >= 500;
|
||||
if (retryable) scheduleSaveSyncRetry(slotId);
|
||||
set({ notice: error instanceof Error ? error.message : "Save upload failed." });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
downloadSlot: async (slotId) => {
|
||||
const { accountId } = get();
|
||||
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
|
||||
try {
|
||||
await onlineSaveQueues.get(slotId)?.catch(() => undefined);
|
||||
await onlineSaveQueues.get(slotId)?.promise.catch(() => undefined);
|
||||
const serverSave = await onlineRepository.readSave(slotId);
|
||||
if (!serverSave) return set({ notice: "No online version exists for this slot." });
|
||||
const downloaded = repository.replaceLocal(serverSave);
|
||||
clearSaveSyncPending(slotId);
|
||||
if (get().accountId === accountId) {
|
||||
set((state) => ({ slots: replaceOnlineSlot(state.slots, downloaded), notice: `Slot ${slotId} downloaded from TrueNAS.` }));
|
||||
}
|
||||
@@ -271,14 +365,88 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
|
||||
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
|
||||
selectPassiveAbility: (selectedPassiveAbilityId) => {
|
||||
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilityId === selectedPassiveAbilityId) ?? "mend-echo";
|
||||
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilitySlotId === selectedPassiveAbilityId) ?? "mend-echo";
|
||||
set({ selectedPassiveAbilityId, selectedPassiveInfusionId, notice: "" });
|
||||
},
|
||||
selectPassiveInfusion: (selectedPassiveInfusionId) => set({
|
||||
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilityId,
|
||||
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilitySlotId,
|
||||
selectedPassiveInfusionId,
|
||||
notice: "",
|
||||
}),
|
||||
selectProfileCollectionView: (profileCollectionView) => set({ profileCollectionView }),
|
||||
selectProfileGroup: (selectedProfileGroupId) => set({ selectedProfileGroupId }),
|
||||
selectProfileStat: (selectedProfileStatId) => set({ selectedProfileStatId }),
|
||||
openAppearanceLab: () => set((state) => {
|
||||
const save = activeSave(state.slots, state.activeSlotId);
|
||||
return {
|
||||
screen: "appearance",
|
||||
appearanceClassId: save?.activeClassId ?? state.appearanceClassId,
|
||||
appearanceDrafts: appearanceDraftsFor(save),
|
||||
previewMode: "modular",
|
||||
previewAnimation: "idle",
|
||||
notice: "Appearance Lab opened. Changes remain drafts until saved.",
|
||||
};
|
||||
}),
|
||||
selectAppearanceClass: (appearanceClassId) => set({ appearanceClassId, notice: "" }),
|
||||
updateAppearanceDraft: (appearance) => set((state) => ({
|
||||
appearanceDrafts: {
|
||||
...state.appearanceDrafts,
|
||||
[state.appearanceClassId]: cloneCharacterAppearance(appearance),
|
||||
},
|
||||
notice: "Preview updated. Save to keep this look.",
|
||||
})),
|
||||
resetAppearanceDraft: () => set((state) => ({
|
||||
appearanceDrafts: {
|
||||
...state.appearanceDrafts,
|
||||
[state.appearanceClassId]: createDefaultHealerAppearance(state.appearanceClassId),
|
||||
},
|
||||
notice: "Class default restored in preview. Save to keep it.",
|
||||
})),
|
||||
saveAppearanceDraft: () => {
|
||||
const { activeSlotId, appearanceClassId, appearanceDrafts } = get();
|
||||
if (!activeSlotId) {
|
||||
set({ notice: "Load a hunter save before changing appearance." });
|
||||
return false;
|
||||
}
|
||||
const appearance = normalizeHealerAppearance(appearanceClassId, appearanceDrafts[appearanceClassId]);
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
||||
...save,
|
||||
healers: {
|
||||
...save.healers,
|
||||
[appearanceClassId]: {
|
||||
...save.healers[appearanceClassId],
|
||||
appearance,
|
||||
},
|
||||
},
|
||||
}));
|
||||
if (!updated) {
|
||||
set({ notice: "Appearance could not be saved." });
|
||||
return false;
|
||||
}
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({
|
||||
slots: refreshLocalSlots(state.slots),
|
||||
appearanceDrafts: {
|
||||
...state.appearanceDrafts,
|
||||
[appearanceClassId]: cloneCharacterAppearance(appearance),
|
||||
},
|
||||
notice: `${appearanceClassId[0].toUpperCase() + appearanceClassId.slice(1)} appearance saved locally.`,
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
closeAppearanceLab: () => set((state) => {
|
||||
const save = activeSave(state.slots, state.activeSlotId);
|
||||
return {
|
||||
screen: "home",
|
||||
appearanceClassId: save?.activeClassId ?? state.appearanceClassId,
|
||||
appearanceDrafts: appearanceDraftsFor(save),
|
||||
previewMode: "modular",
|
||||
previewAnimation: "idle",
|
||||
notice: "Appearance Lab closed.",
|
||||
};
|
||||
}),
|
||||
setAppearancePreviewMode: (previewMode) => set({ previewMode }),
|
||||
setAppearancePreviewAnimation: (previewAnimation) => set({ previewAnimation }),
|
||||
upgradeSelectedGear: () => {
|
||||
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
|
||||
if (!activeSlotId) return false;
|
||||
@@ -392,6 +560,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards,
|
||||
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
|
||||
}));
|
||||
markSaveSyncPending(activeSlotId);
|
||||
return awarded;
|
||||
},
|
||||
recordRoguelikeDefeat: (round) => {
|
||||
@@ -405,6 +574,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordRogueTrialsEndlessDefeat: (bossKills) => {
|
||||
@@ -418,6 +588,118 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordHockeyHealingDefeat: (returns, durationSeconds) => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => {
|
||||
const record = bestHockeyHealingRecord(
|
||||
save.stats.highestHockeyHealingReturns,
|
||||
save.stats.longestHockeyHealingSecondsAtBest,
|
||||
returns,
|
||||
durationSeconds,
|
||||
);
|
||||
return {
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
highestHockeyHealingReturns: record.returns,
|
||||
longestHockeyHealingSecondsAtBest: record.durationSeconds,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordHockeyPvpResult: (won) => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => {
|
||||
const record = hockeyPvpRecordAfterMatch(
|
||||
save.stats.hockeyHealingPvpWins,
|
||||
save.stats.hockeyHealingPvpLosses,
|
||||
won,
|
||||
);
|
||||
return {
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
hockeyHealingPvpWins: record.wins,
|
||||
hockeyHealingPvpLosses: record.losses,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordHockeyPvpBossKill: () => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
hockeyHealingPvpBossKills: save.stats.hockeyHealingPvpBossKills + 1,
|
||||
},
|
||||
}));
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordBlockbreakerDefeat: (bricks, durationSeconds, score) => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => {
|
||||
const record = bestBlockbreakerRecords(
|
||||
save.stats.highestBlockbreakerBricks,
|
||||
save.stats.longestBlockbreakerSeconds,
|
||||
save.stats.highestBlockbreakerScore,
|
||||
bricks,
|
||||
durationSeconds,
|
||||
score,
|
||||
);
|
||||
return {
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
highestBlockbreakerBricks: record.bricks,
|
||||
longestBlockbreakerSeconds: record.durationSeconds,
|
||||
highestBlockbreakerScore: record.score,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordAetherAssaultDefeat: (score, wave, durationSeconds) => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => {
|
||||
const record = bestAetherAssaultRecord(
|
||||
save.stats.highestAetherAssaultScore,
|
||||
save.stats.highestAetherAssaultWaveAtBest,
|
||||
save.stats.longestAetherAssaultSecondsAtBest,
|
||||
score,
|
||||
wave,
|
||||
durationSeconds,
|
||||
);
|
||||
return {
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
highestAetherAssaultScore: record.score,
|
||||
highestAetherAssaultWaveAtBest: record.wave,
|
||||
longestAetherAssaultSecondsAtBest: record.durationSeconds,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
clearRecentRewards: () => set({ recentRewards: [] }),
|
||||
@@ -447,6 +729,17 @@ export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "selectInfusion"
|
||||
| "selectPassiveAbility"
|
||||
| "selectPassiveInfusion"
|
||||
| "selectProfileCollectionView"
|
||||
| "selectProfileGroup"
|
||||
| "selectProfileStat"
|
||||
| "openAppearanceLab"
|
||||
| "selectAppearanceClass"
|
||||
| "updateAppearanceDraft"
|
||||
| "resetAppearanceDraft"
|
||||
| "saveAppearanceDraft"
|
||||
| "closeAppearanceLab"
|
||||
| "setAppearancePreviewMode"
|
||||
| "setAppearancePreviewAnimation"
|
||||
| "upgradeSelectedGear"
|
||||
| "equipSelectedInfusion"
|
||||
| "equipPassiveInfusion"
|
||||
@@ -457,6 +750,11 @@ export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "recordBossVictory"
|
||||
| "recordRoguelikeDefeat"
|
||||
| "recordRogueTrialsEndlessDefeat"
|
||||
| "recordHockeyHealingDefeat"
|
||||
| "recordHockeyPvpResult"
|
||||
| "recordHockeyPvpBossKill"
|
||||
| "recordBlockbreakerDefeat"
|
||||
| "recordAetherAssaultDefeat"
|
||||
| "clearRecentRewards"
|
||||
| "clearNotice"
|
||||
>;
|
||||
@@ -485,6 +783,17 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
selectInfusion: _selectInfusion,
|
||||
selectPassiveAbility: _selectPassiveAbility,
|
||||
selectPassiveInfusion: _selectPassiveInfusion,
|
||||
selectProfileCollectionView: _selectProfileCollectionView,
|
||||
selectProfileGroup: _selectProfileGroup,
|
||||
selectProfileStat: _selectProfileStat,
|
||||
openAppearanceLab: _openAppearanceLab,
|
||||
selectAppearanceClass: _selectAppearanceClass,
|
||||
updateAppearanceDraft: _updateAppearanceDraft,
|
||||
resetAppearanceDraft: _resetAppearanceDraft,
|
||||
saveAppearanceDraft: _saveAppearanceDraft,
|
||||
closeAppearanceLab: _closeAppearanceLab,
|
||||
setAppearancePreviewMode: _setAppearancePreviewMode,
|
||||
setAppearancePreviewAnimation: _setAppearancePreviewAnimation,
|
||||
upgradeSelectedGear: _upgradeSelectedGear,
|
||||
equipSelectedInfusion: _equipSelectedInfusion,
|
||||
equipPassiveInfusion: _equipPassiveInfusion,
|
||||
@@ -495,6 +804,11 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
recordBossVictory: _recordBossVictory,
|
||||
recordRoguelikeDefeat: _recordRoguelikeDefeat,
|
||||
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
|
||||
recordHockeyHealingDefeat: _recordHockeyHealingDefeat,
|
||||
recordHockeyPvpResult: _recordHockeyPvpResult,
|
||||
recordHockeyPvpBossKill: _recordHockeyPvpBossKill,
|
||||
recordBlockbreakerDefeat: _recordBlockbreakerDefeat,
|
||||
recordAetherAssaultDefeat: _recordAetherAssaultDefeat,
|
||||
clearRecentRewards: _clearRecentRewards,
|
||||
clearNotice: _clearNotice,
|
||||
...snapshot
|
||||
|
||||
+18
-3
@@ -2,10 +2,13 @@ import type { BossGroupId } from "../game/bossCatalog";
|
||||
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
|
||||
import type { GearProgress } from "../game/progression/gear";
|
||||
import type { CollectionLog, MaterialStack } from "../game/progression/loot";
|
||||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||||
|
||||
export type SaveSlotId = 1 | 2 | 3;
|
||||
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game";
|
||||
export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
|
||||
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "appearance" | "settings" | "mode" | "game";
|
||||
export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault" | "roguelike-pvp" | "stadium-pvp";
|
||||
export type ProfileCollectionView = "loot" | "trophies" | "stats";
|
||||
export type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless" | "hockey-healing" | "hockey-pvp-wins" | "hockey-pvp-boss-kills" | "blockbreaker-bricks" | "blockbreaker-time" | "blockbreaker-score" | "aether-assault";
|
||||
|
||||
export interface CollectionDrop {
|
||||
id: string;
|
||||
@@ -43,15 +46,27 @@ export interface HunterStats {
|
||||
bossKills: Record<string, number>;
|
||||
highestRoguelikeRound: number;
|
||||
highestRogueTrialsEndlessKills: number;
|
||||
highestHockeyHealingReturns: number;
|
||||
longestHockeyHealingSecondsAtBest: number;
|
||||
hockeyHealingPvpWins: number;
|
||||
hockeyHealingPvpLosses: number;
|
||||
hockeyHealingPvpBossKills: number;
|
||||
highestBlockbreakerBricks: number;
|
||||
longestBlockbreakerSeconds: number;
|
||||
highestBlockbreakerScore: number;
|
||||
highestAetherAssaultScore: number;
|
||||
highestAetherAssaultWaveAtBest: number;
|
||||
longestAetherAssaultSecondsAtBest: number;
|
||||
}
|
||||
|
||||
export interface HealerProgress {
|
||||
level: number;
|
||||
inventory: InventoryItem[];
|
||||
appearance: CharacterAppearanceV1;
|
||||
}
|
||||
|
||||
export interface HunterSave {
|
||||
schemaVersion: 5;
|
||||
schemaVersion: 6;
|
||||
slotId: SaveSlotId;
|
||||
hunterName: string;
|
||||
activeClassId: HealerClassId;
|
||||
|
||||
Reference in New Issue
Block a user