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
+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