update nice visuel

This commit is contained in:
gpatruno
2026-07-25 20:04:34 +02:00
parent 3a8871cbae
commit 810e4ac922
78 changed files with 6742 additions and 3116 deletions
+508
View File
@@ -15,10 +15,448 @@
"express-session": "^1.18.1",
"multer": "^2.2.0",
"prisma": "^6.11.1",
"sharp": "^0.34.4",
"socket.io": "^4.8.3"
},
"devDependencies": {}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz",
"integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz",
"integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.3"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz",
"integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz",
"integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz",
"integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz",
"integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz",
"integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz",
"integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz",
"integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz",
"integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz",
"integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz",
"integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz",
"integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz",
"integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz",
"integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz",
"integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz",
"integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz",
"integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz",
"integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.5.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz",
"integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz",
"integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz",
"integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@prisma/client": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
@@ -457,6 +895,15 @@
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1420,6 +1867,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -1471,6 +1930,48 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz",
"integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.0",
"semver": "^7.7.2"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.4",
"@img/sharp-darwin-x64": "0.34.4",
"@img/sharp-libvips-darwin-arm64": "1.2.3",
"@img/sharp-libvips-darwin-x64": "1.2.3",
"@img/sharp-libvips-linux-arm": "1.2.3",
"@img/sharp-libvips-linux-arm64": "1.2.3",
"@img/sharp-libvips-linux-ppc64": "1.2.3",
"@img/sharp-libvips-linux-s390x": "1.2.3",
"@img/sharp-libvips-linux-x64": "1.2.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.3",
"@img/sharp-libvips-linuxmusl-x64": "1.2.3",
"@img/sharp-linux-arm": "0.34.4",
"@img/sharp-linux-arm64": "0.34.4",
"@img/sharp-linux-ppc64": "0.34.4",
"@img/sharp-linux-s390x": "0.34.4",
"@img/sharp-linux-x64": "0.34.4",
"@img/sharp-linuxmusl-arm64": "0.34.4",
"@img/sharp-linuxmusl-x64": "0.34.4",
"@img/sharp-wasm32": "0.34.4",
"@img/sharp-win32-arm64": "0.34.4",
"@img/sharp-win32-ia32": "0.34.4",
"@img/sharp-win32-x64": "0.34.4"
}
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -1671,6 +2172,13 @@
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+2 -2
View File
@@ -24,7 +24,7 @@
"express-session": "^1.18.1",
"multer": "^2.2.0",
"prisma": "^6.11.1",
"sharp": "^0.34.4",
"socket.io": "^4.8.3"
},
"devDependencies": {}
}
}
Binary file not shown.
+58 -52
View File
@@ -11,29 +11,21 @@ model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
balance BigInt @default(0)
balance String @default("0")
osuBalance Int @default(0)
prBalance Int @default(0)
prBalance String @default("0")
lastAdAt DateTime?
role String @default("user")
avatarUrl String @default("")
bio String @default("")
autoSellEnabled Boolean @default(false)
autoSellThresholdCents BigInt @default(1000)
autoSellThresholdCents String @default("1000")
prestigeCount Int @default(0)
prestigeChanceBonus Int @default(0)
prestigeGainBonus Int @default(0)
prestigeKeyOpenReduction Int @default(0)
prestigeStartBonus Int @default(0)
prestigeAdBonus Int @default(0)
prestigeAdCooldownReduction Int @default(0)
prestigeWearBonus Int @default(0)
prestigePrBonus Int @default(0)
prestigeShopDiscount Int @default(0)
prestigeStartOsu Int @default(0)
prestigeDropValueBonus Int @default(0)
prestigeOpenBonus Int @default(0)
prestigeCaseDiscount Int @default(0)
prestigeQualityBonus Int @default(0)
prestigeAnimReduction Int @default(0)
prestigeRefundChance Int @default(0)
@@ -41,27 +33,46 @@ model User {
prestigeVaultSlots Int @default(0)
prestigeOfflineYield Int @default(0)
prestigeOnlineYield Int @default(0)
vaultYieldAt DateTime?
lastConnection DateTime?
playTimeSeconds BigInt @default(0)
createdAt DateTime @default(now())
inventory InventoryItem[]
transactions Transaction[]
jackpotWins JackpotRound[] @relation("JackpotWinner")
jackpotCreated JackpotRound[] @relation("JackpotCreator")
jackpotDeposits JackpotDeposit[]
duelRoomsCreated DuelRoom[] @relation("DuelCreator")
duelWins DuelRoom[] @relation("DuelWinner")
duelDeposits DuelDeposit[]
caseKeyProgress CaseKeyProgress[]
catalogEntries CatalogEntry[]
prestigeLogs PrestigeLog[]
achievements UserAchievement[]
paymentOrders PaymentOrder[]
}
model PaymentOrder {
id String @id @default(cuid())
userId Int
packId String
osuAmount Int
priceCents Int
currency String @default("eur")
status String @default("pending")
provider String @default("mock")
providerRef String @default("")
meta String @default("{}")
createdAt DateTime @default(now())
paidAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status])
@@index([providerRef])
}
model Case {
id Int @id @default(autoincrement())
name String
imageUrl String @default("")
price BigInt
price String @default("0")
active Boolean @default(true)
createdAt DateTime @default(now())
items CaseItem[]
@@ -81,7 +92,7 @@ model Item {
imageUrl String @default("")
rarity String
category String @default("Misc")
marketValue BigInt
marketValue String @default("0")
createdAt DateTime @default(now())
cases CaseItem[]
inventory InventoryItem[]
@@ -110,20 +121,19 @@ model InventoryItem {
floatValue Float @default(0.265)
wear String @default("Field-Tested")
paintSeed Int @default(0)
valueCents BigInt @default(0)
valueCents String @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[]
}
model Transaction {
id Int @id @default(autoincrement())
userId Int
type String
amount BigInt
amount String
meta String @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -133,11 +143,15 @@ model JackpotRound {
id Int @id @default(autoincrement())
status String @default("open")
endsAt DateTime
creatorId Int?
winnerUserId Int?
createdAt DateTime @default(now())
resolvedAt DateTime?
creator User? @relation("JackpotCreator", fields: [creatorId], references: [id])
winner User? @relation("JackpotWinner", fields: [winnerUserId], references: [id])
deposits JackpotDeposit[]
@@index([status])
}
model JackpotDeposit {
@@ -145,7 +159,7 @@ model JackpotDeposit {
roundId Int
userId Int
inventoryItemId Int
valueCents BigInt
valueCents String
createdAt DateTime @default(now())
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -154,37 +168,6 @@ model JackpotDeposit {
@@unique([roundId, inventoryItemId])
}
model DuelRoom {
id Int @id @default(autoincrement())
creatorId Int
status String @default("open")
maxPlayers Int
maxItemsPerPlayer Int
minItemValue BigInt @default(0)
minPlayersToStart Int @default(2)
winnerUserId Int?
createdAt DateTime @default(now())
startedAt DateTime?
resolvedAt DateTime?
creator User @relation("DuelCreator", fields: [creatorId], references: [id], onDelete: Cascade)
winner User? @relation("DuelWinner", fields: [winnerUserId], references: [id])
deposits DuelDeposit[]
}
model DuelDeposit {
id Int @id @default(autoincrement())
roomId Int
userId Int
inventoryItemId 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)
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Cascade)
@@unique([roomId, inventoryItemId])
}
model CaseKeyProgress {
id Int @id @default(autoincrement())
userId Int
@@ -215,7 +198,7 @@ model PrestigeLog {
id Int @id @default(autoincrement())
userId Int
choice String
prGranted Int @default(0)
prGranted String @default("0")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
@@ -241,3 +224,26 @@ model UserAchievement {
@@unique([userId, achievementId])
}
/** Key/value app settings (e.g. shop.osuTopupsEnabled). */
model AppSetting {
key String @id
value String
updatedAt DateTime @updatedAt
}
/** Configurable real-money → osu packs for the shop. */
model ShopOsuPack {
id String @id
name String
osu Int
priceCents Int
currency String @default("eur")
badge String @default("")
blurb String @default("")
perOsuLabel String @default("")
enabled Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+23 -16
View File
@@ -1,6 +1,7 @@
import { prisma } from './db.js';
import { countCompletedChapters } from './catalog.js';
import { livePlayTimeSeconds } from './presence.js';
import { isPrestigeSkillTreeMaxed } from './prestige.js';
/** First batch of achievements — upserted on seed / boot. */
export const ACHIEVEMENT_DEFS = [
@@ -34,44 +35,44 @@ export const ACHIEVEMENT_DEFS = [
},
{
key: 'jackpot_win',
name: 'Pot Lucky',
description: 'Win a MultiPlayers Case round.',
name: 'Battle Winner',
description: 'Win a Battle pot.',
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,
sortOrder: 6,
},
{
key: 'first_prestige',
name: 'Prestige Bound',
description: 'Prestige for the first time.',
icon: '✦',
sortOrder: 8,
sortOrder: 7,
},
{
key: 'shop_customer',
name: 'Shop Regular',
description: 'Buy a shop pack or claim an ad reward.',
icon: '◈',
sortOrder: 9,
sortOrder: 8,
},
{
key: 'playtime_hour',
name: 'Hourglass',
description: 'Spend 1 hour playing on CaseOrion.',
icon: '⌛',
sortOrder: 9,
},
{
key: 'prestige_skills_max',
name: 'Skill Absolute',
description: 'Reach max level on every Prestige skill tree branch.',
icon: '♛',
sortOrder: 10,
},
];
@@ -89,6 +90,11 @@ export async function ensureAchievements() {
},
});
}
// Removed game modes (e.g. 1vX duels) — drop obsolete achievement defs
await prisma.userAchievement.deleteMany({
where: { achievement: { key: 'duel_win' } },
});
await prisma.achievement.deleteMany({ where: { key: 'duel_win' } });
}
function serializeAchievement(row, unlockedAt = null) {
@@ -160,16 +166,15 @@ 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] =
const [casesOpened, jackpotWins, 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'] },
type: { in: ['shop_purchase', 'ad_reward', 'osu_purchase'] },
},
}),
prisma.userAchievement.findMany({
@@ -186,7 +191,6 @@ export async function checkAchievements(userId) {
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');
@@ -195,6 +199,9 @@ export async function checkAchievements(userId) {
if (livePlayTimeSeconds(user) >= 3600 && !have.has('playtime_hour')) {
candidates.push('playtime_hour');
}
if (isPrestigeSkillTreeMaxed(user) && !have.has('prestige_skills_max')) {
candidates.push('prestige_skills_max');
}
return unlockMany(userId, candidates);
}
+42 -28
View File
@@ -4,48 +4,62 @@
* Prestige gain bonus multiplies credited amount (not the sell threshold check).
*/
import { applyGainMultiplier } from './prestige.js';
import { toCentsBigInt, centsJson } from './cents.js';
export function shouldAutoSell(user, valueCents) {
if (!user?.autoSellEnabled) return false;
const threshold = user.autoSellThresholdCents ?? 0;
return valueCents < threshold;
const threshold = toCentsBigInt(user.autoSellThresholdCents ?? 0);
return toCentsBigInt(valueCents) < threshold;
}
export async function applyAutoSell(tx, user, inventoryItems) {
/** Compute auto-sell payout without mutating inventory (for deferred claims). */
export function computeAutoSell(user, inventoryItems) {
if (!user?.autoSellEnabled) {
return { sold: [], balanceDelta: 0 };
return { sold: [], balanceDelta: 0n };
}
const threshold = user.autoSellThresholdCents ?? 0;
const threshold = toCentsBigInt(user.autoSellThresholdCents ?? 0);
const gainBonus = user.prestigeGainBonus ?? 0;
const sold = [];
let balanceDelta = 0;
let balanceDelta = 0n;
for (const inv of inventoryItems) {
if (!inv || inv.locked || inv.staked || 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,
}),
},
});
if (!inv || inv.locked || inv.staked || inv.favorite) continue;
const value = toCentsBigInt(inv.valueCents);
if (value >= threshold) continue;
sold.push(inv);
balanceDelta += payout;
balanceDelta += applyGainMultiplier(value, gainBonus);
}
return { sold, balanceDelta };
}
export async function applyAutoSell(tx, user, inventoryItems) {
const { sold, balanceDelta } = computeAutoSell(user, inventoryItems);
const gainBonus = user.prestigeGainBonus ?? 0;
if (!sold.length) {
return { sold: [], balanceDelta: 0n };
}
const soldIds = sold.map((inv) => inv.id);
await tx.inventoryItem.deleteMany({
where: { id: { in: soldIds }, userId: user.id },
});
await tx.transaction.create({
data: {
userId: user.id,
type: 'sell_item',
amount: centsJson(balanceDelta),
meta: JSON.stringify({
count: sold.length,
inventoryItemIds: soldIds,
autoSell: true,
prestigeGainBonus: gainBonus,
payout: centsJson(balanceDelta),
}),
},
});
return { sold, balanceDelta };
}
+8 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from './db.js';
import { applyCatalogLuck, normalizeRates } from './weighted.js';
import { toCentsBigInt } from './cents.js';
/**
* Count how many active cases the user has fully collected.
@@ -103,7 +104,13 @@ export async function getCatalogState(userId) {
orderBy: { dropRate: 'asc' },
},
},
orderBy: { price: 'asc' },
});
cases.sort((a, b) => {
const pa = toCentsBigInt(a.price);
const pb = toCentsBigInt(b.price);
if (pa < pb) return -1;
if (pa > pb) return 1;
return a.id - b.id;
});
const entries = await prisma.catalogEntry.findMany({
+227
View File
@@ -0,0 +1,227 @@
/**
* Safe integer-cents helpers. Money is stored as decimal strings in SQLite
* (Prisma BigInt is limited to signed 64-bit ~9.22e18).
*/
export function toCentsBigInt(value) {
if (typeof value === 'bigint') return value;
if (value == null || value === '') return 0n;
if (typeof value === 'number') {
if (!Number.isFinite(value)) return 0n;
return BigInt(Math.trunc(value));
}
const s = String(value).trim();
if (!s) return 0n;
// Accept plain ints or decimals (truncate toward zero)
const m = s.match(/^([+-]?\d+)/);
if (!m) return 0n;
try {
return BigInt(m[1]);
} catch {
return 0n;
}
}
/** JSON-safe cents (string) so clients keep full precision. */
export function centsJson(value) {
return toCentsBigInt(value).toString();
}
/**
* Parse a credits amount (may include decimals) into integer cents, BigInt-safe.
* "15.5" → 1550n, "15000000000000" → 1500000000000000n.
* Returns null if invalid / negative.
*/
export function creditsToCents(raw) {
if (raw == null || raw === '') return null;
if (typeof raw === 'bigint') {
return raw < 0n ? null : raw * 100n;
}
if (typeof raw === 'number') {
if (!Number.isFinite(raw) || raw < 0) return null;
// Prefer string path when outside safe integer range after *100
if (!Number.isSafeInteger(Math.round(raw * 100))) {
return creditsToCents(String(raw));
}
return BigInt(Math.round(raw * 100));
}
const s = String(raw).trim().replace(',', '.');
if (!s) return null;
const m = s.match(/^(\d+)(?:\.(\d{1,2})\d*)?$/);
if (!m) return null;
try {
const whole = BigInt(m[1]);
const frac = m[2] ? m[2].padEnd(2, '0') : '00';
return whole * 100n + BigInt(frac);
} catch {
return null;
}
}
/** Format cents as a decimal credits string without Number precision loss. */
export function centsToCreditsString(cents) {
const bi = toCentsBigInt(cents);
const neg = bi < 0n;
const abs = neg ? -bi : bi;
const whole = abs / 100n;
const frac = (abs % 100n).toString().padStart(2, '0');
const trimmed = frac === '00' ? whole.toString() : `${whole}.${frac}`.replace(/0+$/, '').replace(/\.$/, '');
return `${neg ? '-' : ''}${trimmed}`;
}
/**
* Multiply cents by (1 + pct/100), floored. Uses integer math only.
*/
export function applyPercentFloor(cents, pct) {
const base = toCentsBigInt(cents);
const p = Math.max(0, Math.floor(Number(pct) || 0));
if (base <= 0n) return 0n;
if (p <= 0) return base;
return (base * BigInt(100 + p)) / 100n;
}
/**
* Multiply cents by a float rate (e.g. 0.1), floored, via fixed-point (1e6).
*/
export function applyRateFloor(cents, rate) {
const base = toCentsBigInt(cents);
if (base <= 0n) return 0n;
const r = Number(rate);
if (!Number.isFinite(r) || r <= 0) return 0n;
const scale = 1_000_000;
const rateInt = Math.max(0, Math.floor(r * scale));
return (base * BigInt(rateInt)) / BigInt(scale);
}
export function minBigInt(a, b) {
return a <= b ? a : b;
}
export function maxBigInt(a, b) {
return a >= b ? a : b;
}
/** Add delta to a stored cents string/bigint; returns new value as string. */
export function addCents(current, delta) {
return (toCentsBigInt(current) + toCentsBigInt(delta)).toString();
}
/** SQLite INTEGER is signed 64-bit — beyond this, use CAS (string BigInt). */
const SQLITE_INT64_MAX = 9223372036854775807n;
function fitsSqliteInt64(value) {
const v = toCentsBigInt(value);
return v >= 0n && v <= SQLITE_INT64_MAX;
}
function insufficientBalanceError(message = 'Insufficient balance') {
const err = new Error(message);
err.status = 400;
err.code = 'INSUFFICIENT_BALANCE';
return err;
}
function balanceConflictError() {
const err = new Error('Balance update conflict, retry');
err.status = 409;
err.code = 'BALANCE_CONFLICT';
return err;
}
async function findUserOrThrow(tx, userId) {
const user = await tx.user.findUnique({ where: { id: userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
return user;
}
/**
* Atomically deduct cost: `balance = balance - cost` WHERE `balance >= cost`.
* SQL arithmetic when values fit SQLite INTEGER; otherwise optimistic CAS.
* @returns {Promise<user>}
*/
export async function spendUserBalance(tx, userId, cost, { message } = {}) {
const costBi = toCentsBigInt(cost);
if (costBi < 0n) throw new Error('cost must be non-negative');
if (costBi === 0n) return findUserOrThrow(tx, userId);
const costStr = costBi.toString();
for (let attempt = 0; attempt < 12; attempt += 1) {
const user = await findUserOrThrow(tx, userId);
const bal = toCentsBigInt(user.balance);
if (bal < costBi) throw insufficientBalanceError(message);
if (fitsSqliteInt64(bal) && fitsSqliteInt64(costBi)) {
const rows = await tx.$executeRaw`
UPDATE "User"
SET balance = CAST((CAST(balance AS INTEGER) - CAST(${costStr} AS INTEGER)) AS TEXT)
WHERE id = ${userId}
AND CAST(balance AS INTEGER) >= CAST(${costStr} AS INTEGER)
`;
if (rows > 0) return findUserOrThrow(tx, userId);
continue;
}
const next = (bal - costBi).toString();
const updated = await tx.user.updateMany({
where: { id: userId, balance: user.balance },
data: { balance: next },
});
if (updated.count === 1) return { ...user, balance: next };
}
throw balanceConflictError();
}
/**
* Atomically credit amount: `balance = balance + amount`.
* @returns {Promise<user>}
*/
export async function creditUserBalance(tx, userId, amount) {
const amt = toCentsBigInt(amount);
if (amt < 0n) throw new Error('amount must be non-negative');
if (amt === 0n) return findUserOrThrow(tx, userId);
const amtStr = amt.toString();
for (let attempt = 0; attempt < 12; attempt += 1) {
const user = await findUserOrThrow(tx, userId);
const bal = toCentsBigInt(user.balance);
const nextBi = bal + amt;
if (fitsSqliteInt64(bal) && fitsSqliteInt64(amt) && fitsSqliteInt64(nextBi)) {
const rows = await tx.$executeRaw`
UPDATE "User"
SET balance = CAST((CAST(balance AS INTEGER) + CAST(${amtStr} AS INTEGER)) AS TEXT)
WHERE id = ${userId}
`;
if (rows > 0) return findUserOrThrow(tx, userId);
continue;
}
const next = nextBi.toString();
const updated = await tx.user.updateMany({
where: { id: userId, balance: user.balance },
data: { balance: next },
});
if (updated.count === 1) return { ...user, balance: next };
}
throw balanceConflictError();
}
/**
* Apply signed delta (BigInt-safe). Negative → atomic spend; positive → credit.
* `currentBalance` is ignored (kept for call-site compatibility).
* @returns {Promise<user>}
*/
export async function adjustUserBalance(tx, userId, _currentBalance, delta) {
const d = toCentsBigInt(delta);
if (d < 0n) return spendUserBalance(tx, userId, -d);
if (d > 0n) return creditUserBalance(tx, userId, d);
return findUserOrThrow(tx, userId);
}
+10 -21
View File
@@ -1,23 +1,12 @@
import { PrismaClient } from '@prisma/client';
/** 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);
},
},
});
/**
* Plain Prisma client — no BigInt→Number revive.
*
* Credits / Pr / prices are stored as decimal **strings** and parsed with
* `toCentsBigInt` / `centsJson` so values can grow past Number.MAX_SAFE_INTEGER.
* Any remaining Prisma BigInt fields (e.g. playTimeSeconds) stay as bigint until
* an API boundary stringifies them (`res.json` replacer in index.js) or an
* explicit Number()/toString() conversion.
*/
export const prisma = new PrismaClient();
+11 -1
View File
@@ -13,9 +13,11 @@ function fingerprint(entry) {
return [
entry.username || '',
entry.item?.id || '',
String(entry.item?.valueCents ?? entry.item?.marketValue ?? ''),
entry.count || 1,
entry.openCount || 1,
ids.join(','),
entry.respin ? 'respin' : 'open',
ids.join(',') || `snap:${entry.item?.name || ''}`,
].join('|');
}
@@ -47,3 +49,11 @@ export function pushDrop(entry) {
const { _fp, ...publicEvent } = event;
return publicEvent;
}
export function clearDropFeed() {
feed.length = 0;
const io = getIO();
if (io) {
io.to('feed').emit('feed:history', []);
}
}
+61 -50
View File
@@ -13,7 +13,6 @@ import inventoryRoutes from './routes/inventory.js';
import leaderboardRoutes from './routes/leaderboard.js';
import adminRoutes from './routes/admin.js';
import jackpotRoutes from './routes/jackpot.js';
import duelsRoutes from './routes/duels.js';
import profileRoutes from './routes/profile.js';
import shopRoutes from './routes/shop.js';
import feedRoutes from './routes/feed.js';
@@ -23,12 +22,11 @@ import achievementsRoutes from './routes/achievements.js';
import vaultRoutes from './routes/vault.js';
import { setIO } from './realtime.js';
import { getDropFeed } from './feed.js';
import { ensureJackpotRound } from './services/jackpot.js';
import { recoverDuelSpins, getDuelRoom, listDuelRooms, startEmptyDuelRoomPurge } from './services/duel.js';
import { getJackpotState } from './services/jackpot.js';
import { ensureJackpotRound, listJackpotBattles, getJackpotRound } from './services/jackpot.js';
import { ensureAdminFromEnv } from './ensureAdmin.js';
import { ensureAchievements, checkAchievements } from './achievements.js';
import { ensureCategories } from './categories.js';
import { ensureShopOsuPacks } from './shopOsu.js';
import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -40,7 +38,7 @@ if (!fs.existsSync(uploadsDir)) {
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 CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5888';
const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';
/** true | false | 'auto' (secure cookie only on HTTPS) */
@@ -96,6 +94,19 @@ const sessionMiddleware = session({
app.use(cors(corsOptions));
app.use(express.json());
app.use(sessionMiddleware);
// Serialize BigInt fields (balance / valueCents) as strings for JSON responses
app.use((_req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) =>
originalJson(
JSON.parse(
JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value
)
)
);
next();
});
app.use('/uploads', express.static(uploadsDir));
app.get('/api/health', (_req, res) => {
@@ -108,7 +119,6 @@ app.use('/api/inventory', inventoryRoutes);
app.use('/api/leaderboard', leaderboardRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/jackpot', jackpotRoutes);
app.use('/api/duels', duelsRoutes);
app.use('/api/profile', profileRoutes);
app.use('/api/shop', shopRoutes);
app.use('/api/feed', feedRoutes);
@@ -137,7 +147,7 @@ io.on('connection', async (socket) => {
if (userId) {
try {
const result = await trackConnect(userId, socket.id);
const result = await trackConnect(userId, socket.id, io);
if (!result.ok) {
socket.emit('session:limit', {
max: result.max,
@@ -147,6 +157,7 @@ io.on('connection', async (socket) => {
socket.disconnect(true);
return;
}
// Soft-replace of older sockets no longer emits session:limit (refresh-safe).
socket.join(`user:${userId}`);
socket.emit('session:ok', {
count: result.count,
@@ -158,52 +169,52 @@ io.on('connection', async (socket) => {
}
}
socket.on('jackpot:subscribe', async () => {
socket.on('jackpots:subscribe', async () => {
socket.join('jackpots');
try {
const battles = await listJackpotBattles();
socket.emit('jackpots:list', { battles });
} catch (err) {
console.error(err);
}
});
socket.on('jackpots:unsubscribe', () => {
socket.leave('jackpots');
});
socket.on('jackpot:subscribe', async (roundId) => {
const id = Number(roundId);
if (id) {
socket.join(`jackpot:${id}`);
try {
const state = await getJackpotRound(id);
if (state) socket.emit('jackpot:state', state);
} catch (err) {
console.error(err);
}
return;
}
// Legacy: join global room and send list head
socket.join('jackpot');
try {
const state = await getJackpotState();
socket.emit('jackpot:state', state);
const battles = await listJackpotBattles();
socket.emit('jackpots:list', { battles });
if (battles[0]) socket.emit('jackpot:state', battles[0]);
} catch (err) {
console.error(err);
}
});
socket.on('jackpot:unsubscribe', () => {
socket.on('jackpot:unsubscribe', (roundId) => {
const id = Number(roundId);
if (id) {
socket.leave(`jackpot:${id}`);
return;
}
socket.leave('jackpot');
});
socket.on('duels:subscribe', async () => {
socket.join('duels');
try {
const list = await listDuelRooms();
socket.emit('duels:list', list);
} catch (err) {
console.error(err);
}
});
socket.on('duels:unsubscribe', () => {
socket.leave('duels');
});
socket.on('duel:subscribe', async (roomId) => {
const id = Number(roomId);
if (!id) return;
socket.join(`duel:${id}`);
try {
const room = await getDuelRoom(id);
if (room) socket.emit('duel:state', room);
} catch (err) {
console.error(err);
}
});
socket.on('duel:unsubscribe', (roomId) => {
const id = Number(roomId);
if (!id) return;
socket.leave(`duel:${id}`);
});
socket.on('feed:subscribe', () => {
socket.join('feed');
socket.emit('feed:history', getDropFeed());
@@ -214,9 +225,10 @@ io.on('connection', async (socket) => {
});
socket.on('disconnect', () => {
if (userId) {
trackDisconnect(userId, socket.id)
.then(() => checkAchievements(userId))
const uid = socket.data.userId || userId;
if (uid) {
trackDisconnect(uid, socket.id)
.then(() => checkAchievements(uid))
.catch((err) => console.error(err));
}
});
@@ -244,6 +256,7 @@ async function bootstrap() {
await ensureAdminFromEnv();
await ensureAchievements();
await ensureCategories();
await ensureShopOsuPacks();
} catch (err) {
console.error(err.message || err);
process.exit(1);
@@ -253,14 +266,12 @@ async function bootstrap() {
console.log(`API listening on http://localhost:${PORT}`);
try {
await ensureJackpotRound();
await recoverDuelSpins();
startEmptyDuelRoomPurge();
// Online vault dividends: check every 60s for completed 10-min ticks
// Online vault dividends: check every 30s for completed ticks (5 min online)
setInterval(() => {
settleOnlineVaultForConnected().catch((err) =>
console.error('vault online interval', err)
);
}, 60_000);
}, 30_000);
} catch (err) {
console.error('Failed to bootstrap game services', err);
}
+7 -4
View File
@@ -19,21 +19,24 @@ export function publicUser(user) {
return {
id: user.id,
username: user.username,
balance: user.balance,
balance: user.balance == null ? '0' : user.balance.toString(),
osuBalance: user.osuBalance ?? 0,
prBalance: user.prBalance ?? 0,
prBalance: user.prBalance == null ? '0' : String(user.prBalance),
lastAdAt: user.lastAdAt || null,
role: user.role,
avatarUrl: user.avatarUrl || '',
bio: user.bio || '',
autoSellEnabled: Boolean(user.autoSellEnabled),
autoSellThresholdCents: user.autoSellThresholdCents ?? 1000,
autoSellThresholdCents:
user.autoSellThresholdCents == null
? '1000'
: user.autoSellThresholdCents.toString(),
prestigeCount: user.prestigeCount ?? 0,
prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
prestigeGainBonus: user.prestigeGainBonus ?? 0,
prestigeWearBonus: user.prestigeWearBonus ?? 0,
prestigeOpenBonus: user.prestigeOpenBonus ?? 0,
prestigeCaseDiscount: user.prestigeCaseDiscount ?? 0,
prestigePrBonus: user.prestigePrBonus ?? 0,
prestigeQualityBonus: user.prestigeQualityBonus ?? 0,
prestigeAnimReduction: user.prestigeAnimReduction ?? 0,
prestigeRefundChance: user.prestigeRefundChance ?? 0,
+138
View File
@@ -0,0 +1,138 @@
import { prisma } from './db.js';
import { formatFiat, paymentsProvider } from './shopCatalog.js';
import { getPurchasableOsuPack } from './shopOsu.js';
/**
* Create a pending osu top-up order.
* Mock provider completes client-side via /confirm.
* Stripe provider (when configured) would return a clientSecret.
*/
export async function createOsuCheckout(userId, packId) {
const pack = await getPurchasableOsuPack(packId);
if (!pack) {
const err = new Error('Unknown or disabled osu pack');
err.status = 400;
throw err;
}
const provider = paymentsProvider();
const order = await prisma.paymentOrder.create({
data: {
userId: Number(userId),
packId: pack.id,
osuAmount: pack.osu,
priceCents: pack.priceCents,
currency: pack.currency,
status: 'pending',
provider,
meta: JSON.stringify({
packName: pack.name,
displayPrice: formatFiat(pack.priceCents, pack.currency),
}),
},
});
if (provider === 'stripe') {
// Placeholder for Stripe PaymentIntent wiring.
// Set PAYMENTS_PROVIDER=stripe + STRIPE_SECRET_KEY when ready.
const err = new Error(
'Stripe checkout is configured but not wired yet — set PAYMENTS_PROVIDER=mock for local buys'
);
err.status = 501;
throw err;
}
return {
order: serializeOrder(order),
pack: {
id: pack.id,
name: pack.name,
osu: pack.osu,
priceCents: pack.priceCents,
currency: pack.currency,
displayPrice: formatFiat(pack.priceCents, pack.currency),
},
mode: 'mock',
/** Client calls POST /api/shop/osu/confirm with this id in mock mode */
confirmable: true,
};
}
/**
* Complete a mock checkout and credit osu (idempotent).
*/
export async function confirmMockOsuPayment(userId, orderId) {
const id = String(orderId || '');
if (!id) {
const err = new Error('Missing order id');
err.status = 400;
throw err;
}
return prisma.$transaction(async (tx) => {
const order = await tx.paymentOrder.findUnique({ where: { id } });
if (!order || order.userId !== Number(userId)) {
const err = new Error('Order not found');
err.status = 404;
throw err;
}
if (order.status === 'paid') {
const user = await tx.user.findUnique({ where: { id: userId } });
return { order: serializeOrder(order), user, alreadyPaid: true };
}
if (order.status !== 'pending') {
const err = new Error(`Order is ${order.status}`);
err.status = 400;
throw err;
}
if (order.provider !== 'mock') {
const err = new Error('This order must be completed by the payment provider');
err.status = 400;
throw err;
}
const now = new Date();
const updatedOrder = await tx.paymentOrder.update({
where: { id: order.id },
data: { status: 'paid', paidAt: now },
});
const user = await tx.user.update({
where: { id: userId },
data: { osuBalance: { increment: order.osuAmount } },
});
await tx.transaction.create({
data: {
userId,
type: 'osu_purchase',
amount: String(order.osuAmount),
meta: JSON.stringify({
orderId: order.id,
packId: order.packId,
priceCents: order.priceCents,
currency: order.currency,
provider: order.provider,
}),
},
});
return { order: serializeOrder(updatedOrder), user, alreadyPaid: false };
});
}
export function serializeOrder(order) {
return {
id: order.id,
packId: order.packId,
osuAmount: order.osuAmount,
priceCents: order.priceCents,
currency: order.currency,
status: order.status,
provider: order.provider,
displayPrice: formatFiat(order.priceCents, order.currency),
createdAt: order.createdAt,
paidAt: order.paidAt || null,
};
}
+60 -14
View File
@@ -3,7 +3,7 @@ import { settleVaultYield } from './vault.js';
export const MAX_SOCKETS_PER_USER = Math.max(
1,
Number(process.env.MAX_SOCKETS_PER_USER || 10)
Number(process.env.MAX_SOCKETS_PER_USER || 2)
);
/** @type {Map<number, { sockets: Set<string>, sessionStartedAt: number, lastVaultCredit?: number }>} */
@@ -35,11 +35,13 @@ export function totalSocketCount() {
/**
* Track an authenticated socket. Updates lastConnection on first socket for this user.
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
* Caps concurrent sockets at MAX_SOCKETS_PER_USER. When full, the oldest socket is
* replaced (covers refresh spam / ghost connections) instead of blocking the new tab.
* Slot claim is synchronous so concurrent connects cannot exceed the max.
* @returns {Promise<{ ok: true, count: number, max: number, vaultCredited?: number } | { ok: false, count: number, max: number }>}
* @param {import('socket.io').Server | null} [io]
* @returns {Promise<{ ok: true, count: number, max: number, vaultCredited?: string, replaced?: string[] } | { ok: false, count: number, max: number }>}
*/
export async function trackConnect(userId, socketId) {
export async function trackConnect(userId, socketId, io = null) {
const id = Number(userId);
const max = MAX_SOCKETS_PER_USER;
if (!id || !socketId) {
@@ -52,18 +54,42 @@ export async function trackConnect(userId, socketId) {
online.set(id, entry);
}
// Drop ids that are no longer connected (HMR / crashed tabs leave ghosts otherwise)
pruneDeadSockets(entry, io);
if (entry.sockets.size === 0) {
entry.sessionStartedAt = Date.now();
}
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 };
// At capacity: free the oldest slot(s) so a refresh never soft-locks the only open tab
const replaced = [];
while (entry.sockets.size >= max) {
const oldest = entry.sockets.values().next().value;
if (!oldest) break;
entry.sockets.delete(oldest);
replaced.push(oldest);
const oldSock = io?.sockets?.sockets?.get(oldest);
if (oldSock) {
try {
oldSock.emit('session:replaced', {
reason: 'slot_taken',
max,
});
} catch {
/* ignore */
}
oldSock.disconnect(true);
}
}
// Claim slot before any await (avoids race past the limit)
entry.sockets.add(socketId);
const isFirst = entry.sockets.size === 1;
let vaultCredited = 0;
const isFirst = entry.sockets.size === 1 && replaced.length === 0;
let vaultCredited = '0';
if (isFirst) {
try {
@@ -77,13 +103,23 @@ export async function trackConnect(userId, socketId) {
// Offline yield accrued while disconnected
try {
const settled = await settleVaultYield(id, 'offline');
vaultCredited = settled.credited;
vaultCredited = settled.credited || '0';
} catch (err) {
console.error('presence vault offline settle', err);
}
}
return { ok: true, count: entry.sockets.size, max, vaultCredited };
return { ok: true, count: entry.sockets.size, max, vaultCredited, replaced };
}
function pruneDeadSockets(entry, io) {
if (!entry?.sockets?.size) return;
if (!io?.sockets?.sockets) return;
for (const sid of [...entry.sockets]) {
if (!io.sockets.sockets.has(sid)) {
entry.sockets.delete(sid);
}
}
}
/**
@@ -123,7 +159,17 @@ export async function trackDisconnect(userId, socketId) {
/** Live playtime including the current open session (if any). */
export function livePlayTimeSeconds(user) {
const stored = Number(user?.playTimeSeconds ?? 0);
let stored = 0;
try {
// playTimeSeconds is Prisma BigInt — avoid Number() on the raw value path
// for consistency; seconds stay well within safe integer range for decades.
stored = Number(typeof user?.playTimeSeconds === 'bigint'
? user.playTimeSeconds
: BigInt(user?.playTimeSeconds ?? 0));
} catch {
stored = 0;
}
if (!Number.isFinite(stored) || stored < 0) stored = 0;
const entry = online.get(Number(user?.id));
if (!entry) return stored;
const live = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
@@ -143,14 +189,14 @@ export function serializePresence(user) {
/** Settle online vault yield for all currently connected users. */
export async function settleOnlineVaultForConnected() {
const ids = onlineUserIds();
let total = 0;
let total = 0n;
for (const uid of ids) {
try {
const settled = await settleVaultYield(uid, 'online');
total += settled.credited;
total += BigInt(settled.credited || '0');
} catch (err) {
console.error('vault online tick', uid, err);
}
}
return total;
return total.toString();
}
+134 -57
View File
@@ -1,17 +1,19 @@
import { applyPercentFloor, toCentsBigInt } from './cents.js';
/** 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_VISIBLE_AT_CENTS = 1_000_000_000_000n;
/** Base Pr granted on every prestige. */
export const PR_BASE_REWARD = 1000;
/**
* For every 100_000_000_000.00 cr above the prestige cost, +1000 Pr on average.
* For every 250_000_000_000.00 cr above the prestige cost, +1000 Pr on average.
* (Stored in cents.)
*/
export const PR_EXCESS_CHUNK_CENTS = 10_000_000_000_000n;
export const PR_EXCESS_CHUNK_CENTS = 25_000_000_000_000n;
export const PR_PER_EXCESS_CHUNK = 1000;
const BASE_AD_COOLDOWN_MS = 60_000;
@@ -19,12 +21,14 @@ const BASE_AD_COOLDOWN_MS = 60_000;
/**
* Skill tree upgrades bought with Pr (stackable branches).
* level = how many times this branch has been purchased.
* maxLevel = hard cap on purchases.
*/
export const PRESTIGE_SKILLS = {
chance: {
id: 'chance',
field: 'prestigeChanceBonus',
delta: 1,
maxLevel: 100,
title: 'Fortune',
description: '+1% drop luck on case opens (permanent)',
unit: '%',
@@ -34,6 +38,7 @@ export const PRESTIGE_SKILLS = {
id: 'gain',
field: 'prestigeGainBonus',
delta: 2,
maxLevel: 20,
title: 'Yield',
description: '+2% credits on sell / auto-sell (permanent)',
unit: '%',
@@ -43,6 +48,7 @@ export const PRESTIGE_SKILLS = {
id: 'polish',
field: 'prestigeWearBonus',
delta: 1,
maxLevel: 100,
title: 'Polish',
description: 'Better float / wear rolls on case opens',
unit: '',
@@ -52,25 +58,27 @@ export const PRESTIGE_SKILLS = {
id: 'bulk',
field: 'prestigeOpenBonus',
delta: 1,
maxLevel: 20,
title: 'Bulk',
description: '+1 max simultaneous case opens (permanent)',
unit: '',
branch: 'drops',
},
discount: {
id: 'discount',
field: 'prestigeCaseDiscount',
prFarmer: {
id: 'prFarmer',
field: 'prestigePrBonus',
delta: 2,
title: 'Discount',
description: '2% case open price (permanent)',
maxLevel: 50,
title: 'PR Farmer',
description: '+2% Pr obtained when you prestige (permanent)',
unit: '%',
prefix: '',
branch: 'economy',
},
quality: {
id: 'quality',
field: 'prestigeQualityBonus',
delta: 1,
maxLevel: 100,
title: 'Quality',
description: 'Better float / wear rolls on case opens (items below Field-Tested get upgraded)',
unit: '',
@@ -80,6 +88,7 @@ export const PRESTIGE_SKILLS = {
id: 'noTime',
field: 'prestigeAnimReduction',
delta: 10,
maxLevel: 100,
title: 'noTime',
description: 'Reduced case opening animation time',
unit: '%',
@@ -90,6 +99,7 @@ export const PRESTIGE_SKILLS = {
id: 'refund',
field: 'prestigeRefundChance',
delta: 2,
maxLevel: 20,
title: 'Refund',
description: 'Chance to get a free case open refund',
unit: '%',
@@ -99,8 +109,9 @@ export const PRESTIGE_SKILLS = {
id: 'respin',
field: 'prestigeRespinChance',
delta: 1,
maxLevel: 20,
title: 'Respin',
description: 'Chance to get a free case respin (respins all cases if multi-opening)',
description: 'Chance to free-respin the open (shows first result, then reels restart)',
unit: '%',
branch: 'drops',
},
@@ -108,6 +119,7 @@ export const PRESTIGE_SKILLS = {
id: 'vaultCap',
field: 'prestigeVaultSlots',
delta: 1,
maxLevel: 10,
title: 'Vault Capacity',
description: '+1 max staking slot in the vault',
unit: '',
@@ -118,6 +130,7 @@ export const PRESTIGE_SKILLS = {
id: 'offlineYield',
field: 'prestigeOfflineYield',
delta: 10,
maxLevel: 100,
title: 'Offline Yield',
description: 'Increased dividends for staked items while offline',
unit: '%',
@@ -127,6 +140,7 @@ export const PRESTIGE_SKILLS = {
id: 'onlineYield',
field: 'prestigeOnlineYield',
delta: 10,
maxLevel: 100,
title: 'Online Yield',
description: 'Increased dividends for staked items while online',
unit: '%',
@@ -142,6 +156,37 @@ export function skillUpgradeCost(skillId, level = 0) {
return Math.round((1000 + lv * 500) * mult);
}
/** Total Pr cost to buy `count` levels starting at `level`. */
export function skillUpgradeCostBulk(skillId, level = 0, count = 1) {
const n = Math.max(1, Math.min(100, Math.floor(Number(count) || 1)));
const start = Math.max(0, Number(level) || 0);
let total = 0;
for (let i = 0; i < n; i += 1) {
total += skillUpgradeCost(skillId, start + i);
}
return total;
}
export function skillMaxLevel(skillId) {
const skill = PRESTIGE_SKILLS[skillId];
const max = Number(skill?.maxLevel);
return Number.isFinite(max) && max > 0 ? Math.floor(max) : Infinity;
}
/** How many levels can still be bought before the cap. */
export function skillLevelsRemaining(user, skillId) {
const max = skillMaxLevel(skillId);
if (!Number.isFinite(max)) return Infinity;
return Math.max(0, max - skillLevelFromUser(user, skillId));
}
/** True when every prestige skill is at its max level. */
export function isPrestigeSkillTreeMaxed(user) {
return Object.keys(PRESTIGE_SKILLS).every(
(id) => skillLevelFromUser(user, id) >= skillMaxLevel(id)
);
}
export function skillLevelFromUser(user, skillId) {
const skill = PRESTIGE_SKILLS[skillId];
if (!skill) return 0;
@@ -150,35 +195,64 @@ export function skillLevelFromUser(user, skillId) {
return Math.floor(value / delta);
}
/**
* Expected Pr (no RNG) for UI preview.
*/
export function estimatePrReward(balanceCents) {
const balance = BigInt(balanceCents);
const cost = PRESTIGE_COST_CENTS;
if (balance < cost) return 0;
const excess = balance - cost;
const chunk = PR_EXCESS_CHUNK_CENTS;
const exact = Number(excess) / Number(chunk);
return Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK);
/** Parse Pr amounts (stored as decimal strings; may exceed i64). */
export function toPrBigInt(value) {
return toCentsBigInt(value);
}
export function prJson(value) {
return toPrBigInt(value).toString();
}
/** Apply PR Farmer % to a base Pr reward (floor). Returns BigInt. */
export function applyPrBonus(basePr, prBonusPct = 0) {
const base = toPrBigInt(basePr);
const pct = Math.max(0, Math.floor(Number(prBonusPct) || 0));
if (base <= 0n) return 0n;
if (pct <= 0) return base;
return (base * BigInt(100 + pct)) / 100n;
}
/**
* Pr reward: 1000 base + 1000 per full 100B cr excess, plus a fractional
* roll so partial chunks average to the remaining Pr.
* Expected Pr (no RNG) for UI preview. Returns string.
*/
export function computePrReward(balanceCents) {
const balance = BigInt(balanceCents);
export function estimatePrReward(balanceCents, prBonusPct = 0) {
const balance = toCentsBigInt(balanceCents);
const cost = PRESTIGE_COST_CENTS;
if (balance < cost) return '0';
const excess = balance - cost;
const chunk = PR_EXCESS_CHUNK_CENTS;
const full = excess / chunk;
const rem = excess % chunk;
// Round partial chunk: rem/chunk >= 0.5 → +1 chunk of Pr
const half = chunk / 2n;
const roundedChunks = full + (rem >= half ? 1n : 0n);
const base = BigInt(PR_BASE_REWARD) + roundedChunks * BigInt(PR_PER_EXCESS_CHUNK);
return prJson(applyPrBonus(base, prBonusPct));
}
/**
* Pr reward: 1000 base + 1000 per full 250B cr excess, plus a fractional
* roll so partial chunks average to the remaining Pr.
* PR Farmer adds +N% to the final grant.
* Returns string (may exceed Number.MAX_SAFE_INTEGER).
*/
export function computePrReward(balanceCents, prBonusPct = 0) {
const balance = toCentsBigInt(balanceCents);
const cost = PRESTIGE_COST_CENTS;
const excess = balance > cost ? balance - cost : 0n;
const chunk = PR_EXCESS_CHUNK_CENTS;
const full = Number(excess / chunk);
const full = excess / chunk;
const rem = excess % chunk;
const frac = Number(rem) / Number(chunk);
const bonus =
full * PR_PER_EXCESS_CHUNK +
(Math.random() < frac ? PR_PER_EXCESS_CHUNK : 0);
return PR_BASE_REWARD + bonus;
// Math.random() < rem/chunk without Number overflow
let extra = 0n;
if (rem > 0n) {
const scale = 1_000_000_000n;
const roll = BigInt(Math.floor(Math.random() * 1e9));
if (roll * chunk < rem * scale) extra = BigInt(PR_PER_EXCESS_CHUNK);
}
const base = BigInt(PR_BASE_REWARD) + full * BigInt(PR_PER_EXCESS_CHUNK) + extra;
return prJson(applyPrBonus(base, prBonusPct));
}
export function adCooldownMs(_user) {
@@ -192,7 +266,7 @@ export function serializePrestige(user) {
gainBonus: user?.prestigeGainBonus ?? 0,
wearBonus: user?.prestigeWearBonus ?? 0,
openBonus: user?.prestigeOpenBonus ?? 0,
caseDiscount: user?.prestigeCaseDiscount ?? 0,
prBonus: user?.prestigePrBonus ?? 0,
qualityBonus: user?.prestigeQualityBonus ?? 0,
animReduction: user?.prestigeAnimReduction ?? 0,
refundChance: user?.prestigeRefundChance ?? 0,
@@ -200,7 +274,7 @@ export function serializePrestige(user) {
vaultSlots: user?.prestigeVaultSlots ?? 0,
offlineYield: user?.prestigeOfflineYield ?? 0,
onlineYield: user?.prestigeOnlineYield ?? 0,
prBalance: user?.prBalance ?? 0,
prBalance: prJson(user?.prBalance ?? 0),
costCents: Number(PRESTIGE_COST_CENTS),
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
prBaseReward: PR_BASE_REWARD,
@@ -210,10 +284,17 @@ export function serializePrestige(user) {
}
export function serializeSkillTree(user) {
const pr = toPrBigInt(user?.prBalance ?? 0);
return Object.values(PRESTIGE_SKILLS).map((skill) => {
const level = skillLevelFromUser(user, skill.id);
const maxLevel = skillMaxLevel(skill.id);
const room = Math.max(0, maxLevel - level);
const atMax = room <= 0;
const current = Number(user?.[skill.field] ?? 0);
const cost = skillUpgradeCost(skill.id, level);
const buyCount = atMax ? 0 : 1;
const buyCount10 = atMax ? 0 : Math.min(10, room);
const cost = buyCount ? skillUpgradeCost(skill.id, level) : 0;
const cost10 = buyCount10 ? skillUpgradeCostBulk(skill.id, level, buyCount10) : 0;
return {
id: skill.id,
title: skill.title,
@@ -223,28 +304,24 @@ export function serializeSkillTree(user) {
prefix: skill.prefix || (skill.unit === '%' || skill.unit === 'cr-cents' ? '+' : ''),
branch: skill.branch || 'misc',
level,
maxLevel: Number.isFinite(maxLevel) ? maxLevel : null,
atMax,
room,
buyCount10,
current,
nextValue: current + skill.delta,
nextValue: atMax ? current : current + skill.delta,
nextValue10: atMax ? current : current + skill.delta * buyCount10,
cost,
canAfford: (user?.prBalance ?? 0) >= cost,
cost10,
canAfford: !atMax && pr >= BigInt(cost),
canAfford10: buyCount10 >= 2 && pr >= BigInt(cost10),
};
});
}
/** Apply case price discount (%). Min 1 cent. */
export function applyCaseDiscount(priceCents, discountPct = 0) {
const base = Math.max(0, Math.floor(Number(priceCents) || 0));
const pct = Math.max(0, Number(discountPct) || 0);
if (pct <= 0) return Math.max(1, base);
return Math.max(1, Math.floor(base * (1 - pct / 100)));
}
/** Apply lifetime gain % to a credit amount (cents). */
/** Apply lifetime gain % to a credit amount (cents). Returns BigInt. */
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));
return applyPercentFloor(cents, gainBonusPct);
}
export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
@@ -255,17 +332,17 @@ export function isValidSkill(skillId) {
return Boolean(PRESTIGE_SKILLS[skillId]);
}
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
export function isPrestigePageVisible(user, totalCents) {
if ((user?.prestigeCount ?? 0) > 0) return true;
if ((user?.prBalance ?? 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);
return rows.reduce((sum, r) => sum + toCentsBigInt(r.valueCents), 0n);
}
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
export function isPrestigePageVisible(user, totalCents) {
if ((user?.prestigeCount ?? 0) > 0) return true;
if (toPrBigInt(user?.prBalance) > 0n) return true;
return toCentsBigInt(totalCents) >= PRESTIGE_VISIBLE_AT_CENTS;
}
+12 -8
View File
@@ -8,17 +8,21 @@ export function getIO() {
return io;
}
export function emitJackpot(event, payload) {
export function emitJackpot(roundId, event, payload) {
if (!io) return;
const id = Number(roundId);
if (id) {
io.to(`jackpot:${id}`).emit(event, payload);
}
// Legacy global room
io.to('jackpot').emit(event, payload);
// Hub list subscribers need spin/result for live cards
if (event === 'jackpot:spin' || event === 'jackpot:result') {
io.to('jackpots').emit(event, payload);
}
}
export function emitDuel(roomId, event, payload) {
export function emitJackpotsList(payload) {
if (!io) return;
io.to(`duel:${roomId}`).emit(event, payload);
}
export function emitDuelsList(payload) {
if (!io) return;
io.to('duels').emit('duels:list', payload);
io.to('jackpots').emit('jackpots:list', payload);
}
+174 -26
View File
@@ -3,7 +3,7 @@ import bcrypt from 'bcrypt';
import { prisma } from '../db.js';
import { requireAdmin, publicUser } from '../middleware.js';
import { isValidRarity, RARITIES } from '../rarities.js';
import { createUploader } from '../upload.js';
import { createUploader, processUploadedImage } from '../upload.js';
import { onlineUserCount, totalSocketCount } from '../presence.js';
import { ACHIEVEMENT_DEFS } from '../achievements.js';
import {
@@ -12,10 +12,24 @@ import {
ensureCategory,
listCategoriesWithCounts,
} from '../categories.js';
import { toCentsBigInt, centsJson } from '../cents.js';
import { toPrBigInt, prJson } from '../prestige.js';
import {
listShopOsuPacksAdmin,
updateShopOsuPack,
setOsuTopupsSectionEnabled,
} from '../shopOsu.js';
const router = Router();
const upload = createUploader('asset');
/** Parse admin money input (cents) → non-negative decimal string for Prisma. */
function parseCentsField(raw) {
const bi = toCentsBigInt(raw);
if (bi < 0n) return null;
return centsJson(bi);
}
router.post('/login', async (req, res) => {
try {
const username = String(req.body.username || '').trim();
@@ -45,6 +59,39 @@ router.get('/rarities', requireAdmin, (_req, res) => {
res.json({ rarities: RARITIES });
});
router.get('/shop/osu-packs', requireAdmin, async (_req, res) => {
try {
const data = await listShopOsuPacksAdmin();
res.json(data);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load osu packs' });
}
});
router.put('/shop/osu-section', requireAdmin, async (req, res) => {
try {
const enabled = Boolean(req.body.enabled);
const sectionEnabled = await setOsuTopupsSectionEnabled(enabled);
const data = await listShopOsuPacksAdmin();
res.json({ ...data, sectionEnabled });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update shop section' });
}
});
router.put('/shop/osu-packs/:id', requireAdmin, async (req, res) => {
try {
const pack = await updateShopOsuPack(req.params.id, req.body || {});
res.json({ pack });
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to update pack' });
}
});
router.get('/categories', requireAdmin, async (_req, res) => {
try {
const categories = await listCategoriesWithCounts();
@@ -218,16 +265,21 @@ router.get('/users', requireAdmin, async (_req, res) => {
res.json({
users: users.map((u) => {
const inventoryValue = u.inventory.reduce((s, inv) => s + inv.valueCents, 0);
const inventoryValue = u.inventory.reduce(
(s, inv) => s + toCentsBigInt(inv.valueCents),
0n
);
const balance = toCentsBigInt(u.balance);
return {
id: u.id,
username: u.username,
avatarUrl: u.avatarUrl || '',
balance: u.balance,
balance: centsJson(balance),
osuBalance: u.osuBalance ?? 0,
inventoryValue,
prBalance: prJson(u.prBalance ?? 0),
inventoryValue: centsJson(inventoryValue),
inventoryCount: u.inventory.length,
totalWealth: u.balance + inventoryValue,
totalWealth: centsJson(balance + inventoryValue),
casesOpened: opensByUser.get(u.id) || 0,
achievements: u._count.achievements,
prestigeCount: u.prestigeCount ?? 0,
@@ -253,22 +305,19 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
const data = {};
if (req.body.balance !== undefined) {
const balance = Number(req.body.balance);
if (!Number.isInteger(balance) || balance < 0) {
const balance = String(req.body.balance).trim();
if (!/^\d+$/.test(balance)) {
return res.status(400).json({ error: 'balance must be a non-negative integer (cents)' });
}
data.balance = balance;
}
if (req.body.balanceDelta !== undefined) {
const delta = Number(req.body.balanceDelta);
if (!Number.isInteger(delta)) {
const deltaRaw = String(req.body.balanceDelta).trim();
if (!/^-?\d+$/.test(deltaRaw)) {
return res.status(400).json({ error: 'balanceDelta must be an integer (cents)' });
}
const next = Number(user.balance) + delta;
if (next < 0) {
return res.status(400).json({ error: 'Resulting balance cannot be negative' });
}
data.balance = next;
const next = toCentsBigInt(user.balance) + BigInt(deltaRaw);
data.balance = (next < 0n ? 0n : next).toString();
}
if (req.body.osuBalance !== undefined) {
const osu = Number(req.body.osuBalance);
@@ -277,6 +326,21 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
}
data.osuBalance = osu;
}
if (req.body.osuBalanceDelta !== undefined) {
const delta = Number(req.body.osuBalanceDelta);
if (!Number.isInteger(delta) || delta === 0) {
return res.status(400).json({ error: 'osuBalanceDelta must be a non-zero integer' });
}
data.osuBalance = Math.max(0, (user.osuBalance ?? 0) + delta);
}
if (req.body.prBalanceDelta !== undefined) {
const deltaRaw = String(req.body.prBalanceDelta).trim();
if (!/^-?\d+$/.test(deltaRaw) || deltaRaw === '0' || deltaRaw === '-0') {
return res.status(400).json({ error: 'prBalanceDelta must be a non-zero integer' });
}
const next = toPrBigInt(user.prBalance) + BigInt(deltaRaw);
data.prBalance = prJson(next < 0n ? 0n : next);
}
if (!Object.keys(data).length) {
return res.status(400).json({ error: 'No changes provided' });
@@ -284,12 +348,15 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
const updated = await prisma.$transaction(async (tx) => {
const next = await tx.user.update({ where: { id }, data });
if (data.balance !== undefined && Number(data.balance) !== Number(user.balance)) {
if (
data.balance !== undefined &&
toCentsBigInt(data.balance) !== toCentsBigInt(user.balance)
) {
await tx.transaction.create({
data: {
userId: id,
type: 'admin_adjust',
amount: Number(data.balance) - Number(user.balance),
amount: centsJson(toCentsBigInt(data.balance) - toCentsBigInt(user.balance)),
meta: JSON.stringify({ reason: 'admin_panel', by: req.session.userId }),
},
});
@@ -305,14 +372,20 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
});
router.post('/upload', requireAdmin, (req, res) => {
upload.single('image')(req, res, (err) => {
upload.single('image')(req, res, async (err) => {
if (err) {
return res.status(400).json({ error: err.message || 'Upload failed' });
}
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
res.status(201).json({ url: `/uploads/${req.file.filename}` });
try {
await processUploadedImage(req.file);
res.status(201).json({ url: `/uploads/${req.file.filename}` });
} catch (e) {
console.error(e);
res.status(400).json({ error: 'Image processing failed' });
}
});
});
@@ -356,11 +429,11 @@ router.post('/cases', requireAdmin, async (req, res) => {
try {
const name = String(req.body.name || '').trim();
const imageUrl = String(req.body.imageUrl || '');
const price = Number(req.body.price);
const price = parseCentsField(req.body.price);
const active = req.body.active !== false;
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!Number.isInteger(price) || price < 0) {
if (price == null) {
return res.status(400).json({ error: 'Price must be a non-negative integer (cents)' });
}
@@ -381,8 +454,8 @@ router.put('/cases/:id', requireAdmin, async (req, res) => {
if (req.body.name !== undefined) data.name = String(req.body.name).trim();
if (req.body.imageUrl !== undefined) data.imageUrl = String(req.body.imageUrl);
if (req.body.price !== undefined) {
const price = Number(req.body.price);
if (!Number.isInteger(price) || price < 0) {
const price = parseCentsField(req.body.price);
if (price == null) {
return res.status(400).json({ error: 'Invalid price' });
}
data.price = price;
@@ -425,13 +498,13 @@ router.post('/items', requireAdmin, async (req, res) => {
const imageUrl = String(req.body.imageUrl || '');
const rarity = String(req.body.rarity || '');
const category = await ensureCategory(req.body.category);
const marketValue = Number(req.body.marketValue);
const marketValue = parseCentsField(req.body.marketValue);
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!isValidRarity(rarity)) {
return res.status(400).json({ error: `Invalid rarity. Use: ${RARITIES.join(', ')}` });
}
if (!Number.isInteger(marketValue) || marketValue < 0) {
if (marketValue == null) {
return res.status(400).json({ error: 'marketValue must be a non-negative integer (cents)' });
}
@@ -461,8 +534,8 @@ router.put('/items/:id', requireAdmin, async (req, res) => {
data.category = await ensureCategory(req.body.category);
}
if (req.body.marketValue !== undefined) {
const marketValue = Number(req.body.marketValue);
if (!Number.isInteger(marketValue) || marketValue < 0) {
const marketValue = parseCentsField(req.body.marketValue);
if (marketValue == null) {
return res.status(400).json({ error: 'Invalid marketValue' });
}
data.marketValue = marketValue;
@@ -545,4 +618,79 @@ router.delete('/case-items/:id', requireAdmin, async (req, res) => {
}
});
/**
* Preview counts before a server prestige wipe.
*/
router.get('/prestige-server', requireAdmin, async (_req, res) => {
try {
const [players, inventory, prestigeLogs, opensKept] = await Promise.all([
prisma.user.count({ where: { role: { not: 'admin' } } }),
prisma.inventoryItem.count({
where: { user: { role: { not: 'admin' } } },
}),
prisma.prestigeLog.count({
where: { user: { role: { not: 'admin' } } },
}),
prisma.transaction.count({
where: {
user: { role: { not: 'admin' } },
type: { in: ['open_case', 'jackpot_win'] },
},
}),
]);
res.json({
preview: {
players,
inventoryItems: inventory,
prestigeLogs,
statsTransactionsKept: opensKept,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load prestige-server preview' });
}
});
/**
* Full player wipe (server prestige). Requires admin password confirmation.
* Achievement fields must be configured before activation.
*/
router.post('/prestige-server', requireAdmin, async (req, res) => {
try {
const password = String(req.body.password || '');
if (!password) {
return res.status(400).json({ error: 'Admin password required' });
}
const admin = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!admin || admin.role !== 'admin') {
return res.status(401).json({ error: 'Unauthorized' });
}
const ok = await bcrypt.compare(password, admin.passwordHash);
if (!ok) {
return res.status(401).json({ error: 'Invalid admin password' });
}
const ach = req.body.achievement || {};
const { runServerPrestige } = await import('../serverPrestige.js');
const result = await runServerPrestige({
achievementKey: ach.key,
achievementName: ach.name,
achievementDescription: ach.description,
achievementIcon: ach.icon,
});
res.json({
ok: true,
...result,
});
} catch (err) {
console.error(err);
const status = err.status || 500;
res.status(status).json({ error: err.message || 'Server prestige failed' });
}
});
export default router;
+6 -6
View File
@@ -33,7 +33,7 @@ router.post('/register', async (req, res) => {
data: {
username,
passwordHash,
balance: STARTING_BALANCE,
balance: String(STARTING_BALANCE),
osuBalance: STARTING_OSU,
role: 'user',
},
@@ -43,7 +43,7 @@ router.post('/register', async (req, res) => {
data: {
userId: user.id,
type: 'seed_credit',
amount: STARTING_BALANCE,
amount: String(STARTING_BALANCE),
meta: JSON.stringify({ reason: 'starting_balance' }),
},
});
@@ -52,7 +52,7 @@ router.post('/register', async (req, res) => {
data: {
userId: user.id,
type: 'seed_osu',
amount: STARTING_OSU,
amount: String(STARTING_OSU),
meta: JSON.stringify({ reason: 'starting_osu' }),
},
});
@@ -97,20 +97,20 @@ router.post('/login', async (req, res) => {
});
// Offline vault yield if user is not already socket-connected
let vaultCredited = 0;
let vaultCredited = '0';
try {
const { isConnected } = await import('../presence.js');
const { settleVaultYield } = await import('../vault.js');
if (!isConnected(user.id)) {
const settled = await settleVaultYield(user.id, 'offline');
vaultCredited = settled.credited;
vaultCredited = settled.credited || '0';
}
} catch (err) {
console.error('login vault settle', err);
}
const refreshed =
vaultCredited > 0
vaultCredited !== '0' && vaultCredited !== 0
? await prisma.user.findUnique({ where: { id: user.id } })
: withConn;
+58 -46
View File
@@ -15,7 +15,8 @@ import {
fillCatalogSlots,
formatCaseItemsWithLuck,
} from '../catalog.js';
import { totalLuckPercent, applyCaseDiscount } from '../prestige.js';
import { totalLuckPercent } from '../prestige.js';
import { toCentsBigInt, adjustUserBalance, spendUserBalance, centsJson } from '../cents.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
@@ -47,7 +48,6 @@ async function loadUserLuckAndKeys(userId) {
return {
luckPercent: 0,
openBonus: 0,
caseDiscount: 0,
user: null,
};
}
@@ -59,7 +59,6 @@ async function loadUserLuckAndKeys(userId) {
prestigeChanceBonus: true,
prestigeWearBonus: true,
prestigeOpenBonus: true,
prestigeCaseDiscount: true,
prestigeQualityBonus: true,
prestigeAnimReduction: true,
prestigeRefundChance: true,
@@ -70,7 +69,6 @@ async function loadUserLuckAndKeys(userId) {
return {
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
openBonus: user?.prestigeOpenBonus ?? 0,
caseDiscount: user?.prestigeCaseDiscount ?? 0,
user,
};
}
@@ -102,17 +100,20 @@ router.get('/', async (req, res) => {
include: {
items: { include: { item: true } },
},
orderBy: { price: 'asc' },
});
cases.sort((a, b) => {
const pa = toCentsBigInt(a.price);
const pb = toCentsBigInt(b.price);
if (pa < pb) return -1;
if (pa > pb) return 1;
return a.id - b.id;
});
const progressMap = await loadKeyProgressMap(userId);
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
res.json({
catalogLuckPercent: luckPercent,
cases: cases.map((c) => {
const unitPrice = userId
? applyCaseDiscount(c.price, caseDiscount)
: Number(c.price);
const formatted = formatCase(c, luckPercent, unitPrice);
const formatted = formatCase(c, luckPercent, userId ? c.price : Number(c.price));
if (!userId) return formatted;
const progress = progressMap.get(c.id);
return {
@@ -140,10 +141,8 @@ router.get('/:id', async (req, res) => {
if (!c || !c.active) {
return res.status(404).json({ error: 'Case not found' });
}
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
const unitPrice = userId
? applyCaseDiscount(c.price, caseDiscount)
: Number(c.price);
const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
const unitPrice = userId ? c.price : Number(c.price);
const formatted = formatCase(c, luckPercent, unitPrice);
if (userId) {
const progress = await prisma.caseKeyProgress.findUnique({
@@ -201,13 +200,10 @@ router.post('/:id/open', requireAuth, async (req, res) => {
throw err;
}
const unitPrice = applyCaseDiscount(c.price, user.prestigeCaseDiscount ?? 0);
const totalCost = unitPrice * count;
if (user.balance < totalCost) {
const err = new Error('Insufficient balance');
err.status = 400;
throw err;
}
const unitPrice = toCentsBigInt(c.price);
const totalCost = unitPrice * BigInt(count);
// Charge up front (atomic WHERE balance >= cost). Refunded opens credit back later.
await spendUserBalance(tx, userId, totalCost);
const catalogLuck = await countCompletedChapters(tx, userId);
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
@@ -231,7 +227,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
valueCents: centsJson(rolled.valueCents),
},
});
@@ -241,7 +237,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
data: {
userId,
type: 'open_case',
amount: -unitPrice,
amount: centsJson(-unitPrice),
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
@@ -251,12 +247,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
valueCents: centsJson(rolled.valueCents),
willAutoSell,
openIndex: i,
openCount: batchCount,
unitPrice,
basePrice: Number(c.price),
unitPrice: centsJson(unitPrice),
basePrice: centsJson(c.price),
}),
},
});
@@ -268,11 +264,11 @@ router.post('/:id/open', requireAuth, async (req, res) => {
name: item.name,
imageUrl: item.imageUrl,
rarity: item.rarity,
marketValue: item.marketValue,
marketValue: toCentsBigInt(item.marketValue).toString(),
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
valueCents: toCentsBigInt(rolled.valueCents).toString(),
willAutoSell,
});
}
@@ -281,12 +277,32 @@ router.post('/:id/open', requireAuth, async (req, res) => {
let { drops, createdTxIds } = await createDrops(count);
// Respin: re-roll all drops for free (replace first batch)
// Respin: re-roll all drops for free (replace first batch).
// preRespinItems animate the discarded roll. Auto-sell-eligible items stay in
// inventory (with inventoryId) so the client can credit them when the first
// reels finish; the rest are discarded immediately.
let preRespinItems = null;
const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
if (didRespin) {
const oldIds = drops.map((d) => d.inventoryId);
await tx.inventoryItem.deleteMany({ where: { id: { in: oldIds } } });
const discardIds = drops
.filter((d) => !d.willAutoSell)
.map((d) => d.inventoryId)
.filter(Boolean);
const pendingAutoSellIds = drops
.filter((d) => d.willAutoSell)
.map((d) => d.inventoryId)
.filter(Boolean);
preRespinItems = drops.map((d) => ({
...d,
inventoryId: d.willAutoSell ? d.inventoryId : null,
preRespin: true,
}));
if (discardIds.length) {
await tx.inventoryItem.deleteMany({ where: { id: { in: discardIds } } });
}
await tx.transaction.deleteMany({ where: { id: { in: createdTxIds } } });
const again = await createDrops(count);
drops = again.drops;
@@ -295,11 +311,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
data: {
userId,
type: 'case_respin',
amount: 0,
amount: '0',
meta: JSON.stringify({
caseId: c.id,
openCount: count,
inventoryItemIds: drops.map((d) => d.inventoryId),
preRespinPendingAutoSellIds: pendingAutoSellIds,
}),
},
});
@@ -326,41 +343,35 @@ router.post('/:id/open', requireAuth, async (req, res) => {
},
});
let balanceDelta = -totalCost;
let updatedUser = await tx.user.findUnique({ where: { id: userId } });
if (didRefund) {
balanceDelta += totalCost;
updatedUser = await adjustUserBalance(tx, userId, updatedUser.balance, totalCost);
await tx.transaction.create({
data: {
userId,
type: 'case_refund',
amount: totalCost,
amount: centsJson(totalCost),
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
openCount: count,
unitPrice,
totalCost,
unitPrice: centsJson(unitPrice),
totalCost: centsJson(totalCost),
}),
},
});
}
const updatedUser = await tx.user.update({
where: { id: userId },
data: {
balance: { increment: balanceDelta },
},
});
return {
user: publicUser(updatedUser),
caseName: c.name,
items: drops,
preRespinItems,
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
keysGained: applied.keysGained,
catalog,
unitPrice,
totalCost,
unitPrice: centsJson(unitPrice),
totalCost: centsJson(totalCost),
refunded: didRefund,
respin: didRespin,
animReduction: user.prestigeAnimReduction ?? 0,
@@ -374,6 +385,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
caseName: result.caseName,
count: result.items.length,
items: result.items,
preRespinItems: result.preRespinItems,
keyProgress: result.keyProgress,
keysGained: result.keysGained,
catalog: result.catalog,
-89
View File
@@ -1,89 +0,0 @@
import { Router } from 'express';
import { requireAuth } from '../middleware.js';
import {
listDuelRooms,
getDuelRoom,
createDuelRoom,
placeDuelBet,
leaveDuelRoom,
cancelDuelRoom,
startDuelRoom,
} from '../services/duel.js';
const router = Router();
function handleError(res, err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Request failed' });
}
router.get('/', async (_req, res) => {
try {
const list = await listDuelRooms();
res.json(list);
} catch (err) {
handleError(res, err);
}
});
router.post('/', requireAuth, async (req, res) => {
try {
const room = await createDuelRoom(req.session.userId, req.body);
res.status(201).json({ room });
} catch (err) {
handleError(res, err);
}
});
router.get('/:id', async (req, res) => {
try {
const room = await getDuelRoom(req.params.id);
if (!room) return res.status(404).json({ error: 'Room not found' });
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/bet', requireAuth, async (req, res) => {
try {
const room = await placeDuelBet(
req.session.userId,
req.params.id,
req.body.inventoryItemIds
);
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/leave', requireAuth, async (req, res) => {
try {
const room = await leaveDuelRoom(req.session.userId, req.params.id);
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/cancel', requireAuth, async (req, res) => {
try {
const room = await cancelDuelRoom(req.session.userId, req.params.id);
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/start', requireAuth, async (req, res) => {
try {
const room = await startDuelRoom(req.session.userId, req.params.id);
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
export default router;
+83 -2
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth } from '../middleware.js';
import { getDropFeed, pushDrop } from '../feed.js';
import { toCentsBigInt } from '../cents.js';
const router = Router();
@@ -13,9 +14,89 @@ router.get('/', (_req, res) => {
* 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).
*
* Respin flow:
* - First spin (discarded): body.snapshotItems (no inventory left)
* - Final spin: body.inventoryItemIds + body.respin=true
*/
router.post('/announce', requireAuth, async (req, res) => {
try {
const caseName = String(req.body.caseName || '').slice(0, 80);
const respin = Boolean(req.body.respin);
const snapshotItems = Array.isArray(req.body.snapshotItems)
? req.body.snapshotItems
: null;
// Discarded first roll (respin preview) — announce from client snapshot.
// Prefer compact payload: snapshotBest + openCount (+ optional count).
if (snapshotItems?.length || req.body.snapshotBest) {
const user = await prisma.user.findUnique({
where: { id: req.session.userId },
select: { username: true, avatarUrl: true },
});
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const normalizeOne = (raw) => ({
id: Number(raw?.id) || 0,
name: String(raw?.name || '').slice(0, 120),
imageUrl: String(raw?.imageUrl || '').slice(0, 500),
rarity: String(raw?.rarity || '').slice(0, 40),
valueCents: String(raw?.valueCents ?? raw?.marketValue ?? '0'),
wear: raw?.wear ? String(raw.wear).slice(0, 40) : '',
floatValue: Number(raw?.floatValue) || 0,
paintSeed: Number(raw?.paintSeed) || 0,
});
let best;
let openCount;
let count;
if (req.body.snapshotBest) {
best = normalizeOne(req.body.snapshotBest);
if (!best.id || !best.name) {
return res.status(400).json({ error: 'Invalid snapshot best item' });
}
openCount = Math.max(1, Math.floor(Number(req.body.openCount) || 1));
count = Math.max(1, Math.min(openCount, Math.floor(Number(req.body.count) || 1)));
} else {
const normalized = snapshotItems.map(normalizeOne).filter((it) => it.id && it.name);
if (!normalized.length) {
return res.status(400).json({ error: 'Invalid snapshot items' });
}
best = normalized[0];
for (const it of normalized) {
if (toCentsBigInt(it.valueCents) > toCentsBigInt(best.valueCents)) best = it;
}
openCount = normalized.length;
count = normalized.filter((it) => it.id === best.id).length;
}
const drop = pushDrop({
source: 'case',
caseName,
respin: false,
count,
openCount,
inventoryIds: [],
username: user.username,
avatarUrl: user.avatarUrl || '',
item: {
inventoryId: null,
id: best.id,
name: best.name,
imageUrl: best.imageUrl,
rarity: best.rarity,
marketValue: best.valueCents,
valueCents: best.valueCents,
wear: best.wear,
floatValue: best.floatValue,
paintSeed: best.paintSeed,
},
});
return res.json({ drop });
}
let ids = [];
if (Array.isArray(req.body.inventoryItemIds)) {
ids = req.body.inventoryItemIds.map(Number).filter(Boolean);
@@ -51,15 +132,15 @@ router.post('/announce', requireAuth, async (req, res) => {
// 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;
if (toCentsBigInt(inv.valueCents) > toCentsBigInt(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,
respin,
count,
openCount: invItems.length,
inventoryIds: invItems.map((i) => i.id),
+109 -92
View File
@@ -3,10 +3,9 @@ import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { applyAutoSell } from '../autoSell.js';
import { applyGainMultiplier } from '../prestige.js';
import { toCentsBigInt, adjustUserBalance, centsJson, creditsToCents } from '../cents.js';
import {
maxVaultSlots,
summarizeVaultRates,
estimatePendingYield,
summarizeVaultState,
} from '../vault.js';
import { isConnected } from '../presence.js';
@@ -54,16 +53,10 @@ router.get('/', requireAuth, async (req, res) => {
const staked = items.filter((i) => i.staked);
const mode = isConnected(userId) ? 'online' : 'offline';
const rates = summarizeVaultRates(staked, user);
res.json({
inventory: items.map(serializeInv),
vault: {
used: staked.length,
slots: maxVaultSlots(user),
...rates,
pendingYield: estimatePendingYield(staked, user, mode),
},
vault: summarizeVaultState(user, staked, mode),
});
} catch (err) {
console.error(err);
@@ -78,11 +71,11 @@ router.patch('/auto-sell', requireAuth, async (req, res) => {
data.autoSellEnabled = Boolean(req.body.enabled);
}
if (req.body.thresholdCredits != null) {
const credits = Number(req.body.thresholdCredits);
if (!Number.isFinite(credits) || credits < 0) {
const cents = creditsToCents(req.body.thresholdCredits);
if (cents == null) {
return res.status(400).json({ error: 'Invalid threshold' });
}
data.autoSellThresholdCents = Math.round(credits * 100);
data.autoSellThresholdCents = centsJson(cents);
}
if (!Object.keys(data).length) {
@@ -108,44 +101,56 @@ router.post('/auto-sell/apply', requireAuth, async (req, res) => {
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 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,
staked: false,
},
});
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
staked: false,
},
});
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
if (!balanceDelta) {
return { user, soldIds: [], total: 0 };
}
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
if (balanceDelta === 0n) {
return { user, soldIds: [], total: 0n, sold: [] };
}
const updatedUser = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: balanceDelta } },
});
const updatedUser = await adjustUserBalance(
tx,
req.session.userId,
user.balance,
balanceDelta
);
return {
user: updatedUser,
soldIds: sold.map((inv) => inv.id),
total: balanceDelta,
};
});
const gainBonus = user.prestigeGainBonus ?? 0;
return {
user: updatedUser,
soldIds: sold.map((inv) => inv.id),
total: balanceDelta,
sold: sold.map((inv) => ({
id: inv.id,
valueCents: centsJson(inv.valueCents),
payoutCents: centsJson(applyGainMultiplier(inv.valueCents, gainBonus)),
})),
};
},
{ timeout: 60_000, maxWait: 15_000 }
);
res.json({
user: publicUser(result.user),
soldIds: result.soldIds,
total: result.total,
total: centsJson(result.total),
sold: result.sold || [],
});
} catch (err) {
const status = err.status || 500;
@@ -161,74 +166,86 @@ 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 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,
staked: false,
},
include: { item: true },
});
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
staked: false,
},
select: {
id: true,
itemId: true,
valueCents: true,
favorite: true,
},
});
if (invItems.length !== ids.length) {
const err = new Error('Invalid, locked, or staked inventory items');
err.status = 400;
throw err;
}
if (invItems.length !== ids.length) {
const err = new Error('Invalid, locked, or staked 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;
}
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;
const gainBonus = user.prestigeGainBonus ?? 0;
let total = 0n;
for (const inv of invItems) {
total += applyGainMultiplier(inv.valueCents, gainBonus);
}
await tx.inventoryItem.deleteMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
staked: false,
},
});
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,
amount: centsJson(total),
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,
count: invItems.length,
inventoryItemIds: ids,
prestigeGainBonus: gainBonus,
payout,
payout: centsJson(total),
}),
},
});
}
const updatedUser = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: total } },
});
const updatedUser = await adjustUserBalance(
tx,
req.session.userId,
user.balance,
total
);
return { user: updatedUser, total, soldCount: invItems.length };
});
return { user: updatedUser, total, soldCount: invItems.length };
},
{ timeout: 60_000, maxWait: 15_000 }
);
res.json({
user: publicUser(result.user),
total: result.total,
total: centsJson(result.total),
soldCount: result.soldCount,
});
} catch (err) {
+41 -6
View File
@@ -1,7 +1,9 @@
import { Router } from 'express';
import { requireAuth } from '../middleware.js';
import {
getJackpotState,
listJackpotBattles,
getJackpotRound,
createJackpotBattle,
placeJackpotBet,
withdrawJackpotBet,
} from '../services/jackpot.js';
@@ -16,25 +18,58 @@ function handleError(res, err) {
router.get('/', async (_req, res) => {
try {
const state = await getJackpotState();
res.json({ round: state });
const battles = await listJackpotBattles();
res.json({ battles });
} catch (err) {
handleError(res, err);
}
});
router.get('/:id', async (req, res) => {
try {
const round = await getJackpotRound(req.params.id);
if (!round) {
return res.status(404).json({ error: 'Battle not found' });
}
res.json({ round });
} catch (err) {
handleError(res, err);
}
});
/** Create a battle by placing the first bet (no round id). */
router.post('/bet', requireAuth, async (req, res) => {
try {
const state = await placeJackpotBet(req.session.userId, req.body.inventoryItemIds);
const state = await createJackpotBattle(
req.session.userId,
req.body.inventoryItemIds
);
res.json({ round: state });
} catch (err) {
handleError(res, err);
}
});
router.post('/withdraw', requireAuth, async (req, res) => {
router.post('/:id/bet', requireAuth, async (req, res) => {
try {
const state = await withdrawJackpotBet(req.session.userId, req.body.inventoryItemIds);
const state = await placeJackpotBet(
req.params.id,
req.session.userId,
req.body.inventoryItemIds
);
res.json({ round: state });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/withdraw', requireAuth, async (req, res) => {
try {
const state = await withdrawJackpotBet(
req.params.id,
req.session.userId,
req.body.inventoryItemIds
);
res.json({ round: state });
} catch (err) {
handleError(res, err);
+14 -7
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { toCentsBigInt, centsJson } from '../cents.js';
const router = Router();
@@ -17,22 +18,28 @@ router.get('/', async (_req, res) => {
const ranked = users
.map((u) => {
const inventoryValue = u.inventory.reduce(
(sum, inv) => sum + inv.valueCents,
0
(sum, inv) => sum + toCentsBigInt(inv.valueCents),
0n
);
const balance = toCentsBigInt(u.balance);
const totalWealth = balance + inventoryValue;
return {
id: u.id,
username: u.username,
avatarUrl: u.avatarUrl || '',
balance: u.balance,
inventoryValue,
totalWealth: u.balance + inventoryValue,
balance: centsJson(balance),
inventoryValue: centsJson(inventoryValue),
totalWealth,
itemCount: u.inventory.length,
};
})
.sort((a, b) => b.totalWealth - a.totalWealth)
.sort((a, b) => (a.totalWealth < b.totalWealth ? 1 : a.totalWealth > b.totalWealth ? -1 : 0))
.slice(0, 50)
.map((entry, index) => ({ rank: index + 1, ...entry }));
.map((entry, index) => ({
rank: index + 1,
...entry,
totalWealth: centsJson(entry.totalWealth),
}));
res.json({ leaderboard: ranked });
} catch (err) {
+49 -25
View File
@@ -12,9 +12,13 @@ import {
serializePrestige,
serializeSkillTree,
skillLevelFromUser,
skillUpgradeCost,
skillLevelsRemaining,
skillUpgradeCostBulk,
toPrBigInt,
prJson,
} from '../prestige.js';
import { checkAchievements } from '../achievements.js';
import { centsJson, toCentsBigInt, addCents, spendUserBalance } from '../cents.js';
const router = Router();
const COST = PRESTIGE_COST_CENTS;
@@ -36,18 +40,20 @@ router.get('/', requireAuth, async (req, res) => {
}),
]);
const balance = BigInt(user.balance);
const totalCents = Number(user.balance) + inventoryValue;
const balance = toCentsBigInt(user.balance);
const totalCents = balance + inventoryValue;
const pageVisible = isPrestigePageVisible(user, totalCents);
const canAfford = balance >= COST;
const canPrestige = pageVisible && canAfford && lockedCount === 0;
const previewPr = canAfford ? estimatePrReward(user.balance) : 0;
const previewPr = canAfford
? estimatePrReward(user.balance, user.prestigePrBonus ?? 0)
: 0;
res.json({
prestige: serializePrestige(user),
skills: serializeSkillTree(user),
pageVisible,
totalCents,
totalCents: totalCents.toString(),
canAfford,
lockedItems: lockedCount,
canPrestige,
@@ -55,7 +61,7 @@ router.get('/', requireAuth, async (req, res) => {
history: logs.map((l) => ({
id: l.id,
choice: l.choice,
prGranted: l.prGranted ?? 0,
prGranted: prJson(l.prGranted ?? 0),
createdAt: l.createdAt,
})),
});
@@ -85,11 +91,6 @@ router.post('/', requireAuth, async (req, res) => {
err.status = 403;
throw err;
}
if (BigInt(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 },
@@ -102,22 +103,28 @@ router.post('/', requireAuth, async (req, res) => {
throw err;
}
const prGranted = computePrReward(user.balance);
// Lock funds atomically (balance >= COST). Remainder wiped with the reset below.
await spendUserBalance(tx, user.id, COST, {
message: 'Insufficient balance for prestige',
});
const fresh = await tx.user.findUnique({ where: { id: user.id } });
const previousBalance = toCentsBigInt(user.balance);
const prGranted = computePrReward(previousBalance, user.prestigePrBonus ?? 0);
const prestigeCount = (user.prestigeCount ?? 0) + 1;
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: -Number(COST),
amount: centsJson(-COST),
meta: JSON.stringify({
prestigeCount,
prGranted,
previousBalance: Number(user.balance),
previousBalance: centsJson(previousBalance),
}),
},
});
@@ -126,20 +133,22 @@ router.post('/', requireAuth, async (req, res) => {
data: {
userId: user.id,
choice: 'reset',
prGranted,
prGranted: prJson(prGranted),
},
});
// Wipe leftover credits after the COST spend (prestige resets economy).
const updated = await tx.user.update({
where: { id: user.id },
data: {
prestigeCount,
balance: 0,
prBalance: { increment: prGranted },
balance: '0',
prBalance: addCents(fresh.prBalance, prGranted),
vaultYieldAt: null,
},
});
return { user: updated, prGranted };
return { user: updated, prGranted: prJson(prGranted) };
});
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
@@ -164,6 +173,10 @@ router.post('/upgrade', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Unknown skill' });
}
let count = Number(req.body?.count ?? 1);
if (!Number.isFinite(count)) count = 1;
count = Math.max(1, Math.min(10, Math.floor(count)));
const skill = PRESTIGE_SKILLS[skillId];
const result = await prisma.$transaction(async (tx) => {
@@ -180,8 +193,16 @@ router.post('/upgrade', requireAuth, async (req, res) => {
}
const level = skillLevelFromUser(user, skillId);
const cost = skillUpgradeCost(skillId, level);
if ((user.prBalance ?? 0) < cost) {
const room = skillLevelsRemaining(user, skillId);
if (room <= 0) {
const err = new Error('Skill is already at max level');
err.status = 400;
throw err;
}
count = Math.min(count, room);
const cost = skillUpgradeCostBulk(skillId, level, count);
if (toPrBigInt(user.prBalance) < BigInt(cost)) {
const err = new Error('Not enough Pr');
err.status = 400;
throw err;
@@ -190,8 +211,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
const updated = await tx.user.update({
where: { id: user.id },
data: {
prBalance: { decrement: cost },
[skill.field]: { increment: skill.delta },
prBalance: addCents(user.prBalance, -cost),
[skill.field]: { increment: skill.delta * count },
},
});
@@ -199,11 +220,12 @@ router.post('/upgrade', requireAuth, async (req, res) => {
data: {
userId: user.id,
type: 'prestige_upgrade',
amount: 0,
amount: '0',
meta: JSON.stringify({
skillId,
cost,
delta: skill.delta,
count,
delta: skill.delta * count,
field: skill.field,
}),
},
@@ -212,6 +234,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
return updated;
});
checkAchievements(result.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result),
prestige: serializePrestige(result),
+17 -41
View File
@@ -1,9 +1,5 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { getCompletedCatalogBadges } from '../catalog.js';
@@ -13,32 +9,15 @@ import {
checkAchievements,
getUnlockedAchievements,
} from '../achievements.js';
import { toCentsBigInt, centsJson } from '../cents.js';
import { createUploader, processUploadedImage, safeExt } from '../upload.js';
const router = Router();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
const upload = createUploader('avatar', {
maxBytes: 2 * 1024 * 1024,
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.png';
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
cb(null, `avatar-${req.session.userId}-${Date.now()}${safe}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new Error('Only images are allowed'));
}
cb(null, true);
cb(null, `avatar-${req.session.userId}-${Date.now()}${safeExt(file.originalname, file.mimetype)}`);
},
});
@@ -70,6 +49,7 @@ function serializeInv(inv) {
paintSeed: inv.paintSeed,
valueCents: inv.valueCents,
favorite: Boolean(inv.favorite),
staked: Boolean(inv.staked),
item: {
id: inv.item.id,
name: inv.item.name,
@@ -103,18 +83,18 @@ router.get('/player/:username', async (req, res) => {
});
if (!user) return res.status(404).json({ error: 'Player not found' });
const inventoryValue = user.inventory.reduce((sum, inv) => sum + inv.valueCents, 0);
const inventoryValue = user.inventory.reduce(
(sum, inv) => sum + toCentsBigInt(inv.valueCents),
0n
);
await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
const [jackpotWins, 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' },
}),
@@ -127,10 +107,9 @@ router.get('/player/:username', async (req, res) => {
user: publicUser(user),
profile: {
createdAt: user.createdAt,
inventoryValue,
inventoryValue: centsJson(inventoryValue),
inventoryCount: user.inventory.length,
jackpotWins,
duelWins,
casesOpened,
caseOpens,
catalogCompleted: catalog.completed,
@@ -158,20 +137,17 @@ 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.valueCents),
0
(sum, inv) => sum + (inv.locked ? 0n : toCentsBigInt(inv.valueCents)),
0n
);
await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
const [jackpotWins, 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' },
}),
@@ -184,10 +160,9 @@ router.get('/', requireAuth, async (req, res) => {
user: publicUser(user),
profile: {
createdAt: user.createdAt,
inventoryValue,
inventoryValue: centsJson(inventoryValue),
inventoryCount: user.inventory.filter((i) => !i.locked).length,
jackpotWins,
duelWins,
casesOpened,
caseOpens,
catalogCompleted: catalog.completed,
@@ -256,6 +231,7 @@ router.post('/avatar', requireAuth, (req, res) => {
}
try {
await processUploadedImage(req.file);
const avatarUrl = `/uploads/${req.file.filename}`;
const user = await prisma.user.update({
where: { id: req.session.userId },
+79 -28
View File
@@ -3,17 +3,18 @@ import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { checkAchievements } from '../achievements.js';
import { adCooldownMs } from '../prestige.js';
import { centsJson, creditUserBalance } from '../cents.js';
import {
getCreditPack,
paymentsProvider,
scaleCreditPack,
scaleCreditPacks,
} from '../shopCatalog.js';
import { listEnabledShopOsuPacks, isOsuTopupsSectionEnabled } from '../shopOsu.js';
import { confirmMockOsuPayment, createOsuCheckout } from '../payments.js';
const router = Router();
/** Packages: spend osu → receive credits (cents). */
export const SHOP_PACKAGES = [
{ id: 'starter', name: 'Starter Pack', osuCost: 10, credits: 5000, blurb: '50.00 cr' },
{ id: 'boost', name: 'Boost Pack', osuCost: 25, credits: 15000, blurb: '150.00 cr' },
{ id: 'pro', name: 'Pro Pack', osuCost: 50, credits: 35000, blurb: '350.00 cr' },
{ id: 'whale', name: 'Whale Pack', osuCost: 100, credits: 80000, blurb: '800.00 cr' },
];
const AD_REWARD_MIN = 10000; // 100.00 cr
const AD_REWARD_MAX = 20000; // 200.00 cr
@@ -38,13 +39,19 @@ router.get('/', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
const packages = SHOP_PACKAGES.map((pack) => ({
...pack,
baseOsuCost: pack.osuCost,
}));
const packages = scaleCreditPacks(user.balance);
const sectionEnabled = await isOsuTopupsSectionEnabled();
const osuTopups = sectionEnabled ? await listEnabledShopOsuPacks() : [];
res.json({
packages,
osuTopups,
osuTopupsEnabled: sectionEnabled && osuTopups.length > 0,
payments: {
provider: paymentsProvider(),
currency: 'eur',
mock: paymentsProvider() === 'mock',
},
user: publicUser(user),
ad: adPayload(user),
});
@@ -54,11 +61,12 @@ router.get('/', requireAuth, async (req, res) => {
}
});
/** Spend osu → credits (amount scaled to live balance at purchase time). */
router.post('/buy', requireAuth, async (req, res) => {
try {
const packageId = String(req.body.packageId || '');
const pack = SHOP_PACKAGES.find((p) => p.id === packageId);
if (!pack) {
const packDef = getCreditPack(packageId);
if (!packDef) {
return res.status(400).json({ error: 'Unknown package' });
}
@@ -69,6 +77,7 @@ router.post('/buy', requireAuth, async (req, res) => {
err.status = 404;
throw err;
}
const pack = scaleCreditPack(packDef, user.balance);
const osuCost = pack.osuCost;
if (user.osuBalance < osuCost) {
const err = new Error('Not enough osu');
@@ -76,35 +85,36 @@ router.post('/buy', requireAuth, async (req, res) => {
throw err;
}
const updated = await tx.user.update({
await tx.user.update({
where: { id: user.id },
data: {
osuBalance: { decrement: osuCost },
balance: { increment: pack.credits },
},
data: { osuBalance: { decrement: osuCost } },
});
const updated = await creditUserBalance(tx, user.id, pack.credits);
await tx.transaction.create({
data: {
userId: user.id,
type: 'shop_purchase',
amount: pack.credits,
amount: centsJson(pack.credits),
meta: JSON.stringify({
packageId: pack.id,
osuCost,
credits: pack.credits,
balancePct: pack.balancePct,
balanceBefore: String(user.balance),
scaled: pack.scaled,
}),
},
});
return { user: updated, osuCost };
return { user: updated, osuCost, pack };
});
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result.user),
package: { ...pack, osuCost: result.osuCost },
package: result.pack,
});
} catch (err) {
const status = err.status || 500;
@@ -113,6 +123,49 @@ router.post('/buy', requireAuth, async (req, res) => {
}
});
/** Start real-money → osu checkout (mock or future Stripe). */
router.post('/osu/checkout', requireAuth, async (req, res) => {
try {
const packId = String(req.body.packId || '');
const checkout = await createOsuCheckout(req.session.userId, packId);
res.json(checkout);
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Checkout failed' });
}
});
/** Complete mock payment and credit osu. */
router.post('/osu/confirm', requireAuth, async (req, res) => {
try {
const orderId = String(req.body.orderId || '');
const result = await confirmMockOsuPayment(req.session.userId, orderId);
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result.user),
order: result.order,
alreadyPaid: result.alreadyPaid,
osuGranted: result.order.osuAmount,
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Payment confirmation failed' });
}
});
/**
* Stripe webhook stub — verify signature & fulfill when PAYMENTS_PROVIDER=stripe.
* Keep raw body parsing in mind when wiring Stripe.
*/
router.post('/osu/webhook', async (req, res) => {
if (paymentsProvider() !== 'stripe') {
return res.status(404).json({ error: 'Stripe webhooks not enabled' });
}
res.status(501).json({ error: 'Stripe webhook not implemented yet' });
});
router.post('/ad', requireAuth, async (req, res) => {
try {
const result = await prisma.$transaction(async (tx) => {
@@ -138,19 +191,17 @@ router.post('/ad', requireAuth, async (req, res) => {
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
const now = new Date();
const updated = await tx.user.update({
await tx.user.update({
where: { id: user.id },
data: {
balance: { increment: reward },
lastAdAt: now,
},
data: { lastAdAt: now },
});
const updated = await creditUserBalance(tx, user.id, reward);
await tx.transaction.create({
data: {
userId: user.id,
type: 'ad_reward',
amount: reward,
amount: centsJson(reward),
meta: JSON.stringify({ reward }),
},
});
+37 -46
View File
@@ -4,8 +4,7 @@ import { requireAuth, publicUser } from '../middleware.js';
import {
maxVaultSlots,
settleVaultYield,
summarizeVaultRates,
estimatePendingYield,
summarizeVaultState,
} from '../vault.js';
import { isConnected } from '../presence.js';
@@ -18,6 +17,8 @@ router.post('/stake', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Select at least one item to stake' });
}
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
@@ -61,16 +62,31 @@ router.post('/stake', requireAuth, async (req, res) => {
},
});
return { user, stakedIds: ids, maxSlots, used: used + ids.length };
// Shared timer: start only when vault was empty
let updatedUser = user;
if (used === 0 || !user.vaultYieldAt) {
updatedUser = await tx.user.update({
where: { id: user.id },
data: { vaultYieldAt: now },
});
}
const staked = await tx.inventoryItem.findMany({
where: { userId: user.id, staked: true },
include: { item: true },
});
return {
user: updatedUser,
stakedIds: ids,
vault: summarizeVaultState(updatedUser, staked, mode),
};
});
res.json({
user: publicUser(result.user),
stakedIds: result.stakedIds,
vault: {
used: result.used,
slots: result.maxSlots,
},
vault: result.vault,
});
} catch (err) {
const status = err.status || 500;
@@ -86,7 +102,6 @@ router.post('/unstake', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Select at least one item to unstake' });
}
// Settle yield before unstake (online if connected, else offline)
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
const settled = await settleVaultYield(req.session.userId, mode);
@@ -120,16 +135,25 @@ router.post('/unstake', requireAuth, async (req, res) => {
},
});
const used = await tx.inventoryItem.count({
const remaining = await tx.inventoryItem.findMany({
where: { userId: user.id, staked: true },
include: { item: true },
});
const refreshed = await tx.user.findUnique({ where: { id: user.id } });
let refreshed = user;
if (remaining.length === 0 && user.vaultYieldAt) {
refreshed = await tx.user.update({
where: { id: user.id },
data: { vaultYieldAt: null },
});
} else {
refreshed = await tx.user.findUnique({ where: { id: user.id } });
}
return {
user: refreshed,
unstakedIds: ids,
maxSlots: maxVaultSlots(refreshed),
used,
vault: summarizeVaultState(refreshed, remaining, mode),
};
});
@@ -137,10 +161,7 @@ router.post('/unstake', requireAuth, async (req, res) => {
user: publicUser(result.user),
unstakedIds: result.unstakedIds,
yieldCredited: settled.credited,
vault: {
used: result.used,
slots: result.maxSlots,
},
vault: result.vault,
});
} catch (err) {
const status = err.status || 500;
@@ -149,34 +170,4 @@ router.post('/unstake', requireAuth, async (req, res) => {
}
});
router.post('/claim', requireAuth, async (req, res) => {
try {
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
const settled = await settleVaultYield(req.session.userId, mode);
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
const staked = await prisma.inventoryItem.findMany({
where: { userId: user.id, staked: true },
include: { item: true },
});
const rates = summarizeVaultRates(staked, user);
res.json({
user: publicUser(user),
credited: settled.credited,
ticks: settled.ticks,
vault: {
used: staked.length,
slots: maxVaultSlots(user),
...rates,
pendingYield: estimatePendingYield(staked, user, mode),
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message || 'Failed to claim vault yield' });
}
});
export default router;
+154
View File
@@ -0,0 +1,154 @@
import { prisma } from './db.js';
import { clearDropFeed } from './feed.js';
import { getIO } from './realtime.js';
/**
* Player progress wiped on server prestige.
* Kept: username, password, avatar, bio, role, createdAt (Joined),
* playTimeSeconds, lastConnection, achievements,
* and lifetime stats via transactions open_case / jackpot_win.
*/
export const PLAYER_PROGRESS_RESET = {
balance: '0',
osuBalance: 0,
prBalance: '0',
lastAdAt: null,
autoSellEnabled: false,
autoSellThresholdCents: '1000',
prestigeCount: 0,
prestigeChanceBonus: 0,
prestigeGainBonus: 0,
prestigeWearBonus: 0,
prestigePrBonus: 0,
prestigeOpenBonus: 0,
prestigeQualityBonus: 0,
prestigeAnimReduction: 0,
prestigeRefundChance: 0,
prestigeRespinChance: 0,
prestigeVaultSlots: 0,
prestigeOfflineYield: 0,
prestigeOnlineYield: 0,
vaultYieldAt: null,
};
/** Transaction types that back profile lifetime stats — never wiped. */
export const STATS_TRANSACTION_TYPES = ['open_case', 'jackpot_win'];
export function slugifyAchievementKey(raw) {
const s = String(raw || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 48);
return s || `server_prestige_${Date.now()}`;
}
/**
* Wipe non-admin economy / prestige progress.
* Preserves identity, presence stats, achievements, and lifetime win/open logs.
*/
export async function runServerPrestige({
achievementKey,
achievementName,
achievementDescription,
achievementIcon = '♛',
}) {
const key = slugifyAchievementKey(achievementKey);
const name = String(achievementName || '').trim().slice(0, 80);
const description = String(achievementDescription || '').trim().slice(0, 240);
const icon = String(achievementIcon || '♛').trim().slice(0, 8) || '♛';
if (!name) {
const err = new Error('Achievement name is required');
err.status = 400;
throw err;
}
if (!description) {
const err = new Error('Achievement description is required');
err.status = 400;
throw err;
}
const result = await prisma.$transaction(
async (tx) => {
const players = await tx.user.findMany({
where: { role: { not: 'admin' } },
select: { id: true },
});
const ids = players.map((p) => p.id);
// Active battle pots / locks (inventory FKs)
await tx.jackpotDeposit.deleteMany({});
await tx.jackpotRound.deleteMany({});
if (ids.length) {
await tx.prestigeLog.deleteMany({ where: { userId: { in: ids } } });
await tx.catalogEntry.deleteMany({ where: { userId: { in: ids } } });
await tx.caseKeyProgress.deleteMany({ where: { userId: { in: ids } } });
// Keep open_case / jackpot_win for profile stats
await tx.transaction.deleteMany({
where: {
userId: { in: ids },
type: { notIn: STATS_TRANSACTION_TYPES },
},
});
await tx.inventoryItem.deleteMany({ where: { userId: { in: ids } } });
await tx.user.updateMany({
where: { id: { in: ids } },
data: PLAYER_PROGRESS_RESET,
});
}
const maxOrder = await tx.achievement.aggregate({ _max: { sortOrder: true } });
const sortOrder = (maxOrder._max.sortOrder ?? 0) + 1;
const achievement = await tx.achievement.upsert({
where: { key },
create: { key, name, description, icon, sortOrder },
update: { name, description, icon },
});
if (ids.length) {
const already = await tx.userAchievement.findMany({
where: { userId: { in: ids }, achievementId: achievement.id },
select: { userId: true },
});
const have = new Set(already.map((r) => r.userId));
const missing = ids.filter((userId) => !have.has(userId));
if (missing.length) {
await tx.userAchievement.createMany({
data: missing.map((userId) => ({
userId,
achievementId: achievement.id,
})),
});
}
}
return {
playersReset: ids.length,
achievement: {
id: achievement.id,
key: achievement.key,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
},
};
},
{ timeout: 120_000, maxWait: 20_000 }
);
clearDropFeed();
const io = getIO();
if (io) {
io.emit('server:prestige-reset', {
at: new Date().toISOString(),
achievement: result.achievement,
playersReset: result.playersReset,
});
}
return result;
}
-544
View File
@@ -1,544 +0,0 @@
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 {
user: { select: { id: true, username: true, avatarUrl: true } },
inventoryItem: { include: { item: true } },
};
}
function roomInclude() {
return {
creator: { select: { id: true, username: true, avatarUrl: true } },
winner: { select: { id: true, username: true, avatarUrl: true } },
deposits: { include: depositInclude() },
};
}
export function serializeDuelRoom(room) {
if (!room) return null;
const deposits = (room.deposits || []).map((d) => ({
id: d.id,
userId: d.userId,
username: d.user?.username || '',
avatarUrl: d.user?.avatarUrl || '',
valueCents: d.valueCents,
inventoryItemId: d.inventoryItemId,
item: d.inventoryItem
? {
id: d.inventoryItem.item.id,
name: d.inventoryItem.item.name,
imageUrl: d.inventoryItem.item.imageUrl,
rarity: d.inventoryItem.item.rarity,
marketValue: d.valueCents,
valueCents: d.valueCents,
wear: d.inventoryItem.wear,
floatValue: d.inventoryItem.floatValue,
paintSeed: d.inventoryItem.paintSeed,
}
: null,
}));
const byUser = new Map();
let totalValue = 0;
for (const d of deposits) {
totalValue += d.valueCents;
const prev = byUser.get(d.userId) || {
userId: d.userId,
username: d.username,
avatarUrl: d.avatarUrl,
valueCents: 0,
itemCount: 0,
};
prev.valueCents += d.valueCents;
prev.itemCount += 1;
byUser.set(d.userId, prev);
}
const players = [...byUser.values()].map((p) => ({
...p,
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
}));
return {
id: room.id,
status: room.status,
creatorId: room.creatorId,
creatorUsername: room.creator?.username || '',
maxPlayers: room.maxPlayers,
maxItemsPerPlayer: room.maxItemsPerPlayer,
minItemValue: room.minItemValue,
minPlayersToStart: room.minPlayersToStart,
winnerUserId: room.winnerUserId || null,
winnerUsername: room.winner?.username || null,
createdAt: room.createdAt,
startedAt: room.startedAt || null,
resolvedAt: room.resolvedAt || null,
totalValue,
playerCount: players.length,
players,
deposits,
};
}
async function loadRoom(id) {
return prisma.duelRoom.findUnique({
where: { id: Number(id) },
include: roomInclude(),
});
}
export async function getDuelRoom(id) {
return serializeDuelRoom(await loadRoom(id));
}
export async function listDuelRooms() {
await purgeStaleEmptyDuelRooms();
const rooms = await prisma.duelRoom.findMany({
where: { status: { in: ['open', 'in_progress', 'spinning'] } },
include: roomInclude(),
orderBy: { createdAt: 'desc' },
});
const serialized = rooms.map(serializeDuelRoom);
return {
open: serialized.filter((r) => r.status === 'open'),
inProgress: serialized.filter((r) => r.status === 'in_progress' || r.status === 'spinning'),
};
}
/**
* 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);
return list;
}
function broadcastRoom(room, extra = {}) {
const state = serializeDuelRoom(room);
emitDuel(room.id, 'duel:state', { ...state, ...extra });
return state;
}
export async function createDuelRoom(userId, opts = {}) {
const maxPlayers = Math.min(10, Math.max(2, Number(opts.maxPlayers) || 2));
const maxItemsPerPlayer = Math.min(10, Math.max(1, Number(opts.maxItemsPerPlayer) || 3));
const minItemValue = Math.max(0, Number(opts.minItemValue) || 0);
const minPlayersToStart = Math.min(
maxPlayers,
Math.max(2, Number(opts.minPlayersToStart) || 2)
);
const room = await prisma.duelRoom.create({
data: {
creatorId: userId,
status: 'open',
maxPlayers,
maxItemsPerPlayer,
minItemValue,
minPlayersToStart,
},
include: roomInclude(),
});
await broadcastDuelsList();
return serializeDuelRoom(room);
}
export async function placeDuelBet(userId, roomId, inventoryItemIds) {
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
throw Object.assign(new Error('Select at least one item'), { status: 400 });
}
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: { deposits: true },
});
if (!current || current.status !== 'open') {
throw Object.assign(new Error('Room is not open for bets'), { status: 409 });
}
if (ids.length > current.maxItemsPerPlayer) {
throw Object.assign(
new Error(`Max ${current.maxItemsPerPlayer} items per player`),
{ status: 400 }
);
}
const existingCount = current.deposits.filter((d) => d.userId === userId).length;
if (existingCount + ids.length > current.maxItemsPerPlayer) {
throw Object.assign(
new Error(`You can bet at most ${current.maxItemsPerPlayer} items`),
{ status: 400 }
);
}
const uniquePlayers = new Set(current.deposits.map((d) => d.userId));
const isNewPlayer = !uniquePlayers.has(userId);
if (isNewPlayer && uniquePlayers.size >= current.maxPlayers) {
throw Object.assign(new Error('Room is full'), { status: 409 });
}
const invItems = await tx.inventoryItem.findMany({
where: { id: { in: ids }, userId, locked: false, staked: false },
include: { item: true },
});
if (invItems.length !== ids.length) {
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), { status: 400 });
}
for (const inv of invItems) {
if (inv.valueCents < current.minItemValue) {
throw Object.assign(
new Error(`Items must be worth at least ${current.minItemValue} cents`),
{ status: 400 }
);
}
await tx.inventoryItem.update({
where: { id: inv.id },
data: { locked: true },
});
await tx.duelDeposit.create({
data: {
roomId: current.id,
userId,
inventoryItemId: inv.id,
valueCents: inv.valueCents,
},
});
await tx.transaction.create({
data: {
userId,
type: 'duel_bet',
amount: 0,
meta: JSON.stringify({
roomId: current.id,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: inv.valueCents,
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
}
const refreshed = await tx.duelRoom.findUnique({
where: { id: current.id },
include: roomInclude(),
});
const players = new Set(refreshed.deposits.map((d) => d.userId));
const allHaveItems = [...players].every(
(pid) => refreshed.deposits.some((d) => d.userId === pid)
);
if (players.size >= refreshed.maxPlayers && allHaveItems) {
return startDuelInTx(tx, refreshed.id);
}
return refreshed;
});
const state = broadcastRoom(room);
await broadcastDuelsList();
if (room.status === 'spinning') {
scheduleDuelFinish(room.id);
const reelItemId =
room.deposits.find((d) => d.userId === room.winnerUserId)?.id || null;
emitDuel(room.id, 'duel:spin', { ...state, reelWinnerItemId: reelItemId });
}
return state;
}
export async function leaveDuelRoom(userId, roomId) {
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: { deposits: true },
});
if (!current || current.status !== 'open') {
throw Object.assign(new Error('Cannot leave this room'), { status: 409 });
}
const deposits = current.deposits.filter((d) => d.userId === userId);
for (const d of deposits) {
await tx.duelDeposit.delete({ where: { id: d.id } });
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
data: { locked: false },
});
}
return tx.duelRoom.findUnique({
where: { id: current.id },
include: roomInclude(),
});
});
const state = broadcastRoom(room);
await broadcastDuelsList();
return state;
}
export async function cancelDuelRoom(userId, roomId) {
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: { deposits: true },
});
if (!current) {
throw Object.assign(new Error('Room not found'), { status: 404 });
}
if (current.creatorId !== userId) {
throw Object.assign(new Error('Only the creator can cancel'), { status: 403 });
}
if (current.status !== 'open') {
throw Object.assign(new Error('Room cannot be cancelled'), { status: 409 });
}
for (const d of current.deposits) {
await tx.duelDeposit.delete({ where: { id: d.id } });
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
data: { locked: false },
});
}
return tx.duelRoom.update({
where: { id: current.id },
data: { status: 'cancelled', resolvedAt: new Date() },
include: roomInclude(),
});
});
const state = broadcastRoom(room);
await broadcastDuelsList();
return state;
}
export async function startDuelRoom(userId, roomId) {
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: roomInclude(),
});
if (!current) {
throw Object.assign(new Error('Room not found'), { status: 404 });
}
if (current.creatorId !== userId) {
throw Object.assign(new Error('Only the creator can start'), { status: 403 });
}
if (current.status !== 'open') {
throw Object.assign(new Error('Room is not open'), { status: 409 });
}
const players = new Set(current.deposits.map((d) => d.userId));
if (players.size < current.minPlayersToStart) {
throw Object.assign(
new Error(`Need at least ${current.minPlayersToStart} players`),
{ status: 400 }
);
}
return startDuelInTx(tx, current.id);
});
const state = broadcastRoom(room);
await broadcastDuelsList();
if (room.status === 'spinning') {
scheduleDuelFinish(room.id);
const reelItemId =
room.deposits.find((d) => d.userId === room.winnerUserId)?.id || null;
emitDuel(room.id, 'duel:spin', { ...state, reelWinnerItemId: reelItemId });
}
return state;
}
async function startDuelInTx(tx, roomId) {
const room = await tx.duelRoom.findUnique({
where: { id: roomId },
include: roomInclude(),
});
if (!room || room.status !== 'open') return room;
const byUser = new Map();
for (const d of room.deposits) {
byUser.set(d.userId, (byUser.get(d.userId) || 0) + d.valueCents);
}
if (byUser.size < 2) {
throw Object.assign(new Error('Need at least 2 players with items'), { status: 400 });
}
const entries = [...byUser.entries()].map(([userId, dropRate]) => ({
userId,
dropRate,
}));
const winner = pickWeighted(entries);
return tx.duelRoom.update({
where: { id: roomId },
data: {
status: 'spinning',
winnerUserId: winner.userId,
startedAt: new Date(),
},
include: roomInclude(),
});
}
function scheduleDuelFinish(roomId) {
if (pendingSpins.has(roomId)) {
clearTimeout(pendingSpins.get(roomId));
}
const t = setTimeout(() => {
finishDuelSpin(roomId).catch((err) => console.error('duel finish', err));
}, SPIN_MS + 200);
pendingSpins.set(roomId, t);
}
async function finishDuelSpin(roomId) {
pendingSpins.delete(roomId);
let winnerId = null;
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: { deposits: true },
});
if (!current || current.status !== 'spinning') return null;
winnerId = current.winnerUserId;
for (const d of current.deposits) {
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
data: { userId: winnerId, locked: false },
});
}
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: {
userId: winnerId,
type: 'duel_win',
amount: totalValue,
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() },
include: roomInclude(),
});
});
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);
await broadcastDuelsList();
}
export async function recoverDuelSpins() {
const spinning = await prisma.duelRoom.findMany({
where: { status: 'spinning' },
});
for (const room of spinning) {
scheduleDuelFinish(room.id);
}
}
+328 -252
View File
@@ -1,9 +1,10 @@
import { prisma } from '../db.js';
import { pickWeighted } from '../weighted.js';
import { applyAutoSell } from '../autoSell.js';
import { emitJackpot } from '../realtime.js';
import { emitJackpot, emitJackpotsList } from '../realtime.js';
import { pushDrop } from '../feed.js';
import { checkAchievements } from '../achievements.js';
import { toCentsBigInt, centsJson, creditUserBalance } from '../cents.js';
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 30);
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3);
@@ -12,8 +13,8 @@ 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;
const resolveTimers = new Map();
const spinFollowUps = new Map();
function isTimerWaiting(endsAt) {
return new Date(endsAt).getFullYear() >= 2099;
@@ -30,6 +31,14 @@ function depositInclude() {
};
}
function roundInclude() {
return {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true, avatarUrl: true } },
creator: { select: { id: true, username: true, avatarUrl: true } },
};
}
export function serializeJackpotRound(round) {
if (!round) return null;
@@ -56,24 +65,27 @@ export function serializeJackpotRound(round) {
}));
const byUser = new Map();
let totalValue = 0;
let totalValue = 0n;
for (const d of deposits) {
totalValue += d.valueCents;
const v = toCentsBigInt(d.valueCents);
totalValue += v;
const prev = byUser.get(d.userId) || {
userId: d.userId,
username: d.username,
avatarUrl: d.avatarUrl,
valueCents: 0,
valueCents: 0n,
itemCount: 0,
};
prev.valueCents += d.valueCents;
prev.valueCents += v;
prev.itemCount += 1;
byUser.set(d.userId, prev);
}
const players = [...byUser.values()].map((p) => ({
...p,
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
valueCents: centsJson(p.valueCents),
chance:
totalValue > 0n ? Number((p.valueCents * 10000n) / totalValue) / 100 : 0,
}));
const waitingForPlayers =
@@ -82,6 +94,9 @@ export function serializeJackpotRound(round) {
return {
id: round.id,
status: round.status,
creatorId: round.creatorId || null,
creatorUsername: round.creator?.username || null,
name: `Battle ${round.id}`,
endsAt: waitingForPlayers ? null : round.endsAt,
waitingForPlayers,
roundSeconds: ROUND_SECONDS,
@@ -90,133 +105,198 @@ export function serializeJackpotRound(round) {
resolvedAt: round.resolvedAt || null,
winnerUserId: round.winnerUserId || null,
winnerUsername: round.winner?.username || null,
totalValue,
totalValue: centsJson(totalValue),
maxItemsPerPlayer: MAX_ITEMS,
players,
deposits,
};
}
async function loadOpenOrLatest() {
const open = await prisma.jackpotRound.findFirst({
where: { status: { in: ['open', 'spinning'] } },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
orderBy: { id: 'desc' },
});
if (open) return open;
return prisma.jackpotRound.findFirst({
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
async function loadRound(id) {
return prisma.jackpotRound.findUnique({
where: { id: Number(id) },
include: roundInclude(),
});
}
export async function getJackpotRound(id) {
return serializeJackpotRound(await loadRound(id));
}
export async function listJackpotBattles() {
const rounds = await prisma.jackpotRound.findMany({
where: {
OR: [
{ status: 'spinning' },
{ status: 'open', deposits: { some: {} } },
],
},
include: roundInclude(),
orderBy: { id: 'desc' },
});
return rounds.map(serializeJackpotRound);
}
export async function getJackpotState() {
const round = await loadOpenOrLatest();
return serializeJackpotRound(round);
export async function broadcastJackpotsList() {
const battles = await listJackpotBattles();
emitJackpotsList({ battles });
return battles;
}
export async function broadcastJackpot(extra = {}) {
const state = await getJackpotState();
emitJackpot('jackpot:state', { ...state, ...extra });
return state;
}
async function createOpenRound() {
return prisma.jackpotRound.create({
data: { status: 'open', endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
function clearResolveTimer() {
if (resolveTimer) {
clearTimeout(resolveTimer);
resolveTimer = null;
function clearResolveTimer(roundId) {
const t = resolveTimers.get(roundId);
if (t) {
clearTimeout(t);
resolveTimers.delete(roundId);
}
}
function clearTimers() {
clearResolveTimer();
if (spinFollowUp) {
clearTimeout(spinFollowUp);
spinFollowUp = null;
function clearSpinFollowUp(roundId) {
const t = spinFollowUps.get(roundId);
if (t) {
clearTimeout(t);
spinFollowUps.delete(roundId);
}
}
function scheduleResolve(endsAt) {
clearResolveTimer();
function clearTimers(roundId) {
clearResolveTimer(roundId);
clearSpinFollowUp(roundId);
}
function scheduleResolve(roundId, endsAt) {
clearResolveTimer(roundId);
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));
}, delay);
resolveTimers.set(
roundId,
setTimeout(() => {
resolveJackpotRound(roundId).catch((err) =>
console.error('jackpot resolve', err)
);
}, delay)
);
}
/**
* Recover timers for all active battles (boot). Does not create empty rounds.
* Cancels orphan open battles with zero deposits (legacy singleton leftovers).
*/
export async function ensureJackpotRound() {
let round = await prisma.jackpotRound.findFirst({
where: { status: 'open' },
include: { deposits: true },
orderBy: { id: 'desc' },
await prisma.jackpotRound.updateMany({
where: {
status: 'open',
deposits: { none: {} },
},
data: { status: 'cancelled', resolvedAt: new Date() },
});
if (!round) {
const spinning = await prisma.jackpotRound.findFirst({
where: { status: 'spinning' },
orderBy: { id: 'desc' },
});
if (spinning) {
spinFollowUp = setTimeout(() => {
finishJackpotSpin(spinning.id).catch((err) => console.error(err));
}, 1000);
return getJackpotState();
const rounds = await prisma.jackpotRound.findMany({
where: { status: { in: ['open', 'spinning'] } },
include: { deposits: true },
});
for (const round of rounds) {
if (round.status === 'spinning') {
clearSpinFollowUp(round.id);
spinFollowUps.set(
round.id,
setTimeout(() => {
finishJackpotSpin(round.id).catch((err) => console.error(err));
}, 1000)
);
continue;
}
round = await createOpenRound();
return getJackpotState();
}
const players = uniquePlayerCount(round.deposits);
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(round.id);
continue;
}
if (players < MIN_PLAYERS) {
if (!isTimerWaiting(round.endsAt)) {
if (isTimerWaiting(round.endsAt)) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
await prisma.jackpotRound.update({
where: { id: round.id },
data: { endsAt: WAITING_ENDS_AT },
data: { endsAt },
});
scheduleResolve(round.id, endsAt);
continue;
}
clearResolveTimer();
return getJackpotState();
if (new Date(round.endsAt).getTime() <= Date.now()) {
await resolveJackpotRound(round.id);
continue;
}
scheduleResolve(round.id, round.endsAt);
}
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();
}
scheduleResolve(round.endsAt);
return getJackpotState();
return listJackpotBattles();
}
export async function placeJackpotBet(userId, inventoryItemIds) {
async function applyDeposits(tx, roundId, userId, ids) {
const existingCount = await tx.jackpotDeposit.count({
where: { roundId, userId },
});
if (existingCount + ids.length > MAX_ITEMS) {
throw Object.assign(
new Error(`You can bet at most ${MAX_ITEMS} items this round`),
{ status: 400 }
);
}
const invItems = await tx.inventoryItem.findMany({
where: { id: { in: ids }, userId, locked: false, staked: false },
include: { item: true },
});
if (invItems.length !== ids.length) {
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), {
status: 400,
});
}
for (const inv of invItems) {
await tx.inventoryItem.update({
where: { id: inv.id },
data: { locked: true },
});
await tx.jackpotDeposit.create({
data: {
roundId,
userId,
inventoryItemId: inv.id,
valueCents: inv.valueCents,
},
});
await tx.transaction.create({
data: {
userId,
type: 'jackpot_bet',
amount: '0',
meta: JSON.stringify({
roundId,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: centsJson(inv.valueCents),
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
}
}
/**
* Create a new battle by placing the first bet.
*/
export async function createJackpotBattle(userId, inventoryItemIds) {
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
throw Object.assign(new Error('Select at least one item'), { status: 400 });
@@ -226,74 +306,58 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
}
const round = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({
where: { status: 'open' },
orderBy: { id: 'desc' },
const created = await tx.jackpotRound.create({
data: {
status: 'open',
endsAt: WAITING_ENDS_AT,
creatorId: userId,
},
});
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
await applyDeposits(tx, created.id, userId, ids);
return tx.jackpotRound.findUnique({
where: { id: created.id },
include: roundInclude(),
});
});
const state = serializeJackpotRound(round);
emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
export async function placeJackpotBet(roundId, userId, inventoryItemIds) {
const id = Number(roundId);
if (!id) {
throw Object.assign(new Error('Invalid battle'), { status: 400 });
}
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
throw Object.assign(new Error('Select at least one item'), { status: 400 });
}
if (ids.length > MAX_ITEMS) {
throw Object.assign(new Error(`Max ${MAX_ITEMS} items per bet`), { status: 400 });
}
const round = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findUnique({ where: { id } });
if (!open || open.status !== 'open') {
throw Object.assign(new Error('Battle is not open'), { status: 409 });
}
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() <= Date.now()) {
throw Object.assign(new Error('Round is closing'), { status: 409 });
}
const existingCount = await tx.jackpotDeposit.count({
where: { roundId: open.id, userId },
});
if (existingCount + ids.length > MAX_ITEMS) {
throw Object.assign(
new Error(`You can bet at most ${MAX_ITEMS} items this round`),
{ status: 400 }
);
}
const invItems = await tx.inventoryItem.findMany({
where: { id: { in: ids }, userId, locked: false, staked: false },
include: { item: true },
});
if (invItems.length !== ids.length) {
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), { status: 400 });
}
for (const inv of invItems) {
await tx.inventoryItem.update({
where: { id: inv.id },
data: { locked: true },
});
await tx.jackpotDeposit.create({
data: {
roundId: open.id,
userId,
inventoryItemId: inv.id,
valueCents: inv.valueCents,
},
});
await tx.transaction.create({
data: {
userId,
type: 'jackpot_bet',
amount: 0,
meta: JSON.stringify({
roundId: open.id,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: inv.valueCents,
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
}
await applyDeposits(tx, open.id, userId, ids);
let next = await tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
include: roundInclude(),
});
// Arm countdown once we have enough distinct players
if (
uniquePlayerCount(next.deposits) >= MIN_PLAYERS &&
isTimerWaiting(next.endsAt)
@@ -302,10 +366,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
next = await tx.jackpotRound.update({
where: { id: next.id },
data: { endsAt },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
include: roundInclude(),
});
}
@@ -316,29 +377,35 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
uniquePlayerCount(round.deposits) >= MIN_PLAYERS &&
!isTimerWaiting(round.endsAt)
) {
scheduleResolve(round.endsAt);
scheduleResolve(round.id, round.endsAt);
}
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
export async function withdrawJackpotBet(userId, inventoryItemIds) {
export async function withdrawJackpotBet(roundId, userId, inventoryItemIds) {
const id = Number(roundId);
if (!id) {
throw Object.assign(new Error('Invalid battle'), { status: 400 });
}
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
throw Object.assign(new Error('Select items to withdraw'), { status: 400 });
}
const round = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({
where: { status: 'open' },
orderBy: { id: 'desc' },
});
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
const open = await tx.jackpotRound.findUnique({ where: { id } });
if (!open || open.status !== 'open') {
throw Object.assign(new Error('Battle is not open'), { status: 409 });
}
if (!isTimerWaiting(open.endsAt) && 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 });
}
@@ -363,33 +430,44 @@ export async function withdrawJackpotBet(userId, inventoryItemIds) {
let next = await tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
include: roundInclude(),
});
// 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 } },
},
include: roundInclude(),
});
}
// Creator left alone with no deposits → cancel empty battle
if (!next.deposits.length) {
next = await tx.jackpotRound.update({
where: { id: next.id },
data: { status: 'cancelled', resolvedAt: new Date() },
include: roundInclude(),
});
}
return next;
});
if (round.status === 'cancelled') {
clearTimers(round.id);
const state = serializeJackpotRound(round);
emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
if (isTimerWaiting(round.endsAt) || uniquePlayerCount(round.deposits) < MIN_PLAYERS) {
clearResolveTimer();
clearResolveTimer(round.id);
}
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
@@ -400,10 +478,7 @@ async function finishJackpotSpin(roundId) {
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 } },
},
include: roundInclude(),
});
if (!round || round.status !== 'spinning') return null;
@@ -418,7 +493,7 @@ async function finishJackpotSpin(roundId) {
const acquiredIds = round.deposits
.filter((d) => d.userId !== winnerId)
.map((d) => d.inventoryItemId);
let autoSellTotal = 0;
let autoSellTotal = 0n;
if (acquiredIds.length) {
const winner = await tx.user.findUnique({ where: { id: winnerId } });
const acquiredItems = await tx.inventoryItem.findMany({
@@ -428,39 +503,39 @@ async function finishJackpotSpin(roundId) {
autoSellTotal = balanceDelta;
}
const totalValue = round.deposits.reduce((s, d) => s + d.valueCents, 0);
const totalValue = round.deposits.reduce(
(s, d) => s + toCentsBigInt(d.valueCents),
0n
);
await tx.transaction.create({
data: {
userId: winnerId,
type: 'jackpot_win',
amount: totalValue,
amount: centsJson(totalValue),
meta: JSON.stringify({
roundId: round.id,
depositIds: round.deposits.map((d) => d.id),
autoSellTotal,
autoSellTotal: centsJson(autoSellTotal),
}),
},
});
if (autoSellTotal > 0) {
await tx.user.update({
where: { id: winnerId },
data: { balance: { increment: autoSellTotal } },
});
if (autoSellTotal > 0n) {
await creditUserBalance(tx, winnerId, autoSellTotal);
}
await tx.jackpotRound.update({
const resolved = await tx.jackpotRound.update({
where: { id: round.id },
data: { status: 'resolved', resolvedAt: new Date() },
include: roundInclude(),
});
// 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) {
if (!best || toCentsBigInt(d.valueCents) > toCentsBigInt(best.deposit.valueCents)) {
best = { deposit: d, item, inv };
}
}
@@ -476,7 +551,7 @@ async function finishJackpotSpin(roundId) {
).length;
feedEntry = {
source: 'jackpot',
caseName: 'MultiPlayers Case',
caseName: `Battle ${round.id}`,
count,
openCount: round.deposits.length,
inventoryIds: round.deposits.map((d) => d.inventoryItemId).filter(Boolean),
@@ -497,9 +572,11 @@ async function finishJackpotSpin(roundId) {
};
}
return createOpenRoundInTx(tx);
return resolved;
});
clearTimers(roundId);
if (!result) return;
if (winnerId) {
@@ -515,72 +592,56 @@ async function finishJackpotSpin(roundId) {
}
const state = serializeJackpotRound(result);
// New round waits for players — do not arm timer yet
clearResolveTimer();
emitJackpot('jackpot:result', {
emitJackpot(roundId, 'jackpot:result', {
previousRoundId: roundId,
...state,
});
emitJackpot('jackpot:state', state);
await broadcastJackpotsList();
}
async function createOpenRoundInTx(tx) {
return tx.jackpotRound.create({
data: { status: 'open', endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
export async function resolveJackpotRound() {
clearTimers();
export async function resolveJackpotRound(roundId) {
const id = Number(roundId);
clearTimers(id);
const spun = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({
where: { status: 'open' },
const open = await tx.jackpotRound.findUnique({
where: { id },
include: {
deposits: { include: depositInclude() },
},
orderBy: { id: 'desc' },
});
if (!open) return null;
if (!open || open.status !== 'open') return null;
const players = uniquePlayerCount(open.deposits);
if (players < MIN_PLAYERS) {
if (players < MIN_PLAYERS || !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 };
}
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 } },
},
include: roundInclude(),
});
return { kind: 'waiting', updated: waiting };
}
const byUser = new Map();
for (const d of open.deposits) {
byUser.set(d.userId, (byUser.get(d.userId) || 0) + d.valueCents);
byUser.set(
d.userId,
(byUser.get(d.userId) || 0n) + toCentsBigInt(d.valueCents)
);
}
const entries = [...byUser.entries()].map(([userId, dropRate]) => ({
userId,
dropRate,
}));
const winner = pickWeighted(entries);
const maxV = [...byUser.values()].reduce((a, b) => (a > b ? a : b), 0n);
const weightEntries =
maxV > BigInt(Number.MAX_SAFE_INTEGER)
? [...byUser.entries()].map(([userId, v]) => ({
userId,
dropRate: Number((v * 1_000_000n) / maxV) || 1,
}))
: [...byUser.entries()].map(([userId, v]) => ({
userId,
dropRate: Number(v),
}));
const winner = pickWeighted(weightEntries);
const winnerDeposits = open.deposits.filter((d) => d.userId === winner.userId);
const reelDepositId = winnerDeposits[0]?.id || null;
@@ -588,10 +649,7 @@ export async function resolveJackpotRound() {
const updated = await tx.jackpotRound.update({
where: { id: open.id },
data: { status: 'spinning', winnerUserId: winner.userId },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
include: roundInclude(),
});
return { kind: 'spin', updated, reelItemId: reelDepositId };
@@ -599,23 +657,41 @@ export async function resolveJackpotRound() {
if (!spun) return;
if (spun.kind === 'waiting' || spun.kind === 'extend') {
if (spun.kind === 'waiting') {
const state = serializeJackpotRound(spun.updated);
clearResolveTimer();
emitJackpot('jackpot:state', state);
clearResolveTimer(id);
emitJackpot(id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
const { updated, reelItemId } = spun;
const state = serializeJackpotRound(updated);
emitJackpot('jackpot:spin', {
emitJackpot(id, 'jackpot:spin', {
...state,
reelWinnerItemId: reelItemId,
});
await broadcastJackpotsList();
spinFollowUp = setTimeout(() => {
finishJackpotSpin(updated.id).catch((err) => console.error('jackpot finish', err));
}, SPIN_MS + 200);
spinFollowUps.set(
updated.id,
setTimeout(() => {
finishJackpotSpin(updated.id).catch((err) =>
console.error('jackpot finish', err)
);
}, SPIN_MS + 200)
);
return state;
}
/** @deprecated use listJackpotBattles / getJackpotRound */
export async function getJackpotState() {
const battles = await listJackpotBattles();
return battles[0] || null;
}
export async function broadcastJackpot(extra = {}) {
const battles = await broadcastJackpotsList();
return battles[0] ? { ...battles[0], ...extra } : null;
}
+175
View File
@@ -0,0 +1,175 @@
import { toCentsBigInt } from './cents.js';
/**
* Shop catalogs.
* - OSU_TOPUPS: real-money → osu (premium currency)
* - CREDIT_PACKS: osu → in-game credits (scaled to player balance)
*
* Fiat prices are in minor units (EUR cents). Swap provider later via PAYMENTS_PROVIDER.
*/
/** @typedef {{ id: string, name: string, osu: number, priceCents: number, currency: string, badge?: string|null, blurb: string, perOsuLabel: string }} OsuTopup */
/** @type {OsuTopup[]} */
export const OSU_TOPUPS = [
{
id: 'osu_50',
name: 'Spark',
osu: 50,
priceCents: 499,
currency: 'eur',
badge: null,
blurb: 'A light top-up to open a few cases',
perOsuLabel: '€0.10 / osu',
},
{
id: 'osu_150',
name: 'Pulse',
osu: 150,
priceCents: 1299,
currency: 'eur',
badge: 'Popular',
blurb: '150 osu · better rate than Spark',
perOsuLabel: '€0.087 / osu',
},
{
id: 'osu_400',
name: 'Orbit',
osu: 400,
priceCents: 2999,
currency: 'eur',
badge: null,
blurb: 'Solid stack for grinding cases & vault',
perOsuLabel: '€0.075 / osu',
},
{
id: 'osu_1000',
name: 'Nova',
osu: 1000,
priceCents: 6499,
currency: 'eur',
badge: 'Best value',
blurb: '1000 osu · lowest price per osu',
perOsuLabel: '€0.065 / osu',
},
{
id: 'osu_2500',
name: 'Eclipse',
osu: 2500,
priceCents: 14999,
currency: 'eur',
badge: null,
blurb: 'Whale pack for long prestige runs',
perOsuLabel: '€0.060 / osu',
},
];
/**
* Credit packs priced in osu. Rewards scale with the buyer's credit balance:
* credits = max(minCredits, balance * balancePct / 100).
* Larger packs grant a higher % of balance per osu.
*/
export const CREDIT_PACKS = [
{
id: 'cr_spark',
name: 'Credit Spark',
osuCost: 10,
balancePct: 20,
minCredits: 5000,
tag: null,
},
{
id: 'cr_boost',
name: 'Credit Boost',
osuCost: 25,
balancePct: 55,
minCredits: 15000,
tag: null,
},
{
id: 'cr_surge',
name: 'Credit Surge',
osuCost: 60,
balancePct: 140,
minCredits: 40000,
tag: 'Fair',
},
{
id: 'cr_nova',
name: 'Credit Nova',
osuCost: 150,
balancePct: 400,
minCredits: 110000,
tag: 'Popular',
},
{
id: 'cr_orbit',
name: 'Credit Orbit',
osuCost: 400,
balancePct: 1200,
minCredits: 320000,
tag: null,
},
{
id: 'cr_eclipse',
name: 'Credit Eclipse',
osuCost: 1000,
balancePct: 3500,
minCredits: 900000,
tag: 'Best rate',
},
];
/** @deprecated use CREDIT_PACKS */
export const SHOP_PACKAGES = CREDIT_PACKS;
export function getOsuTopup(id) {
return OSU_TOPUPS.find((p) => p.id === String(id || ''));
}
export function getCreditPack(id) {
return CREDIT_PACKS.find((p) => p.id === String(id || ''));
}
/** Resolve pack payout for a player's current balance (cents → string cents). */
export function scaleCreditPack(pack, balanceCents) {
const bal = toCentsBigInt(balanceCents);
const pct = Math.max(0, Math.floor(Number(pack.balancePct) || 0));
const fromPct = pct > 0 ? (bal * BigInt(pct)) / 100n : 0n;
const min = BigInt(Math.max(0, Math.floor(Number(pack.minCredits) || 0)));
const credits = fromPct > min ? fromPct : min;
return {
id: pack.id,
name: pack.name,
osuCost: pack.osuCost,
baseOsuCost: pack.osuCost,
balancePct: pct,
minCredits: min.toString(),
credits: credits.toString(),
tag: pack.tag ?? null,
scaled: fromPct > min,
};
}
export function scaleCreditPacks(balanceCents) {
return CREDIT_PACKS.map((pack) => scaleCreditPack(pack, balanceCents));
}
export function formatFiat(priceCents, currency = 'eur') {
const n = Number(priceCents) || 0;
const major = n / 100;
try {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: String(currency || 'eur').toUpperCase(),
}).format(major);
} catch {
return `${major.toFixed(2)}`;
}
}
export function paymentsProvider() {
const raw = String(process.env.PAYMENTS_PROVIDER || 'mock').toLowerCase();
if (raw === 'stripe' && process.env.STRIPE_SECRET_KEY) return 'stripe';
return 'mock';
}
+160
View File
@@ -0,0 +1,160 @@
import { prisma } from './db.js';
import { OSU_TOPUPS, formatFiat } from './shopCatalog.js';
export const OSU_SECTION_KEY = 'shop.osuTopupsEnabled';
function serializePack(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
osu: row.osu,
priceCents: row.priceCents,
currency: row.currency || 'eur',
badge: row.badge || null,
blurb: row.blurb || '',
perOsuLabel: row.perOsuLabel || '',
enabled: Boolean(row.enabled),
sortOrder: row.sortOrder ?? 0,
displayPrice: formatFiat(row.priceCents, row.currency),
updatedAt: row.updatedAt || null,
};
}
/** Seed missing packs from code defaults (does not overwrite existing rows). */
export async function ensureShopOsuPacks() {
const existing = await prisma.shopOsuPack.findMany({ select: { id: true } });
const have = new Set(existing.map((r) => r.id));
let order = 0;
for (const pack of OSU_TOPUPS) {
if (have.has(pack.id)) continue;
await prisma.shopOsuPack.create({
data: {
id: pack.id,
name: pack.name,
osu: pack.osu,
priceCents: pack.priceCents,
currency: pack.currency || 'eur',
badge: pack.badge || '',
blurb: pack.blurb || '',
perOsuLabel: pack.perOsuLabel || '',
enabled: true,
sortOrder: order,
},
});
order += 1;
}
const setting = await prisma.appSetting.findUnique({ where: { key: OSU_SECTION_KEY } });
if (!setting) {
await prisma.appSetting.create({
data: { key: OSU_SECTION_KEY, value: '1' },
});
}
}
export async function isOsuTopupsSectionEnabled() {
const setting = await prisma.appSetting.findUnique({ where: { key: OSU_SECTION_KEY } });
if (!setting) return true;
return setting.value !== '0' && setting.value !== 'false';
}
export async function setOsuTopupsSectionEnabled(enabled) {
await prisma.appSetting.upsert({
where: { key: OSU_SECTION_KEY },
create: { key: OSU_SECTION_KEY, value: enabled ? '1' : '0' },
update: { value: enabled ? '1' : '0' },
});
return isOsuTopupsSectionEnabled();
}
export async function listShopOsuPacksAdmin() {
await ensureShopOsuPacks();
const rows = await prisma.shopOsuPack.findMany({ orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] });
const sectionEnabled = await isOsuTopupsSectionEnabled();
return {
sectionEnabled,
packs: rows.map(serializePack),
};
}
/** Public catalog: only when section on + pack enabled. */
export async function listEnabledShopOsuPacks() {
await ensureShopOsuPacks();
if (!(await isOsuTopupsSectionEnabled())) return [];
const rows = await prisma.shopOsuPack.findMany({
where: { enabled: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return rows.map(serializePack);
}
/** For checkout — must be enabled and section on. */
export async function getPurchasableOsuPack(packId) {
await ensureShopOsuPacks();
if (!(await isOsuTopupsSectionEnabled())) return null;
const row = await prisma.shopOsuPack.findUnique({ where: { id: String(packId || '') } });
if (!row || !row.enabled) return null;
return serializePack(row);
}
export async function updateShopOsuPack(id, body) {
const existing = await prisma.shopOsuPack.findUnique({ where: { id: String(id) } });
if (!existing) {
throw Object.assign(new Error('Pack not found'), { status: 404 });
}
const data = {};
if (body.name !== undefined) {
const name = String(body.name || '').trim();
if (!name) throw Object.assign(new Error('Name is required'), { status: 400 });
data.name = name;
}
if (body.osu !== undefined) {
const osu = Number(body.osu);
if (!Number.isInteger(osu) || osu < 1) {
throw Object.assign(new Error('osu must be a positive integer'), { status: 400 });
}
data.osu = osu;
}
if (body.priceCents !== undefined) {
const priceCents = Number(body.priceCents);
if (!Number.isInteger(priceCents) || priceCents < 0) {
throw Object.assign(new Error('priceCents must be a non-negative integer'), { status: 400 });
}
data.priceCents = priceCents;
}
if (body.currency !== undefined) {
data.currency = String(body.currency || 'eur').toLowerCase().slice(0, 8) || 'eur';
}
if (body.badge !== undefined) {
data.badge = body.badge == null ? '' : String(body.badge).trim();
}
if (body.blurb !== undefined) {
data.blurb = String(body.blurb || '').trim();
}
if (body.perOsuLabel !== undefined) {
data.perOsuLabel = String(body.perOsuLabel || '').trim();
}
if (body.enabled !== undefined) {
data.enabled = Boolean(body.enabled);
}
if (body.sortOrder !== undefined) {
const sortOrder = Number(body.sortOrder);
if (!Number.isInteger(sortOrder)) {
throw Object.assign(new Error('sortOrder must be an integer'), { status: 400 });
}
data.sortOrder = sortOrder;
}
if (!Object.keys(data).length) {
throw Object.assign(new Error('No changes provided'), { status: 400 });
}
const updated = await prisma.shopOsuPack.update({
where: { id: existing.id },
data,
});
return serializePack(updated);
}
+104 -9
View File
@@ -1,31 +1,126 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import sharp from 'sharp';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const uploadsDir = path.join(__dirname, '../uploads');
/** Max stored dimensions (aspect ratio preserved, never upscaled). */
export const MAX_IMAGE_WIDTH = 1920;
export const MAX_IMAGE_HEIGHT = 1080;
/** Raster only — no SVG (stored XSS if served as image/*). */
export const ALLOWED_IMAGE_MIMES = new Set([
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/gif',
]);
export const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
/** sharp `metadata().format` values we accept */
const ALLOWED_SHARP_FORMATS = new Set(['png', 'jpeg', 'jpg', 'webp', 'gif']);
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
export function createUploader(prefix) {
export function safeExt(originalname, mimetype = '') {
const fromName = path.extname(originalname || '').toLowerCase();
if (ALLOWED_IMAGE_EXTS.has(fromName)) return fromName === '.jpeg' ? '.jpg' : fromName;
if (mimetype === 'image/png') return '.png';
if (mimetype === 'image/webp') return '.webp';
if (mimetype === 'image/gif') return '.gif';
if (mimetype === 'image/jpeg' || mimetype === 'image/jpg') return '.jpg';
return '.png';
}
function isAllowedUpload(file) {
const mime = String(file?.mimetype || '').toLowerCase();
const ext = path.extname(file?.originalname || '').toLowerCase();
if (!ALLOWED_IMAGE_MIMES.has(mime)) return false;
// If the client sent an extension, it must match the allowlist (blocks .svg renamed tricks loosely)
if (ext && !ALLOWED_IMAGE_EXTS.has(ext)) return false;
return true;
}
/**
* Downscale an uploaded image if larger than 1920×1080, keeping aspect ratio.
* Also rejects non-raster / spoofed files (e.g. SVG with a fake image/* type).
* Overwrites the file in place. No-ops when already within bounds.
*/
export async function processUploadedImage(file) {
if (!file?.path) return;
const filePath = file.path;
let meta;
try {
const animated = file.mimetype === 'image/gif';
meta = await sharp(filePath, { animated, failOn: 'none' }).metadata();
} catch {
await fs.promises.unlink(filePath).catch(() => {});
throw new Error('Invalid or unsupported image file');
}
const format = String(meta.format || '').toLowerCase();
if (!ALLOWED_SHARP_FORMATS.has(format)) {
await fs.promises.unlink(filePath).catch(() => {});
throw new Error('Only PNG, JPEG, WebP, and GIF images are allowed');
}
let width = meta.width || 0;
let height = meta.height || 0;
if (!width || !height) return;
// EXIF orientations 58 swap display width/height
const orientation = meta.orientation || 1;
if (orientation >= 5 && orientation <= 8) {
[width, height] = [height, width];
}
if (width <= MAX_IMAGE_WIDTH && height <= MAX_IMAGE_HEIGHT) return;
const tmpPath = `${filePath}.resized`;
try {
const animated = format === 'gif';
await sharp(filePath, { animated, failOn: 'none' })
.rotate() // bake EXIF orientation before resize
.resize({
width: MAX_IMAGE_WIDTH,
height: MAX_IMAGE_HEIGHT,
fit: 'inside',
withoutEnlargement: true,
})
.toFile(tmpPath);
await fs.promises.rename(tmpPath, filePath);
} catch (err) {
await fs.promises.unlink(tmpPath).catch(() => {});
throw err;
}
}
export function createUploader(prefix, { maxBytes = 3 * 1024 * 1024, filename } = {}) {
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.png';
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
cb(null, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safe}`);
},
filename:
filename ||
((_req, file, cb) => {
cb(
null,
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safeExt(file.originalname, file.mimetype)}`
);
}),
});
return multer({
storage,
limits: { fileSize: 3 * 1024 * 1024 },
limits: { fileSize: maxBytes },
fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new Error('Only images are allowed'));
if (!isAllowedUpload(file)) {
return cb(new Error('Only PNG, JPEG, WebP, and GIF images are allowed'));
}
cb(null, true);
},
+171 -59
View File
@@ -1,10 +1,18 @@
import { prisma } from './db.js';
import { applyRateFloor, toCentsBigInt, minBigInt, centsJson, creditUserBalance } from './cents.js';
import { getIO } from './realtime.js';
import { publicUser } from './middleware.js';
export const BASE_VAULT_SLOTS = 3;
export const VAULT_TICK_MS = 600_000; // 10 minutes
export const ONLINE_TICK_MS = 300_000; // 5 minutes
export const OFFLINE_TICK_MS = 600_000; // 10 minutes
/** @deprecated use ONLINE_TICK_MS / OFFLINE_TICK_MS */
export const VAULT_TICK_MS = OFFLINE_TICK_MS;
export const ONLINE_BASE_RATE = 0.1; // 10% of value per tick
export const OFFLINE_BASE_RATE = 0.01; // 1% of value per tick
export const WEAR_FLOAT_K = 3;
/** Soft cap: at most 500% of item value per tick */
export const MAX_TICK_VALUE_MULT = 5n;
/** Rarity → yield multiplier (seed rarities + fallbacks). */
export const RARITY_YIELD_MULT = {
@@ -17,6 +25,10 @@ export const RARITY_YIELD_MULT = {
Extraordinary: 1.8,
};
export function vaultTickMs(mode) {
return mode === 'online' ? ONLINE_TICK_MS : OFFLINE_TICK_MS;
}
export function maxVaultSlots(user) {
return BASE_VAULT_SLOTS + Math.max(0, Math.floor(Number(user?.prestigeVaultSlots) || 0));
}
@@ -41,117 +53,217 @@ export function skillYieldMult(user, mode) {
}
export function tickPayoutCents(inv, user, mode) {
const value = Math.max(0, Math.floor(Number(inv.valueCents) || 0));
if (value <= 0) return 0;
const value = toCentsBigInt(inv.valueCents);
if (value <= 0n) return 0n;
const baseRate = mode === 'online' ? ONLINE_BASE_RATE : OFFLINE_BASE_RATE;
const wear = wearYieldMult(inv.floatValue);
const rarity = rarityYieldMult(inv.item?.rarity ?? inv.rarity);
const skill = skillYieldMult(user, mode);
const payout = Math.floor(value * baseRate * wear * rarity * skill);
// Soft cap: at most 100% of item value per tick
return Math.min(value, Math.max(0, payout));
const combined = baseRate * wear * rarity * skill;
const payout = applyRateFloor(value, combined);
return minBigInt(value * MAX_TICK_VALUE_MULT, payout);
}
export function estimateTickYield(inv, user, mode) {
return tickPayoutCents(inv, user, mode);
}
/** Shared vault timer anchor (user-level). Falls back to oldest item stake/yield. */
export function resolveVaultAnchor(user, items, now = Date.now()) {
if (user?.vaultYieldAt) {
return new Date(user.vaultYieldAt).getTime();
}
let earliest = null;
for (const inv of items || []) {
const raw = inv.lastYieldAt || inv.stakedAt;
if (!raw) continue;
const ms = new Date(raw).getTime();
if (!Number.isFinite(ms)) continue;
if (earliest == null || ms < earliest) earliest = ms;
}
return earliest ?? now;
}
export function nextYieldAtMs(user, items, mode, now = Date.now()) {
if (!items?.length) return null;
const tickMs = vaultTickMs(mode);
const anchor = resolveVaultAnchor(user, items, now);
const ticksDone = Math.floor(Math.max(0, now - anchor) / tickMs);
return anchor + (ticksDone + 1) * tickMs;
}
/**
* Settle complete vault ticks for a user.
* Settle complete vault ticks for a user (shared timer across all staked items).
* @param {'online'|'offline'} mode
* @param {Date|number} [until] settle up to this time (default now)
* @returns {{ credited: number, ticks: number, itemIds: number[] }}
* @returns {{ credited: string, ticks: number, itemIds: number[], balance?: string, vault?: object }}
*/
export async function settleVaultYield(userId, mode, until = Date.now()) {
const id = Number(userId);
if (!id) return { credited: 0, ticks: 0, itemIds: [] };
if (!id) return { credited: '0', ticks: 0, itemIds: [] };
const untilMs = until instanceof Date ? until.getTime() : Number(until) || Date.now();
const tickMs = vaultTickMs(mode);
return prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id } });
if (!user) return { credited: 0, ticks: 0, itemIds: [] };
if (!user) return { credited: '0', ticks: 0, itemIds: [], user: null, items: [] };
const items = await tx.inventoryItem.findMany({
where: { userId: id, staked: true },
include: { item: true },
});
if (!items.length) return { credited: 0, ticks: 0, itemIds: [] };
if (!items.length) {
if (user.vaultYieldAt) {
await tx.user.update({ where: { id }, data: { vaultYieldAt: null } });
}
return { credited: '0', ticks: 0, itemIds: [], user, items: [] };
}
let totalCredit = 0;
let maxTicks = 0;
const anchorMs = resolveVaultAnchor(user, items, untilMs);
const elapsed = Math.max(0, untilMs - anchorMs);
const ticks = Math.floor(elapsed / tickMs);
if (ticks <= 0) {
// Ensure shared timer is persisted (migration from per-item anchors)
if (!user.vaultYieldAt) {
const updated = await tx.user.update({
where: { id },
data: { vaultYieldAt: new Date(anchorMs) },
});
return { credited: '0', ticks: 0, itemIds: [], user: updated, items };
}
return { credited: '0', ticks: 0, itemIds: [], user, items };
}
let totalCredit = 0n;
const itemIds = [];
const now = new Date(untilMs);
for (const inv of items) {
const anchor = inv.lastYieldAt || inv.stakedAt || now;
const anchorMs = new Date(anchor).getTime();
const elapsed = Math.max(0, untilMs - anchorMs);
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
if (ticks <= 0) continue;
const perTick = tickPayoutCents(inv, user, mode);
const credit = perTick * ticks;
if (credit > 0) {
const credit = perTick * BigInt(ticks);
if (credit > 0n) {
totalCredit += credit;
itemIds.push(inv.id);
}
maxTicks = Math.max(maxTicks, ticks);
const advanced = new Date(anchorMs + ticks * VAULT_TICK_MS);
await tx.inventoryItem.update({
where: { id: inv.id },
data: { lastYieldAt: advanced },
data: { lastYieldAt: new Date(anchorMs + ticks * tickMs) },
});
}
if (totalCredit <= 0) {
return { credited: 0, ticks: maxTicks, itemIds: [] };
const advanced = new Date(anchorMs + ticks * tickMs);
let updatedUser;
if (totalCredit > 0n) {
await creditUserBalance(tx, id, totalCredit);
updatedUser = await tx.user.update({
where: { id },
data: { vaultYieldAt: advanced },
});
} else if (!user.vaultYieldAt || user.vaultYieldAt.getTime() !== advanced.getTime()) {
updatedUser = await tx.user.update({
where: { id },
data: { vaultYieldAt: advanced },
});
} else {
updatedUser = user;
}
await tx.user.update({
where: { id },
data: { balance: { increment: totalCredit } },
});
if (totalCredit > 0n) {
await tx.transaction.create({
data: {
userId: id,
type: 'vault_yield',
amount: centsJson(totalCredit),
meta: JSON.stringify({
mode,
ticks,
inventoryItemIds: itemIds,
tickMs,
}),
},
});
}
await tx.transaction.create({
data: {
userId: id,
type: 'vault_yield',
amount: totalCredit,
meta: JSON.stringify({
mode,
ticks: maxTicks,
inventoryItemIds: itemIds,
}),
},
});
return { credited: totalCredit, ticks: maxTicks, itemIds };
return {
credited: totalCredit.toString(),
ticks,
itemIds,
user: updatedUser,
items,
};
});
const vault = result.user
? summarizeVaultState(result.user, result.items || [], mode, untilMs)
: null;
if (result.credited !== '0' && result.user) {
emitVaultYield(id, {
credited: result.credited,
ticks: result.ticks,
balance: result.user.balance?.toString?.() ?? String(result.user.balance),
user: publicUser(result.user),
vault,
});
}
return {
credited: result.credited,
ticks: result.ticks,
itemIds: result.itemIds,
balance: result.user?.balance?.toString?.() ?? undefined,
vault,
};
}
function emitVaultYield(userId, payload) {
const io = getIO();
if (!io) return;
io.to(`user:${userId}`).emit('vault:yield', payload);
}
/** Estimate pending yield for UI (incomplete ticks not included). */
export function estimatePendingYield(items, user, mode, now = Date.now()) {
let total = 0;
if (!items?.length) return '0';
const tickMs = vaultTickMs(mode);
const anchor = resolveVaultAnchor(user, items, now);
const ticks = Math.floor(Math.max(0, now - anchor) / tickMs);
if (ticks <= 0) return '0';
let total = 0n;
for (const inv of items) {
const anchor = inv.lastYieldAt || inv.stakedAt;
if (!anchor) continue;
const elapsed = Math.max(0, now - new Date(anchor).getTime());
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
if (ticks <= 0) continue;
total += tickPayoutCents(inv, user, mode) * ticks;
total += tickPayoutCents(inv, user, mode) * BigInt(ticks);
}
return total;
return total.toString();
}
/** Per-tick rate summary for UI (one tick online / offline). */
export function summarizeVaultRates(items, user) {
let onlinePerTick = 0;
let offlinePerTick = 0;
let onlinePerTick = 0n;
let offlinePerTick = 0n;
for (const inv of items) {
onlinePerTick += tickPayoutCents(inv, user, 'online');
offlinePerTick += tickPayoutCents(inv, user, 'offline');
}
return { onlinePerTick, offlinePerTick, tickMs: VAULT_TICK_MS };
return {
onlinePerTick: onlinePerTick.toString(),
offlinePerTick: offlinePerTick.toString(),
tickMsOnline: ONLINE_TICK_MS,
tickMsOffline: OFFLINE_TICK_MS,
tickMs: ONLINE_TICK_MS,
};
}
export function summarizeVaultState(user, items, mode, now = Date.now()) {
const rates = summarizeVaultRates(items, user);
const nextMs = nextYieldAtMs(user, items, mode, now);
return {
used: items.length,
slots: maxVaultSlots(user),
...rates,
mode,
tickMs: vaultTickMs(mode),
vaultYieldAt: user.vaultYieldAt ? new Date(user.vaultYieldAt).toISOString() : null,
nextYieldAt: nextMs != null ? new Date(nextMs).toISOString() : null,
pendingYield: estimatePendingYield(items, user, mode, now),
};
}
+10 -4
View File
@@ -5,6 +5,8 @@
* Per-drop float f ∈ [0, 1) drives wear grade and price multiplier.
*/
import { toCentsBigInt } from './cents.js';
export const WEAR_BANDS = [
{
wear: 'Factory New',
@@ -96,10 +98,14 @@ export function priceMultiplier(floatValue) {
}
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);
// Always BigInt-parse — Number() silently corrupts values above ~9e15
let base = toCentsBigInt(baseMarketValue);
if (base < 0n) base = 0n;
if (base <= 0n) return 0n;
// Fixed-point multiplier (×10000) to avoid Number precision loss on huge bases
const multBp = Math.round(priceMultiplier(floatValue) * 10000);
const cents = (base * BigInt(Math.max(0, multBp)) + 5000n) / 10000n;
return cents < 1n ? 1n : cents;
}
export function rollFloat() {