generated from nhcarrigan/template
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:
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user