update
This commit is contained in:
Generated
+2
-37
@@ -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
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 1–10 → 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 },
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user