new rpg mode
This commit is contained in:
+332
-18
@@ -2,9 +2,27 @@ import { create } from "zustand";
|
||||
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
|
||||
import { SaveRepository } from "./saveRepository";
|
||||
import { AccountRepository, type AccountResult } from "./accountRepository";
|
||||
import { onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
|
||||
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
|
||||
import { OnlineApiError, onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
|
||||
import { leaderboardCache } from "./leaderboardCache";
|
||||
import {
|
||||
clearSaveSyncPending,
|
||||
markSaveSyncPending,
|
||||
networkAppearsOnline,
|
||||
scheduleSaveSyncRetry,
|
||||
} from "./saveSync";
|
||||
import type {
|
||||
AppScreen,
|
||||
GameModeId,
|
||||
GameSettings,
|
||||
HunterSave,
|
||||
ProfileCollectionView,
|
||||
ProfileStatId,
|
||||
SaveSlotId,
|
||||
SaveSlotState,
|
||||
} from "./types";
|
||||
import type { BossGroupId } from "../game/bossCatalog";
|
||||
import type { AbilitySlotId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
|
||||
import { HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike";
|
||||
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
|
||||
import {
|
||||
@@ -13,20 +31,27 @@ import {
|
||||
infusionsForOwner,
|
||||
} from "../game/progression/infusions";
|
||||
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
||||
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
|
||||
import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "../game/progression/hunterStats";
|
||||
import {
|
||||
cloneCharacterAppearance,
|
||||
type CharacterAppearanceV1,
|
||||
type CharacterModelMode,
|
||||
} from "../game/characterAppearance";
|
||||
import { createDefaultHealerAppearance, normalizeHealerAppearance } from "../game/healerVisuals";
|
||||
|
||||
const repository = new SaveRepository();
|
||||
const accounts = new AccountRepository();
|
||||
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
|
||||
const onlineSaveQueues = new Map<SaveSlotId, Promise<HunterSave>>();
|
||||
const onlineSaveQueues = new Map<SaveSlotId, { updatedAt: string; promise: Promise<HunterSave> }>();
|
||||
|
||||
function writeServerSaveSerially(save: HunterSave): Promise<HunterSave> {
|
||||
const previous = onlineSaveQueues.get(save.slotId);
|
||||
const next = (previous ? previous.catch(() => save) : Promise.resolve(save))
|
||||
if (previous?.updatedAt === save.updatedAt) return previous.promise;
|
||||
const next = (previous ? previous.promise.catch(() => save) : Promise.resolve(save))
|
||||
.then(() => onlineRepository.writeSave(save));
|
||||
onlineSaveQueues.set(save.slotId, next);
|
||||
onlineSaveQueues.set(save.slotId, { updatedAt: save.updatedAt, promise: next });
|
||||
void next.finally(() => {
|
||||
if (onlineSaveQueues.get(save.slotId) === next) onlineSaveQueues.delete(save.slotId);
|
||||
if (onlineSaveQueues.get(save.slotId)?.promise === next) onlineSaveQueues.delete(save.slotId);
|
||||
}).catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
@@ -76,6 +101,16 @@ function replaceOnlineSlot(current: readonly SaveSlotState[], save: HunterSave):
|
||||
return refreshLocalSlots(current).map((slot) => slot.id === save.slotId ? { ...slot, online: save } : slot);
|
||||
}
|
||||
|
||||
export type AppearancePreviewAnimation = "idle" | "walk" | "cast";
|
||||
export type AppearanceDrafts = Record<HealerClassId, CharacterAppearanceV1>;
|
||||
|
||||
function appearanceDraftsFor(save: HunterSave | null): AppearanceDrafts {
|
||||
return Object.fromEntries(HEALER_CLASS_ORDER.map((classId) => [
|
||||
classId,
|
||||
cloneCharacterAppearance(save?.healers[classId].appearance ?? createDefaultHealerAppearance(classId)),
|
||||
])) as AppearanceDrafts;
|
||||
}
|
||||
|
||||
export interface FrontendState {
|
||||
screen: AppScreen;
|
||||
accountId: string | null;
|
||||
@@ -89,8 +124,15 @@ export interface FrontendState {
|
||||
selectedGearSlotId: GearSlotId;
|
||||
gearWorkshopMode: "upgrade" | "infusion";
|
||||
selectedInfusionId: string;
|
||||
selectedPassiveAbilityId: AbilityId;
|
||||
selectedPassiveAbilityId: AbilitySlotId;
|
||||
selectedPassiveInfusionId: RunBuffId;
|
||||
profileCollectionView: ProfileCollectionView;
|
||||
selectedProfileGroupId: BossGroupId;
|
||||
selectedProfileStatId: ProfileStatId;
|
||||
appearanceClassId: HealerClassId;
|
||||
appearanceDrafts: AppearanceDrafts;
|
||||
previewMode: CharacterModelMode;
|
||||
previewAnimation: AppearancePreviewAnimation;
|
||||
recentRewards: BossRewardAward[];
|
||||
settings: GameSettings;
|
||||
notice: string;
|
||||
@@ -105,7 +147,7 @@ export interface FrontendState {
|
||||
playSlot: (slotId: SaveSlotId) => void;
|
||||
deleteSlot: (slotId: SaveSlotId) => void;
|
||||
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
|
||||
uploadSlot: (slotId: SaveSlotId) => Promise<void>;
|
||||
uploadSlot: (slotId: SaveSlotId) => Promise<boolean>;
|
||||
downloadSlot: (slotId: SaveSlotId) => Promise<void>;
|
||||
selectMode: (mode: GameModeId) => void;
|
||||
selectBoss: (bossId: BossId) => void;
|
||||
@@ -114,8 +156,19 @@ export interface FrontendState {
|
||||
selectGearSlot: (slotId: GearSlotId) => void;
|
||||
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
|
||||
selectInfusion: (infusionId: string) => void;
|
||||
selectPassiveAbility: (abilityId: AbilityId) => void;
|
||||
selectPassiveAbility: (abilityId: AbilitySlotId) => void;
|
||||
selectPassiveInfusion: (passiveId: RunBuffId) => void;
|
||||
selectProfileCollectionView: (view: ProfileCollectionView) => void;
|
||||
selectProfileGroup: (groupId: BossGroupId) => void;
|
||||
selectProfileStat: (statId: ProfileStatId) => void;
|
||||
openAppearanceLab: () => void;
|
||||
selectAppearanceClass: (classId: HealerClassId) => void;
|
||||
updateAppearanceDraft: (appearance: CharacterAppearanceV1) => void;
|
||||
resetAppearanceDraft: () => void;
|
||||
saveAppearanceDraft: () => boolean;
|
||||
closeAppearanceLab: () => void;
|
||||
setAppearancePreviewMode: (mode: CharacterModelMode) => void;
|
||||
setAppearancePreviewAnimation: (animation: AppearancePreviewAnimation) => void;
|
||||
upgradeSelectedGear: () => boolean;
|
||||
equipSelectedInfusion: () => boolean;
|
||||
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
|
||||
@@ -126,6 +179,11 @@ export interface FrontendState {
|
||||
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
||||
recordRoguelikeDefeat: (round: number) => void;
|
||||
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
|
||||
recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void;
|
||||
recordHockeyPvpResult: (won: boolean) => void;
|
||||
recordHockeyPvpBossKill: () => void;
|
||||
recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void;
|
||||
recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void;
|
||||
clearRecentRewards: () => void;
|
||||
clearNotice: () => void;
|
||||
}
|
||||
@@ -147,8 +205,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
selectedGearSlotId: "weapon",
|
||||
gearWorkshopMode: "upgrade",
|
||||
selectedInfusionId: infusionsForOwner("priest")[0].id,
|
||||
selectedPassiveAbilityId: "mend",
|
||||
selectedPassiveAbilityId: "ability1",
|
||||
selectedPassiveInfusionId: "mend-echo",
|
||||
profileCollectionView: "stats",
|
||||
selectedProfileGroupId: "charge",
|
||||
selectedProfileStatId: "roguelike",
|
||||
appearanceClassId: "priest",
|
||||
appearanceDrafts: appearanceDraftsFor(null),
|
||||
previewMode: "modular",
|
||||
previewAnimation: "idle",
|
||||
recentRewards: [],
|
||||
settings: loadSettings(),
|
||||
notice: "",
|
||||
@@ -200,7 +265,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
void accounts.logout();
|
||||
set({ accountId: null, slots: repository.listLocal(), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." });
|
||||
},
|
||||
navigate: (screen) => set({ screen, notice: "" }),
|
||||
navigate: (screen) => set(screen === "profile"
|
||||
? {
|
||||
screen,
|
||||
profileCollectionView: "stats",
|
||||
selectedProfileGroupId: "charge",
|
||||
selectedProfileStatId: "roguelike",
|
||||
notice: "",
|
||||
}
|
||||
: { screen, notice: "" }),
|
||||
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
|
||||
createSlot: (slotId, rawHunterName) => {
|
||||
const hunterName = normalizeHunterName(rawHunterName);
|
||||
@@ -209,6 +282,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
repository.create(slotId, hunterName);
|
||||
leaderboardCache.clearSlot(slotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
|
||||
return true;
|
||||
},
|
||||
@@ -219,6 +293,8 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
},
|
||||
deleteSlot: (slotId) => {
|
||||
repository.deleteLocal(slotId);
|
||||
leaderboardCache.clearSlot(slotId);
|
||||
clearSaveSyncPending(slotId);
|
||||
set((state) => ({
|
||||
slots: refreshLocalSlots(state.slots),
|
||||
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
|
||||
@@ -228,30 +304,48 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
copySlot: (sourceId, targetId) => {
|
||||
const copy = repository.copyLocal(sourceId, targetId);
|
||||
if (!copy) return;
|
||||
leaderboardCache.clearSlot(targetId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
|
||||
},
|
||||
uploadSlot: async (slotId) => {
|
||||
const { accountId } = get();
|
||||
if (!accountId) return set({ notice: "Sign in before syncing online." });
|
||||
if (!accountId) {
|
||||
set({ notice: "Sign in before syncing online." });
|
||||
return false;
|
||||
}
|
||||
const local = repository.listLocal().find((slot) => slot.id === slotId)?.local;
|
||||
if (!local) return set({ notice: "No offline save to sync." });
|
||||
if (!local) {
|
||||
set({ notice: "No offline save to sync." });
|
||||
return false;
|
||||
}
|
||||
markSaveSyncPending(slotId);
|
||||
if (!networkAppearsOnline()) {
|
||||
set({ notice: `Slot ${slotId} saved locally. Online sync waits for connection.` });
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const uploaded = await writeServerSaveSerially(local);
|
||||
if (get().accountId === accountId) {
|
||||
set((state) => ({ slots: replaceOnlineSlot(state.slots, uploaded), notice: `Slot ${slotId} synced to TrueNAS.` }));
|
||||
}
|
||||
clearSaveSyncPending(slotId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const retryable = !(error instanceof OnlineApiError) || error.status === 0 || error.status >= 500;
|
||||
if (retryable) scheduleSaveSyncRetry(slotId);
|
||||
set({ notice: error instanceof Error ? error.message : "Save upload failed." });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
downloadSlot: async (slotId) => {
|
||||
const { accountId } = get();
|
||||
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
|
||||
try {
|
||||
await onlineSaveQueues.get(slotId)?.catch(() => undefined);
|
||||
await onlineSaveQueues.get(slotId)?.promise.catch(() => undefined);
|
||||
const serverSave = await onlineRepository.readSave(slotId);
|
||||
if (!serverSave) return set({ notice: "No online version exists for this slot." });
|
||||
const downloaded = repository.replaceLocal(serverSave);
|
||||
clearSaveSyncPending(slotId);
|
||||
if (get().accountId === accountId) {
|
||||
set((state) => ({ slots: replaceOnlineSlot(state.slots, downloaded), notice: `Slot ${slotId} downloaded from TrueNAS.` }));
|
||||
}
|
||||
@@ -271,14 +365,88 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
|
||||
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
|
||||
selectPassiveAbility: (selectedPassiveAbilityId) => {
|
||||
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilityId === selectedPassiveAbilityId) ?? "mend-echo";
|
||||
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilitySlotId === selectedPassiveAbilityId) ?? "mend-echo";
|
||||
set({ selectedPassiveAbilityId, selectedPassiveInfusionId, notice: "" });
|
||||
},
|
||||
selectPassiveInfusion: (selectedPassiveInfusionId) => set({
|
||||
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilityId,
|
||||
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilitySlotId,
|
||||
selectedPassiveInfusionId,
|
||||
notice: "",
|
||||
}),
|
||||
selectProfileCollectionView: (profileCollectionView) => set({ profileCollectionView }),
|
||||
selectProfileGroup: (selectedProfileGroupId) => set({ selectedProfileGroupId }),
|
||||
selectProfileStat: (selectedProfileStatId) => set({ selectedProfileStatId }),
|
||||
openAppearanceLab: () => set((state) => {
|
||||
const save = activeSave(state.slots, state.activeSlotId);
|
||||
return {
|
||||
screen: "appearance",
|
||||
appearanceClassId: save?.activeClassId ?? state.appearanceClassId,
|
||||
appearanceDrafts: appearanceDraftsFor(save),
|
||||
previewMode: "modular",
|
||||
previewAnimation: "idle",
|
||||
notice: "Appearance Lab opened. Changes remain drafts until saved.",
|
||||
};
|
||||
}),
|
||||
selectAppearanceClass: (appearanceClassId) => set({ appearanceClassId, notice: "" }),
|
||||
updateAppearanceDraft: (appearance) => set((state) => ({
|
||||
appearanceDrafts: {
|
||||
...state.appearanceDrafts,
|
||||
[state.appearanceClassId]: cloneCharacterAppearance(appearance),
|
||||
},
|
||||
notice: "Preview updated. Save to keep this look.",
|
||||
})),
|
||||
resetAppearanceDraft: () => set((state) => ({
|
||||
appearanceDrafts: {
|
||||
...state.appearanceDrafts,
|
||||
[state.appearanceClassId]: createDefaultHealerAppearance(state.appearanceClassId),
|
||||
},
|
||||
notice: "Class default restored in preview. Save to keep it.",
|
||||
})),
|
||||
saveAppearanceDraft: () => {
|
||||
const { activeSlotId, appearanceClassId, appearanceDrafts } = get();
|
||||
if (!activeSlotId) {
|
||||
set({ notice: "Load a hunter save before changing appearance." });
|
||||
return false;
|
||||
}
|
||||
const appearance = normalizeHealerAppearance(appearanceClassId, appearanceDrafts[appearanceClassId]);
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
||||
...save,
|
||||
healers: {
|
||||
...save.healers,
|
||||
[appearanceClassId]: {
|
||||
...save.healers[appearanceClassId],
|
||||
appearance,
|
||||
},
|
||||
},
|
||||
}));
|
||||
if (!updated) {
|
||||
set({ notice: "Appearance could not be saved." });
|
||||
return false;
|
||||
}
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({
|
||||
slots: refreshLocalSlots(state.slots),
|
||||
appearanceDrafts: {
|
||||
...state.appearanceDrafts,
|
||||
[appearanceClassId]: cloneCharacterAppearance(appearance),
|
||||
},
|
||||
notice: `${appearanceClassId[0].toUpperCase() + appearanceClassId.slice(1)} appearance saved locally.`,
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
closeAppearanceLab: () => set((state) => {
|
||||
const save = activeSave(state.slots, state.activeSlotId);
|
||||
return {
|
||||
screen: "home",
|
||||
appearanceClassId: save?.activeClassId ?? state.appearanceClassId,
|
||||
appearanceDrafts: appearanceDraftsFor(save),
|
||||
previewMode: "modular",
|
||||
previewAnimation: "idle",
|
||||
notice: "Appearance Lab closed.",
|
||||
};
|
||||
}),
|
||||
setAppearancePreviewMode: (previewMode) => set({ previewMode }),
|
||||
setAppearancePreviewAnimation: (previewAnimation) => set({ previewAnimation }),
|
||||
upgradeSelectedGear: () => {
|
||||
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
|
||||
if (!activeSlotId) return false;
|
||||
@@ -392,6 +560,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards,
|
||||
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
|
||||
}));
|
||||
markSaveSyncPending(activeSlotId);
|
||||
return awarded;
|
||||
},
|
||||
recordRoguelikeDefeat: (round) => {
|
||||
@@ -405,6 +574,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordRogueTrialsEndlessDefeat: (bossKills) => {
|
||||
@@ -418,6 +588,118 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordHockeyHealingDefeat: (returns, durationSeconds) => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => {
|
||||
const record = bestHockeyHealingRecord(
|
||||
save.stats.highestHockeyHealingReturns,
|
||||
save.stats.longestHockeyHealingSecondsAtBest,
|
||||
returns,
|
||||
durationSeconds,
|
||||
);
|
||||
return {
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
highestHockeyHealingReturns: record.returns,
|
||||
longestHockeyHealingSecondsAtBest: record.durationSeconds,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordHockeyPvpResult: (won) => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => {
|
||||
const record = hockeyPvpRecordAfterMatch(
|
||||
save.stats.hockeyHealingPvpWins,
|
||||
save.stats.hockeyHealingPvpLosses,
|
||||
won,
|
||||
);
|
||||
return {
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
hockeyHealingPvpWins: record.wins,
|
||||
hockeyHealingPvpLosses: record.losses,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordHockeyPvpBossKill: () => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => ({
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
hockeyHealingPvpBossKills: save.stats.hockeyHealingPvpBossKills + 1,
|
||||
},
|
||||
}));
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordBlockbreakerDefeat: (bricks, durationSeconds, score) => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => {
|
||||
const record = bestBlockbreakerRecords(
|
||||
save.stats.highestBlockbreakerBricks,
|
||||
save.stats.longestBlockbreakerSeconds,
|
||||
save.stats.highestBlockbreakerScore,
|
||||
bricks,
|
||||
durationSeconds,
|
||||
score,
|
||||
);
|
||||
return {
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
highestBlockbreakerBricks: record.bricks,
|
||||
longestBlockbreakerSeconds: record.durationSeconds,
|
||||
highestBlockbreakerScore: record.score,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
recordAetherAssaultDefeat: (score, wave, durationSeconds) => {
|
||||
const { activeSlotId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => {
|
||||
const record = bestAetherAssaultRecord(
|
||||
save.stats.highestAetherAssaultScore,
|
||||
save.stats.highestAetherAssaultWaveAtBest,
|
||||
save.stats.longestAetherAssaultSecondsAtBest,
|
||||
score,
|
||||
wave,
|
||||
durationSeconds,
|
||||
);
|
||||
return {
|
||||
...save,
|
||||
stats: {
|
||||
...save.stats,
|
||||
highestAetherAssaultScore: record.score,
|
||||
highestAetherAssaultWaveAtBest: record.wave,
|
||||
longestAetherAssaultSecondsAtBest: record.durationSeconds,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!updated) return;
|
||||
markSaveSyncPending(activeSlotId);
|
||||
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
|
||||
},
|
||||
clearRecentRewards: () => set({ recentRewards: [] }),
|
||||
@@ -447,6 +729,17 @@ export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "selectInfusion"
|
||||
| "selectPassiveAbility"
|
||||
| "selectPassiveInfusion"
|
||||
| "selectProfileCollectionView"
|
||||
| "selectProfileGroup"
|
||||
| "selectProfileStat"
|
||||
| "openAppearanceLab"
|
||||
| "selectAppearanceClass"
|
||||
| "updateAppearanceDraft"
|
||||
| "resetAppearanceDraft"
|
||||
| "saveAppearanceDraft"
|
||||
| "closeAppearanceLab"
|
||||
| "setAppearancePreviewMode"
|
||||
| "setAppearancePreviewAnimation"
|
||||
| "upgradeSelectedGear"
|
||||
| "equipSelectedInfusion"
|
||||
| "equipPassiveInfusion"
|
||||
@@ -457,6 +750,11 @@ export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "recordBossVictory"
|
||||
| "recordRoguelikeDefeat"
|
||||
| "recordRogueTrialsEndlessDefeat"
|
||||
| "recordHockeyHealingDefeat"
|
||||
| "recordHockeyPvpResult"
|
||||
| "recordHockeyPvpBossKill"
|
||||
| "recordBlockbreakerDefeat"
|
||||
| "recordAetherAssaultDefeat"
|
||||
| "clearRecentRewards"
|
||||
| "clearNotice"
|
||||
>;
|
||||
@@ -485,6 +783,17 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
selectInfusion: _selectInfusion,
|
||||
selectPassiveAbility: _selectPassiveAbility,
|
||||
selectPassiveInfusion: _selectPassiveInfusion,
|
||||
selectProfileCollectionView: _selectProfileCollectionView,
|
||||
selectProfileGroup: _selectProfileGroup,
|
||||
selectProfileStat: _selectProfileStat,
|
||||
openAppearanceLab: _openAppearanceLab,
|
||||
selectAppearanceClass: _selectAppearanceClass,
|
||||
updateAppearanceDraft: _updateAppearanceDraft,
|
||||
resetAppearanceDraft: _resetAppearanceDraft,
|
||||
saveAppearanceDraft: _saveAppearanceDraft,
|
||||
closeAppearanceLab: _closeAppearanceLab,
|
||||
setAppearancePreviewMode: _setAppearancePreviewMode,
|
||||
setAppearancePreviewAnimation: _setAppearancePreviewAnimation,
|
||||
upgradeSelectedGear: _upgradeSelectedGear,
|
||||
equipSelectedInfusion: _equipSelectedInfusion,
|
||||
equipPassiveInfusion: _equipPassiveInfusion,
|
||||
@@ -495,6 +804,11 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
recordBossVictory: _recordBossVictory,
|
||||
recordRoguelikeDefeat: _recordRoguelikeDefeat,
|
||||
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
|
||||
recordHockeyHealingDefeat: _recordHockeyHealingDefeat,
|
||||
recordHockeyPvpResult: _recordHockeyPvpResult,
|
||||
recordHockeyPvpBossKill: _recordHockeyPvpBossKill,
|
||||
recordBlockbreakerDefeat: _recordBlockbreakerDefeat,
|
||||
recordAetherAssaultDefeat: _recordAetherAssaultDefeat,
|
||||
clearRecentRewards: _clearRecentRewards,
|
||||
clearNotice: _clearNotice,
|
||||
...snapshot
|
||||
|
||||
Reference in New Issue
Block a user