generated from nhcarrigan/template
feat: v1 prototype — core game systems #30
@@ -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) => {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<CharacterSheetData>(EMPTY_SHEET);
|
||||
@@ -328,6 +328,12 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
|
||||
{sheet.characterName || <em className="character-sheet-empty">Not set</em>}
|
||||
</span>
|
||||
</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 && (
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Title</span>
|
||||
|
||||
@@ -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<Tab>("adventurers");
|
||||
const [editingProfile, setEditingProfile] = useState(false);
|
||||
|
||||
@@ -87,6 +88,9 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
<OfflineModal />
|
||||
<AchievementToast />
|
||||
<CodexToast />
|
||||
{loginBonus && (
|
||||
<LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
|
||||
)}
|
||||
{battleResult && (
|
||||
<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 {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -101,6 +101,12 @@ interface GameContextValue {
|
||||
collectExploration: (areaId: string) => Promise<ExploreCollectResponse>;
|
||||
/** Craft a recipe using collected materials */
|
||||
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);
|
||||
@@ -115,6 +121,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offlineGold, setOfflineGold] = 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 [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export type {
|
||||
LeaderboardCategory,
|
||||
LeaderboardEntry,
|
||||
LeaderboardResponse,
|
||||
LoginBonusResult,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user