feat: add equipment, achievements, and visual polish

- Equipment system: 12 items across weapon/armour/trinket slots with
  common/rare/epic/legendary rarities; starter commons auto-equipped,
  higher tiers drop from boss victories
- Achievement system: 15 milestones with typed conditions; checked
  each tick and crystal rewards applied automatically
- Achievement toast: slide-in notification, auto-dismisses after 4s
- Floating click text: +X gold floats on each manual click
- Expanded quests (9 total) and upgrades (12 total)
- Upgrade panel now shows locked upgrades so players can see their
  progression path
- formatNumber utility (K/M/B/T) used consistently across all panels
- Backfill logic for existing saves to add new content gracefully
- types package now emits .d.ts declarations
This commit is contained in:
2026-03-06 13:27:48 -08:00
committed by Naomi Carrigan
parent a3daed1683
commit e9e0df31fd
33 changed files with 2066 additions and 133 deletions
+139
View File
@@ -0,0 +1,139 @@
import type { Achievement } from "@elysium/types";
export const DEFAULT_ACHIEVEMENTS: Achievement[] = [
{
id: "first_click",
name: "First Strike",
description: "Click the Guild Hall for the first time.",
icon: "👆",
condition: { type: "totalClicks", amount: 1 },
reward: { crystals: 5 },
unlockedAt: null,
},
{
id: "click_enthusiast",
name: "Click Enthusiast",
description: "Click the Guild Hall 100 times.",
icon: "🖱️",
condition: { type: "totalClicks", amount: 100 },
reward: { crystals: 25 },
unlockedAt: null,
},
{
id: "click_master",
name: "Click Master",
description: "Click the Guild Hall 1,000 times.",
icon: "⚡",
condition: { type: "totalClicks", amount: 1_000 },
reward: { crystals: 100 },
unlockedAt: null,
},
{
id: "first_gold",
name: "First Gold",
description: "Earn your first 100 gold.",
icon: "🪙",
condition: { type: "totalGoldEarned", amount: 100 },
reward: { crystals: 5 },
unlockedAt: null,
},
{
id: "wealthy",
name: "Wealthy",
description: "Earn 10,000 gold in total.",
icon: "💰",
condition: { type: "totalGoldEarned", amount: 10_000 },
reward: { crystals: 25 },
unlockedAt: null,
},
{
id: "rich",
name: "Rich",
description: "Earn 1,000,000 gold in total.",
icon: "👑",
condition: { type: "totalGoldEarned", amount: 1_000_000 },
reward: { crystals: 100 },
unlockedAt: null,
},
{
id: "billionaire",
name: "Billionaire",
description: "Earn 1,000,000,000 gold in total.",
icon: "🏦",
condition: { type: "totalGoldEarned", amount: 1_000_000_000 },
reward: { crystals: 500 },
unlockedAt: null,
},
{
id: "first_quest",
name: "Adventurous Spirit",
description: "Complete your first quest.",
icon: "📜",
condition: { type: "questsCompleted", amount: 1 },
reward: { crystals: 10 },
unlockedAt: null,
},
{
id: "quest_veteran",
name: "Quest Veteran",
description: "Complete 5 quests.",
icon: "📚",
condition: { type: "questsCompleted", amount: 5 },
reward: { crystals: 50 },
unlockedAt: null,
},
{
id: "boss_slayer",
name: "Boss Slayer",
description: "Defeat your first boss.",
icon: "⚔️",
condition: { type: "bossesDefeated", amount: 1 },
reward: { crystals: 25 },
unlockedAt: null,
},
{
id: "legendary_hunter",
name: "Legendary Hunter",
description: "Defeat all four bosses.",
icon: "🏆",
condition: { type: "bossesDefeated", amount: 4 },
reward: { crystals: 200 },
unlockedAt: null,
},
{
id: "guild_master",
name: "Guild Master",
description: "Recruit a total of 50 adventurers.",
icon: "🏰",
condition: { type: "adventurerTotal", amount: 50 },
reward: { crystals: 50 },
unlockedAt: null,
},
{
id: "army_commander",
name: "Army Commander",
description: "Recruit a total of 500 adventurers.",
icon: "🛡️",
condition: { type: "adventurerTotal", amount: 500 },
reward: { crystals: 200 },
unlockedAt: null,
},
{
id: "first_prestige",
name: "Born Again",
description: "Prestige for the first time.",
icon: "⭐",
condition: { type: "prestigeCount", amount: 1 },
reward: { crystals: 100 },
unlockedAt: null,
},
{
id: "collector",
name: "Collector",
description: "Acquire your first piece of boss-dropped equipment.",
icon: "🎒",
condition: { type: "equipmentOwned", amount: 4 },
reward: { crystals: 10 },
unlockedAt: null,
},
];
+10
View File
@@ -8,6 +8,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 1,
goldPerSecond: 0.1,
essencePerSecond: 0,
combatPower: 1,
count: 0,
unlocked: true,
},
@@ -18,6 +19,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 2,
goldPerSecond: 0.5,
essencePerSecond: 0,
combatPower: 3,
count: 0,
unlocked: false,
},
@@ -28,6 +30,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 3,
goldPerSecond: 1.5,
essencePerSecond: 0.01,
combatPower: 8,
count: 0,
unlocked: false,
},
@@ -38,6 +41,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 4,
goldPerSecond: 4,
essencePerSecond: 0.02,
combatPower: 20,
count: 0,
unlocked: false,
},
@@ -48,6 +52,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 5,
goldPerSecond: 10,
essencePerSecond: 0.05,
combatPower: 50,
count: 0,
unlocked: false,
},
@@ -58,6 +63,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 6,
goldPerSecond: 25,
essencePerSecond: 0.1,
combatPower: 120,
count: 0,
unlocked: false,
},
@@ -68,6 +74,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 7,
goldPerSecond: 75,
essencePerSecond: 0.2,
combatPower: 300,
count: 0,
unlocked: false,
},
@@ -78,6 +85,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 8,
goldPerSecond: 200,
essencePerSecond: 0.5,
combatPower: 800,
count: 0,
unlocked: false,
},
@@ -88,6 +96,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 9,
goldPerSecond: 600,
essencePerSecond: 1,
combatPower: 2000,
count: 0,
unlocked: false,
},
@@ -98,6 +107,7 @@ export const DEFAULT_ADVENTURERS: Adventurer[] = [
level: 10,
goldPerSecond: 2000,
essencePerSecond: 3,
combatPower: 6000,
count: 0,
unlocked: false,
},
+4
View File
@@ -14,6 +14,7 @@ export const DEFAULT_BOSSES: Boss[] = [
essenceReward: 25,
crystalReward: 0,
upgradeRewards: ["click_2"],
equipmentRewards: ["iron_sword", "chainmail", "mages_focus"],
prestigeRequirement: 0,
},
{
@@ -29,6 +30,7 @@ export const DEFAULT_BOSSES: Boss[] = [
essenceReward: 200,
crystalReward: 10,
upgradeRewards: ["global_2"],
equipmentRewards: ["enchanted_blade", "plate_armour", "arcane_orb"],
prestigeRequirement: 0,
},
{
@@ -44,6 +46,7 @@ export const DEFAULT_BOSSES: Boss[] = [
essenceReward: 1_000,
crystalReward: 50,
upgradeRewards: ["click_3"],
equipmentRewards: ["vorpal_sword", "dragon_scale"],
prestigeRequirement: 1,
},
{
@@ -59,6 +62,7 @@ export const DEFAULT_BOSSES: Boss[] = [
essenceReward: 5_000,
crystalReward: 200,
upgradeRewards: [],
equipmentRewards: ["philosophers_stone"],
prestigeRequirement: 3,
},
];
+127
View File
@@ -0,0 +1,127 @@
import type { Equipment } from "@elysium/types";
export const DEFAULT_EQUIPMENT: Equipment[] = [
// Weapons — drop from bosses; common starts owned
{
id: "rusty_sword",
name: "Rusty Sword",
description: "A battered blade, but still sharp enough to draw blood.",
type: "weapon",
rarity: "common",
bonus: { combatMultiplier: 1.1 },
owned: true,
equipped: true,
},
{
id: "iron_sword",
name: "Iron Sword",
description: "A sturdy weapon issued to veterans of the guild.",
type: "weapon",
rarity: "rare",
bonus: { combatMultiplier: 1.25 },
owned: false,
equipped: false,
},
{
id: "enchanted_blade",
name: "Enchanted Blade",
description: "A sword imbued with ancient magic that makes every strike count.",
type: "weapon",
rarity: "epic",
bonus: { combatMultiplier: 1.5 },
owned: false,
equipped: false,
},
{
id: "vorpal_sword",
name: "Vorpal Sword",
description: "A legendary blade that severs even the strongest bonds.",
type: "weapon",
rarity: "legendary",
bonus: { combatMultiplier: 2.0 },
owned: false,
equipped: false,
},
// Armour — drop from bosses; common starts owned
{
id: "leather_armour",
name: "Leather Armour",
description: "Simple protection that keeps your adventurers moving efficiently.",
type: "armour",
rarity: "common",
bonus: { goldMultiplier: 1.1 },
owned: true,
equipped: true,
},
{
id: "chainmail",
name: "Chainmail",
description: "Interlocked rings that guard against most mundane threats.",
type: "armour",
rarity: "rare",
bonus: { goldMultiplier: 1.25 },
owned: false,
equipped: false,
},
{
id: "plate_armour",
name: "Plate Armour",
description: "Full plate protection that inspires confidence — and gold.",
type: "armour",
rarity: "epic",
bonus: { goldMultiplier: 1.5 },
owned: false,
equipped: false,
},
{
id: "dragon_scale",
name: "Dragon Scale Armour",
description: "Armour forged from the scales of a defeated elder dragon.",
type: "armour",
rarity: "legendary",
bonus: { goldMultiplier: 2.0 },
owned: false,
equipped: false,
},
// Trinkets — drop from bosses; common starts owned
{
id: "lucky_coin",
name: "Lucky Coin",
description: "A coin that always lands on the side you need.",
type: "trinket",
rarity: "common",
bonus: { clickMultiplier: 1.1 },
owned: true,
equipped: true,
},
{
id: "mages_focus",
name: "Mage's Focus",
description: "A crystal lens that sharpens magical precision.",
type: "trinket",
rarity: "rare",
bonus: { clickMultiplier: 1.25 },
owned: false,
equipped: false,
},
{
id: "arcane_orb",
name: "Arcane Orb",
description: "An orb humming with concentrated arcane energy.",
type: "trinket",
rarity: "epic",
bonus: { clickMultiplier: 1.5 },
owned: false,
equipped: false,
},
{
id: "philosophers_stone",
name: "Philosopher's Stone",
description: "The legendary stone that grants mastery over gold and combat alike.",
type: "trinket",
rarity: "legendary",
bonus: { clickMultiplier: 2.0, goldMultiplier: 1.25 },
owned: false,
equipped: false,
},
];
+4
View File
@@ -1,6 +1,8 @@
import type { GameState, Player, PrestigeData } from "@elysium/types";
import { DEFAULT_ACHIEVEMENTS } from "./achievements.js";
import { DEFAULT_ADVENTURERS } from "./adventurers.js";
import { DEFAULT_BOSSES } from "./bosses.js";
import { DEFAULT_EQUIPMENT } from "./equipment.js";
import { DEFAULT_QUESTS } from "./quests.js";
import { DEFAULT_UPGRADES } from "./upgrades.js";
@@ -28,6 +30,8 @@ export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameS
upgrades: structuredClone(DEFAULT_UPGRADES),
quests: structuredClone(DEFAULT_QUESTS),
bosses: structuredClone(DEFAULT_BOSSES),
equipment: structuredClone(DEFAULT_EQUIPMENT),
achievements: structuredClone(DEFAULT_ACHIEVEMENTS),
prestige: INITIAL_PRESTIGE,
baseClickPower: 1,
lastTickAt: Date.now(),
+55
View File
@@ -34,6 +34,20 @@ export const DEFAULT_QUESTS: Quest[] = [
],
prerequisiteIds: ["goblin_camp"],
},
{
id: "necromancer_tower",
name: "Necromancer's Tower",
description:
"A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.",
status: "locked",
durationSeconds: 25 * 60,
rewards: [
{ type: "gold", amount: 15_000 },
{ type: "essence", amount: 20 },
{ type: "upgrade", targetId: "cleric_1" },
],
prerequisiteIds: ["haunted_mine"],
},
{
id: "ancient_ruins",
name: "Ancient Ruins",
@@ -46,6 +60,19 @@ export const DEFAULT_QUESTS: Quest[] = [
],
prerequisiteIds: ["haunted_mine"],
},
{
id: "shadow_mere",
name: "The Shadow Mere",
description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
status: "locked",
durationSeconds: 45 * 60,
rewards: [
{ type: "essence", amount: 150 },
{ type: "upgrade", targetId: "scout_1" },
],
prerequisiteIds: ["ancient_ruins"],
},
{
id: "dragon_lair",
name: "Dragon's Lair",
@@ -60,4 +87,32 @@ export const DEFAULT_QUESTS: Quest[] = [
],
prerequisiteIds: ["ancient_ruins"],
},
{
id: "frozen_wastes",
name: "The Frozen Wastes",
description:
"A tundra at the edge of the world, home to creatures that have never seen the sun. Rumours speak of artefacts buried in the permafrost.",
status: "locked",
durationSeconds: 2 * 60 * 60,
rewards: [
{ type: "gold", amount: 2_000_000 },
{ type: "crystals", amount: 150 },
{ type: "upgrade", targetId: "global_3" },
],
prerequisiteIds: ["dragon_lair"],
},
{
id: "void_rift",
name: "Void Rift",
description:
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
status: "locked",
durationSeconds: 4 * 60 * 60,
rewards: [
{ type: "crystals", amount: 500 },
{ type: "essence", amount: 5_000 },
{ type: "upgrade", targetId: "knight_1" },
],
prerequisiteIds: ["frozen_wastes"],
},
];
+47
View File
@@ -58,6 +58,17 @@ export const DEFAULT_UPGRADES: Upgrade[] = [
purchased: false,
unlocked: false,
},
{
id: "global_3",
name: "Royal Patronage",
description: "The king himself backs your guild. All income doubled.",
target: "global",
multiplier: 2,
costGold: 1_000_000,
costEssence: 100,
purchased: false,
unlocked: false,
},
// Adventurer-specific upgrades
{
id: "peasant_1",
@@ -95,4 +106,40 @@ export const DEFAULT_UPGRADES: Upgrade[] = [
purchased: false,
unlocked: false,
},
{
id: "cleric_1",
name: "Holy Rites",
description: "Sacred ceremonies double the output of your clerics.",
target: "adventurer",
adventurerId: "acolyte",
multiplier: 2,
costGold: 8_000,
costEssence: 3,
purchased: false,
unlocked: false,
},
{
id: "scout_1",
name: "Stealth Training",
description: "Advanced scouting techniques double ranger effectiveness.",
target: "adventurer",
adventurerId: "ranger",
multiplier: 2,
costGold: 15_000,
costEssence: 5,
purchased: false,
unlocked: false,
},
{
id: "knight_1",
name: "Tempered Steel",
description: "Superior forging techniques double the output of your knights.",
target: "adventurer",
adventurerId: "knight",
multiplier: 2,
costGold: 50_000,
costEssence: 10,
purchased: false,
unlocked: false,
},
];
+139 -29
View File
@@ -1,34 +1,67 @@
import type { BossDamageRequest, GameState } from "@elysium/types";
import type { BossChallengeResponse, GameState } from "@elysium/types";
import { Hono } from "hono";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
const RATE_LIMIT_WINDOW_MS = 1_000;
const MAX_DAMAGE_PER_SECOND = 10_000;
export const bossRouter = new Hono();
bossRouter.use("*", authMiddleware);
bossRouter.post("/damage", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<BossDamageRequest>();
if (!body.bossId || body.damage == null || body.damage <= 0) {
return context.json({ error: "Invalid request body" }, 400);
const calculatePartyStats = (
state: GameState,
): { partyDPS: number; partyMaxHp: number } => {
let globalMultiplier = 1;
for (const upgrade of state.upgrades) {
if (upgrade.purchased && upgrade.target === "global") {
globalMultiplier *= upgrade.multiplier;
}
}
// Rate limiting: sum damage dealt to this boss in the last second
const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
const aggregate = await prisma.bossDamageLog.aggregate({
where: { discordId, bossId: body.bossId, dealtAt: { gt: windowStart } },
_sum: { damage: true },
});
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
const recentDamage = aggregate._sum.damage ?? 0;
// Apply equipped weapon's combat bonus
const equipmentCombatMultiplier = (state.equipment ?? [])
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
if (recentDamage + body.damage > MAX_DAMAGE_PER_SECOND) {
return context.json({ error: "Rate limit exceeded" }, 429);
let partyDPS = 0;
let partyMaxHp = 0;
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) continue;
let adventurerMultiplier = 1;
for (const upgrade of state.upgrades) {
if (
upgrade.purchased &&
upgrade.target === "adventurer" &&
upgrade.adventurerId === adventurer.id
) {
adventurerMultiplier *= upgrade.multiplier;
}
}
partyDPS +=
adventurer.combatPower *
adventurer.count *
adventurerMultiplier *
globalMultiplier *
prestigeMultiplier;
partyMaxHp += adventurer.level * 50 * adventurer.count;
}
partyDPS *= equipmentCombatMultiplier;
return { partyDPS, partyMaxHp };
};
bossRouter.post("/challenge", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<{ bossId: string }>();
if (!body.bossId) {
return context.json({ error: "Invalid request body" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
@@ -44,7 +77,7 @@ bossRouter.post("/damage", async (context) => {
return context.json({ error: "Boss not found" }, 404);
}
if (boss.status !== "in_progress" && boss.status !== "available") {
if (boss.status !== "available" && boss.status !== "in_progress") {
return context.json({ error: "Boss is not currently available" }, 400);
}
@@ -52,18 +85,40 @@ bossRouter.post("/damage", async (context) => {
return context.json({ error: "Prestige requirement not met" }, 403);
}
await prisma.bossDamageLog.create({
data: { discordId, bossId: body.bossId, damage: body.damage, dealtAt: Date.now() },
});
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
boss.status = "in_progress";
boss.currentHp = Math.max(0, boss.currentHp - body.damage);
const defeated = boss.currentHp <= 0;
if (partyDPS === 0 || partyMaxHp === 0 || !isFinite(partyDPS) || !isFinite(partyMaxHp)) {
return context.json(
{ error: "Your party has no adventurers ready to fight" },
400,
);
}
let rewards: { gold: number; essence: number; crystals: number; upgradeIds: string[] } | undefined;
const bossHpBefore = boss.currentHp;
const bossDPS = boss.damagePerSecond;
const timeToKillBoss = bossHpBefore / partyDPS;
const timeToKillParty = partyMaxHp / bossDPS;
const won = timeToKillBoss <= timeToKillParty;
let partyHpRemaining: number;
let bossHpAtBattleEnd: number;
let bossNewHp: number;
let rewards: BossChallengeResponse["rewards"];
let casualties: BossChallengeResponse["casualties"];
if (won) {
bossHpAtBattleEnd = 0;
bossNewHp = 0;
partyHpRemaining = Math.max(
0,
partyMaxHp - bossDPS * timeToKillBoss,
);
if (defeated) {
boss.status = "defeated";
boss.currentHp = 0;
state.resources.gold += boss.goldReward;
state.resources.essence += boss.essenceReward;
state.resources.crystals += boss.crystalReward;
@@ -76,6 +131,21 @@ bossRouter.post("/damage", async (context) => {
}
}
// Grant equipment rewards — auto-equip if the slot is currently empty
const equipmentRewards = boss.equipmentRewards ?? [];
for (const equipmentId of equipmentRewards) {
const equipment = (state.equipment ?? []).find((e) => e.id === equipmentId);
if (equipment) {
equipment.owned = true;
const slotAlreadyEquipped = (state.equipment ?? []).some(
(e) => e.type === equipment.type && e.equipped,
);
if (!slotAlreadyEquipped) {
equipment.equipped = true;
}
}
}
const bossIndex = state.bosses.findIndex((b) => b.id === body.bossId);
const nextBoss = state.bosses[bossIndex + 1];
if (nextBoss && nextBoss.prestigeRequirement <= state.prestige.count) {
@@ -87,7 +157,33 @@ bossRouter.post("/damage", async (context) => {
essence: boss.essenceReward,
crystals: boss.crystalReward,
upgradeIds: boss.upgradeRewards,
equipmentIds: equipmentRewards,
};
} else {
bossHpAtBattleEnd = Math.max(
0,
bossHpBefore - partyDPS * timeToKillParty,
);
bossNewHp = boss.maxHp;
partyHpRemaining = 0;
boss.status = "available";
boss.currentHp = boss.maxHp;
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
const casualtyFraction = (1 - victoryProgress) * 0.6;
casualties = [];
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) continue;
const killed = Math.floor(adventurer.count * casualtyFraction);
if (killed > 0) {
adventurer.count = Math.max(1, adventurer.count - killed);
casualties.push({ adventurerId: adventurer.id, killed });
}
}
}
const now = Date.now();
@@ -96,5 +192,19 @@ bossRouter.post("/damage", async (context) => {
data: { state: state as object, updatedAt: now },
});
return context.json({ currentHp: boss.currentHp, defeated, rewards });
const response: BossChallengeResponse = {
won,
partyDPS,
bossDPS,
bossHpBefore,
bossMaxHp: boss.maxHp,
bossHpAtBattleEnd,
bossNewHp,
partyMaxHp,
partyHpRemaining,
};
if (rewards !== undefined) response.rewards = rewards;
if (casualties !== undefined) response.casualties = casualties;
return context.json(response);
});
+76
View File
@@ -1,6 +1,9 @@
import type { GameState, SaveRequest } from "@elysium/types";
import { Hono } from "hono";
import { prisma } from "../db/client.js";
import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.js";
import { DEFAULT_ADVENTURERS } from "../data/adventurers.js";
import { DEFAULT_EQUIPMENT } from "../data/equipment.js";
import { authMiddleware } from "../middleware/auth.js";
import { calculateOfflineGold } from "../services/offlineProgress.js";
@@ -18,6 +21,72 @@ gameRouter.get("/load", async (context) => {
}
const state = record.state as unknown as GameState;
let needsBackfill = false;
// Backfill combatPower on saves that predate the field
for (const adventurer of state.adventurers) {
if (adventurer.combatPower == null) {
const defaults = DEFAULT_ADVENTURERS.find((d) => d.id === adventurer.id);
adventurer.combatPower = defaults?.combatPower ?? 1;
needsBackfill = true;
}
}
// Backfill equipment on saves that predate the feature
if (!Array.isArray(state.equipment) || state.equipment.length === 0) {
state.equipment = structuredClone(DEFAULT_EQUIPMENT);
needsBackfill = true;
} else {
// Merge in any equipment items missing from existing saves (new items added later)
for (const defaultItem of DEFAULT_EQUIPMENT) {
if (!state.equipment.some((e) => e.id === defaultItem.id)) {
state.equipment.push(structuredClone(defaultItem));
needsBackfill = true;
}
}
}
// Backfill achievements on saves that predate the feature
if (!Array.isArray(state.achievements) || state.achievements.length === 0) {
state.achievements = structuredClone(DEFAULT_ACHIEVEMENTS);
needsBackfill = true;
} else {
// Merge in any achievements missing from existing saves
for (const defaultAchievement of DEFAULT_ACHIEVEMENTS) {
if (!state.achievements.some((a) => a.id === defaultAchievement.id)) {
state.achievements.push(structuredClone(defaultAchievement));
needsBackfill = true;
}
}
}
// Backfill equipmentRewards on bosses that predate the field
for (const boss of state.bosses) {
if (!Array.isArray(boss.equipmentRewards)) {
boss.equipmentRewards = [];
needsBackfill = true;
}
}
// Backfill new quests and upgrades from defaults (add missing ones)
const { DEFAULT_QUESTS } = await import("../data/quests.js");
const { DEFAULT_UPGRADES } = await import("../data/upgrades.js");
for (const defaultQuest of DEFAULT_QUESTS) {
if (!state.quests.some((q) => q.id === defaultQuest.id)) {
state.quests.push(structuredClone(defaultQuest));
needsBackfill = true;
}
}
for (const defaultUpgrade of DEFAULT_UPGRADES) {
if (!state.upgrades.some((u) => u.id === defaultUpgrade.id)) {
state.upgrades.push(structuredClone(defaultUpgrade));
needsBackfill = true;
}
}
const now = Date.now();
const { offlineGold, offlineSeconds } = calculateOfflineGold(state, now);
@@ -29,6 +98,13 @@ gameRouter.get("/load", async (context) => {
state.lastTickAt = now;
if (needsBackfill || offlineGold > 0) {
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: now },
});
}
return context.json({ state, offlineGold, offlineSeconds });
});