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)
|
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 {
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type {
|
|||||||
LeaderboardCategory,
|
LeaderboardCategory,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
LeaderboardResponse,
|
LeaderboardResponse,
|
||||||
|
LoginBonusResult,
|
||||||
LoadResponse,
|
LoadResponse,
|
||||||
PrestigeRequest,
|
PrestigeRequest,
|
||||||
PrestigeResponse,
|
PrestigeResponse,
|
||||||
|
|||||||
@@ -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 (1–7) */
|
||||||
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user