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:
@@ -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 { 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" && <AchievementPanel />}
|
||||
{activeTab === "prestige" && <PrestigePanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user