From bec972aed1d7f2036943f7d1381a8f79a86920e8 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Mar 2026 15:18:59 -0800 Subject: [PATCH] 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. --- apps/api/prisma/schema.prisma | 2 + apps/api/src/data/loginBonus.ts | 19 ++ apps/api/src/routes/game.ts | 48 ++++- apps/web/src/components/game/AboutPanel.tsx | 4 + .../components/game/CharacterSheetPanel.tsx | 8 +- apps/web/src/components/game/GameLayout.tsx | 6 +- .../src/components/game/LoginBonusModal.tsx | 83 +++++++++ apps/web/src/context/GameContext.tsx | 21 ++- apps/web/src/styles.css | 164 ++++++++++++++++++ packages/types/src/index.ts | 1 + packages/types/src/interfaces/Api.ts | 17 ++ 11 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/data/loginBonus.ts create mode 100644 apps/web/src/components/game/LoginBonusModal.tsx diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 71b8d82..5ff21c1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -33,6 +33,8 @@ model Player { lifetimeQuestsCompleted Float @default(0) lifetimeAdventurersRecruited Float @default(0) lifetimeAchievementsUnlocked Float @default(0) + lastLoginDate String? + loginStreak Int @default(1) } model GameState { diff --git a/apps/api/src/data/loginBonus.ts b/apps/api/src/data/loginBonus.ts new file mode 100644 index 0000000..a539370 --- /dev/null +++ b/apps/api/src/data/loginBonus.ts @@ -0,0 +1,19 @@ +export interface DayReward { + day: number; + goldBase: number; + crystals?: number; +} + +/** + * Rewards for days 1–7 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 }, +]; diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index ea24d49..ef268b5 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -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 { createHmac } from "node:crypto"; 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_EXPLORATIONS } from "../data/explorations.js"; import { INITIAL_EXPLORATION } from "../data/initialState.js"; +import { DAILY_REWARDS } from "../data/loginBonus.js"; import { DEFAULT_QUESTS } from "../data/quests.js"; import { authMiddleware } from "../middleware/auth.js"; import { getOrResetDailyChallenges } from "../services/dailyChallenges.js"; @@ -357,7 +358,10 @@ gameRouter.use("*", authMiddleware); gameRouter.get("/load", async (context) => { 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) { 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 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; if (needsBackfill || offlineGold > 0 || offlineEssence > 0) { @@ -660,7 +702,7 @@ gameRouter.get("/load", async (context) => { const secret = process.env.ANTI_CHEAT_SECRET; 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) => { diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx index 4fed866..14688c7 100644 --- a/apps/web/src/components/game/AboutPanel.tsx +++ b/apps/web/src/components/game/AboutPanel.tsx @@ -79,6 +79,10 @@ const HOW_TO_PLAY = [ 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.", }, + { + 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", 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.", diff --git a/apps/web/src/components/game/CharacterSheetPanel.tsx b/apps/web/src/components/game/CharacterSheetPanel.tsx index 1f37824..bc2512b 100644 --- a/apps/web/src/components/game/CharacterSheetPanel.tsx +++ b/apps/web/src/components/game/CharacterSheetPanel.tsx @@ -58,7 +58,7 @@ const formatBonus = (bonus: EquipmentBonus): string => { }; export const CharacterSheetPanel = (): React.JSX.Element => { - const { state } = useGame(); + const { state, loginStreak } = useGame(); const player = state?.player; const [sheet, setSheet] = useState(EMPTY_SHEET); @@ -328,6 +328,12 @@ export const CharacterSheetPanel = (): React.JSX.Element => { {sheet.characterName || Not set} +
+ Streak + + 🔥 {loginStreak}-day login streak + +
{sheet.activeTitle && (
Title diff --git a/apps/web/src/components/game/GameLayout.tsx b/apps/web/src/components/game/GameLayout.tsx index 0ee5833..e1d451e 100644 --- a/apps/web/src/components/game/GameLayout.tsx +++ b/apps/web/src/components/game/GameLayout.tsx @@ -23,6 +23,7 @@ import { DailyChallengePanel } from "./DailyChallengePanel.js"; import { ExplorationPanel } from "./ExplorationPanel.js"; import { CharacterSheetPanel } from "./CharacterSheetPanel.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"; @@ -46,7 +47,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [ ]; 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("adventurers"); const [editingProfile, setEditingProfile] = useState(false); @@ -87,6 +88,9 @@ export const GameLayout = (): React.JSX.Element => { + {loginBonus && ( + + )} {battleResult && ( )} diff --git a/apps/web/src/components/game/LoginBonusModal.tsx b/apps/web/src/components/game/LoginBonusModal.tsx new file mode 100644 index 0000000..2781f0d --- /dev/null +++ b/apps/web/src/components/game/LoginBonusModal.tsx @@ -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 ( +
+
+
+ 🔥 + {bonus.streak} + + {bonus.streak === 1 ? "Day Streak" : "Day Streak"} + +
+ +
+ {dayIcon} + Day {bonus.day} Reward + {bonus.weekMultiplier > 1 && ( + ×{bonus.weekMultiplier} Week Bonus! + )} +
+ +
+
+ 🪙 + +{formatGold(bonus.goldEarned)} Gold +
+ {bonus.crystalsEarned > 0 && ( +
+ 💎 + +{bonus.crystalsEarned} Crystals +
+ )} +
+ + {isWeeklyBonus && ( +

+ 🎉 Weekly bonus — keep the streak going! +

+ )} + +
+ {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 ( +
+ {icon} + {dayNum} +
+ ); + })} +
+ + +
+
+ ); +}; diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx index f7b4a50..52e3567 100644 --- a/apps/web/src/context/GameContext.tsx +++ b/apps/web/src/context/GameContext.tsx @@ -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 { createContext, useCallback, @@ -101,6 +101,12 @@ interface GameContextValue { collectExploration: (areaId: string) => Promise; /** Craft a recipe using collected materials */ craftRecipe: (recipeId: string) => Promise; + /** 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(null); @@ -115,6 +121,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React const [error, setError] = useState(null); const [offlineGold, setOfflineGold] = useState(0); const [offlineEssence, setOfflineEssence] = useState(0); + const [loginBonus, setLoginBonus] = useState(null); + const [loginStreak, setLoginStreak] = useState(1); const [battleResult, setBattleResult] = useState(null); const [newAchievements, setNewAchievements] = useState([]); const [lastSavedAt, setLastSavedAt] = useState(null); @@ -152,6 +160,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React if (data.offlineEssence > 0) { setOfflineEssence(data.offlineEssence); } + if (data.loginBonus) { + setLoginBonus(data.loginBonus); + } + setLoginStreak(data.loginStreak ?? 1); // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`) .then(async (res) => { @@ -863,6 +875,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React setNewCodexEntryIds((prev) => prev.filter((e) => e !== id)); }, []); + const dismissLoginBonus = useCallback(() => { + setLoginBonus(null); + }, []); + const boundFormatNumber = useCallback( (value: number) => formatNumberUtil(value, numberFormat), [numberFormat], @@ -906,6 +922,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React startExploration, collectExploration, craftRecipe, + loginBonus, + loginStreak, + dismissLoginBonus, }} > {children} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 53057ac..ee805c7 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -3765,3 +3765,167 @@ body { font-size: 0.75rem; 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; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f3c785b..e313b98 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -41,6 +41,7 @@ export type { LeaderboardCategory, LeaderboardEntry, LeaderboardResponse, + LoginBonusResult, LoadResponse, PrestigeRequest, PrestigeResponse, diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts index 1852d47..f868e5e 100644 --- a/packages/types/src/interfaces/Api.ts +++ b/packages/types/src/interfaces/Api.ts @@ -21,6 +21,19 @@ export interface SaveResponse { 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 (1–7) */ + day: number; + /** Week number multiplier (week 1 = ×1, week 2 = ×2, …) */ + weekMultiplier: number; +} + export interface LoadResponse { state: GameState; /** Offline gold earned since last save (server-calculated) */ @@ -31,6 +44,10 @@ export interface LoadResponse { offlineSeconds: number; /** HMAC-SHA256 signature of the loaded state — store and include in next save request */ 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 {