fix: turn off auto-boss/auto-quest on failure and surface status (#46)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m7s

## Summary

- Auto-boss now turns itself **off** when a boss fight is **lost**, so the player can reassess rather than the system silently looping. A "🤖 Last fight: [Boss] —  Lost" status line appears in the boss panel.
- Auto-boss also turns off (with an ⚠️ error message) when the API call fails outright (e.g. party has no adventurers), replacing the previous behaviour of silently hammering the API every animation frame.
- Auto-quest now turns itself **off** whenever a quest fails the random-chance check, detected inside the tick's `setState` callback immediately after `applyTick`.
- `autoBoss: false` and `autoQuest: false` are now part of `initialGameState`, so these fields persist through save/load cycles from the very first session — preventing a race window where the boss-route DB write could strip them before the first auto-save.
- `toggleAutoBoss` clears both `autoBossLastResult` and `autoBossError` on each toggle so the panel always reflects the current session cleanly.

## Test plan

- [x] `pnpm lint` — 0 errors, 0 warnings
- [x] `pnpm build` — all packages clean
- [x] `pnpm test` — 100% coverage maintained across the board

Closes #40

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #46
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #46.
This commit is contained in:
2026-03-09 21:12:03 -07:00
committed by Naomi Carrigan
parent ac94f67797
commit 4d7e624358
3 changed files with 81 additions and 5 deletions
+2
View File
@@ -76,6 +76,8 @@ const initialGameState = (
achievements: structuredClone(defaultAchievements),
adventurers: structuredClone(defaultAdventurers),
apotheosis: { ...initialApotheosis },
autoBoss: false,
autoQuest: false,
baseClickPower: 1,
bosses: structuredClone(defaultBosses),
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
+25 -1
View File
@@ -226,7 +226,14 @@ const computePartyStats = (
* @returns The JSX element.
*/
const BossPanel = (): JSX.Element => {
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
const {
state,
challengeBoss,
formatNumber,
toggleAutoBoss,
autoBossLastResult,
autoBossError,
} = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
null,
);
@@ -346,6 +353,23 @@ const BossPanel = (): JSX.Element => {
</div>
</div>
{autoBossError === null
? null
: <p className="auto-boss-error">
{"⚠️ Auto-boss stopped: "}
{autoBossError}
</p>
}
{autoBossLastResult !== null && autoBossError === null
? <p className="auto-boss-status">
{"🤖 Last fight: "}
{autoBossLastResult.bossName}
{autoBossLastResult.won
? " — ✅ Won"
: " — ❌ Lost"}
</p>
: null}
<ZoneSelector
activeZoneId={activeZoneId}
onSelectZone={setActiveZoneId}
+54 -4
View File
@@ -545,6 +545,18 @@ interface GameContextValue {
* Reset all progress to a fresh save state (resolves schema outdated).
*/
resetProgress: ()=> Promise<void>;
/**
* Last auto-boss fight result — null until the first auto fight completes or
* when auto-boss is toggled off.
*/
autoBossLastResult: { bossName: string; won: boolean; at: number } | null;
/**
* Error message set when auto-boss stopped due to a critical failure (null
* when no error). Cleared automatically when the player re-enables auto-boss.
*/
autoBossError: string | null;
}
export interface BattleResult {
@@ -588,6 +600,12 @@ export const GameProvider = ({
const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
const [ isSyncing, setIsSyncing ] = useState(false);
const [ syncError, setSyncError ] = useState<string | null>(null);
const [ autoBossLastResult, setAutoBossLastResult ] = useState<{
bossName: string;
won: boolean;
at: number;
} | null>(null);
const [ autoBossError, setAutoBossError ] = useState<string | null>(null);
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
@@ -1059,6 +1077,14 @@ export const GameProvider = ({
},
);
// Quest failure — turn off auto-quest so the player can reassess
if (
newlyFailedQuestsReference.current.length > 0
&& next.autoQuest === true
) {
next = { ...next, autoQuest: false };
}
return next;
});
@@ -1200,14 +1226,32 @@ export const GameProvider = ({
if (previous === null) {
return previous;
}
return applyBossResult(previous, bossId, result);
const afterBoss = applyBossResult(previous, bossId, result);
// Defeat — turn off auto-boss so the player can reassess
if (!result.won) {
return { ...afterBoss, autoBoss: false };
}
return afterBoss;
});
setAutoBossLastResult({
at: Date.now(),
bossName: bossName,
won: result.won,
});
setBattleResult({ bossName, result });
}).
catch((error_: unknown) => {
logError("auto_boss", error_);
/* Silently ignore — will retry next tick */
const message
= error_ instanceof Error
? error_.message
: String(error_);
setAutoBossError(message);
setState((previous) => {
if (previous === null) {
return previous;
}
return { ...previous, autoBoss: false };
});
}).
finally(() => {
isAutoBossingReference.current = false;
@@ -1782,6 +1826,8 @@ export const GameProvider = ({
}, []);
const toggleAutoBoss = useCallback(() => {
setAutoBossError(null);
setAutoBossLastResult(null);
setState((previous) => {
if (previous === null) {
return previous;
@@ -1974,6 +2020,8 @@ export const GameProvider = ({
const contextValue = useMemo<GameContextValue>(() => {
return {
apotheosis,
autoBossError,
autoBossLastResult,
battleResult,
buyAdventurer,
buyEchoUpgrade,
@@ -2040,6 +2088,8 @@ export const GameProvider = ({
};
}, [
apotheosis,
autoBossError,
autoBossLastResult,
battleResult,
completedQuestToasts,
failedQuestToasts,