feat: add daily login bonus system with streak tracking

Awards escalating gold rewards each consecutive day (Day 7 grants bonus
crystals). Streak resets on missed days; a week multiplier boosts all
rewards for longer streaks. Shows a modal on first daily load and
displays the current streak on the character sheet.
This commit is contained in:
2026-03-07 15:18:59 -08:00
committed by Naomi Carrigan
parent 3515de62ee
commit bec972aed1
11 changed files with 367 additions and 6 deletions
+2
View File
@@ -33,6 +33,8 @@ model Player {
lifetimeQuestsCompleted Float @default(0) lifetimeQuestsCompleted Float @default(0)
lifetimeAdventurersRecruited Float @default(0) lifetimeAdventurersRecruited Float @default(0)
lifetimeAchievementsUnlocked Float @default(0) lifetimeAchievementsUnlocked Float @default(0)
lastLoginDate String?
loginStreak Int @default(1)
} }
model GameState { model GameState {
+19
View File
@@ -0,0 +1,19 @@
export interface DayReward {
day: number;
goldBase: number;
crystals?: number;
}
/**
* Rewards for days 17 of a login streak. The cycle repeats every 7 days
* with a multiplier equal to the week number (week 1 = ×1, week 2 = ×2, etc.).
*/
export const DAILY_REWARDS: DayReward[] = [
{ day: 1, goldBase: 500 },
{ day: 2, goldBase: 1_000 },
{ day: 3, goldBase: 2_500 },
{ day: 4, goldBase: 5_000 },
{ day: 5, goldBase: 10_000 },
{ day: 6, goldBase: 25_000 },
{ day: 7, goldBase: 50_000, crystals: 5 },
];
+45 -3
View File
@@ -1,4 +1,4 @@
import type { GameState, SaveRequest } from "@elysium/types"; import type { GameState, LoginBonusResult, SaveRequest } from "@elysium/types";
import { computeSetBonuses } from "@elysium/types"; import { computeSetBonuses } from "@elysium/types";
import { createHmac } from "node:crypto"; import { createHmac } from "node:crypto";
import { Hono } from "hono"; import { Hono } from "hono";
@@ -11,6 +11,7 @@ import { DEFAULT_EQUIPMENT } from "../data/equipment.js";
import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js"; import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { DEFAULT_EXPLORATIONS } from "../data/explorations.js"; import { DEFAULT_EXPLORATIONS } from "../data/explorations.js";
import { INITIAL_EXPLORATION } from "../data/initialState.js"; import { INITIAL_EXPLORATION } from "../data/initialState.js";
import { DAILY_REWARDS } from "../data/loginBonus.js";
import { DEFAULT_QUESTS } from "../data/quests.js"; import { DEFAULT_QUESTS } from "../data/quests.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
@@ -357,7 +358,10 @@ gameRouter.use("*", authMiddleware);
gameRouter.get("/load", async (context) => { gameRouter.get("/load", async (context) => {
const discordId = context.get("discordId") as string; const discordId = context.get("discordId") as string;
const record = await prisma.gameState.findUnique({ where: { discordId } }); const [record, playerRecord] = await Promise.all([
prisma.gameState.findUnique({ where: { discordId } }),
prisma.player.findUnique({ where: { discordId } }),
]);
if (!record) { if (!record) {
return context.json({ error: "No save found" }, 404); return context.json({ error: "No save found" }, 404);
@@ -644,6 +648,44 @@ gameRouter.get("/load", async (context) => {
// Generate or reset daily challenges if a new day has begun // Generate or reset daily challenges if a new day has begun
state.dailyChallenges = getOrResetDailyChallenges(state); state.dailyChallenges = getOrResetDailyChallenges(state);
// Daily login bonus — award once per calendar day (UTC)
const todayUTC = new Date().toISOString().slice(0, 10);
const yesterdayUTC = new Date(now - 86_400_000).toISOString().slice(0, 10);
let loginBonus: LoginBonusResult | null = null;
let loginStreak = playerRecord?.loginStreak ?? 1;
if (playerRecord && playerRecord.lastLoginDate !== todayUTC) {
const prevStreak = playerRecord.loginStreak ?? 0;
const newStreak = playerRecord.lastLoginDate === yesterdayUTC ? prevStreak + 1 : 1;
const dayIndex = (newStreak - 1) % 7;
const weekMultiplier = Math.floor((newStreak - 1) / 7) + 1;
const reward = DAILY_REWARDS[dayIndex];
const goldEarned = (reward?.goldBase ?? 500) * weekMultiplier;
const crystalsEarned = ((reward?.crystals ?? 0) * weekMultiplier);
state.resources.gold = Math.min(state.resources.gold + goldEarned, RESOURCE_CAP);
state.player.totalGoldEarned += goldEarned;
state.resources.crystals = Math.min(state.resources.crystals + crystalsEarned, RESOURCE_CAP);
loginStreak = newStreak;
loginBonus = {
streak: newStreak,
goldEarned,
crystalsEarned,
day: dayIndex + 1,
weekMultiplier,
};
needsBackfill = true;
await prisma.player.update({
where: { discordId },
data: { lastLoginDate: todayUTC, loginStreak: newStreak },
}).catch((err: unknown) => {
const code = (err as { code?: string }).code;
if (code !== "P2034") throw err;
});
}
state.lastTickAt = now; state.lastTickAt = now;
if (needsBackfill || offlineGold > 0 || offlineEssence > 0) { if (needsBackfill || offlineGold > 0 || offlineEssence > 0) {
@@ -660,7 +702,7 @@ gameRouter.get("/load", async (context) => {
const secret = process.env.ANTI_CHEAT_SECRET; const secret = process.env.ANTI_CHEAT_SECRET;
const signature = secret ? computeHmac(JSON.stringify(state), secret) : undefined; const signature = secret ? computeHmac(JSON.stringify(state), secret) : undefined;
return context.json({ state, offlineGold, offlineEssence, offlineSeconds, signature }); return context.json({ state, offlineGold, offlineEssence, offlineSeconds, signature, loginBonus, loginStreak });
}); });
gameRouter.post("/save", async (context) => { gameRouter.post("/save", async (context) => {
@@ -79,6 +79,10 @@ const HOW_TO_PLAY = [
title: "🏆 Leaderboards", title: "🏆 Leaderboards",
body: "Compete with other adventurers on the public Leaderboards page! Categories include Lifetime Gold, Bosses Defeated, Quests Completed, Achievements, Prestige Count, Transcendence Count, and Apotheosis Count. Click any player's row to view their character sheet. You can opt out of appearing on leaderboards via the Privacy section in your profile settings.", body: "Compete with other adventurers on the public Leaderboards page! Categories include Lifetime Gold, Bosses Defeated, Quests Completed, Achievements, Prestige Count, Transcendence Count, and Apotheosis Count. Click any player's row to view their character sheet. You can opt out of appearing on leaderboards via the Privacy section in your profile settings.",
}, },
{
title: "🔥 Daily Login Bonus",
body: "Log in every day to earn escalating rewards! Each consecutive day awards more gold, and the 7th day of your streak grants bonus crystals. Your streak resets if you miss a day. A week multiplier increases all rewards the longer your overall streak runs. Your current streak is displayed on your character sheet.",
},
{ {
title: "☁️ Cloud Saves", title: "☁️ Cloud Saves",
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.", body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
@@ -58,7 +58,7 @@ const formatBonus = (bonus: EquipmentBonus): string => {
}; };
export const CharacterSheetPanel = (): React.JSX.Element => { export const CharacterSheetPanel = (): React.JSX.Element => {
const { state } = useGame(); const { state, loginStreak } = useGame();
const player = state?.player; const player = state?.player;
const [sheet, setSheet] = useState<CharacterSheetData>(EMPTY_SHEET); const [sheet, setSheet] = useState<CharacterSheetData>(EMPTY_SHEET);
@@ -328,6 +328,12 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
{sheet.characterName || <em className="character-sheet-empty">Not set</em>} {sheet.characterName || <em className="character-sheet-empty">Not set</em>}
</span> </span>
</div> </div>
<div className="character-sheet-field">
<span className="character-sheet-field-label">Streak</span>
<span className="character-sheet-streak">
🔥 {loginStreak}-day login streak
</span>
</div>
{sheet.activeTitle && ( {sheet.activeTitle && (
<div className="character-sheet-field"> <div className="character-sheet-field">
<span className="character-sheet-field-label">Title</span> <span className="character-sheet-field-label">Title</span>
+5 -1
View File
@@ -23,6 +23,7 @@ import { DailyChallengePanel } from "./DailyChallengePanel.js";
import { ExplorationPanel } from "./ExplorationPanel.js"; import { ExplorationPanel } from "./ExplorationPanel.js";
import { CharacterSheetPanel } from "./CharacterSheetPanel.js"; import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
import { CraftingPanel } from "./CraftingPanel.js"; import { CraftingPanel } from "./CraftingPanel.js";
import { LoginBonusModal } from "./LoginBonusModal.js";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character"; type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character";
@@ -46,7 +47,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
]; ];
export const GameLayout = (): React.JSX.Element => { export const GameLayout = (): React.JSX.Element => {
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds } = useGame(); const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds, loginBonus, dismissLoginBonus } = useGame();
const [activeTab, setActiveTab] = useState<Tab>("adventurers"); const [activeTab, setActiveTab] = useState<Tab>("adventurers");
const [editingProfile, setEditingProfile] = useState(false); const [editingProfile, setEditingProfile] = useState(false);
@@ -87,6 +88,9 @@ export const GameLayout = (): React.JSX.Element => {
<OfflineModal /> <OfflineModal />
<AchievementToast /> <AchievementToast />
<CodexToast /> <CodexToast />
{loginBonus && (
<LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
)}
{battleResult && ( {battleResult && (
<BattleModal battle={battleResult} onDismiss={dismissBattle} /> <BattleModal battle={battleResult} onDismiss={dismissBattle} />
)} )}
@@ -0,0 +1,83 @@
import type { LoginBonusResult } from "@elysium/types";
interface LoginBonusModalProps {
bonus: LoginBonusResult;
onClose: () => void;
}
const DAY_ICONS = ["🌱", "🌿", "⚔️", "🛡️", "💎", "👑", "🔥"];
const formatGold = (value: number): string => {
const suffixes = ["", "K", "M", "B", "T"];
if (value < 1_000) return value.toLocaleString();
const tier = Math.min(Math.floor(Math.log10(value) / 3), suffixes.length - 1);
const scaled = value / Math.pow(1_000, tier);
return `${parseFloat(scaled.toFixed(1))}${suffixes[tier] ?? ""}`;
};
export const LoginBonusModal = ({ bonus, onClose }: LoginBonusModalProps): React.JSX.Element => {
const isWeeklyBonus = bonus.day === 7;
const dayIcon = DAY_ICONS[bonus.day - 1] ?? "⭐";
return (
<div className="modal-overlay" role="dialog" aria-modal="true">
<div className="modal login-bonus-modal">
<div className="login-bonus-streak">
<span className="login-bonus-fire">🔥</span>
<span className="login-bonus-streak-count">{bonus.streak}</span>
<span className="login-bonus-streak-label">
{bonus.streak === 1 ? "Day Streak" : "Day Streak"}
</span>
</div>
<div className="login-bonus-day-badge">
<span className="login-bonus-day-icon">{dayIcon}</span>
<span className="login-bonus-day-label">Day {bonus.day} Reward</span>
{bonus.weekMultiplier > 1 && (
<span className="login-bonus-week-tag">×{bonus.weekMultiplier} Week Bonus!</span>
)}
</div>
<div className="login-bonus-rewards">
<div className="login-bonus-reward-item">
<span className="login-bonus-reward-icon">🪙</span>
<span className="login-bonus-reward-value">+{formatGold(bonus.goldEarned)} Gold</span>
</div>
{bonus.crystalsEarned > 0 && (
<div className="login-bonus-reward-item">
<span className="login-bonus-reward-icon">💎</span>
<span className="login-bonus-reward-value">+{bonus.crystalsEarned} Crystals</span>
</div>
)}
</div>
{isWeeklyBonus && (
<p className="login-bonus-weekly-message">
🎉 Weekly bonus keep the streak going!
</p>
)}
<div className="login-bonus-calendar">
{DAY_ICONS.map((icon, i) => {
const dayNum = i + 1;
const isCompleted = dayNum < bonus.day || (bonus.day === 7 && dayNum === 7);
const isToday = dayNum === bonus.day;
return (
<div
key={dayNum}
className={`login-bonus-cal-day ${isToday ? "login-bonus-cal-day--today" : ""} ${isCompleted ? "login-bonus-cal-day--done" : ""}`}
>
<span className="login-bonus-cal-icon">{icon}</span>
<span className="login-bonus-cal-num">{dayNum}</span>
</div>
);
})}
</div>
<button className="login-bonus-claim-btn" onClick={onClose} type="button">
Claim Reward
</button>
</div>
</div>
);
};
+20 -1
View File
@@ -1,4 +1,4 @@
import type { Achievement, BossChallengeResponse, ExploreCollectResponse, GameState, NumberFormat } from "@elysium/types"; import type { Achievement, BossChallengeResponse, ExploreCollectResponse, GameState, LoginBonusResult, NumberFormat } from "@elysium/types";
import { import {
createContext, createContext,
useCallback, useCallback,
@@ -101,6 +101,12 @@ interface GameContextValue {
collectExploration: (areaId: string) => Promise<ExploreCollectResponse>; collectExploration: (areaId: string) => Promise<ExploreCollectResponse>;
/** Craft a recipe using collected materials */ /** Craft a recipe using collected materials */
craftRecipe: (recipeId: string) => Promise<void>; craftRecipe: (recipeId: string) => Promise<void>;
/** Daily login bonus earned on this session load (null if already claimed today) */
loginBonus: LoginBonusResult | null;
/** Player's current login streak (days) */
loginStreak: number;
/** Dismiss the login bonus modal */
dismissLoginBonus: () => void;
} }
const GameContext = createContext<GameContextValue | null>(null); const GameContext = createContext<GameContextValue | null>(null);
@@ -115,6 +121,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [offlineGold, setOfflineGold] = useState(0); const [offlineGold, setOfflineGold] = useState(0);
const [offlineEssence, setOfflineEssence] = useState(0); const [offlineEssence, setOfflineEssence] = useState(0);
const [loginBonus, setLoginBonus] = useState<LoginBonusResult | null>(null);
const [loginStreak, setLoginStreak] = useState(1);
const [battleResult, setBattleResult] = useState<BattleResult | null>(null); const [battleResult, setBattleResult] = useState<BattleResult | null>(null);
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]); const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null); const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
@@ -152,6 +160,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
if (data.offlineEssence > 0) { if (data.offlineEssence > 0) {
setOfflineEssence(data.offlineEssence); setOfflineEssence(data.offlineEssence);
} }
if (data.loginBonus) {
setLoginBonus(data.loginBonus);
}
setLoginStreak(data.loginStreak ?? 1);
// Fetch number format preference from profile (fire-and-forget, non-blocking) // Fetch number format preference from profile (fire-and-forget, non-blocking)
void fetch(`/api/profile/${data.state.player.discordId}`) void fetch(`/api/profile/${data.state.player.discordId}`)
.then(async (res) => { .then(async (res) => {
@@ -863,6 +875,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
setNewCodexEntryIds((prev) => prev.filter((e) => e !== id)); setNewCodexEntryIds((prev) => prev.filter((e) => e !== id));
}, []); }, []);
const dismissLoginBonus = useCallback(() => {
setLoginBonus(null);
}, []);
const boundFormatNumber = useCallback( const boundFormatNumber = useCallback(
(value: number) => formatNumberUtil(value, numberFormat), (value: number) => formatNumberUtil(value, numberFormat),
[numberFormat], [numberFormat],
@@ -906,6 +922,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
startExploration, startExploration,
collectExploration, collectExploration,
craftRecipe, craftRecipe,
loginBonus,
loginStreak,
dismissLoginBonus,
}} }}
> >
{children} {children}
+164
View File
@@ -3765,3 +3765,167 @@ body {
font-size: 0.75rem; font-size: 0.75rem;
margin: 0; margin: 0;
} }
/* ── Login Bonus Modal ──────────────────────────────────────────────── */
.login-bonus-modal {
align-items: center;
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 420px;
padding: 2rem;
text-align: center;
}
.login-bonus-streak {
align-items: center;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.login-bonus-fire {
font-size: 2.5rem;
line-height: 1;
}
.login-bonus-streak-count {
color: var(--colour-accent);
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.login-bonus-streak-label {
color: var(--colour-text-muted);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.login-bonus-day-badge {
align-items: center;
background: var(--colour-surface);
border: 2px solid var(--colour-accent);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 1.5rem;
width: 100%;
}
.login-bonus-day-icon {
font-size: 2rem;
line-height: 1;
}
.login-bonus-day-label {
font-size: 1rem;
font-weight: 600;
}
.login-bonus-week-tag {
background: #d4af37;
border-radius: 999px;
color: #000;
font-size: 0.75rem;
font-weight: 700;
margin-top: 0.25rem;
padding: 0.15rem 0.75rem;
}
.login-bonus-rewards {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.login-bonus-reward-item {
align-items: center;
background: var(--colour-surface);
border-radius: var(--radius);
display: flex;
gap: 0.5rem;
justify-content: center;
padding: 0.5rem 1rem;
}
.login-bonus-reward-icon {
font-size: 1.25rem;
}
.login-bonus-reward-value {
font-size: 1.1rem;
font-weight: 600;
}
.login-bonus-weekly-message {
color: #d4af37;
font-size: 0.9rem;
font-weight: 600;
margin: 0;
}
.login-bonus-calendar {
display: grid;
gap: 0.4rem;
grid-template-columns: repeat(7, 1fr);
width: 100%;
}
.login-bonus-cal-day {
align-items: center;
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.4rem 0.2rem;
}
.login-bonus-cal-day--done {
border-color: var(--colour-success, #4caf50);
opacity: 0.6;
}
.login-bonus-cal-day--today {
border-color: var(--colour-accent);
border-width: 2px;
}
.login-bonus-cal-icon {
font-size: 0.9rem;
line-height: 1;
}
.login-bonus-cal-num {
color: var(--colour-text-muted);
font-size: 0.65rem;
}
.login-bonus-claim-btn {
background: var(--colour-accent);
border-radius: var(--radius);
color: #fff;
font-size: 1rem;
font-weight: 700;
padding: 0.7rem 2.5rem;
transition: opacity 0.15s;
width: 100%;
}
.login-bonus-claim-btn:hover {
opacity: 0.85;
}
/* ── Character Sheet Streak ─────────────────────────────────────────── */
.character-sheet-streak {
color: var(--colour-accent);
font-size: 0.95rem;
font-weight: 600;
}
+1
View File
@@ -41,6 +41,7 @@ export type {
LeaderboardCategory, LeaderboardCategory,
LeaderboardEntry, LeaderboardEntry,
LeaderboardResponse, LeaderboardResponse,
LoginBonusResult,
LoadResponse, LoadResponse,
PrestigeRequest, PrestigeRequest,
PrestigeResponse, PrestigeResponse,
+17
View File
@@ -21,6 +21,19 @@ export interface SaveResponse {
signature?: string; signature?: string;
} }
export interface LoginBonusResult {
/** Current login streak day count */
streak: number;
/** Gold awarded for today's login */
goldEarned: number;
/** Crystals awarded (day 7 bonus, scaled by week multiplier) */
crystalsEarned: number;
/** Day within the 7-day cycle (17) */
day: number;
/** Week number multiplier (week 1 = ×1, week 2 = ×2, …) */
weekMultiplier: number;
}
export interface LoadResponse { export interface LoadResponse {
state: GameState; state: GameState;
/** Offline gold earned since last save (server-calculated) */ /** Offline gold earned since last save (server-calculated) */
@@ -31,6 +44,10 @@ export interface LoadResponse {
offlineSeconds: number; offlineSeconds: number;
/** HMAC-SHA256 signature of the loaded state — store and include in next save request */ /** HMAC-SHA256 signature of the loaded state — store and include in next save request */
signature?: string; signature?: string;
/** Daily login bonus awarded on this load (null if already claimed today) */
loginBonus: LoginBonusResult | null;
/** Current login streak (always present) */
loginStreak: number;
} }
export interface BossChallengeRequest { export interface BossChallengeRequest {