Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions server/lib/prisma.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});

function gracefulShutdown(signal) {
console.log(`${signal} received — closing Prisma connection`);
prisma.$disconnect().then(() => process.exit(0));
}
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);
// Shutdown is handled by server/index.js which closes HTTP, Socket.IO,
// then calls prisma.$disconnect() in the correct order.

export default prisma;
69 changes: 45 additions & 24 deletions server/routes/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,14 +331,21 @@ router.post('/users/:id/gold', requireRole('admin'), async (req, res) => {
}

try {
const state = await prisma.gameState.findUnique({ where: { userId } });
if (!state) return res.status(404).json({ error: 'Game state not found' });

const newGold = Math.max(0, state.gold + Math.floor(amount));
await prisma.gameState.update({ where: { userId }, data: { gold: newGold } });
await logAudit(req.user.userId, 'add_gold', userId, { amount, newGold });

res.json({ gold: newGold });
const adj = Math.floor(amount);
const result = await prisma.$transaction(async (tx) => {
const state = await tx.gameState.findUnique({ where: { userId }, select: { gold: true } });
if (!state) return null;
const clampedAdj = adj < 0 ? Math.max(-state.gold, adj) : adj;
const gs = await tx.gameState.update({
where: { userId },
data: { gold: { increment: clampedAdj } },
select: { gold: true },
});
return gs;
});
if (!result) return res.status(404).json({ error: 'Game state not found' });
await logAudit(req.user.userId, 'add_gold', userId, { amount, newGold: result.gold });
res.json({ gold: result.gold });
} catch (err) {
console.error('Add gold error:', err);
res.status(500).json({ error: 'Internal server error' });
Expand All @@ -355,14 +362,21 @@ router.post('/users/:id/essence', requireRole('admin'), async (req, res) => {
}

try {
const state = await prisma.gameState.findUnique({ where: { userId } });
if (!state) return res.status(404).json({ error: 'Game state not found' });

const newEssence = Math.max(0, state.essence + Math.floor(amount));
await prisma.gameState.update({ where: { userId }, data: { essence: newEssence } });
await logAudit(req.user.userId, 'add_essence', userId, { amount, newEssence });

res.json({ essence: newEssence });
const adj = Math.floor(amount);
const result = await prisma.$transaction(async (tx) => {
const state = await tx.gameState.findUnique({ where: { userId }, select: { essence: true } });
if (!state) return null;
const clampedAdj = adj < 0 ? Math.max(-state.essence, adj) : adj;
const gs = await tx.gameState.update({
where: { userId },
data: { essence: { increment: clampedAdj } },
select: { essence: true },
});
return gs;
});
if (!result) return res.status(404).json({ error: 'Game state not found' });
await logAudit(req.user.userId, 'add_essence', userId, { amount, newEssence: result.essence });
res.json({ essence: result.essence });
} catch (err) {
console.error('Add essence error:', err);
res.status(500).json({ error: 'Internal server error' });
Expand All @@ -379,14 +393,21 @@ router.post('/users/:id/diamonds', requireRole('admin'), async (req, res) => {
}

try {
const state = await prisma.gameState.findUnique({ where: { userId } });
if (!state) return res.status(404).json({ error: 'Game state not found' });

const newDiamonds = Math.max(0, state.diamonds + Math.floor(amount));
await prisma.gameState.update({ where: { userId }, data: { diamonds: newDiamonds } });
await logAudit(req.user.userId, 'add_diamonds', userId, { amount, newDiamonds });

res.json({ diamonds: newDiamonds });
const adj = Math.floor(amount);
const result = await prisma.$transaction(async (tx) => {
const state = await tx.gameState.findUnique({ where: { userId }, select: { diamonds: true } });
if (!state) return null;
const clampedAdj = adj < 0 ? Math.max(-state.diamonds, adj) : adj;
const gs = await tx.gameState.update({
where: { userId },
data: { diamonds: { increment: clampedAdj } },
select: { diamonds: true },
});
return gs;
});
if (!result) return res.status(404).json({ error: 'Game state not found' });
await logAudit(req.user.userId, 'add_diamonds', userId, { amount, newDiamonds: result.diamonds });
res.json({ diamonds: result.diamonds });
} catch (err) {
console.error('Add diamonds error:', err);
res.status(500).json({ error: 'Internal server error' });
Expand Down
17 changes: 16 additions & 1 deletion server/routes/clans.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ const MEMBER_INCLUDE = {

const FULL_CLAN_INCLUDE = { ...MEMBER_INCLUDE, _count: { select: { members: true } } };

// Lightweight include for list views — loads only the data needed for power computation
// and member count, skipping full player/gameState JSON blobs.
const LIST_CLAN_INCLUDE = {
members: {
include: {
user: {
select: {
gameState: { select: { equipment: true } },
},
},
},
},
_count: { select: { members: true } },
};

/** Load the requesting user's membership (or null). */
function getMembership(userId) {
return prisma.clanMember.findUnique({ where: { userId } });
Expand Down Expand Up @@ -220,7 +235,7 @@ router.get('/', requireAuth, async (req, res) => {
where,
orderBy: { xp: 'desc' },
take: 25,
include: FULL_CLAN_INCLUDE,
include: LIST_CLAN_INCLUDE,
});
res.json(clans.map((c) => serializeClan(c)));
} catch (err) {
Expand Down
53 changes: 24 additions & 29 deletions server/routes/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import { requireAuth } from '../middleware/auth.js';
import prisma from '../lib/prisma.js';
import { MAX_FORGE_LEVEL } from '../../shared/stats.js';
import {
isFiniteNumber,
isNonNegativeNumber,
isValidEquipment,
isValidCombat,
isValidForgeUpgrade,
Expand Down Expand Up @@ -57,7 +55,8 @@ router.get('/state', requireAuth, async (req, res) => {

// PUT /api/game/state — save player's game state
router.put('/state', requireAuth, async (req, res) => {
const { equipment, gold, diamonds, forgeLevel, forgeUpgrade, combat, essence, player, research, forgeHighestLevel, shopState, skills } = req.body;
const { equipment, forgeUpgrade, combat, player, research, forgeHighestLevel, skills } = req.body;
// gold, diamonds, essence, forgeLevel are handled separately below (anti-cheat)

try {
const data = {};
Expand All @@ -67,23 +66,27 @@ router.put('/state', requireAuth, async (req, res) => {
}
data.equipment = equipment;
}
if (gold !== undefined) {
if (!isNonNegativeNumber(gold)) {
return res.status(400).json({ error: 'Gold must be a non-negative number' });
}
data.gold = Math.floor(gold);
}
if (diamonds !== undefined) {
if (!isNonNegativeNumber(diamonds)) {
return res.status(400).json({ error: 'Diamonds must be a non-negative number' });
}
data.diamonds = Math.floor(diamonds);
}
if (forgeLevel !== undefined) {
if (!isFiniteNumber(forgeLevel) || forgeLevel < 1 || forgeLevel > MAX_FORGE_LEVEL) {
return res.status(400).json({ error: 'Invalid forge level' });
// Currency & forge level: accept from client only if it decreased (spending).
// The client can spend gold (forge upgrades, cosmetics) but never inflate it.
// Server-side grants (payments, expeditions, admin) use atomic increments.
if (req.body.gold !== undefined || req.body.diamonds !== undefined ||
req.body.essence !== undefined || req.body.forgeLevel !== undefined) {
const current = await prisma.gameState.findUnique({
where: { userId: req.user.userId },
select: { gold: true, diamonds: true, essence: true, forgeLevel: true },
});
if (current) {
const g = Math.floor(Number(req.body.gold));
if (Number.isFinite(g) && g >= 0 && g <= current.gold) data.gold = g;
const d = Math.floor(Number(req.body.diamonds));
if (Number.isFinite(d) && d >= 0 && d <= current.diamonds) data.diamonds = d;
const e = Math.floor(Number(req.body.essence));
if (Number.isFinite(e) && e >= 0 && e <= current.essence) data.essence = e;
const fl = Math.floor(Number(req.body.forgeLevel));
if (Number.isFinite(fl) && fl >= 1 && fl >= current.forgeLevel && fl <= MAX_FORGE_LEVEL) {
data.forgeLevel = fl;
}
}
data.forgeLevel = Math.floor(forgeLevel);
}
if (forgeUpgrade !== undefined) {
if (!isValidForgeUpgrade(forgeUpgrade)) {
Expand All @@ -97,12 +100,6 @@ router.put('/state', requireAuth, async (req, res) => {
}
data.combat = combat;
}
if (essence !== undefined) {
if (!isNonNegativeNumber(essence)) {
return res.status(400).json({ error: 'Essence must be a non-negative number' });
}
data.essence = Math.floor(essence);
}
if (player !== undefined) {
if (!isValidPlayer(player)) {
return res.status(400).json({ error: 'Invalid player structure' });
Expand Down Expand Up @@ -134,12 +131,10 @@ router.put('/state', requireAuth, async (req, res) => {
create: {
userId: req.user.userId,
equipment: equipment || {},
gold: typeof gold === 'number' ? Math.floor(gold) : 100, // STARTING_GOLD
diamonds: typeof diamonds === 'number' ? Math.floor(diamonds) : 100,
forgeLevel: forgeLevel || 1,
gold: 100, // STARTING_GOLD — server-authoritative
forgeLevel: 1,
forgeUpgrade: forgeUpgrade || null,
combat: combat || { currentWave: 1, currentSubWave: 1, highestWave: 1, highestSubWave: 1 },
essence: typeof essence === 'number' ? Math.floor(essence) : 0,
player: player || { level: 1, xp: 0, profilePicture: 'wizard' },
research: research || { completed: {}, active: null, queue: [] },
forgeHighestLevel: forgeHighestLevel || {},
Expand Down
1 change: 1 addition & 0 deletions server/routes/monsters.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ router.get('/templates', async (req, res) => {
: null,
}));

res.set('Cache-Control', 'public, max-age=3600');
res.json({ spriteSheet, templates });
} catch (err) {
console.error('Monster templates error:', err);
Expand Down
22 changes: 13 additions & 9 deletions server/routes/payment.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,15 +257,19 @@ router.post('/webhook', async (req, res) => {
});

if (claim.count > 0) {
// Clawback the gold, clamped so the balance can't go negative.
const gs = await prisma.gameState.findUnique({
where: { userId: purchase.userId },
select: { gold: true },
});
const newGold = Math.max(0, (gs?.gold ?? 0) - purchase.goldGranted);
await prisma.gameState.update({
where: { userId: purchase.userId },
data: { gold: newGold },
// Atomic clawback: decrement gold, floor at 0.
await prisma.$transaction(async (tx) => {
const gs = await tx.gameState.findUnique({
where: { userId: purchase.userId },
select: { gold: true },
});
const clawback = Math.min(purchase.goldGranted, gs?.gold ?? 0);
if (clawback > 0) {
await tx.gameState.update({
where: { userId: purchase.userId },
data: { gold: { decrement: clawback } },
});
}
});

await logAudit(purchase.userId, 'refund_gold', purchase.userId, {
Expand Down
5 changes: 4 additions & 1 deletion src/game/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export function grantGold(base) {
}

export function spendGold(amount) {
if (state.gold < amount) return false;
if (amount <= 0 || state.gold < amount) return false;
state.gold -= amount;
save();
gameEvents.emit(EVENTS.STATE_CHANGED);
Expand Down Expand Up @@ -300,6 +300,9 @@ export function equipItem(item) {

/** Discard an item. Gold can't be recovered from gear — gold is deliberately scarce. */
export function trashItem(item) {
if (item?.type && state.equipment[item.type] === item) {
state.equipment[item.type] = null;
}
save();
gameEvents.emit(EVENTS.ITEM_TRASHED, { item });
gameEvents.emit(EVENTS.STATE_CHANGED);
Expand Down
3 changes: 1 addition & 2 deletions src/screens/home.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { renderItemCard, renderDeltaBadge, renderItemComparison, powerDelta } fr
import { EQUIPMENT_TYPES, MAX_FORGE_LEVEL, TIERS, avatarEmoji, stageInfo, arenaXp, arenaFallbackRank } from '../game/config.js';
import { slotIcon, itemIcon, slotLabel, rarityColor, rarityName, itemName } from '../game/items.js';
import {
getEquipment, getEquippedItem, getForgeLevel, getForgeUpgradeCost, getForgeChances,
getEquippedItem, getForgeLevel, getForgeUpgradeCost, getForgeChances,
upgradeForge, equipItem, trashItem, getGold,
getArenaRank, setArenaRank, getPowerScore, getAvatar, getCombatStats, grantGold,
grantPlayerXp, getForgeLevelProgress, getForgeSpeedPct, getForgeBestOf,
Expand Down Expand Up @@ -478,7 +478,6 @@ function showSlotDetail(type) {
onclick: async () => {
const ok = await confirmDialog({ title: 'Trash equipped item?', message: `${itemName(item)} will be removed for good — gear can't be sold for gold.`, confirmText: 'Trash' });
if (ok) {
getEquipment()[type] = null;
trashItem(item);
closeModal();
toast(`Trashed ${itemName(item)}`, 'info');
Expand Down
Loading