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)
lifetimeAdventurersRecruited Float @default(0)
lifetimeAchievementsUnlocked Float @default(0)
lastLoginDate String?
loginStreak Int @default(1)
}
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 { 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) => {