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