update nice visuel
This commit is contained in:
Generated
+508
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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 5–8 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
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user