Release v0.1.6 2026-07-12
This commit is contained in:
@@ -1,53 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StorageAdapter } from "./saveRepository";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AccountRepository } from "./accountRepository";
|
||||
import { OnlineApiError, type OnlineRepository } from "./onlineRepository";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
function onlineStub(overrides: Partial<OnlineRepository> = {}): OnlineRepository {
|
||||
return {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => { data.set(key, value); },
|
||||
};
|
||||
register: vi.fn(async (username: string) => ({ id: 1, username })),
|
||||
login: vi.fn(async (username: string) => ({ id: 1, username })),
|
||||
session: vi.fn(async () => null),
|
||||
logout: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
} as unknown as OnlineRepository;
|
||||
}
|
||||
|
||||
const testHasher = async (password: string, salt: string) => {
|
||||
const checksum = [...password].reduce((total, character) => total + character.charCodeAt(0), 0);
|
||||
return `derived:${salt}:${checksum}`;
|
||||
};
|
||||
|
||||
describe("AccountRepository", () => {
|
||||
it("requires both a username and password", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
|
||||
it("requires both username and password before contacting server", async () => {
|
||||
const online = onlineStub();
|
||||
const repository = new AccountRepository(online);
|
||||
await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
await expect(repository.create("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
await expect(repository.authenticate("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
expect(online.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires account creation before sign-in", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
|
||||
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" });
|
||||
await expect(repository.create("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
|
||||
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
|
||||
it("creates and authenticates real server accounts", async () => {
|
||||
const repository = new AccountRepository(onlineStub());
|
||||
await expect(repository.create("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" });
|
||||
await expect(repository.authenticate("Wayfinder", "long-password")).resolves.toEqual({ ok: true, username: "Wayfinder" });
|
||||
});
|
||||
|
||||
it("rejects an incorrect password and duplicate account names", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
await repository.create("Wayfinder", "correct");
|
||||
|
||||
await expect(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" });
|
||||
await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" });
|
||||
});
|
||||
|
||||
it("persists only a derived password verifier", async () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new AccountRepository(storage, testHasher, () => "unique-salt");
|
||||
await repository.create("healer", "plaintext-secret");
|
||||
|
||||
const persisted = storage.getItem("i-want-to-heal:accounts:v1") ?? "";
|
||||
expect(persisted).toContain("derived:unique-salt:");
|
||||
expect(persisted).not.toContain("plaintext-secret");
|
||||
expect(JSON.parse(persisted).healer).not.toHaveProperty("password");
|
||||
it("maps server conflicts, invalid credentials, and outages", async () => {
|
||||
const conflict = new AccountRepository(onlineStub({ register: vi.fn(async () => { throw new OnlineApiError("exists", 409); }) }));
|
||||
await expect(conflict.create("Wayfinder", "long-password")).resolves.toMatchObject({ ok: false, reason: "account-exists" });
|
||||
const invalid = new AccountRepository(onlineStub({ login: vi.fn(async () => { throw new OnlineApiError("bad login", 401); }) }));
|
||||
await expect(invalid.authenticate("Wayfinder", "wrong-password")).resolves.toMatchObject({ ok: false, reason: "invalid-password" });
|
||||
const outage = new AccountRepository(onlineStub({ login: vi.fn(async () => { throw new OnlineApiError("offline", 0); }) }));
|
||||
await expect(outage.authenticate("Wayfinder", "long-password")).resolves.toMatchObject({ ok: false, reason: "server-unavailable" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,132 +1,40 @@
|
||||
import type { StorageAdapter } from "./saveRepository";
|
||||
|
||||
const ACCOUNTS_KEY = "i-want-to-heal:accounts:v1";
|
||||
const PASSWORD_ITERATIONS = 120_000;
|
||||
|
||||
interface AccountRecord {
|
||||
username: string;
|
||||
salt: string;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
type AccountMap = Record<string, AccountRecord>;
|
||||
type PasswordHasher = (password: string, salt: string) => Promise<string>;
|
||||
import { OnlineApiError, OnlineRepository, onlineRepository } from "./onlineRepository";
|
||||
|
||||
export type AccountResult =
|
||||
| { ok: true; username: string }
|
||||
| { ok: false; reason: "missing-credentials" | "account-exists" | "account-not-found" | "invalid-password" | "storage-unavailable" };
|
||||
| { ok: false; reason: "missing-credentials" | "account-exists" | "invalid-password" | "server-unavailable" | "invalid-request"; message?: string };
|
||||
|
||||
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 failure(error: unknown): Extract<AccountResult, { ok: false }> {
|
||||
if (!(error instanceof OnlineApiError)) return { ok: false, reason: "server-unavailable" };
|
||||
if (error.status === 0 || error.status >= 500) return { ok: false, reason: "server-unavailable", message: error.message };
|
||||
if (error.status === 409) return { ok: false, reason: "account-exists", message: error.message };
|
||||
if (error.status === 401) return { ok: false, reason: "invalid-password", message: error.message };
|
||||
return { ok: false, reason: "invalid-request", message: error.message };
|
||||
}
|
||||
|
||||
function encodeBytes(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function hashPassword(password: string, salt: string) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits({
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
salt: decodeBytes(salt),
|
||||
iterations: PASSWORD_ITERATIONS,
|
||||
}, key, 256);
|
||||
return encodeBytes(new Uint8Array(bits));
|
||||
}
|
||||
|
||||
function randomSalt() {
|
||||
const salt = new Uint8Array(16);
|
||||
crypto.getRandomValues(salt);
|
||||
return encodeBytes(salt);
|
||||
}
|
||||
|
||||
function canonicalUsername(username: string) {
|
||||
return username.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function parseAccounts(raw: string | null): AccountMap {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AccountMap;
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local prototype account registry. Passwords are salted and derived before
|
||||
* persistence; replace this adapter with the server authentication API when
|
||||
* remote sync leaves local prototype storage.
|
||||
*/
|
||||
export class AccountRepository {
|
||||
constructor(
|
||||
private readonly storage: StorageAdapter = browserStorage(),
|
||||
private readonly hasher: PasswordHasher = hashPassword,
|
||||
private readonly createSalt: () => string = randomSalt,
|
||||
) {}
|
||||
|
||||
async create(usernameInput: string, password: string): Promise<AccountResult> {
|
||||
const username = usernameInput.trim();
|
||||
const canonical = canonicalUsername(username);
|
||||
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
|
||||
|
||||
const accounts = this.read();
|
||||
if (accounts[canonical]) return { ok: false, reason: "account-exists" };
|
||||
constructor(private readonly online: OnlineRepository = onlineRepository) {}
|
||||
|
||||
async create(username: string, password: string): Promise<AccountResult> {
|
||||
if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" };
|
||||
try {
|
||||
const salt = this.createSalt();
|
||||
accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) };
|
||||
this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
|
||||
return { ok: true, username };
|
||||
} catch {
|
||||
return { ok: false, reason: "storage-unavailable" };
|
||||
const account = await this.online.register(username, password);
|
||||
return { ok: true, username: account.username };
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(usernameInput: string, password: string): Promise<AccountResult> {
|
||||
const canonical = canonicalUsername(usernameInput);
|
||||
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
|
||||
|
||||
const account = this.read()[canonical];
|
||||
if (!account) return { ok: false, reason: "account-not-found" };
|
||||
|
||||
async authenticate(username: string, password: string): Promise<AccountResult> {
|
||||
if (!username.trim() || !password) return { ok: false, reason: "missing-credentials" };
|
||||
try {
|
||||
const passwordHash = await this.hasher(password, account.salt);
|
||||
return passwordHash === account.passwordHash
|
||||
? { ok: true, username: account.username }
|
||||
: { ok: false, reason: "invalid-password" };
|
||||
} catch {
|
||||
return { ok: false, reason: "storage-unavailable" };
|
||||
const account = await this.online.login(username, password);
|
||||
return { ok: true, username: account.username };
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
}
|
||||
|
||||
private read() {
|
||||
return parseAccounts(this.storage.getItem(ACCOUNTS_KEY));
|
||||
}
|
||||
session() { return this.online.session(); }
|
||||
logout() { return this.online.logout(); }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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");
|
||||
expect(MODE_COPY["rogue-trials"].detail).toContain("unseen");
|
||||
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
|
||||
});
|
||||
|
||||
@@ -33,4 +34,12 @@ describe("game mode configuration", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("derives trophy ownership from the saved boss pet collection", () => {
|
||||
const collections = buildCollections({ dropsFound: {}, petsFound: { "bulldrome-pet": 1 } }, { bulldrome: 37 });
|
||||
const bulldrome = collections.flatMap((group) => group.bosses).find((boss) => boss.bossId === "bulldrome");
|
||||
expect(bulldrome?.pet.count).toBe(1);
|
||||
expect(bulldrome?.pet.chance).toBe("1 in 500");
|
||||
expect(bulldrome?.kills).toBe(37);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,14 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
|
||||
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",
|
||||
status: "Playable prototype",
|
||||
status: "Playable now",
|
||||
},
|
||||
"rogue-trials": {
|
||||
eyebrow: "1–4 hunters · five-round PVE trial",
|
||||
title: "Rogue Trials",
|
||||
description: "Build through four randomized dual-boss rounds, then face three bosses together in a final trial.",
|
||||
detail: "Round 5 trio always uses bosses unseen during that run",
|
||||
status: "Playable now",
|
||||
},
|
||||
dungeons: {
|
||||
eyebrow: "1–4 hunters · chosen encounter",
|
||||
@@ -131,6 +138,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
||||
alliesSaved: 0,
|
||||
healingDone: 0,
|
||||
bossKills: {},
|
||||
highestRoguelikeRound: 0,
|
||||
},
|
||||
materials: [] as MaterialStack[],
|
||||
collectionLog: createEmptyCollectionLog(),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { HunterSave, SaveSlotId } from "./types";
|
||||
import type { BossId } from "../game/types";
|
||||
|
||||
export interface OnlineAccount {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface OnlineSaveSlot {
|
||||
slotId: SaveSlotId;
|
||||
save: HunterSave;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
username: string;
|
||||
hunterName: string;
|
||||
slotId: SaveSlotId;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface LeaderboardResult {
|
||||
kind: "boss" | "roguelike";
|
||||
bossId?: BossId;
|
||||
top: LeaderboardEntry[];
|
||||
current: LeaderboardEntry | null;
|
||||
}
|
||||
|
||||
interface TokenStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
type Requester = typeof fetch;
|
||||
|
||||
const TOKEN_KEY = "i-want-to-heal:auth-token:v1";
|
||||
const PRODUCTION_API_URL = "https://iwanttoheal.phenomrom.com";
|
||||
|
||||
function defaultApiBaseUrl() {
|
||||
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "");
|
||||
if (configured) return configured;
|
||||
return Capacitor.isNativePlatform() ? PRODUCTION_API_URL : "";
|
||||
}
|
||||
|
||||
function browserStorage(): TokenStorage {
|
||||
if (typeof localStorage !== "undefined") return localStorage;
|
||||
const memory = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => memory.get(key) ?? null,
|
||||
setItem: (key, value) => { memory.set(key, value); },
|
||||
removeItem: (key) => { memory.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
export class OnlineApiError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class OnlineRepository {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly requester: Requester = (...args) => fetch(...args),
|
||||
private readonly storage: TokenStorage = browserStorage(),
|
||||
baseUrl: string = defaultApiBaseUrl(),
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = this.storage.getItem(TOKEN_KEY);
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.requester(`${this.baseUrl}${path}`, { ...init, headers });
|
||||
} catch {
|
||||
throw new OnlineApiError("Online server is unreachable.", 0);
|
||||
}
|
||||
const body = await response.json().catch(() => ({})) as { error?: string } & T;
|
||||
if (!response.ok) throw new OnlineApiError(body.error ?? "Online request failed.", response.status);
|
||||
return body;
|
||||
}
|
||||
|
||||
private rememberAuth(result: { account: OnlineAccount; token: string }) {
|
||||
this.storage.setItem(TOKEN_KEY, result.token);
|
||||
return result.account;
|
||||
}
|
||||
|
||||
async register(username: string, password: string): Promise<OnlineAccount> {
|
||||
return this.rememberAuth(await this.request<{ account: OnlineAccount; token: string }>("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}));
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<OnlineAccount> {
|
||||
return this.rememberAuth(await this.request<{ account: OnlineAccount; token: string }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}));
|
||||
}
|
||||
|
||||
async session(): Promise<OnlineAccount | null> {
|
||||
if (!this.storage.getItem(TOKEN_KEY)) return null;
|
||||
try {
|
||||
return (await this.request<{ account: OnlineAccount }>("/api/auth/session")).account;
|
||||
} catch (error) {
|
||||
if (error instanceof OnlineApiError && error.status === 401) this.storage.removeItem(TOKEN_KEY);
|
||||
if (error instanceof OnlineApiError && error.status === 401) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try { await this.request("/api/auth/logout", { method: "POST" }); }
|
||||
finally { this.storage.removeItem(TOKEN_KEY); }
|
||||
}
|
||||
|
||||
async listSaves(): Promise<OnlineSaveSlot[]> {
|
||||
return (await this.request<{ slots: OnlineSaveSlot[] }>("/api/saves")).slots;
|
||||
}
|
||||
|
||||
async writeSave(save: HunterSave): Promise<HunterSave> {
|
||||
return (await this.request<{ save: HunterSave }>(`/api/saves/${save.slotId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ save }),
|
||||
})).save;
|
||||
}
|
||||
|
||||
async readSave(slotId: SaveSlotId): Promise<HunterSave | null> {
|
||||
return (await this.request<{ save: HunterSave | null }>(`/api/saves/${slotId}`)).save;
|
||||
}
|
||||
|
||||
bossLeaderboard(bossId: BossId, slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/boss/${encodeURIComponent(bossId)}?slot=${slotId}`);
|
||||
}
|
||||
|
||||
roguelikeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
|
||||
return this.request(`/api/leaderboards/roguelike?slot=${slotId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const onlineRepository = new OnlineRepository();
|
||||
@@ -16,7 +16,7 @@ describe("SaveRepository", () => {
|
||||
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
|
||||
repository.create(2, "Seraphine");
|
||||
|
||||
const slots = repository.list(null);
|
||||
const slots = repository.listLocal();
|
||||
expect(slots.map((slot) => slot.id)).toEqual([1, 2, 3]);
|
||||
expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z");
|
||||
expect(slots[1].local?.hunterName).toBe("Seraphine");
|
||||
@@ -34,41 +34,30 @@ describe("SaveRepository", () => {
|
||||
healers: { ...save.healers, druid: { ...save.healers.druid, level: 99 } },
|
||||
}));
|
||||
|
||||
const slots = repository.list(null);
|
||||
const slots = repository.listLocal();
|
||||
expect(slots[0].local?.healers.druid.level).toBe(1);
|
||||
expect(slots[2].local?.healers.druid.level).toBe(99);
|
||||
expect(slots[2].local?.slotId).toBe(3);
|
||||
});
|
||||
|
||||
it("uploads local state and can later overwrite it with the online version", () => {
|
||||
it("replaces a local slot with a downloaded server snapshot", () => {
|
||||
let now = "2026-07-10T12:00:00.000Z";
|
||||
const repository = new SaveRepository(memoryStorage(), () => now);
|
||||
repository.create(1, "Aelia");
|
||||
now = "2026-07-10T13:00:00.000Z";
|
||||
repository.upload(1, "healer@example.com");
|
||||
repository.updateLocal(1, (save) => ({
|
||||
...save,
|
||||
healers: { ...save.healers, priest: { ...save.healers.priest, level: 40 } },
|
||||
}));
|
||||
|
||||
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(40);
|
||||
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(1);
|
||||
|
||||
const serverSave = repository.create(1, "Aelia");
|
||||
serverSave.healers.priest.level = 40;
|
||||
now = "2026-07-10T14:00:00.000Z";
|
||||
repository.download(1, "healer@example.com");
|
||||
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(1);
|
||||
expect(repository.list("healer@example.com")[0].local?.updatedAt).toBe(now);
|
||||
repository.replaceLocal(serverSave);
|
||||
expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40);
|
||||
expect(repository.listLocal()[0].local?.updatedAt).toBe(now);
|
||||
});
|
||||
|
||||
it("deletes only the local copy so the online record can restore it", () => {
|
||||
it("deletes the local copy without inventing an online record", () => {
|
||||
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
|
||||
repository.create(1, "Aelia");
|
||||
repository.upload(1, "healer");
|
||||
repository.deleteLocal(1);
|
||||
|
||||
const slot = repository.list("healer")[0];
|
||||
const slot = repository.listLocal()[0];
|
||||
expect(slot.local).toBeNull();
|
||||
expect(slot.online).not.toBeNull();
|
||||
expect(slot.online).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps class progression and inventories independent under one hunter name", () => {
|
||||
@@ -83,7 +72,7 @@ describe("SaveRepository", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
const save = repository.list(null)[0].local!;
|
||||
const save = repository.listLocal()[0].local!;
|
||||
expect(save.hunterName).toBe("Aelia");
|
||||
expect(save.activeClassId).toBe("druid");
|
||||
expect(save.healers.druid.level).toBe(8);
|
||||
@@ -102,7 +91,7 @@ describe("SaveRepository", () => {
|
||||
activeClassId: "druid",
|
||||
playSeconds: 999,
|
||||
healers: Object.fromEntries(Object.entries(created.healers).map(([id, healer]) => [id, { ...healer, level: 27 }])),
|
||||
stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 } },
|
||||
stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 }, highestRoguelikeRound: 12 },
|
||||
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 99, rarity: "common", itemLevel: 1, glyph: "R" }],
|
||||
collectionLog: { dropsFound: { "legacy-boss-coin": 99 }, petsFound: { "bulldrome-pet": 1 } },
|
||||
gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, {
|
||||
@@ -113,39 +102,19 @@ describe("SaveRepository", () => {
|
||||
} as Record<string, unknown>;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
const migrated = repository.listLocal()[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
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: {} });
|
||||
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 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);
|
||||
});
|
||||
|
||||
it("resets and persists legacy cloud saves when they are listed", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Cloud Legacy");
|
||||
const legacy = {
|
||||
...created,
|
||||
schemaVersion: 4,
|
||||
stats: { ...created.stats, totalBossKills: 8, bossKills: { bulldrome: 8 } },
|
||||
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 8, rarity: "common", itemLevel: 1, glyph: "R" }],
|
||||
};
|
||||
const cloudKey = "i-want-to-heal:saves:cloud:v1:cloud@example.com";
|
||||
storage.setItem(cloudKey, JSON.stringify({ 1: legacy }));
|
||||
|
||||
const online = repository.list("cloud@example.com")[0].online!;
|
||||
expect(online.schemaVersion).toBe(5);
|
||||
expect(online.stats.totalBossKills).toBe(0);
|
||||
expect(online.materials).toEqual([]);
|
||||
expect(JSON.parse(storage.getItem(cloudKey) ?? "{}")["1"].schemaVersion).toBe(5);
|
||||
});
|
||||
|
||||
it("preserves valid v5 progression and group-drop inventory", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
@@ -157,7 +126,7 @@ describe("SaveRepository", () => {
|
||||
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
const migrated = repository.listLocal()[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.healers.priest.level).toBe(8);
|
||||
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
|
||||
@@ -176,7 +145,7 @@ describe("SaveRepository", () => {
|
||||
created.gearProgress.brann.passiveInfusionId = "deep-wells" as never;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
const migrated = repository.listLocal()[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
|
||||
expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull();
|
||||
@@ -192,7 +161,7 @@ describe("SaveRepository", () => {
|
||||
const created = repository.create(1, "Infused");
|
||||
created.gearProgress.priest.passiveInfusionId = passiveId;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||
expect(repository.list(null)[0].local?.gearProgress.priest.passiveInfusionId).toBe(passiveId);
|
||||
expect(repository.listLocal()[0].local?.gearProgress.priest.passiveInfusionId).toBe(passiveId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface StorageAdapter {
|
||||
type SaveMap = Partial<Record<SaveSlotId, HunterSave>>;
|
||||
|
||||
const LOCAL_KEY = "i-want-to-heal:saves:local:v1";
|
||||
const CLOUD_KEY = (accountId: string) => `i-want-to-heal:saves:cloud:v1:${accountId.toLowerCase()}`;
|
||||
const SLOT_IDS: SaveSlotId[] = [1, 2, 3];
|
||||
|
||||
const fallbackMemory = new Map<string, string>();
|
||||
@@ -150,6 +149,7 @@ function normalizeSave(value: unknown): HunterSave | null {
|
||||
alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0),
|
||||
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
|
||||
bossKills,
|
||||
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
|
||||
},
|
||||
materials: normalizeMaterials(candidate.materials, collectionLog),
|
||||
collectionLog,
|
||||
@@ -181,10 +181,9 @@ export class SaveRepository {
|
||||
private readonly now: () => string = () => new Date().toISOString(),
|
||||
) {}
|
||||
|
||||
list(accountId: string | null): SaveSlotState[] {
|
||||
listLocal(): SaveSlotState[] {
|
||||
const local = this.read(LOCAL_KEY);
|
||||
const online = accountId ? this.read(CLOUD_KEY(accountId)) : {};
|
||||
return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: online[id] ?? null }));
|
||||
return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: null }));
|
||||
}
|
||||
|
||||
create(slotId: SaveSlotId, hunterName: string): HunterSave {
|
||||
@@ -223,23 +222,10 @@ export class SaveRepository {
|
||||
return copy;
|
||||
}
|
||||
|
||||
upload(slotId: SaveSlotId, accountId: string): HunterSave | null {
|
||||
const local = this.read(LOCAL_KEY)[slotId];
|
||||
if (!local) return null;
|
||||
const cloud = this.read(CLOUD_KEY(accountId));
|
||||
const uploaded = { ...cloneSave(local), updatedAt: this.now() };
|
||||
cloud[slotId] = uploaded;
|
||||
this.write(CLOUD_KEY(accountId), cloud);
|
||||
this.setLocal(uploaded);
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
download(slotId: SaveSlotId, accountId: string): HunterSave | null {
|
||||
const cloud = this.read(CLOUD_KEY(accountId))[slotId];
|
||||
if (!cloud) return null;
|
||||
const downloaded = { ...cloneSave(cloud), slotId, updatedAt: this.now() };
|
||||
this.setLocal(downloaded);
|
||||
return downloaded;
|
||||
replaceLocal(save: HunterSave): HunterSave {
|
||||
const normalized = { ...cloneSave(save), updatedAt: this.now() };
|
||||
this.setLocal(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private setLocal(save: HunterSave): void {
|
||||
|
||||
+125
-32
@@ -2,6 +2,7 @@ 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 { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike";
|
||||
@@ -12,10 +13,23 @@ import {
|
||||
infusionsForOwner,
|
||||
} from "../game/progression/infusions";
|
||||
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
||||
import { highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
|
||||
|
||||
const repository = new SaveRepository();
|
||||
const accounts = new AccountRepository();
|
||||
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
|
||||
const onlineSaveQueues = new Map<SaveSlotId, Promise<HunterSave>>();
|
||||
|
||||
function writeServerSaveSerially(save: HunterSave): Promise<HunterSave> {
|
||||
const previous = onlineSaveQueues.get(save.slotId);
|
||||
const next = (previous ? previous.catch(() => save) : Promise.resolve(save))
|
||||
.then(() => onlineRepository.writeSave(save));
|
||||
onlineSaveQueues.set(save.slotId, next);
|
||||
void next.finally(() => {
|
||||
if (onlineSaveQueues.get(save.slotId) === next) onlineSaveQueues.delete(save.slotId);
|
||||
}).catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
|
||||
function loadSettings(): GameSettings {
|
||||
try {
|
||||
@@ -38,14 +52,30 @@ function accountNotice(result: Extract<AccountResult, { ok: false }>, action: "s
|
||||
switch (result.reason) {
|
||||
case "missing-credentials": return "Enter both username and password.";
|
||||
case "account-exists": return "Account already exists. Sign in with its password.";
|
||||
case "account-not-found": return "Account not found. Create an account before enabling online sync.";
|
||||
case "invalid-password": return "Username or password is incorrect.";
|
||||
case "storage-unavailable": return action === "create"
|
||||
? "Account could not be saved on this device. Continue offline or try again."
|
||||
: "Account could not be verified on this device. Continue offline or try again.";
|
||||
case "server-unavailable": return "Online server is unreachable. Continue offline or try again.";
|
||||
case "invalid-request": return result.message ?? (action === "create" ? "Account could not be created." : "Sign-in failed.");
|
||||
}
|
||||
}
|
||||
|
||||
function refreshLocalSlots(current: readonly SaveSlotState[]): SaveSlotState[] {
|
||||
return repository.listLocal().map((slot) => ({
|
||||
...slot,
|
||||
online: current.find((candidate) => candidate.id === slot.id)?.online ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function mergeServerSlots(serverSlots: readonly OnlineSaveSlot[]): SaveSlotState[] {
|
||||
return repository.listLocal().map((slot) => ({
|
||||
...slot,
|
||||
online: serverSlots.find((candidate) => candidate.slotId === slot.id)?.save ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function replaceOnlineSlot(current: readonly SaveSlotState[], save: HunterSave): SaveSlotState[] {
|
||||
return refreshLocalSlots(current).map((slot) => slot.id === save.slotId ? { ...slot, online: save } : slot);
|
||||
}
|
||||
|
||||
export interface FrontendState {
|
||||
screen: AppScreen;
|
||||
accountId: string | null;
|
||||
@@ -64,6 +94,7 @@ export interface FrontendState {
|
||||
recentRewards: BossRewardAward[];
|
||||
settings: GameSettings;
|
||||
notice: string;
|
||||
restoreSession: () => Promise<boolean>;
|
||||
signIn: (username: string, password: string) => Promise<boolean>;
|
||||
createAccount: (username: string, password: string) => Promise<boolean>;
|
||||
continueOffline: () => void;
|
||||
@@ -74,8 +105,8 @@ export interface FrontendState {
|
||||
playSlot: (slotId: SaveSlotId) => void;
|
||||
deleteSlot: (slotId: SaveSlotId) => void;
|
||||
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
|
||||
uploadSlot: (slotId: SaveSlotId) => void;
|
||||
downloadSlot: (slotId: SaveSlotId) => void;
|
||||
uploadSlot: (slotId: SaveSlotId) => Promise<void>;
|
||||
downloadSlot: (slotId: SaveSlotId) => Promise<void>;
|
||||
selectMode: (mode: GameModeId) => void;
|
||||
selectBoss: (bossId: BossId) => void;
|
||||
selectDifficulty: (difficultySlug: DifficultySlug) => void;
|
||||
@@ -93,6 +124,7 @@ export interface FrontendState {
|
||||
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
|
||||
touchActiveSave: () => void;
|
||||
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
||||
recordRoguelikeDefeat: (round: number) => void;
|
||||
clearRecentRewards: () => void;
|
||||
clearNotice: () => void;
|
||||
}
|
||||
@@ -104,7 +136,7 @@ function activeSave(slots: SaveSlotState[], activeSlotId: SaveSlotId | null): Hu
|
||||
export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
screen: "login",
|
||||
accountId: null,
|
||||
slots: repository.list(null),
|
||||
slots: repository.listLocal(),
|
||||
selectedSlotId: 1,
|
||||
activeSlotId: null,
|
||||
selectedMode: "roguelike-pve",
|
||||
@@ -120,14 +152,32 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
settings: loadSettings(),
|
||||
notice: "",
|
||||
|
||||
restoreSession: async () => {
|
||||
try {
|
||||
const account = await accounts.session();
|
||||
if (!account) return false;
|
||||
const serverSlots = await onlineRepository.listSaves();
|
||||
set({ accountId: account.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Online session restored for ${account.username}.` });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
signIn: async (username, password) => {
|
||||
const result = await accounts.authenticate(username, password);
|
||||
if (!result.ok) {
|
||||
set({ notice: accountNotice(result, "sign-in") });
|
||||
return false;
|
||||
}
|
||||
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Online sync connected as ${result.username}.` });
|
||||
return true;
|
||||
try {
|
||||
const serverSlots = await onlineRepository.listSaves();
|
||||
set({ accountId: result.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Online sync connected as ${result.username}.` });
|
||||
return true;
|
||||
} catch {
|
||||
set({ notice: "Signed in, but server saves could not be loaded." });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
createAccount: async (username, password) => {
|
||||
const result = await accounts.create(username, password);
|
||||
@@ -135,11 +185,20 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
set({ notice: accountNotice(result, "create") });
|
||||
return false;
|
||||
}
|
||||
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` });
|
||||
return true;
|
||||
try {
|
||||
const serverSlots = await onlineRepository.listSaves();
|
||||
set({ accountId: result.username, slots: mergeServerSlots(serverSlots), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` });
|
||||
return true;
|
||||
} catch {
|
||||
set({ notice: "Account created, but server saves could not be loaded." });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
continueOffline: () => set({ accountId: null, slots: repository.listLocal(), screen: "saves", notice: "Offline saves ready." }),
|
||||
signOut: () => {
|
||||
void accounts.logout();
|
||||
set({ accountId: null, slots: repository.listLocal(), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." });
|
||||
},
|
||||
continueOffline: () => set({ accountId: null, slots: repository.list(null), screen: "saves", notice: "Offline saves ready." }),
|
||||
signOut: () => set({ accountId: null, slots: repository.list(null), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }),
|
||||
navigate: (screen) => set({ screen, notice: "" }),
|
||||
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
|
||||
createSlot: (slotId, rawHunterName) => {
|
||||
@@ -149,18 +208,18 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
repository.create(slotId, hunterName);
|
||||
set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
|
||||
return true;
|
||||
},
|
||||
playSlot: (slotId) => {
|
||||
const local = repository.touch(slotId);
|
||||
if (!local) return;
|
||||
set((state) => ({ activeSlotId: slotId, selectedSlotId: slotId, slots: repository.list(state.accountId), screen: "home", notice: "Offline save loaded." }));
|
||||
set((state) => ({ activeSlotId: slotId, selectedSlotId: slotId, slots: refreshLocalSlots(state.slots), screen: "home", notice: "Save loaded." }));
|
||||
},
|
||||
deleteSlot: (slotId) => {
|
||||
repository.deleteLocal(slotId);
|
||||
set((state) => ({
|
||||
slots: repository.list(state.accountId),
|
||||
slots: refreshLocalSlots(state.slots),
|
||||
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
|
||||
notice: `Local slot ${slotId} deleted. Online copy preserved.`,
|
||||
}));
|
||||
@@ -168,19 +227,36 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
copySlot: (sourceId, targetId) => {
|
||||
const copy = repository.copyLocal(sourceId, targetId);
|
||||
if (!copy) return;
|
||||
set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
|
||||
},
|
||||
uploadSlot: (slotId) => {
|
||||
uploadSlot: async (slotId) => {
|
||||
const { accountId } = get();
|
||||
if (!accountId) return set({ notice: "Sign in before syncing online." });
|
||||
const uploaded = repository.upload(slotId, accountId);
|
||||
set({ slots: repository.list(accountId), notice: uploaded ? `Slot ${slotId} synced to server.` : "No offline save to sync." });
|
||||
const local = repository.listLocal().find((slot) => slot.id === slotId)?.local;
|
||||
if (!local) return set({ notice: "No offline save to sync." });
|
||||
try {
|
||||
const uploaded = await writeServerSaveSerially(local);
|
||||
if (get().accountId === accountId) {
|
||||
set((state) => ({ slots: replaceOnlineSlot(state.slots, uploaded), notice: `Slot ${slotId} synced to TrueNAS.` }));
|
||||
}
|
||||
} catch (error) {
|
||||
set({ notice: error instanceof Error ? error.message : "Save upload failed." });
|
||||
}
|
||||
},
|
||||
downloadSlot: (slotId) => {
|
||||
downloadSlot: async (slotId) => {
|
||||
const { accountId } = get();
|
||||
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
|
||||
const downloaded = repository.download(slotId, accountId);
|
||||
set({ slots: repository.list(accountId), notice: downloaded ? `Slot ${slotId} overwritten with online version.` : "No online version exists for this slot." });
|
||||
try {
|
||||
await onlineSaveQueues.get(slotId)?.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);
|
||||
if (get().accountId === accountId) {
|
||||
set((state) => ({ slots: replaceOnlineSlot(state.slots, downloaded), notice: `Slot ${slotId} downloaded from TrueNAS.` }));
|
||||
}
|
||||
} catch (error) {
|
||||
set({ notice: error instanceof Error ? error.message : "Save download failed." });
|
||||
}
|
||||
},
|
||||
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
|
||||
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
|
||||
@@ -218,7 +294,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
return save;
|
||||
}
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: message });
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
|
||||
return upgraded;
|
||||
},
|
||||
equipSelectedInfusion: () => {
|
||||
@@ -238,7 +314,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
return save;
|
||||
}
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: message });
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
|
||||
return equipped;
|
||||
},
|
||||
equipPassiveInfusion: (passiveId) => {
|
||||
@@ -257,7 +333,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
return save;
|
||||
}
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: message });
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots), notice: message }));
|
||||
return equipped;
|
||||
},
|
||||
selectHealerClass: (classId) => {
|
||||
@@ -266,7 +342,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
|
||||
if (!updated) return;
|
||||
set({
|
||||
slots: repository.list(accountId),
|
||||
slots: refreshLocalSlots(get().slots),
|
||||
selectedGearOwnerId: classId,
|
||||
selectedInfusionId: infusionsForOwner(classId)[0].id,
|
||||
notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`,
|
||||
@@ -282,7 +358,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
[save.activeClassId]: { ...save.healers[save.activeClassId], inventory: structuredClone(inventory) },
|
||||
},
|
||||
}));
|
||||
set({ slots: repository.list(accountId) });
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
updateSetting: (key, value) => set((state) => {
|
||||
const settings = { ...state.settings, [key]: value };
|
||||
@@ -293,10 +369,10 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
const { activeSlotId, accountId } = get();
|
||||
if (!activeSlotId) return;
|
||||
repository.touch(activeSlotId);
|
||||
set({ slots: repository.list(accountId) });
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordBossVictory: (bossId, difficultySlug) => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return null;
|
||||
let awarded: BossRewardAward | null = null;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
@@ -311,17 +387,31 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
};
|
||||
});
|
||||
set((state) => ({
|
||||
slots: repository.list(accountId),
|
||||
slots: refreshLocalSlots(state.slots),
|
||||
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
|
||||
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
|
||||
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
|
||||
}));
|
||||
return awarded;
|
||||
},
|
||||
recordRoguelikeDefeat: (round) => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
highestRoguelikeRound: highestRoguelikeRoundAfterDefeat(save.stats.highestRoguelikeRound, round),
|
||||
},
|
||||
}));
|
||||
if (!updated) return;
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
clearRecentRewards: () => set({ recentRewards: [] }),
|
||||
clearNotice: () => set({ notice: "" }),
|
||||
}));
|
||||
|
||||
export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "restoreSession"
|
||||
| "signIn"
|
||||
| "createAccount"
|
||||
| "continueOffline"
|
||||
@@ -351,12 +441,14 @@ export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "updateSetting"
|
||||
| "touchActiveSave"
|
||||
| "recordBossVictory"
|
||||
| "recordRoguelikeDefeat"
|
||||
| "clearRecentRewards"
|
||||
| "clearNotice"
|
||||
>;
|
||||
|
||||
export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
const {
|
||||
restoreSession: _restoreSession,
|
||||
signIn: _signIn,
|
||||
createAccount: _createAccount,
|
||||
continueOffline: _continueOffline,
|
||||
@@ -386,6 +478,7 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
updateSetting: _updateSetting,
|
||||
touchActiveSave: _touchActiveSave,
|
||||
recordBossVictory: _recordBossVictory,
|
||||
recordRoguelikeDefeat: _recordRoguelikeDefeat,
|
||||
clearRecentRewards: _clearRecentRewards,
|
||||
clearNotice: _clearNotice,
|
||||
...snapshot
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CollectionLog, MaterialStack } from "../game/progression/loot";
|
||||
|
||||
export type SaveSlotId = 1 | 2 | 3;
|
||||
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game";
|
||||
export type GameModeId = "roguelike-pve" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
|
||||
export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
|
||||
|
||||
export interface CollectionDrop {
|
||||
id: string;
|
||||
@@ -41,6 +41,7 @@ export interface HunterStats {
|
||||
alliesSaved: number;
|
||||
healingDone: number;
|
||||
bossKills: Record<string, number>;
|
||||
highestRoguelikeRound: number;
|
||||
}
|
||||
|
||||
export interface HealerProgress {
|
||||
|
||||
Reference in New Issue
Block a user