Release v0.1.6 2026-07-12
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user