Release v0.1.6 2026-07-12

This commit is contained in:
Warren H
2026-07-12 23:20:15 -04:00
parent 35553c18dd
commit 122f159b94
55 changed files with 2229 additions and 587 deletions
+24 -39
View File
@@ -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" });
});
});
+23 -115
View File
@@ -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(); }
}
+9
View File
@@ -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);
});
});
+9 -1
View File
@@ -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: "14 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: "14 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(),
+152
View File
@@ -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();
+18 -49
View File
@@ -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);
}
});
});
+7 -21
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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 {