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