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.
This commit is contained in:
2026-03-06 22:22:18 -08:00
committed by Naomi Carrigan
parent a7d4b72805
commit aaeece1a18
15 changed files with 462 additions and 8 deletions
+4 -4
View File
@@ -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. - [ ] **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. - [ ] **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 ## 📊 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. - [ ] **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) ## 💜 Priority Order (Suggested)
1. ~~Offline earnings~~ 1. ~~Offline earnings~~
2. Statistics panel (low effort, high satisfaction) 2. ~~Statistics panel~~
3. Daily challenges (retention driver) 3. ~~Daily challenges~~
4. Boss first-kill bounties (easy content win) 4. Boss first-kill bounties (easy content win)
5. Milestone prestige bonuses (easy content win) 5. Milestone prestige bonuses (easy content win)
6. Equipment set bonuses (medium effort) 6. Equipment set bonuses (medium effort)
+25
View File
@@ -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 },
];
+12
View File
@@ -2,6 +2,7 @@ import type { BossChallengeResponse, GameState } from "@elysium/types";
import { Hono } from "hono"; import { Hono } from "hono";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
export const bossRouter = new Hono(); 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 = { rewards = {
gold: boss.goldReward, gold: boss.goldReward,
essence: boss.essenceReward, essence: boss.essenceReward,
+4
View File
@@ -6,6 +6,7 @@ import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.js";
import { DEFAULT_ADVENTURERS } from "../data/adventurers.js"; import { DEFAULT_ADVENTURERS } from "../data/adventurers.js";
import { DEFAULT_EQUIPMENT } from "../data/equipment.js"; import { DEFAULT_EQUIPMENT } from "../data/equipment.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
import { calculateOfflineEarnings } from "../services/offlineProgress.js"; import { calculateOfflineEarnings } from "../services/offlineProgress.js";
const RESOURCE_CAP = 1e300; const RESOURCE_CAP = 1e300;
@@ -327,6 +328,9 @@ gameRouter.get("/load", async (context) => {
state.resources.essence += offlineEssence; state.resources.essence += offlineEssence;
} }
// Generate or reset daily challenges if a new day has begun
state.dailyChallenges = getOrResetDailyChallenges(state);
state.lastTickAt = now; state.lastTickAt = now;
if (needsBackfill || offlineGold > 0 || offlineEssence > 0) { if (needsBackfill || offlineGold > 0 || offlineEssence > 0) {
+21 -1
View File
@@ -3,6 +3,7 @@ import { Hono } from "hono";
import { prisma } from "../db/client.js"; import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { DEFAULT_PRESTIGE_UPGRADES } from "../data/prestigeUpgrades.js"; import { DEFAULT_PRESTIGE_UPGRADES } from "../data/prestigeUpgrades.js";
import { updateChallengeProgress } from "../services/dailyChallenges.js";
import { import {
buildPostPrestigeState, buildPostPrestigeState,
computeRunestoneMultipliers, 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( const { newState, newPrestigeData, runestonesEarned } = buildPostPrestigeState(
state, state,
characterName, 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(); const now = Date.now();
await prisma.gameState.update({ await prisma.gameState.update({
where: { discordId }, where: { discordId },
data: { state: newState as object, updatedAt: now }, data: { state: finalState as object, updatedAt: now },
}); });
await prisma.player.update({ await prisma.player.update({
+103
View File
@@ -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 = <T>(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 };
};
@@ -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 <section className="panel"><p>Loading...</p></section>;
const { dailyChallenges } = state;
if (!dailyChallenges) {
return (
<section className="panel daily-challenge-panel">
<h2>📅 Daily Challenges</h2>
<p className="daily-challenge-subtitle">Load the game to generate today's challenges!</p>
</section>
);
}
const completedCount = dailyChallenges.challenges.filter((c) => c.completed).length;
return (
<section className="panel daily-challenge-panel">
<h2>📅 Daily Challenges</h2>
<div className="daily-challenge-header">
<p className="daily-challenge-subtitle">
Complete challenges for bonus 💎 crystals! Resets in{" "}
<strong>{formatTimeUntilReset()}</strong> (PST midnight).
</p>
<p className="daily-challenge-progress">
{completedCount} / {dailyChallenges.challenges.length} completed
</p>
</div>
<div className="daily-challenge-list">
{dailyChallenges.challenges.map((challenge) => {
const progressPercent = Math.min(
100,
Math.floor((challenge.progress / challenge.target) * 100),
);
return (
<div
key={challenge.id}
className={`daily-challenge-card ${challenge.completed ? "completed" : ""}`}
>
<div className="daily-challenge-info">
<h3 className="daily-challenge-label">{challenge.label}</h3>
<p className="daily-challenge-reward">
Reward: <strong>💎 {formatNumber(challenge.rewardCrystals)} crystals</strong>
</p>
</div>
<div className="daily-challenge-right">
{challenge.completed ? (
<span className="daily-challenge-done"> Complete!</span>
) : (
<>
<p className="daily-challenge-count">
{formatNumber(challenge.progress)} / {formatNumber(challenge.target)}
</p>
<div className="daily-challenge-bar-track">
<div
className="daily-challenge-bar-fill"
style={{ width: `${String(progressPercent)}%` }}
/>
</div>
</>
)}
</div>
</div>
);
})}
</div>
</section>
);
};
+4 -1
View File
@@ -14,8 +14,9 @@ import { PrestigePanel } from "./PrestigePanel.js";
import { QuestPanel } from "./QuestPanel.js"; import { QuestPanel } from "./QuestPanel.js";
import { StatisticsPanel } from "./StatisticsPanel.js"; import { StatisticsPanel } from "./StatisticsPanel.js";
import { UpgradePanel } from "./UpgradePanel.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 }[] = [ const TABS: { id: Tab; label: string }[] = [
{ id: "adventurers", label: "⚔️ Adventurers" }, { id: "adventurers", label: "⚔️ Adventurers" },
@@ -26,6 +27,7 @@ const TABS: { id: Tab; label: string }[] = [
{ id: "achievements", label: "🏆 Achievements" }, { id: "achievements", label: "🏆 Achievements" },
{ id: "prestige", label: "⭐ Prestige" }, { id: "prestige", label: "⭐ Prestige" },
{ id: "statistics", label: "📊 Statistics" }, { id: "statistics", label: "📊 Statistics" },
{ id: "daily", label: "📅 Daily" },
]; ];
export const GameLayout = (): React.JSX.Element => { export const GameLayout = (): React.JSX.Element => {
@@ -101,6 +103,7 @@ export const GameLayout = (): React.JSX.Element => {
{activeTab === "achievements" && <AchievementPanel />} {activeTab === "achievements" && <AchievementPanel />}
{activeTab === "prestige" && <PrestigePanel />} {activeTab === "prestige" && <PrestigePanel />}
{activeTab === "statistics" && <StatisticsPanel />} {activeTab === "statistics" && <StatisticsPanel />}
{activeTab === "daily" && <DailyChallengePanel />}
</div> </div>
</main> </main>
</div> </div>
+16 -1
View File
@@ -14,6 +14,7 @@ import {
saveGame, saveGame,
} from "../api/client.js"; } from "../api/client.js";
import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js"; import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.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; if (!prev) return prev;
const clickPower = calculateClickPower(prev); const clickPower = calculateClickPower(prev);
const newGold = Math.min(prev.resources.gold + clickPower, RESOURCE_CAP); 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 { return {
...prev, ...prev,
resources: { ...prev.resources, gold: newGold }, resources: {
...prev.resources,
gold: newGold,
crystals: Math.min(prev.resources.crystals + challengeCrystals, RESOURCE_CAP),
},
player: { player: {
...prev.player, ...prev.player,
totalGoldEarned: prev.player.totalGoldEarned + clickPower, totalGoldEarned: prev.player.totalGoldEarned + clickPower,
totalClicks: prev.player.totalClicks + 1, totalClicks: prev.player.totalClicks + 1,
}, },
dailyChallenges: updatedDailyChallenges,
}; };
}); });
}, []); }, []);
+20 -1
View File
@@ -1,4 +1,5 @@
import type { Achievement, Equipment, GameState } from "@elysium/types"; 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 * 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 newGold = capResource(state.resources.gold + goldGained + questGold);
const newEssence = capResource(state.resources.essence + essenceGained + questEssence); const newEssence = capResource(state.resources.essence + essenceGained + questEssence);
const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold; const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold;
@@ -208,8 +226,9 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
...state.resources, ...state.resources,
gold: newGold, gold: newGold,
essence: newEssence, essence: newEssence,
crystals: capResource(state.resources.crystals + questCrystals), crystals: capResource(state.resources.crystals + questCrystals + challengeCrystals),
}, },
dailyChallenges: updatedDailyChallenges,
player: { player: {
...state.player, ...state.player,
totalGoldEarned: newTotalGoldEarned, totalGoldEarned: newTotalGoldEarned,
+105
View File
@@ -1837,3 +1837,108 @@ body {
height: 100vh; height: 100vh;
justify-content: center; 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;
}
+32
View File
@@ -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 };
};
+5
View File
@@ -22,6 +22,11 @@ export type {
UpdateProfileResponse, UpdateProfileResponse,
} from "./interfaces/Api.js"; } from "./interfaces/Api.js";
export type { Boss, BossStatus } from "./interfaces/Boss.js"; export type { Boss, BossStatus } from "./interfaces/Boss.js";
export type {
DailyChallenge,
DailyChallengeState,
DailyChallengeType,
} from "./interfaces/DailyChallenge.js";
export type { export type {
Equipment, Equipment,
EquipmentBonus, EquipmentBonus,
@@ -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[];
}
@@ -1,6 +1,7 @@
import type { Achievement } from "./Achievement.js"; import type { Achievement } from "./Achievement.js";
import type { Adventurer } from "./Adventurer.js"; import type { Adventurer } from "./Adventurer.js";
import type { Boss } from "./Boss.js"; import type { Boss } from "./Boss.js";
import type { DailyChallengeState } from "./DailyChallenge.js";
import type { Equipment } from "./Equipment.js"; import type { Equipment } from "./Equipment.js";
import type { Player } from "./Player.js"; import type { Player } from "./Player.js";
import type { PrestigeData } from "./Prestige.js"; import type { PrestigeData } from "./Prestige.js";
@@ -24,4 +25,6 @@ export interface GameState {
baseClickPower: number; baseClickPower: number;
/** Unix timestamp of the last client-side tick */ /** Unix timestamp of the last client-side tick */
lastTickAt: number; lastTickAt: number;
/** Daily challenge progress — optional for backwards compatibility with old saves */
dailyChallenges?: DailyChallengeState;
} }