From aaeece1a183f673d305d7d9f79d48ea300b5a1b9 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 22:22:18 -0800 Subject: [PATCH] feat: add daily challenges system Three PST-midnight-resetting challenges generated deterministically per day from click, boss, quest, and prestige types. Progress tracked server-side for bosses and prestige, client-side for clicks and quests. Crystal rewards awarded on completion and preserved through prestige resets. --- IDEAS.md | 8 +- apps/api/src/data/dailyChallenges.ts | 25 +++++ apps/api/src/routes/boss.ts | 12 ++ apps/api/src/routes/game.ts | 4 + apps/api/src/routes/prestige.ts | 22 +++- apps/api/src/services/dailyChallenges.ts | 103 +++++++++++++++++ .../components/game/DailyChallengePanel.tsx | 91 +++++++++++++++ apps/web/src/components/game/GameLayout.tsx | 5 +- apps/web/src/context/GameContext.tsx | 17 ++- apps/web/src/engine/tick.ts | 21 +++- apps/web/src/styles.css | 105 ++++++++++++++++++ apps/web/src/utils/dailyChallenges.ts | 32 ++++++ packages/types/src/index.ts | 5 + .../types/src/interfaces/DailyChallenge.ts | 17 +++ packages/types/src/interfaces/GameState.ts | 3 + 15 files changed, 462 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/data/dailyChallenges.ts create mode 100644 apps/api/src/services/dailyChallenges.ts create mode 100644 apps/web/src/components/game/DailyChallengePanel.tsx create mode 100644 apps/web/src/utils/dailyChallenges.ts create mode 100644 packages/types/src/interfaces/DailyChallenge.ts diff --git a/IDEAS.md b/IDEAS.md index 2f89fbc..36a9cb3 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -10,7 +10,7 @@ A running list of planned features and content additions. Strike through items a - [ ] **Second prestige layer (Transcendence)** — Unlocked after ~10 prestiges. Sacrifice all runestones for a new currency ("Echoes"?). Echoes are permanent account-wide currency that persist across prestiges. Has its own upgrade tree with truly game-changing bonuses. Gives endgame players a long-term goal. -- [ ] **Daily challenges** — Three rotating objectives each day (e.g. kill X boss, earn X gold this run, complete X quests). Reward bonus runestones. Encourages daily logins even when idling comfortably. +- [x] **Daily challenges** — Three rotating objectives each day (e.g. kill X boss, earn X gold this run, complete X quests). Reward bonus crystals. Encourages daily logins even when idling comfortably. - [ ] **Boss first-kill bounties** — Defeating a boss for the very first time grants a one-time runestone bonus. Rewards exploration and makes conquering a new zone feel extra satisfying. @@ -30,7 +30,7 @@ A running list of planned features and content additions. Strike through items a ## 📊 UI / Statistics -- [ ] **Statistics panel** — All-time totals: gold earned across all runs, total prestiges, bosses defeated, quests completed, time played. Idle game players love seeing big numbers about their big numbers. +- [x] **Statistics panel** — All-time totals: gold earned across all runs, total prestiges, bosses defeated, quests completed, time played. Idle game players love seeing big numbers about their big numbers. - [ ] **Last cloud save date + Force cloud save button** — Display when the last cloud save occurred (always visible, e.g. in the ResourceBar). Include a manual "Force Save" button for peace of mind. @@ -39,8 +39,8 @@ A running list of planned features and content additions. Strike through items a ## 💜 Priority Order (Suggested) 1. ~~Offline earnings~~ ✅ -2. Statistics panel (low effort, high satisfaction) -3. Daily challenges (retention driver) +2. ~~Statistics panel~~ ✅ +3. ~~Daily challenges~~ ✅ 4. Boss first-kill bounties (easy content win) 5. Milestone prestige bonuses (easy content win) 6. Equipment set bonuses (medium effort) diff --git a/apps/api/src/data/dailyChallenges.ts b/apps/api/src/data/dailyChallenges.ts new file mode 100644 index 0000000..e54a828 --- /dev/null +++ b/apps/api/src/data/dailyChallenges.ts @@ -0,0 +1,25 @@ +import type { DailyChallengeType } from "@elysium/types"; + +interface DailyChallengeTemplate { + type: DailyChallengeType; + label: string; + target: number; + rewardCrystals: number; +} + +export const DAILY_CHALLENGE_TEMPLATES: DailyChallengeTemplate[] = [ + // Clicks — always requires active play + { type: "clicks", label: "Click 500 times", target: 500, rewardCrystals: 50 }, + { type: "clicks", label: "Click 1,000 times", target: 1_000, rewardCrystals: 100 }, + { type: "clicks", label: "Click 5,000 times", target: 5_000, rewardCrystals: 300 }, + // Boss defeats — requires active combat + { type: "bossesDefeated", label: "Defeat 1 boss", target: 1, rewardCrystals: 75 }, + { type: "bossesDefeated", label: "Defeat 3 bosses", target: 3, rewardCrystals: 200 }, + { type: "bossesDefeated", label: "Defeat 5 bosses", target: 5, rewardCrystals: 400 }, + // Quest completions — requires starting quests + { type: "questsCompleted", label: "Complete 3 quests", target: 3, rewardCrystals: 100 }, + { type: "questsCompleted", label: "Complete 5 quests", target: 5, rewardCrystals: 200 }, + { type: "questsCompleted", label: "Complete 10 quests", target: 10, rewardCrystals: 400 }, + // Prestige — the big one + { type: "prestige", label: "Prestige once", target: 1, rewardCrystals: 750 }, +]; diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index e7c9105..42d268c 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -2,6 +2,7 @@ import type { BossChallengeResponse, GameState } from "@elysium/types"; import { Hono } from "hono"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; +import { updateChallengeProgress } from "../services/dailyChallenges.js"; export const bossRouter = new Hono(); @@ -173,6 +174,17 @@ bossRouter.post("/challenge", async (context) => { } } + // Update daily boss challenge progress + if (state.dailyChallenges) { + const { updatedChallenges, crystalsAwarded } = updateChallengeProgress( + state.dailyChallenges, + "bossesDefeated", + 1, + ); + state.dailyChallenges = updatedChallenges; + state.resources.crystals += crystalsAwarded; + } + rewards = { gold: boss.goldReward, essence: boss.essenceReward, diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index be4ea49..066f477 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -6,6 +6,7 @@ import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.js"; import { DEFAULT_ADVENTURERS } from "../data/adventurers.js"; import { DEFAULT_EQUIPMENT } from "../data/equipment.js"; import { authMiddleware } from "../middleware/auth.js"; +import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js"; const RESOURCE_CAP = 1e300; @@ -327,6 +328,9 @@ gameRouter.get("/load", async (context) => { state.resources.essence += offlineEssence; } + // Generate or reset daily challenges if a new day has begun + state.dailyChallenges = getOrResetDailyChallenges(state); + state.lastTickAt = now; if (needsBackfill || offlineGold > 0 || offlineEssence > 0) { diff --git a/apps/api/src/routes/prestige.ts b/apps/api/src/routes/prestige.ts index 9365394..449ce84 100644 --- a/apps/api/src/routes/prestige.ts +++ b/apps/api/src/routes/prestige.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { DEFAULT_PRESTIGE_UPGRADES } from "../data/prestigeUpgrades.js"; +import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { buildPostPrestigeState, computeRunestoneMultipliers, @@ -37,15 +38,34 @@ prestigeRouter.post("/", async (context) => { ); } + // Update daily prestige challenge progress before resetting the run + let updatedDailyChallenges = state.dailyChallenges; + let challengeCrystals = 0; + if (updatedDailyChallenges) { + const result = updateChallengeProgress(updatedDailyChallenges, "prestige", 1); + updatedDailyChallenges = result.updatedChallenges; + challengeCrystals = result.crystalsAwarded; + } + const { newState, newPrestigeData, runestonesEarned } = buildPostPrestigeState( state, characterName, ); + // Preserve daily challenges across the prestige reset and apply any crystal rewards + const finalState: GameState = { + ...newState, + dailyChallenges: updatedDailyChallenges, + resources: { + ...newState.resources, + crystals: newState.resources.crystals + challengeCrystals, + }, + }; + const now = Date.now(); await prisma.gameState.update({ where: { discordId }, - data: { state: newState as object, updatedAt: now }, + data: { state: finalState as object, updatedAt: now }, }); await prisma.player.update({ diff --git a/apps/api/src/services/dailyChallenges.ts b/apps/api/src/services/dailyChallenges.ts new file mode 100644 index 0000000..7e43abe --- /dev/null +++ b/apps/api/src/services/dailyChallenges.ts @@ -0,0 +1,103 @@ +import type { + DailyChallenge, + DailyChallengeState, + DailyChallengeType, + GameState, +} from "@elysium/types"; +import { DAILY_CHALLENGE_TEMPLATES } from "../data/dailyChallenges.js"; + +// Use the server's PST/PDT timezone so challenges roll over at PST midnight +const getTodayString = (): string => + new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles" }).format(new Date()); + +/** Simple deterministic pseudo-random based on a numeric seed. */ +const seededRandom = (seed: number): number => { + const x = Math.sin(seed + 1) * 10_000; + return x - Math.floor(x); +}; + +/** Converts a date string into a stable numeric seed. */ +const dateSeed = (dateStr: string): number => + dateStr.split("").reduce((acc, char, i) => acc + char.charCodeAt(0) * (i + 1), 0); + +/** Deterministically shuffles an array using a numeric seed. */ +const shuffleWithSeed = (arr: T[], seed: number): T[] => { + const result = [...arr]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(seededRandom(seed + i) * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +}; + +const CHALLENGE_TYPES: DailyChallengeType[] = [ + "clicks", + "bossesDefeated", + "questsCompleted", + "prestige", +]; + +/** + * Generates 3 daily challenges for the given date string, deterministically. + * Picks one challenge from 3 different randomly-selected types. + */ +export const generateDailyChallenges = (dateStr: string): DailyChallenge[] => { + const seed = dateSeed(dateStr); + const selectedTypes = shuffleWithSeed([...CHALLENGE_TYPES], seed).slice(0, 3); + + return selectedTypes.map((type, index) => { + const templates = DAILY_CHALLENGE_TEMPLATES.filter((t) => t.type === type); + const templateIndex = Math.floor(seededRandom(seed + index * 100) * templates.length); + const template = templates[templateIndex]; + + return { + id: `${dateStr}_${type}`, + type: template.type, + label: template.label, + target: template.target, + progress: 0, + completed: false, + rewardCrystals: template.rewardCrystals, + }; + }); +}; + +/** + * Returns the current daily challenge state, generating fresh challenges if + * the stored date doesn't match today (i.e. a new day has begun). + */ +export const getOrResetDailyChallenges = (state: GameState): DailyChallengeState => { + const today = getTodayString(); + if (state.dailyChallenges?.date === today) { + return state.dailyChallenges; + } + return { date: today, challenges: generateDailyChallenges(today) }; +}; + +/** + * Increments progress for challenges matching the given type. + * Returns the updated challenge state and total crystals awarded for newly completed challenges. + */ +export const updateChallengeProgress = ( + challengeState: DailyChallengeState, + type: DailyChallengeType, + amount: number, +): { updatedChallenges: DailyChallengeState; crystalsAwarded: number } => { + let crystalsAwarded = 0; + + const updatedChallenges: DailyChallengeState = { + ...challengeState, + challenges: challengeState.challenges.map((challenge) => { + if (challenge.type !== type || challenge.completed) return challenge; + + const newProgress = Math.min(challenge.progress + amount, challenge.target); + const nowCompleted = newProgress >= challenge.target; + + if (nowCompleted) crystalsAwarded += challenge.rewardCrystals; + + return { ...challenge, progress: newProgress, completed: nowCompleted }; + }), + }; + + return { updatedChallenges, crystalsAwarded }; +}; diff --git a/apps/web/src/components/game/DailyChallengePanel.tsx b/apps/web/src/components/game/DailyChallengePanel.tsx new file mode 100644 index 0000000..b72b945 --- /dev/null +++ b/apps/web/src/components/game/DailyChallengePanel.tsx @@ -0,0 +1,91 @@ +import { useGame } from "../../context/GameContext.js"; + +const formatTimeUntilReset = (): string => { + const now = new Date(); + // Mirror the server's PST/PDT-based rollover: challenges reset at PST midnight + const nowAsPST = new Date(now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" })); + const tomorrowMidnightPST = new Date(nowAsPST); + tomorrowMidnightPST.setDate(tomorrowMidnightPST.getDate() + 1); + tomorrowMidnightPST.setHours(0, 0, 0, 0); + const pstOffset = nowAsPST.getTime() - now.getTime(); + const resetAt = new Date(tomorrowMidnightPST.getTime() - pstOffset); + const msRemaining = resetAt.getTime() - now.getTime(); + const hoursRemaining = Math.floor(msRemaining / (1000 * 60 * 60)); + const minutesRemaining = Math.floor((msRemaining % (1000 * 60 * 60)) / (1000 * 60)); + return `${String(hoursRemaining)}h ${String(minutesRemaining)}m`; +}; + +export const DailyChallengePanel = (): React.JSX.Element => { + const { state, formatNumber } = useGame(); + + if (!state) return

