feat: add auto-quest and auto-boss toggles

Adds optional automation to the quest and boss panels. Auto-quest
automatically starts the highest-zone available quest (respecting CP
requirements) as soon as none is active. Auto-boss automatically
challenges the highest available boss when one is ready. Both run
exclusively in the client-side RAF tick loop — offline calculations
are unaffected. Toggles persist in GameState via cloud save.
This commit is contained in:
2026-03-07 15:26:41 -08:00
committed by Naomi Carrigan
parent bec972aed1
commit 74d1d21419
6 changed files with 237 additions and 112 deletions
@@ -83,6 +83,10 @@ const HOW_TO_PLAY = [
title: "🔥 Daily Login Bonus",
body: "Log in every day to earn escalating rewards! Each consecutive day awards more gold, and the 7th day of your streak grants bonus crystals. Your streak resets if you miss a day. A week multiplier increases all rewards the longer your overall streak runs. Your current streak is displayed on your character sheet.",
},
{
title: "🤖 Auto-Quest & Auto-Boss",
body: "Toggle automation in the Quests and Boss Encounters panels! Auto-Quest automatically sends your party on the highest-zone available quest as soon as one completes, skipping quests whose combat power requirement isn't met. Auto-Boss automatically challenges the highest available boss as soon as one is ready. Both can be toggled on or off at any time using the 🤖 Auto button in each panel header.",
},
{
title: "☁️ Cloud Saves",
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
+16 -6
View File
@@ -96,7 +96,7 @@ const BossCard = ({
};
export const BossPanel = (): React.JSX.Element => {
const { state, challengeBoss, formatNumber } = useGame();
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
const [challengingBossId, setChallengingBossId] = useState<string | null>(null);
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
const [showLocked, setShowLocked] = useState(true);
@@ -187,11 +187,21 @@ export const BossPanel = (): React.JSX.Element => {
<section className="panel boss-panel">
<div className="panel-header">
<h2>Boss Encounters</h2>
<LockToggle
lockedCount={lockedCount}
showLocked={showLocked}
onToggle={() => { setShowLocked((v) => !v); }}
/>
<div className="panel-header-controls">
<button
className={`auto-toggle-btn ${state.autoBoss ? "auto-toggle-on" : "auto-toggle-off"}`}
onClick={toggleAutoBoss}
title="Automatically challenge the highest available boss"
type="button"
>
🤖 Auto: {state.autoBoss ? "ON" : "OFF"}
</button>
<LockToggle
lockedCount={lockedCount}
showLocked={showLocked}
onToggle={() => { setShowLocked((v) => !v); }}
/>
</div>
</div>
<ZoneSelector
+16 -6
View File
@@ -86,7 +86,7 @@ const QuestCard = ({ quest, partyCombatPower, unlockHint, zoneHint }: QuestCardP
};
export const QuestPanel = (): React.JSX.Element => {
const { state } = useGame();
const { state, toggleAutoQuest } = useGame();
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
const [showLocked, setShowLocked] = useState(true);
@@ -128,11 +128,21 @@ export const QuestPanel = (): React.JSX.Element => {
<section className="panel quest-panel">
<div className="panel-header">
<h2>Quests</h2>
<LockToggle
lockedCount={lockedCount}
showLocked={showLocked}
onToggle={() => { setShowLocked((v) => !v); }}
/>
<div className="panel-header-controls">
<button
className={`auto-toggle-btn ${state.autoQuest ? "auto-toggle-on" : "auto-toggle-off"}`}
onClick={toggleAutoQuest}
title="Automatically send the party on the highest available quest"
type="button"
>
🤖 Auto: {state.autoQuest ? "ON" : "OFF"}
</button>
<LockToggle
lockedCount={lockedCount}
showLocked={showLocked}
onToggle={() => { setShowLocked((v) => !v); }}
/>
</div>
</div>
<ZoneSelector
+160 -100
View File
@@ -1,4 +1,91 @@
import type { Achievement, BossChallengeResponse, ExploreCollectResponse, GameState, LoginBonusResult, NumberFormat } from "@elysium/types";
/**
* Pure function — applies a boss challenge result to the game state.
* Used by both the manual challengeBoss flow and the auto-boss tick logic.
*/
const applyBossResult = (prev: GameState, bossId: string, result: BossChallengeResponse): GameState => {
if (result.won) {
const defeatedBoss = prev.bosses.find((b) => b.id === bossId);
const zoneBosses = prev.bosses.filter((b) => b.zoneId === defeatedBoss?.zoneId);
const zoneIdx = zoneBosses.findIndex((b) => b.id === bossId);
const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id;
const newlyUnlockedZones = (prev.zones ?? []).filter((z) => {
if (z.status !== "locked" || z.unlockBossId !== bossId) return false;
const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk;
});
const newZoneFirstBossIds = newlyUnlockedZones.map((z) => {
const firstBoss = prev.bosses.find((b) => b.zoneId === z.id);
return firstBoss?.id;
}).filter(Boolean);
return {
...prev,
bosses: prev.bosses.map((b) => {
if (b.id === bossId) return { ...b, status: "defeated" as const, currentHp: 0 };
if (b.id === nextZoneBossId && b.prestigeRequirement <= prev.prestige.count) {
return { ...b, status: "available" as const };
}
if (newZoneFirstBossIds.includes(b.id) && b.prestigeRequirement <= prev.prestige.count) {
return { ...b, status: "available" as const };
}
return b;
}),
zones: (prev.zones ?? []).map((z) => {
if (z.status !== "locked" || z.unlockBossId !== bossId) return z;
const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk ? { ...z, status: "unlocked" as const } : z;
}),
resources: result.rewards
? {
...prev.resources,
gold: prev.resources.gold + result.rewards.gold,
essence: prev.resources.essence + result.rewards.essence,
crystals: prev.resources.crystals + result.rewards.crystals,
}
: prev.resources,
prestige: result.rewards?.bountyRunestones
? { ...prev.prestige, runestones: prev.prestige.runestones + result.rewards.bountyRunestones }
: prev.prestige,
player: result.rewards
? { ...prev.player, totalGoldEarned: prev.player.totalGoldEarned + result.rewards.gold }
: prev.player,
upgrades: result.rewards
? prev.upgrades.map((u) =>
result.rewards!.upgradeIds.includes(u.id) ? { ...u, unlocked: true } : u,
)
: prev.upgrades,
equipment: result.rewards
? (prev.equipment ?? []).map((e) => {
if (!result.rewards!.equipmentIds.includes(e.id)) return e;
const slotEmpty = !(prev.equipment ?? []).some(
(other) => other.type === e.type && other.equipped,
);
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
})
: prev.equipment ?? [],
};
}
// Loss: reset boss HP and apply casualties
return {
...prev,
bosses: prev.bosses.map((b) =>
b.id === bossId ? { ...b, status: "available" as const, currentHp: b.maxHp } : b,
),
adventurers: prev.adventurers.map((a) => {
const casualty = result.casualties?.find((c) => c.adventurerId === a.id);
if (!casualty) return a;
return { ...a, count: Math.max(0, a.count - casualty.killed) };
}),
};
};
import {
createContext,
useCallback,
@@ -85,6 +172,10 @@ interface GameContextValue {
buyPrestigeUpgrade: (upgradeId: string) => Promise<void>;
/** Toggle the auto-prestige setting on/off (requires auto_prestige upgrade) */
toggleAutoPrestige: () => void;
/** Toggle the auto-quest setting on/off */
toggleAutoQuest: () => void;
/** Toggle the auto-boss setting on/off */
toggleAutoBoss: () => void;
/** Queue of newly unlocked codex entry IDs (for toast notifications) */
newCodexEntryIds: string[];
/** Remove a codex entry ID from the notification queue */
@@ -137,6 +228,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
const newlyUnlockedRef = useRef<Achievement[]>([]);
const signatureRef = useRef<string | null>(localStorage.getItem("elysium_save_signature"));
const isAutoPrestigingRef = useRef(false);
const isAutoBossingRef = useRef(false);
const reloadRef = useRef<() => Promise<void>>(() => Promise.resolve());
const [newCodexEntryIds, setNewCodexEntryIds] = useState<string[]>([]);
const codexProcessedRef = useRef<Set<string>>(new Set());
@@ -314,7 +406,33 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
setState((prev) => {
if (!prev) return prev;
const next = applyTick(prev, deltaSeconds);
let next = applyTick(prev, deltaSeconds);
// Auto-quest: start the highest-zone available quest when none is active
if (next.autoQuest) {
const hasActiveQuest = next.quests.some((q) => q.status === "active");
if (!hasActiveQuest) {
const partyCombatPower = next.adventurers.reduce(
(total, a) => total + a.combatPower * a.count,
0,
);
const zoneOrder = new Map(next.zones.map((z, i) => [z.id, i]));
const candidates = next.quests
.filter((q) => q.status === "available" && (q.combatPowerRequired ?? 0) <= partyCombatPower)
.sort((a, b) => (zoneOrder.get(b.zoneId) ?? 0) - (zoneOrder.get(a.zoneId) ?? 0));
const best = candidates[0];
if (best) {
next = {
...next,
quests: next.quests.map((q) =>
q.id === best.id
? { ...q, status: "active" as const, startedAt: Date.now() }
: q,
),
};
}
}
}
// Detect newly unlocked achievements
newlyUnlockedRef.current = next.achievements.filter((a, i) => {
@@ -370,6 +488,30 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
.finally(() => { isAutoPrestigingRef.current = false; });
}
// Auto-boss: challenge the highest-zone available boss when not already fighting
if (!isAutoBossingRef.current && autoState?.autoBoss) {
const prestigeCount = autoState.prestige.count;
const zoneOrder = new Map((autoState.zones ?? []).map((z, i) => [z.id, i]));
const availableBoss = autoState.bosses
.filter((b) => b.status === "available" && b.prestigeRequirement <= prestigeCount)
.sort((a, b) => (zoneOrder.get(b.zoneId) ?? 0) - (zoneOrder.get(a.zoneId) ?? 0))[0];
if (availableBoss) {
const bossId = availableBoss.id;
const bossName = availableBoss.name;
isAutoBossingRef.current = true;
void challengeBossApi({ bossId })
.then((result) => {
setState((prev) => {
if (!prev) return prev;
return applyBossResult(prev, bossId, result);
});
setBattleResult({ bossName, result });
})
.catch(() => { /* silently ignore — will retry next tick */ })
.finally(() => { isAutoBossingRef.current = false; });
}
}
rafRef.current = requestAnimationFrame(tick);
};
@@ -743,6 +885,20 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
});
}, []);
const toggleAutoQuest = useCallback(() => {
setState((prev) => {
if (!prev) return prev;
return { ...prev, autoQuest: !prev.autoQuest };
});
}, []);
const toggleAutoBoss = useCallback(() => {
setState((prev) => {
if (!prev) return prev;
return { ...prev, autoBoss: !prev.autoBoss };
});
}, []);
const challengeBoss = useCallback(async (bossId: string) => {
if (!stateRef.current) return;
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
@@ -750,108 +906,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
try {
const result = await challengeBossApi({ bossId });
// Update local state to match server result
setState((prev) => {
if (!prev) return prev;
if (result.won) {
const defeatedBoss = prev.bosses.find((b) => b.id === bossId);
const zoneBosses = prev.bosses.filter((b) => b.zoneId === defeatedBoss?.zoneId);
const zoneIdx = zoneBosses.findIndex((b) => b.id === bossId);
const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id;
// Find newly unlocked zones and their first bosses
// A zone unlocks when BOTH the gate boss is defeated AND the gate quest is completed
const newlyUnlockedZones = (prev.zones ?? []).filter((z) => {
if (z.status !== "locked" || z.unlockBossId !== bossId) return false;
const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk;
});
const newZoneFirstBossIds = newlyUnlockedZones.map((z) => {
const firstBoss = prev.bosses.find((b) => b.zoneId === z.id);
return firstBoss?.id;
}).filter(Boolean);
return {
...prev,
bosses: prev.bosses.map((b) => {
if (b.id === bossId) return { ...b, status: "defeated" as const, currentHp: 0 };
if (b.id === nextZoneBossId && b.prestigeRequirement <= prev.prestige.count) {
return { ...b, status: "available" as const };
}
if (newZoneFirstBossIds.includes(b.id) && b.prestigeRequirement <= prev.prestige.count) {
return { ...b, status: "available" as const };
}
return b;
}),
zones: (prev.zones ?? []).map((z) => {
if (z.status !== "locked" || z.unlockBossId !== bossId) return z;
const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk ? { ...z, status: "unlocked" as const } : z;
}),
resources: result.rewards
? {
...prev.resources,
gold: prev.resources.gold + result.rewards.gold,
essence: prev.resources.essence + result.rewards.essence,
crystals: prev.resources.crystals + result.rewards.crystals,
}
: prev.resources,
prestige: result.rewards?.bountyRunestones
? {
...prev.prestige,
runestones: prev.prestige.runestones + result.rewards.bountyRunestones,
}
: prev.prestige,
player: result.rewards
? {
...prev.player,
totalGoldEarned:
prev.player.totalGoldEarned + result.rewards.gold,
}
: prev.player,
upgrades: result.rewards
? prev.upgrades.map((u) =>
result.rewards!.upgradeIds.includes(u.id)
? { ...u, unlocked: true }
: u,
)
: prev.upgrades,
equipment: result.rewards
? (prev.equipment ?? []).map((e) => {
if (!result.rewards!.equipmentIds.includes(e.id)) return e;
const slotEmpty = !(prev.equipment ?? []).some(
(other) => other.type === e.type && other.equipped,
);
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
})
: prev.equipment ?? [],
};
}
// Loss: reset boss HP and apply casualties
return {
...prev,
bosses: prev.bosses.map((b) =>
b.id === bossId
? { ...b, status: "available" as const, currentHp: b.maxHp }
: b,
),
adventurers: prev.adventurers.map((a) => {
const casualty = result.casualties?.find(
(c) => c.adventurerId === a.id,
);
if (!casualty) return a;
return { ...a, count: Math.max(0, a.count - casualty.killed) };
}),
};
return applyBossResult(prev, bossId, result);
});
setBattleResult({ bossName: boss.name, result });
} catch {
// Silently ignore — server errors shouldn't crash the UI
@@ -914,6 +972,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
formatNumber: boundFormatNumber,
buyPrestigeUpgrade,
toggleAutoPrestige,
toggleAutoQuest,
toggleAutoBoss,
newCodexEntryIds,
dismissCodexEntry,
transcend,
+37
View File
@@ -3766,6 +3766,43 @@ body {
margin: 0;
}
/* ── Auto Toggle Buttons ────────────────────────────────────────────── */
.panel-header-controls {
align-items: center;
display: flex;
gap: 0.5rem;
}
.auto-toggle-btn {
border-radius: var(--radius);
font-size: 0.8rem;
font-weight: 600;
padding: 0.3rem 0.7rem;
transition: background 0.15s, color 0.15s;
}
.auto-toggle-off {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
color: var(--colour-text-muted);
}
.auto-toggle-off:hover {
border-color: var(--colour-accent);
color: var(--colour-accent);
}
.auto-toggle-on {
background: var(--colour-accent);
border: 1px solid var(--colour-accent);
color: #fff;
}
.auto-toggle-on:hover {
opacity: 0.85;
}
/* ── Login Bonus Modal ──────────────────────────────────────────────── */
.login-bonus-modal {