From 74d1d21419512c601a9f289cd2c0a24c6cad1a5e Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Mar 2026 15:26:41 -0800 Subject: [PATCH] feat: add auto-quest and auto-boss toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/src/components/game/AboutPanel.tsx | 4 + apps/web/src/components/game/BossPanel.tsx | 22 +- apps/web/src/components/game/QuestPanel.tsx | 22 +- apps/web/src/context/GameContext.tsx | 260 ++++++++++++-------- apps/web/src/styles.css | 37 +++ packages/types/src/interfaces/GameState.ts | 4 + 6 files changed, 237 insertions(+), 112 deletions(-) diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx index 14688c7..bd7ced4 100644 --- a/apps/web/src/components/game/AboutPanel.tsx +++ b/apps/web/src/components/game/AboutPanel.tsx @@ -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.", diff --git a/apps/web/src/components/game/BossPanel.tsx b/apps/web/src/components/game/BossPanel.tsx index 52082c5..96ffa7a 100644 --- a/apps/web/src/components/game/BossPanel.tsx +++ b/apps/web/src/components/game/BossPanel.tsx @@ -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(null); const [activeZoneId, setActiveZoneId] = useState("verdant_vale"); const [showLocked, setShowLocked] = useState(true); @@ -187,11 +187,21 @@ export const BossPanel = (): React.JSX.Element => {

Boss Encounters

- { setShowLocked((v) => !v); }} - /> +
+ + { setShowLocked((v) => !v); }} + /> +
{ - 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 => {

Quests

- { setShowLocked((v) => !v); }} - /> +
+ + { setShowLocked((v) => !v); }} + /> +
{ + 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; /** 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([]); const signatureRef = useRef(localStorage.getItem("elysium_save_signature")); const isAutoPrestigingRef = useRef(false); + const isAutoBossingRef = useRef(false); const reloadRef = useRef<() => Promise>(() => Promise.resolve()); const [newCodexEntryIds, setNewCodexEntryIds] = useState([]); const codexProcessedRef = useRef>(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, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index ee805c7..63ca4ed 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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 { diff --git a/packages/types/src/interfaces/GameState.ts b/packages/types/src/interfaces/GameState.ts index 125ebb0..f34ba04 100644 --- a/packages/types/src/interfaces/GameState.ts +++ b/packages/types/src/interfaces/GameState.ts @@ -39,4 +39,8 @@ export interface GameState { apotheosis?: ApotheosisData; /** Exploration and crafting state — optional for backwards compatibility */ exploration?: ExplorationState; + /** When true, the tick engine automatically starts the highest-zone available quest */ + autoQuest?: boolean; + /** When true, the tick engine automatically challenges the highest available boss */ + autoBoss?: boolean; }