Loading...

; + + const { dailyChallenges } = state; + + if (!dailyChallenges) { + return ( +
+

📅 Daily Challenges

+

Load the game to generate today's challenges!

+
+ ); + } + + const completedCount = dailyChallenges.challenges.filter((c) => c.completed).length; + + return ( +
+

📅 Daily Challenges

+
+

+ Complete challenges for bonus 💎 crystals! Resets in{" "} + {formatTimeUntilReset()} (PST midnight). +

+

+ {completedCount} / {dailyChallenges.challenges.length} completed +

+
+ +
+ {dailyChallenges.challenges.map((challenge) => { + const progressPercent = Math.min( + 100, + Math.floor((challenge.progress / challenge.target) * 100), + ); + + return ( +
+
+

{challenge.label}

+

+ Reward: 💎 {formatNumber(challenge.rewardCrystals)} crystals +

+
+ +
+ {challenge.completed ? ( + ✅ Complete! + ) : ( + <> +

+ {formatNumber(challenge.progress)} / {formatNumber(challenge.target)} +

+
+
+
+ + )} +
+
+ ); + })} +
+
+ ); +}; diff --git a/apps/web/src/components/game/GameLayout.tsx b/apps/web/src/components/game/GameLayout.tsx index 3d8f973..684f530 100644 --- a/apps/web/src/components/game/GameLayout.tsx +++ b/apps/web/src/components/game/GameLayout.tsx @@ -14,8 +14,9 @@ import { PrestigePanel } from "./PrestigePanel.js"; import { QuestPanel } from "./QuestPanel.js"; import { StatisticsPanel } from "./StatisticsPanel.js"; import { UpgradePanel } from "./UpgradePanel.js"; +import { DailyChallengePanel } from "./DailyChallengePanel.js"; -type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "statistics"; +type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "statistics" | "daily"; const TABS: { id: Tab; label: string }[] = [ { id: "adventurers", label: "⚔️ Adventurers" }, @@ -26,6 +27,7 @@ const TABS: { id: Tab; label: string }[] = [ { id: "achievements", label: "🏆 Achievements" }, { id: "prestige", label: "⭐ Prestige" }, { id: "statistics", label: "📊 Statistics" }, + { id: "daily", label: "📅 Daily" }, ]; export const GameLayout = (): React.JSX.Element => { @@ -101,6 +103,7 @@ export const GameLayout = (): React.JSX.Element => { {activeTab === "achievements" && } {activeTab === "prestige" && } {activeTab === "statistics" && } + {activeTab === "daily" && } diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx index ca5d6b3..d34e284 100644 --- a/apps/web/src/context/GameContext.tsx +++ b/apps/web/src/context/GameContext.tsx @@ -14,6 +14,7 @@ import { saveGame, } from "../api/client.js"; import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js"; +import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js"; @@ -245,14 +246,28 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React if (!prev) return prev; const clickPower = calculateClickPower(prev); const newGold = Math.min(prev.resources.gold + clickPower, RESOURCE_CAP); + + let updatedDailyChallenges = prev.dailyChallenges; + let challengeCrystals = 0; + if (updatedDailyChallenges) { + const result = updateChallengeProgress(updatedDailyChallenges, "clicks", 1); + updatedDailyChallenges = result.updatedChallenges; + challengeCrystals = result.crystalsAwarded; + } + return { ...prev, - resources: { ...prev.resources, gold: newGold }, + resources: { + ...prev.resources, + gold: newGold, + crystals: Math.min(prev.resources.crystals + challengeCrystals, RESOURCE_CAP), + }, player: { ...prev.player, totalGoldEarned: prev.player.totalGoldEarned + clickPower, totalClicks: prev.player.totalClicks + 1, }, + dailyChallenges: updatedDailyChallenges, }; }); }, []); diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 0c2c52b..a268431 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -1,4 +1,5 @@ import type { Achievement, Equipment, GameState } from "@elysium/types"; +import { updateChallengeProgress } from "../utils/dailyChallenges.js"; /** * Checks all achievements against the current game state and returns an updated @@ -198,6 +199,23 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => }); } + // Count quests newly completed this tick and update daily challenge progress + const newlyCompletedQuestCount = updatedQuests.filter( + (q, i) => q.status === "completed" && state.quests[i]?.status !== "completed", + ).length; + + let updatedDailyChallenges = state.dailyChallenges; + let challengeCrystals = 0; + if (updatedDailyChallenges && newlyCompletedQuestCount > 0) { + const result = updateChallengeProgress( + updatedDailyChallenges, + "questsCompleted", + newlyCompletedQuestCount, + ); + updatedDailyChallenges = result.updatedChallenges; + challengeCrystals = result.crystalsAwarded; + } + const newGold = capResource(state.resources.gold + goldGained + questGold); const newEssence = capResource(state.resources.essence + essenceGained + questEssence); const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold; @@ -208,8 +226,9 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState => ...state.resources, gold: newGold, essence: newEssence, - crystals: capResource(state.resources.crystals + questCrystals), + crystals: capResource(state.resources.crystals + questCrystals + challengeCrystals), }, + dailyChallenges: updatedDailyChallenges, player: { ...state.player, totalGoldEarned: newTotalGoldEarned, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index b151df0..93fc982 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1837,3 +1837,108 @@ body { height: 100vh; justify-content: center; } + +/* ===================== DAILY CHALLENGES ===================== */ + +.daily-challenge-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.daily-challenge-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; +} + +.daily-challenge-subtitle { + color: var(--colour-text-muted); + font-size: 0.85rem; + margin: 0; +} + +.daily-challenge-progress { + color: var(--colour-accent); + font-size: 0.85rem; + font-weight: bold; + white-space: nowrap; + margin: 0; +} + +.daily-challenge-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.daily-challenge-card { + background: var(--colour-bg-secondary); + border: 1px solid var(--colour-border); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + transition: border-color 0.2s ease; +} + +.daily-challenge-card.completed { + border-color: var(--colour-success, #4caf50); + opacity: 0.8; +} + +.daily-challenge-info { + flex: 1; + min-width: 0; +} + +.daily-challenge-label { + font-size: 0.95rem; + font-weight: bold; + margin: 0 0 0.25rem; +} + +.daily-challenge-reward { + color: var(--colour-text-muted); + font-size: 0.8rem; + margin: 0; +} + +.daily-challenge-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + min-width: 120px; +} + +.daily-challenge-done { + color: var(--colour-success, #4caf50); + font-size: 0.9rem; + font-weight: bold; +} + +.daily-challenge-count { + color: var(--colour-text-muted); + font-size: 0.8rem; + margin: 0; +} + +.daily-challenge-bar-track { + background: var(--colour-bg-primary, #1a1a2e); + border-radius: 4px; + height: 6px; + width: 120px; + overflow: hidden; +} + +.daily-challenge-bar-fill { + background: var(--colour-accent); + border-radius: 4px; + height: 100%; + transition: width 0.3s ease; +} diff --git a/apps/web/src/utils/dailyChallenges.ts b/apps/web/src/utils/dailyChallenges.ts new file mode 100644 index 0000000..88c814c --- /dev/null +++ b/apps/web/src/utils/dailyChallenges.ts @@ -0,0 +1,32 @@ +import type { DailyChallengeState, DailyChallengeType } from "@elysium/types"; + +/** + * Increments progress for daily challenges matching the given type. + * Returns the updated challenge state and any crystals awarded for newly-completed challenges. + * + * Note: challenge generation and daily resets are handled server-side only. + * This utility is purely for client-side progress tracking. + */ +export const updateChallengeProgress = ( + challengeState: DailyChallengeState, + type: DailyChallengeType, + amount: number, +): { updatedChallenges: DailyChallengeState; crystalsAwarded: number } => { + let crystalsAwarded = 0; + + const updatedChallenges: DailyChallengeState = { + ...challengeState, + challenges: challengeState.challenges.map((challenge) => { + if (challenge.type !== type || challenge.completed) return challenge; + + const newProgress = Math.min(challenge.progress + amount, challenge.target); + const nowCompleted = newProgress >= challenge.target; + + if (nowCompleted) crystalsAwarded += challenge.rewardCrystals; + + return { ...challenge, progress: newProgress, completed: nowCompleted }; + }), + }; + + return { updatedChallenges, crystalsAwarded }; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 29b0ce5..68226e7 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -22,6 +22,11 @@ export type { UpdateProfileResponse, } from "./interfaces/Api.js"; export type { Boss, BossStatus } from "./interfaces/Boss.js"; +export type { + DailyChallenge, + DailyChallengeState, + DailyChallengeType, +} from "./interfaces/DailyChallenge.js"; export type { Equipment, EquipmentBonus, diff --git a/packages/types/src/interfaces/DailyChallenge.ts b/packages/types/src/interfaces/DailyChallenge.ts new file mode 100644 index 0000000..ec8edd8 --- /dev/null +++ b/packages/types/src/interfaces/DailyChallenge.ts @@ -0,0 +1,17 @@ +export type DailyChallengeType = "clicks" | "bossesDefeated" | "questsCompleted" | "prestige"; + +export interface DailyChallenge { + id: string; + type: DailyChallengeType; + label: string; + target: number; + progress: number; + completed: boolean; + rewardCrystals: number; +} + +export interface DailyChallengeState { + /** ISO date string (e.g. "2026-03-06") used to detect when to reset */ + date: string; + challenges: DailyChallenge[]; +} diff --git a/packages/types/src/interfaces/GameState.ts b/packages/types/src/interfaces/GameState.ts index 5274ba6..16a6eb4 100644 --- a/packages/types/src/interfaces/GameState.ts +++ b/packages/types/src/interfaces/GameState.ts @@ -1,6 +1,7 @@ import type { Achievement } from "./Achievement.js"; import type { Adventurer } from "./Adventurer.js"; import type { Boss } from "./Boss.js"; +import type { DailyChallengeState } from "./DailyChallenge.js"; import type { Equipment } from "./Equipment.js"; import type { Player } from "./Player.js"; import type { PrestigeData } from "./Prestige.js"; @@ -24,4 +25,6 @@ export interface GameState { baseClickPower: number; /** Unix timestamp of the last client-side tick */ lastTickAt: number; + /** Daily challenge progress — optional for backwards compatibility with old saves */ + dailyChallenges?: DailyChallengeState; }