This commit is contained in:
gpatruno
2026-07-18 16:26:08 +02:00
parent 49c444e8b9
commit 2614d679c8
85 changed files with 8074 additions and 710 deletions
+2 -37
View File
@@ -14,11 +14,10 @@
"express": "^5.1.0",
"express-session": "^1.18.1",
"multer": "^2.2.0",
"prisma": "^6.11.1",
"socket.io": "^4.8.3"
},
"devDependencies": {
"prisma": "^6.11.1"
}
"devDependencies": {}
},
"node_modules/@prisma/client": {
"version": "6.19.3",
@@ -46,7 +45,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
@@ -59,14 +57,12 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -80,14 +76,12 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3",
@@ -99,7 +93,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3"
@@ -115,7 +108,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/cors": {
@@ -254,7 +246,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
@@ -312,7 +303,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -328,7 +318,6 @@
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
@@ -353,14 +342,12 @@
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
@@ -444,7 +431,6 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@@ -454,7 +440,6 @@
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/depd": {
@@ -470,14 +455,12 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -510,7 +493,6 @@
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -521,7 +503,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -745,14 +726,12 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -860,7 +839,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
@@ -971,7 +949,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -1122,7 +1099,6 @@
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/node-gyp-build": {
@@ -1140,7 +1116,6 @@
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz",
"integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.2.2",
@@ -1158,7 +1133,6 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/object-assign": {
@@ -1186,7 +1160,6 @@
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/on-finished": {
@@ -1242,21 +1215,18 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
@@ -1268,7 +1238,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1307,7 +1276,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -1377,7 +1345,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
@@ -1402,7 +1369,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -1691,7 +1657,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
+5 -3
View File
@@ -8,6 +8,9 @@
"start": "node --env-file=.env src/index.js",
"db:push": "prisma db push",
"db:seed": "node prisma/seed.js",
"db:backfill-wear": "node prisma/backfill-wear.js",
"db:backfill-case-keys": "node prisma/backfill-case-keys.js",
"db:backfill-catalog": "node prisma/backfill-catalog.js",
"db:setup": "prisma db push && node prisma/seed.js"
},
"prisma": {
@@ -20,9 +23,8 @@
"express": "^5.1.0",
"express-session": "^1.18.1",
"multer": "^2.2.0",
"prisma": "^6.11.1",
"socket.io": "^4.8.3"
},
"devDependencies": {
"prisma": "^6.11.1"
}
"devDependencies": {}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Backfill CaseKeyProgress from open_case transactions.
* Run after: npx prisma db push
*
* npm run db:backfill-case-keys --prefix server
*/
import { prisma } from '../src/db.js';
import { progressFromTotalOpens } from '../src/caseKeys.js';
async function main() {
const txs = await prisma.transaction.findMany({
where: { type: 'open_case' },
select: { userId: true, meta: true },
orderBy: { createdAt: 'asc' },
});
const counts = new Map();
for (const tx of txs) {
let meta;
try {
meta = JSON.parse(tx.meta || '{}');
} catch {
continue;
}
const caseId = Number(meta.caseId);
if (!caseId) continue;
const key = `${tx.userId}:${caseId}`;
counts.set(key, (counts.get(key) || 0) + 1);
}
let upserted = 0;
for (const [key, totalOpens] of counts) {
const [userId, caseId] = key.split(':').map(Number);
const { keys, opensSinceLastKey } = progressFromTotalOpens(totalOpens);
await prisma.caseKeyProgress.upsert({
where: { userId_caseId: { userId, caseId } },
create: { userId, caseId, keys, opensSinceLastKey, totalOpens },
update: { keys, opensSinceLastKey, totalOpens },
});
upserted += 1;
}
console.log(`Backfilled ${upserted} user/case key progress rows from ${txs.length} open_case transactions`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+66
View File
@@ -0,0 +1,66 @@
/**
* Backfill CatalogEntry from open_case transactions.
* Run after: npx prisma db push
*
* npm run db:backfill-catalog --prefix server
*/
import { prisma } from '../src/db.js';
async function main() {
const txs = await prisma.transaction.findMany({
where: { type: 'open_case' },
select: { userId: true, meta: true, createdAt: true },
orderBy: { createdAt: 'asc' },
});
const pairs = new Map();
for (const tx of txs) {
let meta;
try {
meta = JSON.parse(tx.meta || '{}');
} catch {
continue;
}
const caseId = Number(meta.caseId);
const itemId = Number(meta.itemId);
if (!caseId || !itemId) continue;
const key = `${tx.userId}:${caseId}:${itemId}`;
if (!pairs.has(key)) {
pairs.set(key, {
userId: tx.userId,
caseId,
itemId,
filledAt: tx.createdAt,
});
}
}
let created = 0;
let skipped = 0;
for (const row of pairs.values()) {
try {
await prisma.catalogEntry.create({
data: {
userId: row.userId,
caseId: row.caseId,
itemId: row.itemId,
filledAt: row.filledAt,
},
});
created += 1;
} catch {
skipped += 1;
}
}
console.log(
`Backfilled catalog: ${created} new entries, ${skipped} already present (${pairs.size} unique drops from ${txs.length} opens)`
);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+38
View File
@@ -0,0 +1,38 @@
/**
* Backfill existing InventoryItem rows with FT mid float + catalog marketValue.
* Run after: npx prisma db push
*
* npm run db:backfill-wear --prefix server
*/
import { prisma } from '../src/db.js';
import { FT_MID_FLOAT } from '../src/wear.js';
async function main() {
const rows = await prisma.inventoryItem.findMany({
include: { item: true },
});
let updated = 0;
for (const inv of rows) {
if (inv.valueCents !== 0) continue;
await prisma.inventoryItem.update({
where: { id: inv.id },
data: {
floatValue: FT_MID_FLOAT,
wear: 'Field-Tested',
paintSeed: 0,
valueCents: inv.item.marketValue,
},
});
updated += 1;
}
console.log(`Backfilled ${updated} / ${rows.length} inventory items`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+90 -14
View File
@@ -11,12 +11,20 @@ model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
balance Int @default(0)
balance BigInt @default(0)
osuBalance Int @default(0)
lastAdAt DateTime?
role String @default("user")
avatarUrl String @default("")
bio String @default("")
autoSellEnabled Boolean @default(false)
autoSellThresholdCents BigInt @default(1000)
prestigeCount Int @default(0)
prestigeChanceBonus Int @default(0)
prestigeGainBonus Int @default(0)
prestigeKeyOpenReduction Int @default(0)
lastConnection DateTime?
playTimeSeconds BigInt @default(0)
createdAt DateTime @default(now())
inventory InventoryItem[]
transactions Transaction[]
@@ -25,16 +33,22 @@ model User {
duelRoomsCreated DuelRoom[] @relation("DuelCreator")
duelWins DuelRoom[] @relation("DuelWinner")
duelDeposits DuelDeposit[]
caseKeyProgress CaseKeyProgress[]
catalogEntries CatalogEntry[]
prestigeLogs PrestigeLog[]
achievements UserAchievement[]
}
model Case {
id Int @id @default(autoincrement())
name String
imageUrl String @default("")
price Int
price BigInt
active Boolean @default(true)
createdAt DateTime @default(now())
items CaseItem[]
keyProgress CaseKeyProgress[]
catalogEntries CatalogEntry[]
}
model Item {
@@ -42,10 +56,11 @@ model Item {
name String
imageUrl String @default("")
rarity String
marketValue Int
marketValue BigInt
createdAt DateTime @default(now())
cases CaseItem[]
inventory InventoryItem[]
catalogEntries CatalogEntry[]
}
model CaseItem {
@@ -60,13 +75,18 @@ model CaseItem {
}
model InventoryItem {
id Int @id @default(autoincrement())
userId Int
itemId Int
locked Boolean @default(false)
obtainedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
itemId Int
locked Boolean @default(false)
floatValue Float @default(0.265)
wear String @default("Field-Tested")
paintSeed Int @default(0)
valueCents BigInt @default(0)
favorite Boolean @default(false)
obtainedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
jackpotDeposits JackpotDeposit[]
duelDeposits DuelDeposit[]
}
@@ -75,7 +95,7 @@ model Transaction {
id Int @id @default(autoincrement())
userId Int
type String
amount Int
amount BigInt
meta String @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -97,7 +117,7 @@ model JackpotDeposit {
roundId Int
userId Int
inventoryItemId Int
valueCents Int
valueCents BigInt
createdAt DateTime @default(now())
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -112,7 +132,7 @@ model DuelRoom {
status String @default("open")
maxPlayers Int
maxItemsPerPlayer Int
minItemValue Int @default(0)
minItemValue BigInt @default(0)
minPlayersToStart Int @default(2)
winnerUserId Int?
createdAt DateTime @default(now())
@@ -128,7 +148,7 @@ model DuelDeposit {
roomId Int
userId Int
inventoryItemId Int
valueCents Int
valueCents BigInt
createdAt DateTime @default(now())
room DuelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -136,3 +156,59 @@ model DuelDeposit {
@@unique([roomId, inventoryItemId])
}
model CaseKeyProgress {
id Int @id @default(autoincrement())
userId Int
caseId Int
keys Int @default(1)
opensSinceLastKey Int @default(0)
totalOpens Int @default(0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
@@unique([userId, caseId])
}
model CatalogEntry {
id Int @id @default(autoincrement())
userId Int
caseId Int
itemId Int
filledAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@unique([userId, caseId, itemId])
}
model PrestigeLog {
id Int @id @default(autoincrement())
userId Int
choice String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Achievement {
id Int @id @default(autoincrement())
key String @unique
name String
description String
icon String @default("")
sortOrder Int @default(0)
createdAt DateTime @default(now())
unlocks UserAchievement[]
}
model UserAchievement {
id Int @id @default(autoincrement())
userId Int
achievementId Int
unlockedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
achievement Achievement @relation(fields: [achievementId], references: [id], onDelete: Cascade)
@@unique([userId, achievementId])
}
+13 -3
View File
@@ -25,6 +25,8 @@ const ITEMS = [
];
async function main() {
await prisma.userAchievement.deleteMany();
await prisma.achievement.deleteMany();
await prisma.transaction.deleteMany();
await prisma.inventoryItem.deleteMany();
await prisma.caseItem.deleteMany();
@@ -32,12 +34,19 @@ async function main() {
await prisma.item.deleteMany();
await prisma.user.deleteMany();
const adminHash = await bcrypt.hash('admin123', 10);
const { ACHIEVEMENT_DEFS } = await import('../src/achievements.js');
for (const def of ACHIEVEMENT_DEFS) {
await prisma.achievement.create({ data: def });
}
const adminUsername = String(process.env.ADMIN_USERNAME || 'admin').trim() || 'admin';
const adminPassword = String(process.env.ADMIN_PASSWORD || 'admin123');
const adminHash = await bcrypt.hash(adminPassword, 10);
const userHash = await bcrypt.hash('player123', 10);
await prisma.user.create({
data: {
username: 'admin',
username: adminUsername,
passwordHash: adminHash,
balance: 0,
role: 'admin',
@@ -144,8 +153,9 @@ async function main() {
}
console.log('Seed complete.');
console.log(' admin / admin123');
console.log(` admin: ${adminUsername} / ${adminPassword}`);
console.log(' player / player123 (balance: 100.00, osu: 100)');
console.log(` achievements: ${ACHIEVEMENT_DEFS.length} definitions`);
}
main()
+200
View File
@@ -0,0 +1,200 @@
import { prisma } from './db.js';
import { countCompletedChapters } from './catalog.js';
import { livePlayTimeSeconds } from './presence.js';
/** First batch of achievements — upserted on seed / boot. */
export const ACHIEVEMENT_DEFS = [
{
key: 'welcome',
name: 'Welcome Aboard',
description: 'Create your CaseForge account.',
icon: '★',
sortOrder: 1,
},
{
key: 'first_open',
name: 'First Crack',
description: 'Open your first case.',
icon: '▣',
sortOrder: 2,
},
{
key: 'case_25',
name: 'Case Regular',
description: 'Open 25 cases.',
icon: '25',
sortOrder: 3,
},
{
key: 'case_100',
name: 'Case Addict',
description: 'Open 100 cases.',
icon: '100',
sortOrder: 4,
},
{
key: 'jackpot_win',
name: 'Pot Lucky',
description: 'Win a MultiPlayers Case round.',
icon: '♠',
sortOrder: 5,
},
{
key: 'duel_win',
name: 'Duel Victor',
description: 'Win a 1vX Case battle.',
icon: '⚔',
sortOrder: 6,
},
{
key: 'catalog_chapter',
name: 'Chapter Complete',
description: 'Finish a full catalog chapter.',
icon: '◆',
sortOrder: 7,
},
{
key: 'first_prestige',
name: 'Prestige Bound',
description: 'Prestige for the first time.',
icon: '✦',
sortOrder: 8,
},
{
key: 'shop_customer',
name: 'Shop Regular',
description: 'Buy a shop pack or claim an ad reward.',
icon: '◈',
sortOrder: 9,
},
{
key: 'playtime_hour',
name: 'Hourglass',
description: 'Spend 1 hour playing on CaseForge.',
icon: '⌛',
sortOrder: 10,
},
];
export async function ensureAchievements() {
for (const def of ACHIEVEMENT_DEFS) {
await prisma.achievement.upsert({
where: { key: def.key },
create: def,
update: {
name: def.name,
description: def.description,
icon: def.icon,
sortOrder: def.sortOrder,
},
});
}
}
function serializeAchievement(row, unlockedAt = null) {
return {
id: row.id,
key: row.key,
name: row.name,
description: row.description,
icon: row.icon || '',
sortOrder: row.sortOrder,
unlockedAt,
};
}
export async function getUnlockedAchievements(userId) {
const rows = await prisma.userAchievement.findMany({
where: { userId },
include: { achievement: true },
orderBy: { unlockedAt: 'asc' },
});
return rows.map((r) => serializeAchievement(r.achievement, r.unlockedAt));
}
export async function listAchievementsForUser(userId) {
const [defs, unlocks] = await Promise.all([
prisma.achievement.findMany({ orderBy: { sortOrder: 'asc' } }),
prisma.userAchievement.findMany({
where: { userId },
select: { achievementId: true, unlockedAt: true },
}),
]);
const byId = new Map(unlocks.map((u) => [u.achievementId, u.unlockedAt]));
return defs.map((d) => serializeAchievement(d, byId.get(d.id) || null));
}
/**
* Unlock by key if not already owned. Returns the achievement if newly unlocked.
*/
export async function unlockAchievement(userId, key) {
const achievement = await prisma.achievement.findUnique({ where: { key } });
if (!achievement) return null;
try {
const row = await prisma.userAchievement.create({
data: { userId, achievementId: achievement.id },
include: { achievement: true },
});
return serializeAchievement(row.achievement, row.unlockedAt);
} catch {
// Unique constraint — already unlocked
return null;
}
}
async function unlockMany(userId, keys) {
const newly = [];
for (const key of keys) {
const unlocked = await unlockAchievement(userId, key);
if (unlocked) newly.push(unlocked);
}
return newly;
}
/**
* Evaluate progress-based achievements and unlock any that now qualify.
* Safe to call after game events or when loading profile / achievements page.
*/
export async function checkAchievements(userId) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || user.role === 'admin') return [];
const [casesOpened, jackpotWins, duelWins, chapters, shopOrAd, owned] =
await Promise.all([
prisma.transaction.count({ where: { userId, type: 'open_case' } }),
prisma.transaction.count({ where: { userId, type: 'jackpot_win' } }),
prisma.transaction.count({ where: { userId, type: 'duel_win' } }),
countCompletedChapters(prisma, userId),
prisma.transaction.count({
where: {
userId,
type: { in: ['shop_purchase', 'ad_reward'] },
},
}),
prisma.userAchievement.findMany({
where: { userId },
select: { achievement: { select: { key: true } } },
}),
]);
const have = new Set(owned.map((o) => o.achievement.key));
const candidates = [];
if (!have.has('welcome')) candidates.push('welcome');
if (casesOpened >= 1 && !have.has('first_open')) candidates.push('first_open');
if (casesOpened >= 25 && !have.has('case_25')) candidates.push('case_25');
if (casesOpened >= 100 && !have.has('case_100')) candidates.push('case_100');
if (jackpotWins >= 1 && !have.has('jackpot_win')) candidates.push('jackpot_win');
if (duelWins >= 1 && !have.has('duel_win')) candidates.push('duel_win');
if (chapters >= 1 && !have.has('catalog_chapter')) candidates.push('catalog_chapter');
if ((user.prestigeCount ?? 0) >= 1 && !have.has('first_prestige')) {
candidates.push('first_prestige');
}
if (shopOrAd >= 1 && !have.has('shop_customer')) candidates.push('shop_customer');
if (livePlayTimeSeconds(user) >= 3600 && !have.has('playtime_hour')) {
candidates.push('playtime_hour');
}
return unlockMany(userId, candidates);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Auto-sell inventory items below the user's configured threshold.
* Returns sold items and total credits added (caller updates balance).
* Prestige gain bonus multiplies credited amount (not the sell threshold check).
*/
import { applyGainMultiplier } from './prestige.js';
export function shouldAutoSell(user, valueCents) {
if (!user?.autoSellEnabled) return false;
const threshold = user.autoSellThresholdCents ?? 0;
return valueCents < threshold;
}
export async function applyAutoSell(tx, user, inventoryItems) {
if (!user?.autoSellEnabled) {
return { sold: [], balanceDelta: 0 };
}
const threshold = user.autoSellThresholdCents ?? 0;
const gainBonus = user.prestigeGainBonus ?? 0;
const sold = [];
let balanceDelta = 0;
for (const inv of inventoryItems) {
if (!inv || inv.locked || inv.favorite || inv.valueCents >= threshold) continue;
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
await tx.inventoryItem.delete({ where: { id: inv.id } });
await tx.transaction.create({
data: {
userId: user.id,
type: 'sell_item',
amount: payout,
meta: JSON.stringify({
inventoryItemId: inv.id,
itemId: inv.itemId,
autoSell: true,
valueCents: inv.valueCents,
prestigeGainBonus: gainBonus,
payout,
}),
},
});
sold.push(inv);
balanceDelta += payout;
}
return { sold, balanceDelta };
}
+89
View File
@@ -0,0 +1,89 @@
/** Base simultaneous opens at key 1 (x10). Each +1 key adds +1 open until x20, then continues +1 per key. */
export const BASE_MAX_OPENS = 10;
/** Keys 110 → next key always costs this many opens. */
export const OPENS_PER_KEY_EARLY = 100;
/** From key 11 onward: cost = OPENS_PER_KEY_EARLY × (keys - 9). */
export const ESCALATION_KEY_THRESHOLD = 11;
export function maxOpensForKeys(keys) {
const k = Math.max(1, keys);
return k + (BASE_MAX_OPENS - 1);
}
export function opensRequiredForNextKey(keys, keyOpenReduction = 0) {
const k = Math.max(1, keys);
const base =
k < ESCALATION_KEY_THRESHOLD
? OPENS_PER_KEY_EARLY
: OPENS_PER_KEY_EARLY * (k - (ESCALATION_KEY_THRESHOLD - 2));
const reduction = Math.max(0, Number(keyOpenReduction) || 0);
return Math.max(1, base - reduction);
}
export function serializeKeyProgress(progress, keyOpenReduction = 0) {
const keys = progress?.keys ?? 1;
const opensSinceLastKey = progress?.opensSinceLastKey ?? 0;
const totalOpens = progress?.totalOpens ?? 0;
const opensRequired = opensRequiredForNextKey(keys, keyOpenReduction);
return {
keys,
maxOpens: maxOpensForKeys(keys),
opensSinceLastKey,
opensRequired,
totalOpens,
};
}
/**
* Apply opens to progress state. Returns updated fields + keysGained.
*/
export function applyOpens(progress, count, keyOpenReduction = 0) {
let keys = Math.max(1, progress.keys ?? 1);
let opensSinceLastKey = progress.opensSinceLastKey ?? 0;
let totalOpens = progress.totalOpens ?? 0;
let keysGained = 0;
opensSinceLastKey += count;
totalOpens += count;
while (opensSinceLastKey >= opensRequiredForNextKey(keys, keyOpenReduction)) {
opensSinceLastKey -= opensRequiredForNextKey(keys, keyOpenReduction);
keys += 1;
keysGained += 1;
}
return { keys, opensSinceLastKey, totalOpens, keysGained };
}
/**
* Rebuild keys/opensSinceLastKey from a lifetime open count (backfill).
* Prestige reduction is ignored here — backfill uses base costs.
*/
export function progressFromTotalOpens(totalOpens) {
let keys = 1;
let remaining = totalOpens;
while (remaining >= opensRequiredForNextKey(keys, 0)) {
remaining -= opensRequiredForNextKey(keys, 0);
keys += 1;
}
return {
keys,
opensSinceLastKey: remaining,
totalOpens,
};
}
export async function getOrCreateProgress(tx, userId, caseId) {
const existing = await tx.caseKeyProgress.findUnique({
where: { userId_caseId: { userId, caseId } },
});
if (existing) return existing;
return tx.caseKeyProgress.create({
data: { userId, caseId, keys: 1, opensSinceLastKey: 0, totalOpens: 0 },
});
}
+176
View File
@@ -0,0 +1,176 @@
import { prisma } from './db.js';
import { applyCatalogLuck, normalizeRates } from './weighted.js';
/**
* Count how many active cases the user has fully collected.
* @param {import('@prisma/client').Prisma.TransactionClient | typeof prisma} db
*/
export async function countCompletedChapters(db, userId) {
const cases = await db.case.findMany({
where: { active: true },
include: { items: { select: { itemId: true } } },
});
if (!cases.length) return 0;
const entries = await db.catalogEntry.findMany({
where: { userId },
select: { caseId: true, itemId: true },
});
const filledByCase = new Map();
for (const e of entries) {
if (!filledByCase.has(e.caseId)) filledByCase.set(e.caseId, new Set());
filledByCase.get(e.caseId).add(e.itemId);
}
let completed = 0;
for (const c of cases) {
if (!c.items.length) continue;
const filled = filledByCase.get(c.id);
if (!filled) continue;
if (c.items.every((ci) => filled.has(ci.itemId))) completed += 1;
}
return completed;
}
/**
* Fill catalog slots for newly dropped items. Idempotent upserts.
* @returns {{ newlyFilled: object[], chapterJustCompleted: boolean, luckPercent: number }}
*/
export async function fillCatalogSlots(db, userId, caseId, itemIds) {
const uniqueIds = [...new Set(itemIds.filter((id) => Number.isFinite(id)))];
if (!uniqueIds.length) {
const luckPercent = await countCompletedChapters(db, userId);
return { newlyFilled: [], chapterJustCompleted: false, luckPercent };
}
const existing = await db.catalogEntry.findMany({
where: {
userId,
caseId,
itemId: { in: uniqueIds },
},
select: { itemId: true },
});
const already = new Set(existing.map((e) => e.itemId));
const toCreate = uniqueIds.filter((id) => !already.has(id));
const newlyFilled = [];
for (const itemId of toCreate) {
const entry = await db.catalogEntry.create({
data: { userId, caseId, itemId },
include: { item: true },
});
newlyFilled.push({
itemId: entry.itemId,
name: entry.item.name,
imageUrl: entry.item.imageUrl,
rarity: entry.item.rarity,
filledAt: entry.filledAt,
});
}
const caseItems = await db.caseItem.findMany({
where: { caseId },
select: { itemId: true },
});
const allFilled = await db.catalogEntry.findMany({
where: { userId, caseId },
select: { itemId: true },
});
const filledSet = new Set(allFilled.map((e) => e.itemId));
const chapterComplete =
caseItems.length > 0 && caseItems.every((ci) => filledSet.has(ci.itemId));
const filledBefore = new Set(already);
const completeBefore =
caseItems.length > 0 && caseItems.every((ci) => filledBefore.has(ci.itemId));
const chapterJustCompleted = chapterComplete && !completeBefore;
const luckPercent = await countCompletedChapters(db, userId);
return {
newlyFilled,
chapterJustCompleted,
luckPercent,
};
}
export async function getCatalogState(userId) {
const cases = await prisma.case.findMany({
where: { active: true },
include: {
items: {
include: { item: true },
orderBy: { dropRate: 'asc' },
},
},
orderBy: { price: 'asc' },
});
const entries = await prisma.catalogEntry.findMany({
where: { userId },
select: { caseId: true, itemId: true, filledAt: true },
});
const filledMap = new Map();
for (const e of entries) {
const key = `${e.caseId}:${e.itemId}`;
filledMap.set(key, e.filledAt);
}
const chapters = cases.map((c) => {
const slots = c.items.map((ci) => {
const filledAt = filledMap.get(`${c.id}:${ci.itemId}`) || null;
return {
itemId: ci.item.id,
name: ci.item.name,
imageUrl: ci.item.imageUrl,
rarity: ci.item.rarity,
marketValue: ci.item.marketValue,
dropRate: ci.dropRate,
filled: Boolean(filledAt),
filledAt,
};
});
const filled = slots.filter((s) => s.filled).length;
const total = slots.length;
const completed = total > 0 && filled === total;
return {
caseId: c.id,
name: c.name,
imageUrl: c.imageUrl,
price: c.price,
filled,
total,
completed,
slots,
};
});
const luckPercent = chapters.filter((ch) => ch.completed).length;
return { chapters, luckPercent, completedChapters: luckPercent };
}
/** Compact list of completed case chapters for profile badges. */
export async function getCompletedCatalogBadges(userId) {
const { chapters, luckPercent } = await getCatalogState(userId);
return {
luckPercent,
completed: chapters
.filter((ch) => ch.completed)
.map((ch) => ({
caseId: ch.caseId,
name: ch.name,
imageUrl: ch.imageUrl || '',
})),
};
}
/** Format case items with optional catalog luck applied to displayed chances. */
export function formatCaseItemsWithLuck(items, luckPercent = 0) {
const base = items.map((i) => ({ ...i, dropRate: i.dropRate }));
const boosted = applyCatalogLuck(base, luckPercent);
return normalizeRates(boosted).map(({ dropRate, chance, ...rest }) => ({
...rest,
dropRate,
chance: Number(chance.toFixed(4)),
}));
}
+21 -1
View File
@@ -1,3 +1,23 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
/** Prisma BigInt → Number so the rest of the app can keep using number arithmetic / JSON. */
function reviveMoney(value) {
if (typeof value === 'bigint') return Number(value);
if (value == null || typeof value !== 'object') return value;
if (value instanceof Date) return value;
if (Array.isArray(value)) return value.map(reviveMoney);
for (const key of Object.keys(value)) {
value[key] = reviveMoney(value[key]);
}
return value;
}
const base = new PrismaClient();
export const prisma = base.$extends({
query: {
$allOperations({ args, query }) {
return query(args).then(reviveMoney);
},
},
});
+54
View File
@@ -0,0 +1,54 @@
import bcrypt from 'bcrypt';
import { prisma } from './db.js';
/**
* Sync admin credentials from ADMIN_USERNAME / ADMIN_PASSWORD.
* Required in production (Docker); optional in local dev (seed still creates admin).
*/
export async function ensureAdminFromEnv() {
const username = String(process.env.ADMIN_USERNAME || '').trim();
const password = String(process.env.ADMIN_PASSWORD || '');
const required = process.env.NODE_ENV === 'production';
if (!username || !password) {
if (required) {
throw new Error(
'ADMIN_USERNAME and ADMIN_PASSWORD must be set in .env.prod for production',
);
}
return;
}
if (password.length < 6) {
throw new Error('ADMIN_PASSWORD must be at least 6 characters');
}
const passwordHash = await bcrypt.hash(password, 10);
const existing = await prisma.user.findUnique({ where: { username } });
if (existing) {
await prisma.user.update({
where: { id: existing.id },
data: { passwordHash, role: 'admin' },
});
} else {
const soleAdmin = await prisma.user.findFirst({ where: { role: 'admin' } });
if (soleAdmin) {
await prisma.user.update({
where: { id: soleAdmin.id },
data: { username, passwordHash },
});
} else {
await prisma.user.create({
data: {
username,
passwordHash,
balance: 0,
role: 'admin',
},
});
}
}
console.log(`Admin account ready: ${username}`);
}
+31 -4
View File
@@ -1,22 +1,49 @@
import { getIO } from './realtime.js';
const MAX_FEED = 40;
const DEDUPE_MS = 15_000;
const feed = [];
export function getDropFeed() {
return [...feed];
return feed.map(({ _fp, ...publicEvent }) => publicEvent);
}
function fingerprint(entry) {
const ids = (entry.inventoryIds || [entry.item?.inventoryId]).filter(Boolean).sort();
return [
entry.username || '',
entry.item?.id || '',
entry.count || 1,
entry.openCount || 1,
ids.join(','),
].join('|');
}
export function pushDrop(entry) {
const fp = fingerprint(entry);
const now = Date.now();
// Ignore duplicate announces for the same open batch
const dup = feed.find(
(d) => d._fp === fp && now - new Date(d.at).getTime() < DEDUPE_MS
);
if (dup) return dup;
const event = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
id: `${now}-${Math.random().toString(36).slice(2, 8)}`,
at: new Date().toISOString(),
_fp: fp,
...entry,
};
feed.unshift(event);
if (feed.length > MAX_FEED) feed.length = MAX_FEED;
const io = getIO();
if (io) io.to('feed').emit('feed:drop', event);
return event;
if (io) {
const { _fp, ...publicEvent } = event;
io.to('feed').emit('feed:drop', publicEvent);
return publicEvent;
}
const { _fp, ...publicEvent } = event;
return publicEvent;
}
+94 -10
View File
@@ -17,11 +17,17 @@ import duelsRoutes from './routes/duels.js';
import profileRoutes from './routes/profile.js';
import shopRoutes from './routes/shop.js';
import feedRoutes from './routes/feed.js';
import catalogRoutes from './routes/catalog.js';
import prestigeRoutes from './routes/prestige.js';
import achievementsRoutes from './routes/achievements.js';
import { setIO } from './realtime.js';
import { getDropFeed } from './feed.js';
import { ensureJackpotRound } from './services/jackpot.js';
import { recoverDuelSpins, getDuelRoom, listDuelRooms } from './services/duel.js';
import { recoverDuelSpins, getDuelRoom, listDuelRooms, startEmptyDuelRoomPurge } from './services/duel.js';
import { getJackpotState } from './services/jackpot.js';
import { ensureAdminFromEnv } from './ensureAdmin.js';
import { ensureAchievements, checkAchievements } from './achievements.js';
import { trackConnect, trackDisconnect } from './presence.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '../uploads');
@@ -33,10 +39,28 @@ const app = express();
const server = http.createServer(app);
const PORT = Number(process.env.PORT || 3001);
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';
/** true | false | 'auto' (secure cookie only on HTTPS) */
function cookieSecureOption() {
const v = String(process.env.COOKIE_SECURE ?? 'auto').toLowerCase();
if (v === 'true' || v === '1') return true;
if (v === 'false' || v === '0') return false;
return 'auto';
}
if (TRUST_PROXY) {
app.set('trust proxy', 1);
}
function isAllowedOrigin(origin) {
if (!origin) return true;
if (origin === CLIENT_ORIGIN) return true;
const extra = (process.env.CORS_ORIGINS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (extra.includes(origin)) return true;
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return true;
// Private LAN ranges (dev access from phones / other PCs)
if (/^http:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) return true;
@@ -58,10 +82,11 @@ const sessionMiddleware = session({
secret: process.env.SESSION_SECRET || 'dev-secret',
resave: false,
saveUninitialized: false,
proxy: TRUST_PROXY,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: false,
secure: cookieSecureOption(),
maxAge: 7 * 24 * 60 * 60 * 1000,
},
});
@@ -85,6 +110,9 @@ app.use('/api/duels', duelsRoutes);
app.use('/api/profile', profileRoutes);
app.use('/api/shop', shopRoutes);
app.use('/api/feed', feedRoutes);
app.use('/api/catalog', catalogRoutes);
app.use('/api/prestige', prestigeRoutes);
app.use('/api/achievements', achievementsRoutes);
app.use((err, _req, res, _next) => {
console.error(err);
@@ -100,12 +128,30 @@ io.use((socket, next) => {
sessionMiddleware(socket.request, {}, next);
});
io.on('connection', (socket) => {
io.on('connection', async (socket) => {
const userId = socket.request.session?.userId || null;
socket.data.userId = userId;
if (userId) {
socket.join(`user:${userId}`);
try {
const result = await trackConnect(userId, socket.id);
if (!result.ok) {
socket.emit('session:limit', {
max: result.max,
count: result.count,
message: `Too many open windows for this account (max ${result.max}). Close another tab and reload.`,
});
socket.disconnect(true);
return;
}
socket.join(`user:${userId}`);
socket.emit('session:ok', {
count: result.count,
max: result.max,
});
} catch (err) {
console.error('presence connect', err);
}
}
socket.on('jackpot:subscribe', async () => {
@@ -162,14 +208,52 @@ io.on('connection', (socket) => {
socket.on('feed:unsubscribe', () => {
socket.leave('feed');
});
socket.on('disconnect', () => {
if (userId) {
trackDisconnect(userId, socket.id)
.then(() => checkAchievements(userId))
.catch((err) => console.error(err));
}
});
});
server.listen(PORT, async () => {
console.log(`API listening on http://localhost:${PORT}`);
// Production: serve Vite build (SPA)
const publicDir = path.join(__dirname, '../public');
if (process.env.NODE_ENV === 'production' && fs.existsSync(publicDir)) {
app.use(express.static(publicDir));
app.use((req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
if (
req.path.startsWith('/api') ||
req.path.startsWith('/uploads') ||
req.path.startsWith('/socket.io')
) {
return next();
}
res.sendFile(path.join(publicDir, 'index.html'));
});
}
async function bootstrap() {
try {
await ensureJackpotRound();
await recoverDuelSpins();
await ensureAdminFromEnv();
await ensureAchievements();
} catch (err) {
console.error('Failed to bootstrap game services', err);
console.error(err.message || err);
process.exit(1);
}
});
server.listen(PORT, async () => {
console.log(`API listening on http://localhost:${PORT}`);
try {
await ensureJackpotRound();
await recoverDuelSpins();
startEmptyDuelRoomPurge();
} catch (err) {
console.error('Failed to bootstrap game services', err);
}
});
}
bootstrap();
+8
View File
@@ -25,5 +25,13 @@ export function publicUser(user) {
role: user.role,
avatarUrl: user.avatarUrl || '',
bio: user.bio || '',
autoSellEnabled: Boolean(user.autoSellEnabled),
autoSellThresholdCents: user.autoSellThresholdCents ?? 1000,
prestigeCount: user.prestigeCount ?? 0,
prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
prestigeGainBonus: user.prestigeGainBonus ?? 0,
prestigeKeyOpenReduction: user.prestigeKeyOpenReduction ?? 0,
lastConnection: user.lastConnection || null,
playTimeSeconds: Number(user.playTimeSeconds ?? 0),
};
}
+108
View File
@@ -0,0 +1,108 @@
import { prisma } from './db.js';
export const MAX_SOCKETS_PER_USER = Math.max(
1,
Number(process.env.MAX_SOCKETS_PER_USER || 10)
);
/** @type {Map<number, { sockets: Set<string>, sessionStartedAt: number }>} */
const online = new Map();
export function isConnected(userId) {
return online.has(Number(userId));
}
export function connectionCount(userId) {
return online.get(Number(userId))?.sockets.size || 0;
}
/**
* Track an authenticated socket. Updates lastConnection on first socket for this user.
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
* Slot claim is synchronous so concurrent connects cannot exceed the max.
* @returns {Promise<{ ok: true, count: number, max: number } | { ok: false, count: number, max: number }>}
*/
export async function trackConnect(userId, socketId) {
const id = Number(userId);
const max = MAX_SOCKETS_PER_USER;
if (!id || !socketId) {
return { ok: false, count: 0, max };
}
let entry = online.get(id);
if (!entry) {
entry = { sockets: new Set(), sessionStartedAt: Date.now() };
online.set(id, entry);
}
if (entry.sockets.has(socketId)) {
return { ok: true, count: entry.sockets.size, max };
}
if (entry.sockets.size >= max) {
return { ok: false, count: entry.sockets.size, max };
}
// Claim slot before any await (avoids race past the limit)
entry.sockets.add(socketId);
const isFirst = entry.sockets.size === 1;
if (isFirst) {
try {
await prisma.user.update({
where: { id },
data: { lastConnection: new Date() },
});
} catch (err) {
console.error('presence lastConnection', err);
}
}
return { ok: true, count: entry.sockets.size, max };
}
/**
* Remove a socket. When the last socket for a user drops, add session time to playTimeSeconds.
*/
export async function trackDisconnect(userId, socketId) {
const id = Number(userId);
if (!id || !socketId) return;
const entry = online.get(id);
if (!entry) return;
entry.sockets.delete(socketId);
if (entry.sockets.size > 0) return;
online.delete(id);
const elapsed = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
if (elapsed <= 0) return;
try {
await prisma.user.update({
where: { id },
data: { playTimeSeconds: { increment: elapsed } },
});
} catch (err) {
console.error('presence playTime', err);
}
}
/** Live playtime including the current open session (if any). */
export function livePlayTimeSeconds(user) {
const stored = Number(user?.playTimeSeconds ?? 0);
const entry = online.get(Number(user?.id));
if (!entry) return stored;
const live = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
return stored + live;
}
export function serializePresence(user) {
return {
isConnected: isConnected(user.id),
playTimeSeconds: livePlayTimeSeconds(user),
lastConnection: user.lastConnection || null,
openWindows: connectionCount(user.id),
maxWindows: MAX_SOCKETS_PER_USER,
};
}
+52
View File
@@ -0,0 +1,52 @@
/** 1_000_000_000_000.00 cr in cents */
export const PRESTIGE_COST_CENTS = 100_000_000_000_000n;
/** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000;
export const PRESTIGE_CHOICES = {
chance: { field: 'prestigeChanceBonus', delta: 1, label: 'chance' },
gain: { field: 'prestigeGainBonus', delta: 2, label: 'gain' },
key: { field: 'prestigeKeyOpenReduction', delta: 10, label: 'key' },
};
export function serializePrestige(user) {
return {
count: user?.prestigeCount ?? 0,
chanceBonus: user?.prestigeChanceBonus ?? 0,
gainBonus: user?.prestigeGainBonus ?? 0,
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
costCents: Number(PRESTIGE_COST_CENTS),
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
};
}
/** Apply lifetime gain % to a credit amount (cents). */
export function applyGainMultiplier(cents, gainBonusPct = 0) {
const base = Number(cents) || 0;
const pct = Number(gainBonusPct) || 0;
if (pct <= 0) return Math.floor(base);
return Math.floor(base * (1 + pct / 100));
}
export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
return (Number(catalogLuck) || 0) + (Number(prestigeChanceBonus) || 0);
}
export function isValidPrestigeChoice(choice) {
return Boolean(PRESTIGE_CHOICES[choice]);
}
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
export function isPrestigePageVisible(user, totalCents) {
if ((user?.prestigeCount ?? 0) > 0) return true;
return (Number(totalCents) || 0) >= PRESTIGE_VISIBLE_AT_CENTS;
}
export async function getUnlockedInventoryValueCents(db, userId) {
const rows = await db.inventoryItem.findMany({
where: { userId, locked: false },
select: { valueCents: true },
});
return rows.reduce((sum, r) => sum + (Number(r.valueCents) || 0), 0);
}
+48
View File
@@ -0,0 +1,48 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth } from '../middleware.js';
import {
checkAchievements,
getUnlockedAchievements,
listAchievementsForUser,
} from '../achievements.js';
const router = Router();
/** Full list for the logged-in player (locked + unlocked). */
router.get('/', requireAuth, async (req, res) => {
try {
await checkAchievements(req.session.userId);
const achievements = await listAchievementsForUser(req.session.userId);
const unlocked = achievements.filter((a) => a.unlockedAt).length;
res.json({
achievements,
unlocked,
total: achievements.length,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load achievements' });
}
});
/** Public unlocked badges for a username. */
router.get('/player/:username', async (req, res) => {
try {
const username = String(req.params.username || '').trim();
const user = await prisma.user.findFirst({
where: { username, role: 'user' },
select: { id: true },
});
if (!user) return res.status(404).json({ error: 'Player not found' });
await checkAchievements(user.id);
const achievements = await getUnlockedAchievements(user.id);
res.json({ achievements });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load achievements' });
}
});
export default router;
+14 -2
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import bcrypt from 'bcrypt';
import { prisma } from '../db.js';
import { publicUser, requireAuth } from '../middleware.js';
import { unlockAchievement } from '../achievements.js';
const router = Router();
const STARTING_BALANCE = Number(process.env.STARTING_BALANCE || 10000);
@@ -59,7 +60,13 @@ router.post('/register', async (req, res) => {
req.session.userId = user.id;
req.session.role = user.role;
res.status(201).json({ user: publicUser(user) });
const withConn = await prisma.user.update({
where: { id: user.id },
data: { lastConnection: new Date() },
});
await unlockAchievement(user.id, 'welcome');
res.status(201).json({ user: publicUser(withConn) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Registration failed' });
@@ -84,7 +91,12 @@ router.post('/login', async (req, res) => {
req.session.userId = user.id;
req.session.role = user.role;
res.json({ user: publicUser(user) });
const withConn = await prisma.user.update({
where: { id: user.id },
data: { lastConnection: new Date() },
});
res.json({ user: publicUser(withConn) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Login failed' });
+182 -58
View File
@@ -1,12 +1,26 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { normalizeRates, pickWeighted } from '../weighted.js';
import { pushDrop } from '../feed.js';
import { applyCatalogLuck, pickWeighted } from '../weighted.js';
import { rollInstanceValue } from '../wear.js';
import { shouldAutoSell } from '../autoSell.js';
import {
applyOpens,
getOrCreateProgress,
maxOpensForKeys,
serializeKeyProgress,
} from '../caseKeys.js';
import {
countCompletedChapters,
fillCatalogSlots,
formatCaseItemsWithLuck,
} from '../catalog.js';
import { totalLuckPercent } from '../prestige.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
function formatCase(c, includeRates = false) {
function formatCase(c, luckPercent = 0) {
const items = (c.items || []).map((ci) => ({
id: ci.item.id,
name: ci.item.name,
@@ -14,29 +28,51 @@ function formatCase(c, includeRates = false) {
rarity: ci.item.rarity,
marketValue: ci.item.marketValue,
dropRate: ci.dropRate,
...(includeRates ? {} : {}),
}));
const withChance = normalizeRates(
items.map((i) => ({ ...i, dropRate: i.dropRate }))
);
return {
id: c.id,
name: c.name,
imageUrl: c.imageUrl,
price: c.price,
active: c.active,
items: withChance.map(({ dropRate, chance, ...rest }) => ({
...rest,
dropRate,
chance: Number(chance.toFixed(4)),
})),
catalogLuckPercent: luckPercent,
items: formatCaseItemsWithLuck(items, luckPercent),
};
}
router.get('/', async (_req, res) => {
async function loadUserLuckAndKeys(userId) {
if (!userId) {
return { luckPercent: 0, keyOpenReduction: 0, user: null };
}
const [catalogLuck, user] = await Promise.all([
countCompletedChapters(prisma, userId),
prisma.user.findUnique({
where: { id: userId },
select: {
prestigeChanceBonus: true,
prestigeKeyOpenReduction: true,
},
}),
]);
return {
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
user,
};
}
async function loadKeyProgressMap(userId) {
if (!userId) return new Map();
const rows = await prisma.caseKeyProgress.findMany({
where: { userId },
});
return new Map(rows.map((r) => [r.caseId, r]));
}
router.get('/', async (req, res) => {
try {
const userId = req.session?.userId;
const cases = await prisma.case.findMany({
where: { active: true },
include: {
@@ -44,7 +80,20 @@ router.get('/', async (_req, res) => {
},
orderBy: { price: 'asc' },
});
res.json({ cases: cases.map((c) => formatCase(c)) });
const progressMap = await loadKeyProgressMap(userId);
const { luckPercent, keyOpenReduction } = await loadUserLuckAndKeys(userId);
res.json({
catalogLuckPercent: luckPercent,
cases: cases.map((c) => {
const formatted = formatCase(c, luckPercent);
if (!userId) return formatted;
const progress = progressMap.get(c.id);
return {
...formatted,
keyProgress: serializeKeyProgress(progress, keyOpenReduction),
};
}),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load cases' });
@@ -54,6 +103,7 @@ router.get('/', async (_req, res) => {
router.get('/:id', async (req, res) => {
try {
const id = Number(req.params.id);
const userId = req.session?.userId;
const c = await prisma.case.findUnique({
where: { id },
include: {
@@ -63,7 +113,15 @@ router.get('/:id', async (req, res) => {
if (!c || !c.active) {
return res.status(404).json({ error: 'Case not found' });
}
res.json({ case: formatCase(c, true) });
const { luckPercent, keyOpenReduction } = await loadUserLuckAndKeys(userId);
const formatted = formatCase(c, luckPercent);
if (userId) {
const progress = await prisma.caseKeyProgress.findUnique({
where: { userId_caseId: { userId, caseId: id } },
});
formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction);
}
res.json({ case: formatted });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load case' });
@@ -74,6 +132,9 @@ router.post('/:id/open', requireAuth, async (req, res) => {
try {
const caseId = Number(req.params.id);
const userId = req.session.userId;
let count = Number(req.body?.count ?? 1);
if (!Number.isFinite(count)) count = 1;
count = Math.max(1, Math.floor(count));
const result = await prisma.$transaction(async (tx) => {
const c = await tx.case.findUnique({
@@ -100,72 +161,135 @@ router.post('/:id/open', requireAuth, async (req, res) => {
err.status = 401;
throw err;
}
if (user.balance < c.price) {
const progressRow = await getOrCreateProgress(tx, userId, caseId);
const maxOpens = maxOpensForKeys(progressRow.keys);
if (count > maxOpens) {
const err = new Error(`You can open at most ${maxOpens} at once for this case`);
err.status = 400;
throw err;
}
const totalCost = c.price * count;
if (user.balance < totalCost) {
const err = new Error('Insufficient balance');
err.status = 400;
throw err;
}
const winner = pickWeighted(
const catalogLuck = await countCompletedChapters(tx, userId);
const keyOpenReduction = user.prestigeKeyOpenReduction ?? 0;
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
const weighted = applyCatalogLuck(
c.items.map((ci) => ({
caseItem: ci,
dropRate: ci.dropRate,
}))
})),
luckPercent
);
const item = winner.caseItem.item;
const drops = [];
for (let i = 0; i < count; i += 1) {
const winner = pickWeighted(weighted);
const item = winner.caseItem.item;
const rolled = rollInstanceValue(item.marketValue);
const updatedUser = await tx.user.update({
where: { id: userId },
data: { balance: { decrement: c.price } },
});
const inventoryItem = await tx.inventoryItem.create({
data: {
userId,
itemId: item.id,
},
include: { item: true },
});
await tx.transaction.create({
data: {
userId,
type: 'open_case',
amount: -c.price,
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
const inventoryItem = await tx.inventoryItem.create({
data: {
userId,
itemId: item.id,
itemName: item.name,
inventoryItemId: inventoryItem.id,
}),
},
});
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
},
});
return {
user: publicUser(updatedUser),
caseName: c.name,
item: {
const willAutoSell = shouldAutoSell(user, rolled.valueCents);
await tx.transaction.create({
data: {
userId,
type: 'open_case',
amount: -c.price,
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
itemId: item.id,
itemName: item.name,
inventoryItemId: inventoryItem.id,
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
willAutoSell,
openIndex: i,
openCount: count,
}),
},
});
drops.push({
inventoryId: inventoryItem.id,
id: item.id,
name: item.name,
imageUrl: item.imageUrl,
rarity: item.rarity,
marketValue: item.marketValue,
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
willAutoSell,
});
}
const catalog = await fillCatalogSlots(
tx,
userId,
caseId,
drops.map((d) => d.id)
);
const applied = applyOpens(progressRow, count, keyOpenReduction);
const updatedProgress = await tx.caseKeyProgress.update({
where: { id: progressRow.id },
data: {
keys: applied.keys,
opensSinceLastKey: applied.opensSinceLastKey,
totalOpens: applied.totalOpens,
},
});
const updatedUser = await tx.user.update({
where: { id: userId },
data: {
balance: { decrement: totalCost },
},
});
return {
user: publicUser(updatedUser),
caseName: c.name,
items: drops,
keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction),
keysGained: applied.keysGained,
catalog,
};
});
pushDrop({
source: 'case',
caseName: result.caseName,
username: result.user.username,
avatarUrl: result.user.avatarUrl || '',
item: result.item,
});
checkAchievements(userId).catch((err) => console.error('achievements', err));
res.json({ user: result.user, item: result.item });
res.json({
user: result.user,
caseName: result.caseName,
count: result.items.length,
items: result.items,
keyProgress: result.keyProgress,
keysGained: result.keysGained,
catalog: result.catalog,
item: result.items[0],
});
} catch (err) {
console.error(err);
const status = err.status || 500;
+17
View File
@@ -0,0 +1,17 @@
import { Router } from 'express';
import { requireAuth } from '../middleware.js';
import { getCatalogState } from '../catalog.js';
const router = Router();
router.get('/', requireAuth, async (req, res) => {
try {
const state = await getCatalogState(req.session.userId);
res.json(state);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load catalog' });
}
});
export default router;
+80 -1
View File
@@ -1,5 +1,7 @@
import { Router } from 'express';
import { getDropFeed } from '../feed.js';
import { prisma } from '../db.js';
import { requireAuth } from '../middleware.js';
import { getDropFeed, pushDrop } from '../feed.js';
const router = Router();
@@ -7,4 +9,81 @@ router.get('/', (_req, res) => {
res.json({ drops: getDropFeed() });
});
/**
* Announce case drop(s) to the live feed (after client spin finishes).
* For multi-opens: send all inventoryItemIds — only the best drop is shown,
* with count of how many times that item appeared (e.g. x3).
*/
router.post('/announce', requireAuth, async (req, res) => {
try {
let ids = [];
if (Array.isArray(req.body.inventoryItemIds)) {
ids = req.body.inventoryItemIds.map(Number).filter(Boolean);
} else if (req.body.inventoryItemId) {
ids = [Number(req.body.inventoryItemId)].filter(Boolean);
}
ids = [...new Set(ids)];
if (!ids.length) {
return res.status(400).json({ error: 'inventoryItemId(s) required' });
}
const invItems = await prisma.inventoryItem.findMany({
where: { id: { in: ids }, userId: req.session.userId },
include: {
item: true,
user: { select: { username: true, avatarUrl: true } },
},
});
if (invItems.length !== ids.length) {
return res.status(404).json({ error: 'Inventory item not found' });
}
const now = Date.now();
for (const inv of invItems) {
const ageMs = now - new Date(inv.obtainedAt).getTime();
if (ageMs > 2 * 60 * 1000) {
return res.status(400).json({ error: 'Drop too old to announce' });
}
}
// Best drop = highest instance value (tie-break: first in batch)
let best = invItems[0];
for (const inv of invItems) {
if (inv.valueCents > best.valueCents) best = inv;
}
const count = invItems.filter((inv) => inv.itemId === best.itemId).length;
const caseName = String(req.body.caseName || '').slice(0, 80);
const drop = pushDrop({
source: 'case',
caseName,
count,
openCount: invItems.length,
inventoryIds: invItems.map((i) => i.id),
username: best.user.username,
avatarUrl: best.user.avatarUrl || '',
item: {
inventoryId: best.id,
id: best.item.id,
name: best.item.name,
imageUrl: best.item.imageUrl,
rarity: best.item.rarity,
marketValue: best.valueCents,
valueCents: best.valueCents,
wear: best.wear,
floatValue: best.floatValue,
paintSeed: best.paintSeed,
},
});
res.json({ drop });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to announce drop' });
}
});
export default router;
+208 -41
View File
@@ -1,9 +1,36 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { applyAutoSell } from '../autoSell.js';
import { applyGainMultiplier } from '../prestige.js';
const router = Router();
function serializeInv(inv) {
return {
id: inv.id,
obtainedAt: inv.obtainedAt,
locked: inv.locked,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
valueCents: inv.valueCents,
favorite: Boolean(inv.favorite),
item: {
id: inv.item.id,
name: inv.item.name,
imageUrl: inv.item.imageUrl,
rarity: inv.item.rarity,
marketValue: inv.item.marketValue,
// Instance price for UI (prefer over catalog FT reference)
valueCents: inv.valueCents,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
},
};
}
router.get('/', requireAuth, async (req, res) => {
try {
const items = await prisma.inventoryItem.findMany({
@@ -13,18 +40,7 @@ router.get('/', requireAuth, async (req, res) => {
});
res.json({
inventory: items.map((inv) => ({
id: inv.id,
obtainedAt: inv.obtainedAt,
locked: inv.locked,
item: {
id: inv.item.id,
name: inv.item.name,
imageUrl: inv.item.imageUrl,
rarity: inv.item.rarity,
marketValue: inv.item.marketValue,
},
})),
inventory: items.map(serializeInv),
});
} catch (err) {
console.error(err);
@@ -32,6 +48,88 @@ router.get('/', requireAuth, async (req, res) => {
}
});
router.patch('/auto-sell', requireAuth, async (req, res) => {
try {
const data = {};
if (req.body.enabled != null) {
data.autoSellEnabled = Boolean(req.body.enabled);
}
if (req.body.thresholdCredits != null) {
const credits = Number(req.body.thresholdCredits);
if (!Number.isFinite(credits) || credits < 0) {
return res.status(400).json({ error: 'Invalid threshold' });
}
data.autoSellThresholdCents = Math.round(credits * 100);
}
if (!Object.keys(data).length) {
return res.status(400).json({ error: 'Nothing to update' });
}
const user = await prisma.user.update({
where: { id: req.session.userId },
data,
});
res.json({ user: publicUser(user) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update auto-sell settings' });
}
});
router.post('/auto-sell/apply', requireAuth, async (req, res) => {
try {
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
return res.json({ user: publicUser(await prisma.user.findUnique({ where: { id: req.session.userId } })), soldIds: [], total: 0 });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
},
});
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
if (!balanceDelta) {
return { user, soldIds: [], total: 0 };
}
const updatedUser = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: balanceDelta } },
});
return {
user: updatedUser,
soldIds: sold.map((inv) => inv.id),
total: balanceDelta,
};
});
res.json({
user: publicUser(result.user),
soldIds: result.soldIds,
total: result.total,
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to apply auto-sell' });
}
});
router.post('/sell', requireAuth, async (req, res) => {
try {
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
@@ -39,6 +137,93 @@ router.post('/sell', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Select at least one item to sell' });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
},
include: { item: true },
});
if (invItems.length !== ids.length) {
const err = new Error('Invalid or locked inventory items');
err.status = 400;
throw err;
}
if (invItems.some((inv) => inv.favorite)) {
const err = new Error('Favorite items cannot be sold');
err.status = 400;
throw err;
}
const gainBonus = user.prestigeGainBonus ?? 0;
let total = 0;
for (const inv of invItems) {
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
total += payout;
await tx.inventoryItem.delete({ where: { id: inv.id } });
await tx.transaction.create({
data: {
userId: req.session.userId,
type: 'sell_item',
amount: payout,
meta: JSON.stringify({
inventoryItemId: inv.id,
itemId: inv.itemId,
itemName: inv.item.name,
valueCents: inv.valueCents,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
prestigeGainBonus: gainBonus,
payout,
}),
},
});
}
const updatedUser = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: total } },
});
return { user: updatedUser, total, soldCount: invItems.length };
});
res.json({
user: publicUser(result.user),
total: result.total,
soldCount: result.soldCount,
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to sell items' });
}
});
router.patch('/favorite', requireAuth, async (req, res) => {
try {
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
return res.status(400).json({ error: 'Select at least one item' });
}
if (req.body.favorite == null) {
return res.status(400).json({ error: 'favorite flag required' });
}
const favorite = Boolean(req.body.favorite);
const result = await prisma.$transaction(async (tx) => {
const invItems = await tx.inventoryItem.findMany({
where: {
@@ -55,42 +240,24 @@ router.post('/sell', requireAuth, async (req, res) => {
throw err;
}
const total = invItems.reduce((sum, inv) => sum + inv.item.marketValue, 0);
for (const inv of invItems) {
await tx.inventoryItem.delete({ where: { id: inv.id } });
await tx.transaction.create({
data: {
userId: req.session.userId,
type: 'sell_item',
amount: inv.item.marketValue,
meta: JSON.stringify({
inventoryItemId: inv.id,
itemId: inv.itemId,
itemName: inv.item.name,
valueCents: inv.item.marketValue,
}),
},
});
}
const user = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: total } },
await tx.inventoryItem.updateMany({
where: { id: { in: ids }, userId: req.session.userId },
data: { favorite },
});
return { user, total, soldCount: invItems.length };
const updated = await tx.inventoryItem.findMany({
where: { id: { in: ids }, userId: req.session.userId },
include: { item: true },
});
return updated;
});
res.json({
user: publicUser(result.user),
total: result.total,
soldCount: result.soldCount,
});
res.json({ inventory: result.map(serializeInv) });
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to sell items' });
res.status(status).json({ error: err.message || 'Failed to update favorites' });
}
});
+2 -1
View File
@@ -17,12 +17,13 @@ router.get('/', async (_req, res) => {
const ranked = users
.map((u) => {
const inventoryValue = u.inventory.reduce(
(sum, inv) => sum + inv.item.marketValue,
(sum, inv) => sum + inv.valueCents,
0
);
return {
id: u.id,
username: u.username,
avatarUrl: u.avatarUrl || '',
balance: u.balance,
inventoryValue,
totalWealth: u.balance + inventoryValue,
+168
View File
@@ -0,0 +1,168 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import {
PRESTIGE_CHOICES,
PRESTIGE_COST_CENTS,
getUnlockedInventoryValueCents,
isPrestigePageVisible,
isValidPrestigeChoice,
serializePrestige,
} from '../prestige.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
const COST = Number(PRESTIGE_COST_CENTS);
router.get('/', requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
const [lockedCount, inventoryValue, logs] = await Promise.all([
prisma.inventoryItem.count({
where: { userId: user.id, locked: true },
}),
getUnlockedInventoryValueCents(prisma, user.id),
prisma.prestigeLog.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
take: 20,
}),
]);
const totalCents = (Number(user.balance) || 0) + inventoryValue;
const pageVisible = isPrestigePageVisible(user, totalCents);
const canAfford = user.balance >= COST;
const canPrestige = pageVisible && canAfford && lockedCount === 0;
res.json({
prestige: serializePrestige(user),
pageVisible,
totalCents,
canAfford,
lockedItems: lockedCount,
canPrestige,
choices: [
{
id: 'chance',
title: 'Chance',
description: '+1% catalog-style drop luck (permanent, stacks)',
delta: PRESTIGE_CHOICES.chance.delta,
current: user.prestigeChanceBonus ?? 0,
},
{
id: 'gain',
title: 'Gain multiplier',
description: '+2% credits on sell / auto-sell (permanent, stacks)',
delta: PRESTIGE_CHOICES.gain.delta,
current: user.prestigeGainBonus ?? 0,
},
{
id: 'key',
title: 'Key progress',
description: '10 opens required for next key on all cases (permanent, stacks)',
delta: PRESTIGE_CHOICES.key.delta,
current: user.prestigeKeyOpenReduction ?? 0,
},
],
history: logs.map((l) => ({
id: l.id,
choice: l.choice,
createdAt: l.createdAt,
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load prestige' });
}
});
router.post('/', requireAuth, async (req, res) => {
try {
const choice = String(req.body?.choice || '');
if (!isValidPrestigeChoice(choice)) {
return res.status(400).json({ error: 'Choose chance, gain, or key' });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
if (user.role === 'admin') {
const err = new Error('Admins cannot prestige');
err.status = 403;
throw err;
}
if (user.balance < COST) {
const err = new Error('Insufficient balance for prestige');
err.status = 400;
throw err;
}
const lockedCount = await tx.inventoryItem.count({
where: { userId: user.id, locked: true },
});
if (lockedCount > 0) {
const err = new Error(
'Withdraw items from battles before prestiging (locked inventory)'
);
err.status = 400;
throw err;
}
const boost = PRESTIGE_CHOICES[choice];
const prestigeData = {
prestigeCount: (user.prestigeCount ?? 0) + 1,
[boost.field]: (user[boost.field] ?? 0) + boost.delta,
balance: 0,
};
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
await tx.catalogEntry.deleteMany({ where: { userId: user.id } });
await tx.transaction.create({
data: {
userId: user.id,
type: 'prestige',
amount: -COST,
meta: JSON.stringify({
choice,
prestigeCount: prestigeData.prestigeCount,
delta: boost.delta,
field: boost.field,
}),
},
});
await tx.prestigeLog.create({
data: { userId: user.id, choice },
});
const updated = await tx.user.update({
where: { id: user.id },
data: prestigeData,
});
return updated;
});
checkAchievements(result.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result),
prestige: serializePrestige(result),
choice,
});
} catch (err) {
console.error(err);
const status = err.status || 500;
res.status(status).json({ error: err.message || 'Failed to prestige' });
}
});
export default router;
+136 -12
View File
@@ -6,6 +6,13 @@ import fs from 'fs';
import { fileURLToPath } from 'url';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { getCompletedCatalogBadges } from '../catalog.js';
import { serializePrestige } from '../prestige.js';
import { serializePresence } from '../presence.js';
import {
checkAchievements,
getUnlockedAchievements,
} from '../achievements.js';
const router = Router();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -35,6 +42,111 @@ const upload = multer({
},
});
async function loadCaseOpenStats(userId) {
const rows = await prisma.caseKeyProgress.findMany({
where: { userId, totalOpens: { gt: 0 } },
include: {
case: { select: { id: true, name: true, imageUrl: true } },
},
orderBy: { totalOpens: 'desc' },
});
return rows.map((row) => ({
caseId: row.caseId,
caseName: row.case.name,
imageUrl: row.case.imageUrl || '',
count: row.totalOpens,
keys: row.keys,
}));
}
function serializeInv(inv) {
return {
id: inv.id,
obtainedAt: inv.obtainedAt,
locked: inv.locked,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
valueCents: inv.valueCents,
favorite: Boolean(inv.favorite),
item: {
id: inv.item.id,
name: inv.item.name,
imageUrl: inv.item.imageUrl,
rarity: inv.item.rarity,
marketValue: inv.item.marketValue,
valueCents: inv.valueCents,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
},
};
}
router.get('/player/:username', async (req, res) => {
try {
const username = String(req.params.username || '').trim();
if (!username) {
return res.status(400).json({ error: 'Username required' });
}
const user = await prisma.user.findFirst({
where: { username, role: 'user' },
include: {
inventory: {
where: { locked: false },
include: { item: true },
orderBy: { obtainedAt: 'desc' },
},
},
});
if (!user) return res.status(404).json({ error: 'Player not found' });
const inventoryValue = user.inventory.reduce((sum, inv) => sum + inv.valueCents, 0);
await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
await Promise.all([
prisma.transaction.count({
where: { userId: user.id, type: 'jackpot_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'duel_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'open_case' },
}),
loadCaseOpenStats(user.id),
getCompletedCatalogBadges(user.id),
getUnlockedAchievements(user.id),
]);
res.json({
user: publicUser(user),
profile: {
createdAt: user.createdAt,
inventoryValue,
inventoryCount: user.inventory.length,
jackpotWins,
duelWins,
casesOpened,
caseOpens,
catalogCompleted: catalog.completed,
catalogLuckPercent: catalog.luckPercent,
prestige: serializePrestige(user),
presence: serializePresence(user),
achievements,
},
inventory: user.inventory.map(serializeInv),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load player profile' });
}
});
router.get('/', requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({
@@ -46,21 +158,27 @@ router.get('/', requireAuth, async (req, res) => {
if (!user) return res.status(404).json({ error: 'User not found' });
const inventoryValue = user.inventory.reduce(
(sum, inv) => sum + (inv.locked ? 0 : inv.item.marketValue),
(sum, inv) => sum + (inv.locked ? 0 : inv.valueCents),
0
);
const [jackpotWins, duelWins, casesOpened] = await Promise.all([
prisma.transaction.count({
where: { userId: user.id, type: 'jackpot_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'duel_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'open_case' },
}),
]);
await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
await Promise.all([
prisma.transaction.count({
where: { userId: user.id, type: 'jackpot_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'duel_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'open_case' },
}),
loadCaseOpenStats(user.id),
getCompletedCatalogBadges(user.id),
getUnlockedAchievements(user.id),
]);
res.json({
user: publicUser(user),
@@ -71,6 +189,12 @@ router.get('/', requireAuth, async (req, res) => {
jackpotWins,
duelWins,
casesOpened,
caseOpens,
catalogCompleted: catalog.completed,
catalogLuckPercent: catalog.luckPercent,
prestige: serializePrestige(user),
presence: serializePresence(user),
achievements,
},
});
} catch (err) {
+5
View File
@@ -1,6 +1,7 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
@@ -88,6 +89,8 @@ router.post('/buy', requireAuth, async (req, res) => {
return updated;
});
checkAchievements(result.id).catch((err) => console.error('achievements', err));
res.json({ user: publicUser(result), package: pack });
} catch (err) {
const status = err.status || 500;
@@ -140,6 +143,8 @@ router.post('/ad', requireAuth, async (req, res) => {
return { user: updated, reward };
});
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result.user),
reward: result.reward,
+94 -5
View File
@@ -1,9 +1,14 @@
import { prisma } from '../db.js';
import { pickWeighted } from '../weighted.js';
import { applyAutoSell } from '../autoSell.js';
import { emitDuel, emitDuelsList } from '../realtime.js';
import { checkAchievements } from '../achievements.js';
const SPIN_MS = 5500;
/** Open rooms with zero deposits older than this are auto-cancelled. */
const EMPTY_ROOM_TTL_MS = 3 * 60 * 1000;
const pendingSpins = new Map();
let emptyRoomPurgeTimer = null;
function depositInclude() {
return {
@@ -36,7 +41,11 @@ export function serializeDuelRoom(room) {
name: d.inventoryItem.item.name,
imageUrl: d.inventoryItem.item.imageUrl,
rarity: d.inventoryItem.item.rarity,
marketValue: d.inventoryItem.item.marketValue,
marketValue: d.valueCents,
valueCents: d.valueCents,
wear: d.inventoryItem.wear,
floatValue: d.inventoryItem.floatValue,
paintSeed: d.inventoryItem.paintSeed,
}
: null,
}));
@@ -95,6 +104,7 @@ export async function getDuelRoom(id) {
}
export async function listDuelRooms() {
await purgeStaleEmptyDuelRooms();
const rooms = await prisma.duelRoom.findMany({
where: { status: { in: ['open', 'in_progress', 'spinning'] } },
include: roomInclude(),
@@ -107,6 +117,57 @@ export async function listDuelRooms() {
};
}
/**
* Cancel open 1vX rooms that still have no bets (not even the creator)
* after EMPTY_ROOM_TTL_MS.
*/
export async function purgeStaleEmptyDuelRooms() {
const cutoff = new Date(Date.now() - EMPTY_ROOM_TTL_MS);
const stale = await prisma.duelRoom.findMany({
where: {
status: 'open',
createdAt: { lt: cutoff },
deposits: { none: {} },
},
select: { id: true },
});
if (!stale.length) return 0;
const ids = stale.map((r) => r.id);
await prisma.duelRoom.updateMany({
where: { id: { in: ids }, status: 'open' },
data: { status: 'cancelled', resolvedAt: new Date() },
});
for (const id of ids) {
emitDuel(id, 'duel:state', {
id,
status: 'cancelled',
playerCount: 0,
deposits: [],
players: [],
totalValue: 0,
});
}
await broadcastDuelsList();
return ids.length;
}
export function startEmptyDuelRoomPurge() {
if (emptyRoomPurgeTimer) return;
emptyRoomPurgeTimer = setInterval(() => {
purgeStaleEmptyDuelRooms().catch((err) =>
console.error('purge stale empty duel rooms', err)
);
}, 30_000);
// Run once shortly after boot
setTimeout(() => {
purgeStaleEmptyDuelRooms().catch((err) =>
console.error('purge stale empty duel rooms', err)
);
}, 5_000);
}
export async function broadcastDuelsList() {
const list = await listDuelRooms();
emitDuelsList(list);
@@ -188,7 +249,7 @@ export async function placeDuelBet(userId, roomId, inventoryItemIds) {
}
for (const inv of invItems) {
if (inv.item.marketValue < current.minItemValue) {
if (inv.valueCents < current.minItemValue) {
throw Object.assign(
new Error(`Items must be worth at least ${current.minItemValue} cents`),
{ status: 400 }
@@ -203,7 +264,7 @@ export async function placeDuelBet(userId, roomId, inventoryItemIds) {
roomId: current.id,
userId,
inventoryItemId: inv.id,
valueCents: inv.item.marketValue,
valueCents: inv.valueCents,
},
});
await tx.transaction.create({
@@ -215,7 +276,9 @@ export async function placeDuelBet(userId, roomId, inventoryItemIds) {
roomId: current.id,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: inv.item.marketValue,
valueCents: inv.valueCents,
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
@@ -401,6 +464,7 @@ function scheduleDuelFinish(roomId) {
async function finishDuelSpin(roomId) {
pendingSpins.delete(roomId);
let winnerId = null;
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
@@ -409,7 +473,7 @@ async function finishDuelSpin(roomId) {
});
if (!current || current.status !== 'spinning') return null;
const winnerId = current.winnerUserId;
winnerId = current.winnerUserId;
for (const d of current.deposits) {
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
@@ -417,6 +481,19 @@ async function finishDuelSpin(roomId) {
});
}
const acquiredIds = current.deposits
.filter((d) => d.userId !== winnerId)
.map((d) => d.inventoryItemId);
let autoSellTotal = 0;
if (acquiredIds.length) {
const winner = await tx.user.findUnique({ where: { id: winnerId } });
const acquiredItems = await tx.inventoryItem.findMany({
where: { id: { in: acquiredIds } },
});
const { balanceDelta } = await applyAutoSell(tx, winner, acquiredItems);
autoSellTotal = balanceDelta;
}
const totalValue = current.deposits.reduce((s, d) => s + d.valueCents, 0);
await tx.transaction.create({
data: {
@@ -426,10 +503,18 @@ async function finishDuelSpin(roomId) {
meta: JSON.stringify({
roomId: current.id,
depositIds: current.deposits.map((d) => d.id),
autoSellTotal,
}),
},
});
if (autoSellTotal > 0) {
await tx.user.update({
where: { id: winnerId },
data: { balance: { increment: autoSellTotal } },
});
}
return tx.duelRoom.update({
where: { id: current.id },
data: { status: 'resolved', resolvedAt: new Date() },
@@ -439,6 +524,10 @@ async function finishDuelSpin(roomId) {
if (!room) return;
if (winnerId) {
checkAchievements(winnerId).catch((err) => console.error('achievements', err));
}
const state = serializeDuelRoom(room);
emitDuel(room.id, 'duel:result', state);
emitDuel(room.id, 'duel:state', state);
+217 -26
View File
@@ -1,14 +1,28 @@
import { prisma } from '../db.js';
import { pickWeighted } from '../weighted.js';
import { applyAutoSell } from '../autoSell.js';
import { emitJackpot } from '../realtime.js';
import { pushDrop } from '../feed.js';
import { checkAchievements } from '../achievements.js';
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 60);
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 30);
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3);
const MIN_PLAYERS = 2;
const SPIN_MS = 5500;
/** Sentinel endsAt while waiting for enough players (timer not armed). */
const WAITING_ENDS_AT = new Date('2099-01-01T00:00:00.000Z');
let resolveTimer = null;
let spinFollowUp = null;
function isTimerWaiting(endsAt) {
return new Date(endsAt).getFullYear() >= 2099;
}
function uniquePlayerCount(deposits) {
return new Set((deposits || []).map((d) => d.userId)).size;
}
function depositInclude() {
return {
user: { select: { id: true, username: true, avatarUrl: true } },
@@ -32,7 +46,11 @@ export function serializeJackpotRound(round) {
name: d.inventoryItem.item.name,
imageUrl: d.inventoryItem.item.imageUrl,
rarity: d.inventoryItem.item.rarity,
marketValue: d.inventoryItem.item.marketValue,
marketValue: d.valueCents,
valueCents: d.valueCents,
wear: d.inventoryItem.wear,
floatValue: d.inventoryItem.floatValue,
paintSeed: d.inventoryItem.paintSeed,
}
: null,
}));
@@ -58,10 +76,16 @@ export function serializeJackpotRound(round) {
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
}));
const waitingForPlayers =
players.length < MIN_PLAYERS || isTimerWaiting(round.endsAt);
return {
id: round.id,
status: round.status,
endsAt: round.endsAt,
endsAt: waitingForPlayers ? null : round.endsAt,
waitingForPlayers,
roundSeconds: ROUND_SECONDS,
minPlayers: MIN_PLAYERS,
createdAt: round.createdAt,
resolvedAt: round.resolvedAt || null,
winnerUserId: round.winnerUserId || null,
@@ -105,9 +129,8 @@ export async function broadcastJackpot(extra = {}) {
}
async function createOpenRound() {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
return prisma.jackpotRound.create({
data: { status: 'open', endsAt },
data: { status: 'open', endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
@@ -115,11 +138,15 @@ async function createOpenRound() {
});
}
function clearTimers() {
function clearResolveTimer() {
if (resolveTimer) {
clearTimeout(resolveTimer);
resolveTimer = null;
}
}
function clearTimers() {
clearResolveTimer();
if (spinFollowUp) {
clearTimeout(spinFollowUp);
spinFollowUp = null;
@@ -127,7 +154,8 @@ function clearTimers() {
}
function scheduleResolve(endsAt) {
clearTimers();
clearResolveTimer();
if (isTimerWaiting(endsAt)) return;
const delay = Math.max(0, new Date(endsAt).getTime() - Date.now());
resolveTimer = setTimeout(() => {
resolveJackpotRound().catch((err) => console.error('jackpot resolve', err));
@@ -137,6 +165,7 @@ function scheduleResolve(endsAt) {
export async function ensureJackpotRound() {
let round = await prisma.jackpotRound.findFirst({
where: { status: 'open' },
include: { deposits: true },
orderBy: { id: 'desc' },
});
@@ -146,14 +175,39 @@ export async function ensureJackpotRound() {
orderBy: { id: 'desc' },
});
if (spinning) {
// Server restarted mid-spin — finish after a short delay
spinFollowUp = setTimeout(() => {
finishJackpotSpin(spinning.id).catch((err) => console.error(err));
}, 1000);
return getJackpotState();
}
round = await createOpenRound();
} else if (new Date(round.endsAt).getTime() <= Date.now()) {
return getJackpotState();
}
const players = uniquePlayerCount(round.deposits);
if (players < MIN_PLAYERS) {
if (!isTimerWaiting(round.endsAt)) {
await prisma.jackpotRound.update({
where: { id: round.id },
data: { endsAt: WAITING_ENDS_AT },
});
}
clearResolveTimer();
return getJackpotState();
}
if (isTimerWaiting(round.endsAt)) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
await prisma.jackpotRound.update({
where: { id: round.id },
data: { endsAt },
});
scheduleResolve(endsAt);
return getJackpotState();
}
if (new Date(round.endsAt).getTime() <= Date.now()) {
await resolveJackpotRound();
return getJackpotState();
}
@@ -179,7 +233,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
}
if (new Date(open.endsAt).getTime() <= Date.now()) {
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() <= Date.now()) {
throw Object.assign(new Error('Round is closing'), { status: 409 });
}
@@ -211,7 +265,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
roundId: open.id,
userId,
inventoryItemId: inv.id,
valueCents: inv.item.marketValue,
valueCents: inv.valueCents,
},
});
await tx.transaction.create({
@@ -223,21 +277,48 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
roundId: open.id,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: inv.item.marketValue,
valueCents: inv.valueCents,
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
}
return tx.jackpotRound.findUnique({
let next = await tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
// Arm countdown once we have enough distinct players
if (
uniquePlayerCount(next.deposits) >= MIN_PLAYERS &&
isTimerWaiting(next.endsAt)
) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
next = await tx.jackpotRound.update({
where: { id: next.id },
data: { endsAt },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
return next;
});
if (
uniquePlayerCount(round.deposits) >= MIN_PLAYERS &&
!isTimerWaiting(round.endsAt)
) {
scheduleResolve(round.endsAt);
}
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
return state;
@@ -257,7 +338,7 @@ export async function withdrawJackpotBet(userId, inventoryItemIds) {
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
}
if (new Date(open.endsAt).getTime() - Date.now() < 3000) {
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() - Date.now() < 3000) {
throw Object.assign(new Error('Too late to withdraw'), { status: 409 });
}
@@ -280,31 +361,53 @@ export async function withdrawJackpotBet(userId, inventoryItemIds) {
});
}
return tx.jackpotRound.findUnique({
let next = await tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
// Disarm timer if we drop below min players
if (uniquePlayerCount(next.deposits) < MIN_PLAYERS && !isTimerWaiting(next.endsAt)) {
next = await tx.jackpotRound.update({
where: { id: next.id },
data: { endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
return next;
});
if (isTimerWaiting(round.endsAt) || uniquePlayerCount(round.deposits) < MIN_PLAYERS) {
clearResolveTimer();
}
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
return state;
}
async function finishJackpotSpin(roundId) {
let feedEntry = null;
let winnerId = null;
const result = await prisma.$transaction(async (tx) => {
const round = await tx.jackpotRound.findUnique({
where: { id: roundId },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true, avatarUrl: true } },
},
});
if (!round || round.status !== 'spinning') return null;
const winnerId = round.winnerUserId;
winnerId = round.winnerUserId;
for (const d of round.deposits) {
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
@@ -312,6 +415,19 @@ async function finishJackpotSpin(roundId) {
});
}
const acquiredIds = round.deposits
.filter((d) => d.userId !== winnerId)
.map((d) => d.inventoryItemId);
let autoSellTotal = 0;
if (acquiredIds.length) {
const winner = await tx.user.findUnique({ where: { id: winnerId } });
const acquiredItems = await tx.inventoryItem.findMany({
where: { id: { in: acquiredIds } },
});
const { balanceDelta } = await applyAutoSell(tx, winner, acquiredItems);
autoSellTotal = balanceDelta;
}
const totalValue = round.deposits.reduce((s, d) => s + d.valueCents, 0);
await tx.transaction.create({
data: {
@@ -321,22 +437,86 @@ async function finishJackpotSpin(roundId) {
meta: JSON.stringify({
roundId: round.id,
depositIds: round.deposits.map((d) => d.id),
autoSellTotal,
}),
},
});
if (autoSellTotal > 0) {
await tx.user.update({
where: { id: winnerId },
data: { balance: { increment: autoSellTotal } },
});
}
await tx.jackpotRound.update({
where: { id: round.id },
data: { status: 'resolved', resolvedAt: new Date() },
});
// Best item in the pot (highest instance value) for Feed Drop, with xN count
let best = null;
for (const d of round.deposits) {
const inv = d.inventoryItem;
const item = inv?.item;
if (!item) continue;
if (!best || d.valueCents > best.deposit.valueCents) {
best = { deposit: d, item, inv };
}
}
const winnerUser =
round.winner ||
round.deposits.find((d) => d.userId === winnerId)?.user ||
null;
if (best && winnerUser) {
const count = round.deposits.filter(
(d) => d.inventoryItem?.itemId === best.item.id
).length;
feedEntry = {
source: 'jackpot',
caseName: 'MultiPlayers Case',
count,
openCount: round.deposits.length,
inventoryIds: round.deposits.map((d) => d.inventoryItemId).filter(Boolean),
username: winnerUser.username,
avatarUrl: winnerUser.avatarUrl || '',
item: {
inventoryId: best.deposit.inventoryItemId,
id: best.item.id,
name: best.item.name,
imageUrl: best.item.imageUrl,
rarity: best.item.rarity,
marketValue: best.deposit.valueCents,
valueCents: best.deposit.valueCents,
wear: best.inv.wear,
floatValue: best.inv.floatValue,
paintSeed: best.inv.paintSeed,
},
};
}
return createOpenRoundInTx(tx);
});
if (!result) return;
if (winnerId) {
checkAchievements(winnerId).catch((err) => console.error('achievements', err));
}
if (feedEntry) {
try {
pushDrop(feedEntry);
} catch (err) {
console.error('jackpot feed drop', err);
}
}
const state = serializeJackpotRound(result);
scheduleResolve(result.endsAt);
// New round waits for players — do not arm timer yet
clearResolveTimer();
emitJackpot('jackpot:result', {
previousRoundId: roundId,
...state,
@@ -345,9 +525,8 @@ async function finishJackpotSpin(roundId) {
}
async function createOpenRoundInTx(tx) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
return tx.jackpotRound.create({
data: { status: 'open', endsAt },
data: { status: 'open', endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
@@ -368,17 +547,29 @@ export async function resolveJackpotRound() {
});
if (!open) return null;
if (!open.deposits.length) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
const extended = await tx.jackpotRound.update({
const players = uniquePlayerCount(open.deposits);
if (players < MIN_PLAYERS) {
const waiting = await tx.jackpotRound.update({
where: { id: open.id },
data: { endsAt },
data: { endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
return { kind: 'extend', updated: extended };
return { kind: 'waiting', updated: waiting };
}
if (!open.deposits.length) {
const waiting = await tx.jackpotRound.update({
where: { id: open.id },
data: { endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
return { kind: 'waiting', updated: waiting };
}
const byUser = new Map();
@@ -408,9 +599,9 @@ export async function resolveJackpotRound() {
if (!spun) return;
if (spun.kind === 'extend') {
if (spun.kind === 'waiting' || spun.kind === 'extend') {
const state = serializeJackpotRound(spun.updated);
scheduleResolve(state.endsAt);
clearResolveTimer();
emitJackpot('jackpot:state', state);
return state;
}
+122
View File
@@ -0,0 +1,122 @@
/**
* CS-style float / wear for inventory instances.
*
* Item.marketValue = Field-Tested reference price (mid-band ≈ 1.00×).
* Per-drop float f ∈ [0, 1) drives wear grade and price multiplier.
*/
export const WEAR_BANDS = [
{
wear: 'Factory New',
min: 0,
max: 0.07,
multBest: 1.5,
multWorst: 1.35,
},
{
wear: 'Minimal Wear',
min: 0.07,
max: 0.15,
multBest: 1.3,
multWorst: 1.15,
},
{
wear: 'Field-Tested',
min: 0.15,
max: 0.38,
multBest: 1.05,
multWorst: 0.8,
},
{
wear: 'Well-Worn',
min: 0.38,
max: 0.45,
multBest: 0.8,
multWorst: 0.6,
},
{
wear: 'Battle-Scarred',
min: 0.45,
max: 1,
multBest: 0.55,
multWorst: 0.4,
},
];
/** Mid Field-Tested float → ~1.00× catalog price */
export const FT_MID_FLOAT = 0.265;
export function clampFloat(f) {
const n = Number(f);
if (!Number.isFinite(n)) return 0;
if (n < 0) return 0;
if (n >= 1) return 0.999999;
return n;
}
export function wearFromFloat(floatValue) {
const f = clampFloat(floatValue);
for (const band of WEAR_BANDS) {
if (f >= band.min && f < band.max) return band.wear;
}
return 'Battle-Scarred';
}
function bandForFloat(floatValue) {
const f = clampFloat(floatValue);
for (const band of WEAR_BANDS) {
if (f >= band.min && f < band.max) return band;
}
return WEAR_BANDS[WEAR_BANDS.length - 1];
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
/** Position in wear band: 0 = best (low float), 1 = worst (high float) */
export function priceMultiplier(floatValue) {
const f = clampFloat(floatValue);
const band = bandForFloat(f);
const span = band.max - band.min;
// Field-Tested: pin mid-band to 1.00× catalog (better half → 1.05, worse → 0.80)
if (band.wear === 'Field-Tested') {
const mid = (band.min + band.max) / 2;
if (f <= mid) {
const t = mid > band.min ? (f - band.min) / (mid - band.min) : 0;
return lerp(band.multBest, 1, Math.min(1, Math.max(0, t)));
}
const t = band.max > mid ? (f - mid) / (band.max - mid) : 0;
return lerp(1, band.multWorst, Math.min(1, Math.max(0, t)));
}
const t = span > 0 ? (f - band.min) / span : 0;
return lerp(band.multBest, band.multWorst, Math.min(1, Math.max(0, t)));
}
export function computeValueCents(baseMarketValue, floatValue) {
const base = Math.max(0, Math.floor(Number(baseMarketValue) || 0));
if (base <= 0) return 0;
const cents = Math.round(base * priceMultiplier(floatValue));
return Math.max(1, cents);
}
export function rollFloat() {
return Math.random();
}
export function rollPaintSeed() {
return Math.floor(Math.random() * 1000);
}
/** Full roll for a new inventory instance from catalog marketValue */
export function rollInstanceValue(baseMarketValue) {
const floatValue = rollFloat();
return {
floatValue,
wear: wearFromFloat(floatValue),
paintSeed: rollPaintSeed(),
valueCents: computeValueCents(baseMarketValue, floatValue),
};
}
+20
View File
@@ -22,3 +22,23 @@ export function normalizeRates(entries) {
chance: (e.dropRate / total) * 100,
}));
}
/**
* Boost drop rates below the median by (1 + luckPercent/100).
* Same factor on all weights would not change odds — only rares get the boost.
*/
export function applyCatalogLuck(entries, luckPercent = 0) {
const luck = Number(luckPercent) || 0;
if (luck <= 0 || !entries?.length) return entries;
const rates = entries.map((e) => e.dropRate).sort((a, b) => a - b);
const mid = Math.floor(rates.length / 2);
const median =
rates.length % 2 === 0 ? (rates[mid - 1] + rates[mid]) / 2 : rates[mid];
const factor = 1 + luck / 100;
return entries.map((e) => ({
...e,
dropRate: e.dropRate < median ? e.dropRate * factor : e.dropRate,
}));
}