diff --git a/Dockerfile b/Dockerfile index 206dd7a0..d263b27b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ FROM deps AS build COPY . . RUN npm run build # -> dist/ RUN mkdir -p dist/fonts && cp -r src/fonts/. dist/fonts/ +RUN mkdir -p dist/assets && cp -r src/assets/. dist/assets/ FROM node:20-bookworm-slim AS run WORKDIR /app diff --git a/bun.lock b/bun.lock index 6f59b0c1..b15ad1c3 100644 --- a/bun.lock +++ b/bun.lock @@ -30,12 +30,15 @@ }, }, }, + "trustedDependencies": [ + "skia-canvas", + ], "packages": { "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@8.1.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-tQFxVs05J/6QXXqIzj6rTRk3nj1HFs4pe+uThwE95jL5II2JfpVXkK+CqkO7aT0Do5AYqO6LDrKpleLUFXgY+g=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@discordjs/builders": ["@discordjs/builders@1.12.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.26", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-C5iNx2PgNj5MTZZ3WZeybJ7N0erYVBGDQpNPHZ4rCD21n9DejLpmQDTI8nxxGm3NapS3QwYHKZtHBEVPWBhhVw=="], + "@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="], "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], @@ -49,9 +52,9 @@ "@hono/node-server": ["@hono/node-server@1.19.5", "", { "peerDependencies": { "hono": "^4" } }, "sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ=="], - "@hono/zod-openapi": ["@hono/zod-openapi@1.1.0", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.1.0", "@hono/zod-validator": "^0.7.2", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "^4.0.0" } }, "sha512-S4jVR+A/jI4MA/RKJqmpjdHAN2l/EsqLnKHBv68x3WxV1NGVe3Sh7f6LV6rHEGYNHfiqpD75664A/erc+r9dQA=="], + "@hono/zod-openapi": ["@hono/zod-openapi@1.1.4", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.1.0", "@hono/zod-validator": "^0.7.4", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "^4.0.0" } }, "sha512-4BbOtd6oKg20yo6HLluVbEycBLLIfdKX5o/gUSoKZ2uBmeP4Og/VDfIX3k9pbNEX5W3fRkuPeVjGA+zaQDVY1A=="], - "@hono/zod-validator": ["@hono/zod-validator@0.7.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ub5eL/NeZ4eLZawu78JpW/J+dugDAYhwqUIdp9KYScI6PZECij4Hx4UsrthlEUutqDDhPwRI0MscUfNkvn/mqQ=="], + "@hono/zod-validator": ["@hono/zod-validator@0.7.4", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-biKGn3BRJVaftZlIPMyK+HCe/UHAjJ6sH0UyXe3+v0OcgVr9xfImDROTJFLtn9e3XEEAHGZIM9U6evu85abm8Q=="], "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], @@ -71,13 +74,13 @@ "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], - "@scalar/core": ["@scalar/core@0.3.14", "", { "dependencies": { "@scalar/types": "0.2.13" } }, "sha512-Fjgscu148WY1g28VNVZikQxGX8iwronPttIoCf7ayausnVMWX6s6wOZm0D3StNbxqMl2js8FQ/s0GlfCAG6XEg=="], + "@scalar/core": ["@scalar/core@0.3.20", "", { "dependencies": { "@scalar/types": "0.3.2" } }, "sha512-bIlrePx41pSvjDcaJPa9YVVhbSm0N9SKQm2Fzl489S0bUVToyXIQtMFVR4i+BmXGjOcATm/66ELW4vdXRjHoRA=="], - "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.9.16", "", { "dependencies": { "@scalar/core": "0.3.14" }, "peerDependencies": { "hono": "^4.0.0" } }, "sha512-kItGQFtZfUjKCI0L5B1WwVJ2Vf61ixFf4QqvjfwEiNh8hWirwkqGLlFwPGlvwZVGzuScfoVJrpXUjQQ782txUw=="], + "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.9.22", "", { "dependencies": { "@scalar/core": "0.3.20" }, "peerDependencies": { "hono": "^4.0.0" } }, "sha512-utIz4F6YZj3QU/9HK4P1wIcHY8ceMbL7w+VahFbnmZbE3Gy+haaaHfhZOs2nAfdx1cKOhkm11/inZTtw8+I2bw=="], - "@scalar/openapi-types": ["@scalar/openapi-types@0.3.7", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-QHSvHBVDze3+dUwAhIGq6l1iOev4jdoqdBK7QpfeN1Q4h+6qpVEw3EEqBiH0AXUSh/iWwObBv4uMgfIx0aNZ5g=="], + "@scalar/openapi-types": ["@scalar/openapi-types@0.5.0", "", { "dependencies": { "zod": "4.1.11" } }, "sha512-HJBcLa+/mPP+3TCcQngj/iW5UqynRosOQdEETXjmdy6Ngw8wBjwIcT6C86J5jufJ6sI8++HYnt+e7pAvp5FO6A=="], - "@scalar/types": ["@scalar/types@0.2.13", "", { "dependencies": { "@scalar/openapi-types": "0.3.7", "nanoid": "5.1.5", "zod": "3.24.1" } }, "sha512-rO6KGMJqOsBnN/2R4fErMFLpRSPVJElni+HABDpf+ZlLJp2lvxuPn0IXLumK5ytfplUH9iqKgSXjndnZfxSYLQ=="], + "@scalar/types": ["@scalar/types@0.3.2", "", { "dependencies": { "@scalar/openapi-types": "0.5.0", "nanoid": "5.1.5", "type-fest": "5.0.0", "zod": "4.1.11" } }, "sha512-+X10CCvG57nAqYbTGteiSzRFQcMYm7DLfCRMeEfiWQ9Bq2ladat17XsMSvkvwcfpOSlsoepWf3P5dErERUSOQQ=="], "@tsconfig/node10": ["@tsconfig/node10@1.0.11", "", {}, "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw=="], @@ -91,9 +94,9 @@ "@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="], - "@types/node": ["@types/node@24.3.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g=="], + "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], - "@types/pg": ["@types/pg@8.15.5", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ=="], + "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], "@types/skia-canvas": ["@types/skia-canvas@0.9.28", "", { "dependencies": { "skia-canvas": "*" } }, "sha512-buN0xnXKC2TvCMGdA3uFTzdTXgcyNBbbHwkSOnt9buxhhvz5UXyDEw9oWjm3T1tjFr7M4ycUZZhLSGL+nke6+g=="], @@ -137,17 +140,17 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="], - "discord-api-types": ["discord-api-types@0.38.29", "", {}, "sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg=="], + "discord-api-types": ["discord-api-types@0.38.31", "", {}, "sha512-kC94ANsk8ackj8ENTuO8joTNEL0KtymVhHy9dyEC/s4QAZ7GCx40dYEzQaadyo8w+oP0X8QydE/nzAWRylTGtQ=="], - "discord.js": ["discord.js@14.23.0", "", { "dependencies": { "@discordjs/builders": "^1.12.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.1", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.29", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-0oYtQnsEnqabRAq4aiNA2+cmJ2MZ8+KiU2d0BJbUih8ZK+fn7qO/UUyINapjDvNMN1F/Al7D3lPGvPxsKc8e0w=="], + "discord.js": ["discord.js@14.24.0", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.1", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.31", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-KNq/ekT8bsmT3ZAfVre8cPbl+DfVYSdlLnDmGZPoz7Cw21LYeWHllRA9MivqNq5b1GPGAxGvyUN1vxbTb/PQWw=="], - "dotenv": ["dotenv@17.2.2", "", {}, "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q=="], + "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -173,7 +176,7 @@ "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "hono": ["hono@4.9.6", "", {}, "sha512-doVjXhSFvYZ7y0dNokjwwSahcrAfdz+/BCLvAMa/vHLzjj8+CFyV5xteThGUsKdkaasgN+gF2mUxao+SGLpUeA=="], + "hono": ["hono@4.10.3", "", {}, "sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -201,7 +204,7 @@ "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], - "lru-cache": ["lru-cache@11.2.1", "", {}, "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ=="], + "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], "magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="], @@ -267,7 +270,7 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -277,7 +280,7 @@ "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], - "skia-canvas": ["skia-canvas@3.0.6", "", { "dependencies": { "detect-libc": "^2.0.4", "follow-redirects": "^1.15.11", "https-proxy-agent": "^7.0.6", "string-split-by": "^1.0.0" } }, "sha512-OehNQUz6Oucji41Rh82I5V9IqbTfaEqiD5ua+dnrZXymuQDEGOfUXH9JdgvsVEAD+VEdc3iJLQ4lKsSpV8AC5g=="], + "skia-canvas": ["skia-canvas@3.0.8", "", { "dependencies": { "detect-libc": "^2.1.1", "follow-redirects": "^1.15.11", "https-proxy-agent": "^7.0.6", "string-split-by": "^1.0.0" } }, "sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], @@ -295,6 +298,8 @@ "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="], @@ -307,13 +312,15 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + "type-fest": ["type-fest@5.0.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-GeJop7+u7BYlQ6yQCAY1nBQiRSHR+6OdCEtd8Bwp9a3NK3+fWAVjOaPKJDteB9f6cIJ0wt4IfnScjLG450EpXA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], "undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="], - "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], @@ -337,7 +344,7 @@ "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], - "zod": ["zod@4.1.5", "", {}, "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg=="], + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], @@ -345,20 +352,20 @@ "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@scalar/openapi-types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], + "@scalar/openapi-types/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], - "@scalar/types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], + "@scalar/types/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "glob/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], } } diff --git a/migrations/1761962620020_stat-background.js b/migrations/1761962620020_stat-background.js new file mode 100644 index 00000000..56e81f2b --- /dev/null +++ b/migrations/1761962620020_stat-background.js @@ -0,0 +1,22 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const up = (pgm) => { + pgm.addColumn('users', { stat_background: { type: 'varchar(255)', notNull: false, default: 'bgMain.png' }}); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => { + pgm.dropColumn('users', 'stat_background'); +}; diff --git a/package.json b/package.json index a792e0b6..bba692d7 100644 --- a/package.json +++ b/package.json @@ -43,5 +43,8 @@ "prettier": "^3.6.2", "ts-node": "^10.9.2", "typescript": "^5.9.2" - } + }, + "trustedDependencies": [ + "skia-canvas" + ] } diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 2d45a025..a8259c7a 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -165,13 +165,14 @@ declare module 'psqlDB' { mmr: number peak_mmr: number win_streak: number + stat_background: string stats: { label: string value: string percentile: number isTop: boolean }[] - previous_games: { change: number; time: Date }[] + previous_games: { change: number; time: Date; deck: string; stake: string }[] elo_graph_data: { date: Date; rating: number }[] rank_name?: string | null rank_color?: string | null diff --git a/src/assets/BlackBL.png b/src/assets/BlackBL.png new file mode 100644 index 00000000..ec4ab51e Binary files /dev/null and b/src/assets/BlackBL.png differ diff --git a/src/assets/BlackBR.png b/src/assets/BlackBR.png new file mode 100644 index 00000000..ac18248e Binary files /dev/null and b/src/assets/BlackBR.png differ diff --git a/src/assets/BlackTL.png b/src/assets/BlackTL.png new file mode 100644 index 00000000..8f8faea5 Binary files /dev/null and b/src/assets/BlackTL.png differ diff --git a/src/assets/BlackTR.png b/src/assets/BlackTR.png new file mode 100644 index 00000000..fba03ad9 Binary files /dev/null and b/src/assets/BlackTR.png differ diff --git a/src/assets/BlueBL.png b/src/assets/BlueBL.png new file mode 100644 index 00000000..14194ff1 Binary files /dev/null and b/src/assets/BlueBL.png differ diff --git a/src/assets/BlueBR.png b/src/assets/BlueBR.png new file mode 100644 index 00000000..96d6aa51 Binary files /dev/null and b/src/assets/BlueBR.png differ diff --git a/src/assets/BlueTL.png b/src/assets/BlueTL.png new file mode 100644 index 00000000..2fca6ec9 Binary files /dev/null and b/src/assets/BlueTL.png differ diff --git a/src/assets/BlueTR.png b/src/assets/BlueTR.png new file mode 100644 index 00000000..897a2b61 Binary files /dev/null and b/src/assets/BlueTR.png differ diff --git a/src/assets/GrayBL.png b/src/assets/GrayBL.png new file mode 100644 index 00000000..d426b843 Binary files /dev/null and b/src/assets/GrayBL.png differ diff --git a/src/assets/GrayBR.png b/src/assets/GrayBR.png new file mode 100644 index 00000000..3175c26f Binary files /dev/null and b/src/assets/GrayBR.png differ diff --git a/src/assets/GrayTL.png b/src/assets/GrayTL.png new file mode 100644 index 00000000..d92accbe Binary files /dev/null and b/src/assets/GrayTL.png differ diff --git a/src/assets/GrayTR.png b/src/assets/GrayTR.png new file mode 100644 index 00000000..47836c54 Binary files /dev/null and b/src/assets/GrayTR.png differ diff --git a/src/assets/HideBL.png b/src/assets/HideBL.png new file mode 100644 index 00000000..f4b59d18 Binary files /dev/null and b/src/assets/HideBL.png differ diff --git a/src/assets/HideBR.png b/src/assets/HideBR.png new file mode 100644 index 00000000..9d62d814 Binary files /dev/null and b/src/assets/HideBR.png differ diff --git a/src/assets/HideTL.png b/src/assets/HideTL.png new file mode 100644 index 00000000..0339076b Binary files /dev/null and b/src/assets/HideTL.png differ diff --git a/src/assets/HideTR.png b/src/assets/HideTR.png new file mode 100644 index 00000000..c3e70fbe Binary files /dev/null and b/src/assets/HideTR.png differ diff --git a/src/assets/RedBL.png b/src/assets/RedBL.png new file mode 100644 index 00000000..aa64462a Binary files /dev/null and b/src/assets/RedBL.png differ diff --git a/src/assets/RedBR.png b/src/assets/RedBR.png new file mode 100644 index 00000000..fab88a81 Binary files /dev/null and b/src/assets/RedBR.png differ diff --git a/src/assets/RedTR.png b/src/assets/RedTR.png new file mode 100644 index 00000000..6623f1ee Binary files /dev/null and b/src/assets/RedTR.png differ diff --git a/src/assets/antiBL.png b/src/assets/antiBL.png new file mode 100644 index 00000000..11fe3a89 Binary files /dev/null and b/src/assets/antiBL.png differ diff --git a/src/assets/antiBR.png b/src/assets/antiBR.png new file mode 100644 index 00000000..28e7a2dd Binary files /dev/null and b/src/assets/antiBR.png differ diff --git a/src/assets/antiTL.png b/src/assets/antiTL.png new file mode 100644 index 00000000..88a83e1c Binary files /dev/null and b/src/assets/antiTL.png differ diff --git a/src/assets/antiTR.png b/src/assets/antiTR.png new file mode 100644 index 00000000..2cab8644 Binary files /dev/null and b/src/assets/antiTR.png differ diff --git a/src/assets/backgrounds/bgAbandoned.png b/src/assets/backgrounds/bgAbandoned.png new file mode 100644 index 00000000..a1378b87 Binary files /dev/null and b/src/assets/backgrounds/bgAbandoned.png differ diff --git a/src/assets/backgrounds/bgAnaglyph.png b/src/assets/backgrounds/bgAnaglyph.png new file mode 100644 index 00000000..ef4f528b Binary files /dev/null and b/src/assets/backgrounds/bgAnaglyph.png differ diff --git a/src/assets/backgrounds/bgBlack.png b/src/assets/backgrounds/bgBlack.png new file mode 100644 index 00000000..0a78ee93 Binary files /dev/null and b/src/assets/backgrounds/bgBlack.png differ diff --git a/src/assets/backgrounds/bgBlue.png b/src/assets/backgrounds/bgBlue.png new file mode 100644 index 00000000..d908e92f Binary files /dev/null and b/src/assets/backgrounds/bgBlue.png differ diff --git a/src/assets/backgrounds/bgCheckered.png b/src/assets/backgrounds/bgCheckered.png new file mode 100644 index 00000000..393ab267 Binary files /dev/null and b/src/assets/backgrounds/bgCheckered.png differ diff --git a/src/assets/backgrounds/bgCocktail.png b/src/assets/backgrounds/bgCocktail.png new file mode 100644 index 00000000..13c618f2 Binary files /dev/null and b/src/assets/backgrounds/bgCocktail.png differ diff --git a/src/assets/backgrounds/bgErratic.png b/src/assets/backgrounds/bgErratic.png new file mode 100644 index 00000000..395f7c32 Binary files /dev/null and b/src/assets/backgrounds/bgErratic.png differ diff --git a/src/assets/backgrounds/bgFelt.png b/src/assets/backgrounds/bgFelt.png new file mode 100644 index 00000000..79e7ae40 Binary files /dev/null and b/src/assets/backgrounds/bgFelt.png differ diff --git a/src/assets/backgrounds/bgGhost.png b/src/assets/backgrounds/bgGhost.png new file mode 100644 index 00000000..848d3548 Binary files /dev/null and b/src/assets/backgrounds/bgGhost.png differ diff --git a/src/assets/backgrounds/bgGreen.png b/src/assets/backgrounds/bgGreen.png new file mode 100644 index 00000000..895f6c77 Binary files /dev/null and b/src/assets/backgrounds/bgGreen.png differ diff --git a/src/assets/backgrounds/bgMagic.png b/src/assets/backgrounds/bgMagic.png new file mode 100644 index 00000000..890f0672 Binary files /dev/null and b/src/assets/backgrounds/bgMagic.png differ diff --git a/src/assets/backgrounds/bgMain.png b/src/assets/backgrounds/bgMain.png new file mode 100644 index 00000000..24cce193 Binary files /dev/null and b/src/assets/backgrounds/bgMain.png differ diff --git a/src/assets/backgrounds/bgNebula.png b/src/assets/backgrounds/bgNebula.png new file mode 100644 index 00000000..00a1330e Binary files /dev/null and b/src/assets/backgrounds/bgNebula.png differ diff --git a/src/assets/backgrounds/bgOrange.png b/src/assets/backgrounds/bgOrange.png new file mode 100644 index 00000000..a17d9140 Binary files /dev/null and b/src/assets/backgrounds/bgOrange.png differ diff --git a/src/assets/backgrounds/bgPainted.png b/src/assets/backgrounds/bgPainted.png new file mode 100644 index 00000000..4cc8f689 Binary files /dev/null and b/src/assets/backgrounds/bgPainted.png differ diff --git a/src/assets/backgrounds/bgPlanet.png b/src/assets/backgrounds/bgPlanet.png new file mode 100644 index 00000000..1338f6fd Binary files /dev/null and b/src/assets/backgrounds/bgPlanet.png differ diff --git a/src/assets/backgrounds/bgPlasma.png b/src/assets/backgrounds/bgPlasma.png new file mode 100644 index 00000000..e2ba7476 Binary files /dev/null and b/src/assets/backgrounds/bgPlasma.png differ diff --git a/src/assets/backgrounds/bgRed.png b/src/assets/backgrounds/bgRed.png new file mode 100644 index 00000000..03b0842a Binary files /dev/null and b/src/assets/backgrounds/bgRed.png differ diff --git a/src/assets/backgrounds/bgViolet.png b/src/assets/backgrounds/bgViolet.png new file mode 100644 index 00000000..775f3b3c Binary files /dev/null and b/src/assets/backgrounds/bgViolet.png differ diff --git a/src/assets/backgrounds/bgYellow.png b/src/assets/backgrounds/bgYellow.png new file mode 100644 index 00000000..d8d8c098 Binary files /dev/null and b/src/assets/backgrounds/bgYellow.png differ diff --git a/src/assets/backgrounds/bgZodiac.png b/src/assets/backgrounds/bgZodiac.png new file mode 100644 index 00000000..4fe85b5b Binary files /dev/null and b/src/assets/backgrounds/bgZodiac.png differ diff --git a/src/assets/bgBL.png b/src/assets/bgBL.png new file mode 100644 index 00000000..d555d0d1 Binary files /dev/null and b/src/assets/bgBL.png differ diff --git a/src/assets/bgBR.png b/src/assets/bgBR.png new file mode 100644 index 00000000..6e23e758 Binary files /dev/null and b/src/assets/bgBR.png differ diff --git a/src/assets/bgTL.png b/src/assets/bgTL.png new file mode 100644 index 00000000..794c0a50 Binary files /dev/null and b/src/assets/bgTL.png differ diff --git a/src/assets/bgTR.png b/src/assets/bgTR.png new file mode 100644 index 00000000..ac1e9df5 Binary files /dev/null and b/src/assets/bgTR.png differ diff --git a/src/assets/bonus.png b/src/assets/bonus.png new file mode 100644 index 00000000..35817bc1 Binary files /dev/null and b/src/assets/bonus.png differ diff --git a/src/assets/glass.png b/src/assets/glass.png new file mode 100644 index 00000000..f217e8ff Binary files /dev/null and b/src/assets/glass.png differ diff --git a/src/assets/gold.png b/src/assets/gold.png new file mode 100644 index 00000000..f45ae001 Binary files /dev/null and b/src/assets/gold.png differ diff --git a/src/assets/lucky.png b/src/assets/lucky.png new file mode 100644 index 00000000..526b40ad Binary files /dev/null and b/src/assets/lucky.png differ diff --git a/src/assets/mult.png b/src/assets/mult.png new file mode 100644 index 00000000..9b1078dc Binary files /dev/null and b/src/assets/mult.png differ diff --git a/src/assets/redTL.png b/src/assets/redTL.png new file mode 100644 index 00000000..983a07ee Binary files /dev/null and b/src/assets/redTL.png differ diff --git a/src/assets/standard.png b/src/assets/standard.png new file mode 100644 index 00000000..4587ea8a Binary files /dev/null and b/src/assets/standard.png differ diff --git a/src/assets/steel.png b/src/assets/steel.png new file mode 100644 index 00000000..5e7ab7ea Binary files /dev/null and b/src/assets/steel.png differ diff --git a/src/assets/stone.png b/src/assets/stone.png new file mode 100644 index 00000000..38f9179b Binary files /dev/null and b/src/assets/stone.png differ diff --git a/src/assets/wild.png b/src/assets/wild.png new file mode 100644 index 00000000..34e07e5b Binary files /dev/null and b/src/assets/wild.png differ diff --git a/src/commands/queues/setStatsBackground.ts b/src/commands/queues/setStatsBackground.ts new file mode 100644 index 00000000..10b8aedc --- /dev/null +++ b/src/commands/queues/setStatsBackground.ts @@ -0,0 +1,72 @@ +import { + ChatInputCommandInteraction, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ActionRowBuilder, + AttachmentBuilder, + MessageFlags, +} from 'discord.js' +import { BACKGROUNDS, getBackgroundById } from '../../utils/backgroundManager' +import { pool } from '../../db' +import { Canvas } from 'skia-canvas' +import path from 'path' +import { loadImage } from 'skia-canvas' + +export default { + async execute(interaction: ChatInputCommandInteraction) { + try { + // Create select menu with all backgrounds + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('stats-background-select') + .setPlaceholder('Choose background for your stats card!') + .addOptions( + BACKGROUNDS.map((bg) => + new StringSelectMenuOptionBuilder() + .setLabel(bg.name) + .setValue(bg.id) + .setDescription(`Set ${bg.name} as your stats background`), + ), + ) + + const row = new ActionRowBuilder().addComponents( + selectMenu, + ) + + await interaction.reply({ + content: 'Select a background for your stats card:', + components: [row], + flags: MessageFlags.Ephemeral, + }) + } catch (error: any) { + console.error('Error showing background selector:', error) + await interaction.reply({ + content: `Failed to show background selector: ${error.message}`, + flags: MessageFlags.Ephemeral, + }) + } + }, +} + +// Helper function to generate a preview of the background +export async function generateBackgroundPreview( + backgroundFilename: string, +): Promise { + const scale = 2 + const width = 800 + const height = 600 + + const canvas = new Canvas(width * scale, height * scale) + const ctx = canvas.getContext('2d') + + ctx.scale(scale, scale) + ctx.imageSmoothingEnabled = false + + // Draw background + const bg = await loadImage( + path.join(__dirname, '../../assets/backgrounds', backgroundFilename), + ) + ctx.drawImage(bg, 0, 0) + + const buffer = await canvas.toBuffer('png', { quality: 1.0, density: scale }) + return new AttachmentBuilder(buffer, { name: 'preview.png' }) +} diff --git a/src/commands/queues/statsQueue.ts b/src/commands/queues/statsQueue.ts index 9af6d076..d15d2e6b 100644 --- a/src/commands/queues/statsQueue.ts +++ b/src/commands/queues/statsQueue.ts @@ -13,9 +13,15 @@ export default { const queueName = interaction.options.getString('queue-name', true) const targetUser = interaction.options.getUser('user') || interaction.user + const byDate = + interaction.options.getString('by-date') === 'yes' ? true : false const queueId = await getQueueIdFromName(queueName) const playerStats = await getStatsCanvasUserData(targetUser.id, queueId) - const statFile = await drawPlayerStatsCanvas(queueName, playerStats) + const statFile = await drawPlayerStatsCanvas( + queueName, + playerStats, + byDate, + ) const viewStatsButtons = setupViewStatsButtons(queueName) await interaction.editReply({ @@ -24,7 +30,6 @@ export default { }) // Update queue role, just to be sure it's correct when they check - // This is a nice bandaid fix till we update leaderboard roles better await setUserQueueRole(queueId, targetUser.id) } catch (err: any) { console.error(err) diff --git a/src/commands/superCommands/config.ts b/src/commands/superCommands/config.ts index 986b18de..9d5b34f9 100644 --- a/src/commands/superCommands/config.ts +++ b/src/commands/superCommands/config.ts @@ -7,6 +7,7 @@ import { import setDefaultDeckBans from '../queues/setDefaultDeckBans' import setPriorityQueue from '../queues/setPriorityQueue' import queue from './queue' +import setStatsBackground from '../queues/setStatsBackground' export default { data: new SlashCommandBuilder() @@ -39,6 +40,12 @@ export default { .setRequired(true) .setAutocomplete(true), ), + ) + + .addSubcommand((sub) => + sub + .setName('stats-background') + .setDescription('Choose a background for your stats card'), ), async execute(interaction: ChatInputCommandInteraction) { @@ -46,6 +53,8 @@ export default { await setPriorityQueue.execute(interaction) } else if (interaction.options.getSubcommand() === 'preset-deck-bans') { await setDefaultDeckBans.execute(interaction) + } else if (interaction.options.getSubcommand() === 'stats-background') { + await setStatsBackground.execute(interaction) } }, diff --git a/src/commands/superCommands/stats.ts b/src/commands/superCommands/stats.ts index 10ecb5c3..9c0a36cf 100644 --- a/src/commands/superCommands/stats.ts +++ b/src/commands/superCommands/stats.ts @@ -26,6 +26,13 @@ export default { .setName('user') .setDescription('The user to view stats for (defaults to yourself)') .setRequired(false), + ) + .addStringOption((option) => + option + .setName('by-date') + .setDescription('Sort the stats by date') + .addChoices([{ name: 'yes', value: 'yes' }]) + .setRequired(false), ), ), async execute(interaction: ChatInputCommandInteraction) { diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index ff4b9d78..1ee33b00 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -63,6 +63,8 @@ import { handleVoting, } from '../utils/voteHelpers' import { drawPlayerStatsCanvas } from '../utils/canvasHelpers' +import { getBackgroundById } from '../utils/backgroundManager' +import { generateBackgroundPreview } from '../commands/queues/setStatsBackground' // Track users currently processing queue joins to prevent duplicates const processingQueueJoins = new Set() @@ -214,6 +216,45 @@ export default { } } + if (interaction.customId === 'stats-background-select') { + try { + await interaction.deferUpdate() + + const selectedId = interaction.values[0] + const background = getBackgroundById(selectedId) + + if (!background) { + await interaction.followUp({ + content: 'Invalid background selected.', + flags: MessageFlags.Ephemeral, + }) + return + } + + const previewImage = await generateBackgroundPreview( + background.filename, + ) + + // Update user's background in database + await pool.query( + 'UPDATE users SET stat_background = $1 WHERE user_id = $2', + [background.filename, interaction.user.id], + ) + + await interaction.followUp({ + content: `Background set to **${background.name}**!\n\nHere's a preview of your stats background:`, + files: [previewImage], + flags: MessageFlags.Ephemeral, + }) + } catch (error: any) { + console.error('Error setting background:', error) + await interaction.followUp({ + content: `Failed to set background: ${error.message}`, + flags: MessageFlags.Ephemeral, + }) + } + } + if (interaction.values[0].includes('winmatch_')) { const customSelId = interaction.values[0] const matchId = parseInt(customSelId.split('_')[1]) @@ -538,23 +579,27 @@ export default { if (interaction.customId.startsWith('view-stats-')) { try { + await interaction.deferReply() const queueName = interaction.customId.split('-')[2] const queueId = await getQueueIdFromName(queueName) const playerStats = await getStatsCanvasUserData( interaction.user.id, queueId, ) - const statFile = await drawPlayerStatsCanvas(queueName, playerStats) + const statFile = await drawPlayerStatsCanvas( + queueName, + playerStats, + false, + ) const viewStatsButtons = setupViewStatsButtons(queueName) - await interaction.reply({ + await interaction.editReply({ files: [statFile], components: [viewStatsButtons], }) } catch (err) { - await interaction.reply({ + await interaction.editReply({ content: `You don't have any stats for this queue.`, - flags: [MessageFlags.Ephemeral], }) } } diff --git a/src/fonts/m6x11.ttf b/src/fonts/m6x11.ttf new file mode 100644 index 00000000..cb212248 Binary files /dev/null and b/src/fonts/m6x11.ttf differ diff --git a/src/index.ts b/src/index.ts index 77dd82aa..31e9e0fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,12 @@ client.on('error', (error: Error) => { console.error('Stack:', error.stack) }) +// Preload background images +import { preloadBackgrounds } from './utils/backgroundManager' +void preloadBackgrounds().catch((error) => { + console.error('[BACKGROUND PRELOAD ERROR]', error) +}) + void client.login(token).catch((error) => { console.error('[LOGIN FAILED]', error) process.exit(1) diff --git a/src/utils/backgroundManager.ts b/src/utils/backgroundManager.ts new file mode 100644 index 00000000..d298d255 --- /dev/null +++ b/src/utils/backgroundManager.ts @@ -0,0 +1,75 @@ +import { loadImage } from 'skia-canvas' +import path from 'path' + +// Background metadata +export interface Background { + id: string + name: string + filename: string +} + +// Cache for preloaded images +const imageCache = new Map() + +// List of all available backgrounds +export const BACKGROUNDS: Background[] = [ + { id: 'abandoned', name: 'Abandoned', filename: 'bgAbandoned.png' }, + { id: 'anaglyph', name: 'Anaglyph', filename: 'bgAnaglyph.png' }, + { id: 'black', name: 'Black', filename: 'bgBlack.png' }, + { id: 'blue', name: 'Blue', filename: 'bgBlue.png' }, + { id: 'checkered', name: 'Checkered', filename: 'bgCheckered.png' }, + { id: 'cocktail', name: 'Cocktail', filename: 'bgCocktail.png' }, + { id: 'erratic', name: 'Erratic', filename: 'bgErratic.png' }, + { id: 'felt', name: 'Felt', filename: 'bgFelt.png' }, + { id: 'ghost', name: 'Ghost', filename: 'bgGhost.png' }, + { id: 'green', name: 'Green', filename: 'bgGreen.png' }, + { id: 'magic', name: 'Magic', filename: 'bgMagic.png' }, + { id: 'main', name: 'Main', filename: 'bgMain.png' }, + { id: 'nebula', name: 'Nebula', filename: 'bgNebula.png' }, + { id: 'orange', name: 'Orange', filename: 'bgOrange.png' }, + { id: 'painted', name: 'Painted', filename: 'bgPainted.png' }, + { id: 'planet', name: 'Planet', filename: 'bgPlanet.png' }, + { id: 'plasma', name: 'Plasma', filename: 'bgPlasma.png' }, + { id: 'red', name: 'Red', filename: 'bgRed.png' }, + { id: 'violet', name: 'Violet', filename: 'bgViolet.png' }, + { id: 'yellow', name: 'Yellow', filename: 'bgYellow.png' }, + { id: 'zodiac', name: 'Zodiac', filename: 'bgZodiac.png' }, +] + +// Preload all background images +export async function preloadBackgrounds(): Promise { + console.log('Preloading background images...') + + for (const bg of BACKGROUNDS) { + try { + const imagePath = path.join( + __dirname, + '../assets/backgrounds', + bg.filename, + ) + const image = await loadImage(imagePath) + imageCache.set(bg.filename, image) + } catch (error) { + console.error(`Failed to load ${bg.filename}:`, error) + } + } + + console.log(`Preloaded ${imageCache.size}/${BACKGROUNDS.length} backgrounds`) +} + +// Get a preloaded background image +export function getBackground(filename: string): any | null { + return imageCache.get(filename) || null +} + +// Get background by ID +export function getBackgroundById(id: string): Background | undefined { + return BACKGROUNDS.find((bg) => bg.id === id) +} + +// Get background by filename +export function getBackgroundByFilename( + filename: string, +): Background | undefined { + return BACKGROUNDS.find((bg) => bg.filename === filename) +} diff --git a/src/utils/canvasHelpers.ts b/src/utils/canvasHelpers.ts index 377c702e..0f14347f 100644 --- a/src/utils/canvasHelpers.ts +++ b/src/utils/canvasHelpers.ts @@ -8,40 +8,44 @@ import { StatsCanvasPlayerData } from 'psqlDB' import { client, getGuild } from 'client' import path from 'path' -const font = 'Capitana' +const font = 'm6x11' -FontLibrary.use(font, [ - path.join(__dirname, '../fonts', `${font}-Regular.otf`), - path.join(__dirname, '../fonts', `${font}-Bold.otf`), -]) +FontLibrary.use(font, [path.join(__dirname, '../fonts', `${font}.ttf`)]) const config = { - width: 868, - height: 677, - padding: 20, + width: 800, + height: 600, colors: { - background: '#19191a', - panel: '#282b30', - gridLines: '#424549', + background: '#3a5055', + panel: '#1e2b2d', + gridLines: '#3a5055', textPrimary: '#ffffff', textSecondary: '#b0b3b8', - textTertiary: '#72767d', + textTertiary: '#ffffffc5', + textQuaternary: '#000000', accent: '#4a4e54', win: '#00ff38', lose: '#ff3636', - graphLine: '#ff0000', + stone: '#868687', + steel: '#c3dee0', + gold: '#ffd081', + lucky: '#ffefc4', + glass: '#7debf3', + chips: '#0093FF', + mult: '#FF4C40', }, fonts: { ui: font, title: `bold 52px ${font}`, - value: `bold 44px ${font}`, - statLabel: `bold 24px ${font}`, + statLabel: `bold 44px ${font}`, + prevgameLabel: `bold 24px ${font}`, label: `bold 18px ${font}`, small: `bold 20px ${font}`, - graphSmall: `16px ${font}`, - percentile: `16px ${font}`, - gameList: `17px ${font}`, + tiny: `16px ${font}`, + mini: `12px ${font}`, + gameList: `20px ${font}`, }, + eloSplits: [250, 320, 460, 620], } function timeAgo(date: Date) { @@ -66,86 +70,35 @@ function timeAgo(date: Date) { return `<1 min ago` } -function formatNumber(num: number): string { - if (num >= 1000) return `${(num / 1000).toFixed(1).replace(/\.0$/, '')}k` - return num.toString() -} - -function drawBackground(ctx: CanvasRenderingContext2D) { - ctx.fillStyle = config.colors.background - ctx.fillRect(0, 0, config.width, config.height) - - ctx.fillStyle = config.colors.panel - ctx.fillRect(0, 0, config.width, 150) // Top panel - ctx.fillRect( - config.padding, - 170, - config.width - 300 - config.padding * 2, - 190, - ) // Left middle panel - ctx.fillRect(config.width - 300, 170, config.width - 589, 190) // Right middle panel - ctx.fillRect( - config.padding, - 380, - config.width - config.padding * 2, - config.height - 400, - ) // Bottom panel -} - -async function drawAvatar( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, - playerData: StatsCanvasPlayerData, -) { - const user = await client.users.fetch(playerData.user_id) - const avatar = await loadImage(user.displayAvatarURL({ extension: 'png' })) - - ctx.save() - ctx.beginPath() - ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2) - ctx.closePath() - ctx.clip() - ctx.drawImage(avatar, x, y, size, size) - ctx.restore() -} - -async function drawHeader( +async function addTopText( ctx: CanvasRenderingContext2D, playerData: StatsCanvasPlayerData, queueName: string, ) { - const { padding } = config const guild = await getGuild() const member = await guild.members.fetch(playerData.user_id).catch(() => null) const user = await client.users.fetch(playerData.user_id) - const avatarY = (150 - 110) / 2 - - await drawAvatar(ctx, padding, avatarY, 110, playerData) - // Player name and leaderboard position + // Player leaderboard position ctx.textAlign = 'left' ctx.font = config.fonts.label - ctx.fillStyle = config.colors.textSecondary + ctx.fillStyle = config.colors.textTertiary ctx.fillText( playerData.leaderboard_position ? `${queueName.toUpperCase()}: #${playerData.leaderboard_position}` : `${queueName.toUpperCase()} PLAYER`, - padding + 128, - 40, + 190, + 65, ) // Player name with dynamic font sizing const displayName = !member ? user.username : member.displayName - const nameStartX = padding + 125 - const nameMaxWidth = config.width - nameStartX - 300 - - // Start with the default title font size (52px) - let nameFontSize = 52 + const nameStartX = 190 + const nameMaxWidth = 360 + // Start with the default title font size (62px) + let nameFontSize = 62 ctx.font = `bold ${nameFontSize}px ${font}` let nameWidth = ctx.measureText(displayName).width - // Scale down if name is too wide if (nameWidth > nameMaxWidth) { nameFontSize = Math.max( @@ -154,235 +107,741 @@ async function drawHeader( ) ctx.font = `bold ${nameFontSize}px ${font}` } - + //add player name to canvas ctx.fillStyle = config.colors.textPrimary ctx.textBaseline = 'middle' - ctx.fillText(displayName, nameStartX, 75) + ctx.fillText(displayName, nameStartX, 105) - // Rank progress bar - const barHeight = 22 - const barY = 115 - const rankColor = playerData.rank_color || config.colors.textTertiary - const nextRankColor = playerData.next_rank_color || config.colors.textPrimary - const rankName = (playerData.rank_name || 'UNRANKED').toUpperCase() - const nextRankName = playerData.next_rank_name - ? playerData.next_rank_name.toUpperCase() - : '' + // Current MMR and peak + ctx.textAlign = 'right' + ctx.font = config.fonts.title + ctx.fillStyle = config.colors.textPrimary + ctx.font = `bold ${62}px ${font}` + ctx.fillText(`${playerData.mmr}`, 735, 105) - // Measure rank name widths to calculate bar width dynamically - ctx.font = config.fonts.small - const rankNameWidth = ctx.measureText(rankName).width - const nextRankNameWidth = nextRankName - ? ctx.measureText(nextRankName).width - : 0 - const rankNameX = padding + 125 - const barX = rankNameX + rankNameWidth + 15 // 15px spacing after rank name - - // Calculate maximum available width for bar and next rank label - // Leave space for MMR on the right (around 260px from the right edge) - const maxRightX = config.width - 240 - const availableWidth = maxRightX - barX - 10 - nextRankNameWidth // 10px spacing before next rank - const barWidth = Math.max(120, Math.min(260, availableWidth)) // Between 120-260px - - if ( - rankName != 'UNRANKED' && - playerData.next_rank_mmr && - playerData.rank_mmr !== null - ) { - // MMR-based ranks: Draw progress bar showing advancement to next rank - const mmrRange = playerData.next_rank_mmr - playerData.rank_mmr! - const mmrProgress = playerData.mmr - playerData.rank_mmr! - const progress = Math.max(0, Math.min(1, mmrProgress / mmrRange)) + ctx.font = config.fonts.label + ctx.fillStyle = config.colors.textTertiary + //ctx.fillText(`PEAK: ${playerData.peak_mmr}`, 733, 65) + ctx.fillText(`MMR`, 733, 65) - ctx.fillStyle = rankColor - ctx.fillRect(barX, barY, barWidth * progress, barHeight) - ctx.fillStyle = nextRankColor - ctx.fillRect( - barX + barWidth * progress, - barY, - barWidth * (1 - progress), - barHeight, - ) - } else if (rankName != 'UNRANKED' && playerData.rank_mmr !== null) { - // Max MMR-based rank - fully filled bar - ctx.fillStyle = rankColor - ctx.fillRect(barX, barY, barWidth, barHeight) - } else if ( - rankName != 'UNRANKED' && - playerData.rank_mmr === null && - playerData.next_rank_name && - playerData.next_rank_position && - playerData.rank_position - ) { - // Leaderboard-based ranks: Draw progress bar showing advancement to next rank - const rankRange = playerData.rank_position - playerData.next_rank_position - const currentPos = playerData.leaderboard_position || 999 - const rankProgress = playerData.rank_position - currentPos - const progress = Math.max(0, Math.min(1, rankProgress / rankRange)) + ctx.textAlign = 'left' + ctx.font = config.fonts.label + ctx.fillStyle = config.colors.textTertiary + //ctx.fillText('MMR', 585, 65) +} - ctx.fillStyle = rankColor - ctx.fillRect(barX, barY, barWidth * progress, barHeight) - ctx.fillStyle = nextRankColor - ctx.fillRect( - barX + barWidth * progress, - barY, - barWidth * (1 - progress), - barHeight, - ) - } else if (rankName != 'UNRANKED' && playerData.rank_mmr === null) { - // Max leaderboard rank - fully filled bar - ctx.fillStyle = rankColor - ctx.fillRect(barX, barY, barWidth, barHeight) +async function drawAvatar( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + playerData: StatsCanvasPlayerData, +) { + const user = await client.users.fetch(playerData.user_id) + const avatar = await loadImage(user.displayAvatarURL({ extension: 'png' })) + + //drawCircle(ctx,x + size/2,y + size/2, size/11 * 6,"#47746C") + + ctx.imageSmoothingEnabled = true + ctx.save() + ctx.drawImage(avatar, x, y, size, size) + ctx.restore() + ctx.imageSmoothingEnabled = false + + //hide corners + const tl = await loadImage('src/assets/hideTL.png') + const tr = await loadImage('src/assets/hideTR.png') + const bl = await loadImage('src/assets/hideBL.png') + const br = await loadImage('src/assets/hideBR.png') + + //shadow + ctx.fillStyle = '#1E2E32' + ctx.fillRect(x - 2, y + 20, 2, size - 30) + + ctx.fillRect(x + 16, y + size, size - 36, 6) + + //fill corners + ctx.drawImage(tl, x, y) + ctx.drawImage(tr, x + size - 16, y) + ctx.drawImage(bl, x, y + size - 16) + ctx.drawImage(br, x + size - 22, y + size - 16) +} + +async function drawCircle( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + radius: number, + color: string, +) { + ctx.beginPath() + ctx.arc(x, y, radius, 0, Math.PI * 2) + ctx.fillStyle = color + ctx.fill() +} + +async function addBackground( + ctx: CanvasRenderingContext2D, + filename: string = 'bgPlanet.png', +) { + const bg = await loadImage(`src/assets/backgrounds/${filename}`) + ctx.drawImage(bg, 0, 0) +} + +function drawBoxCorners( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + tl: any, + tr: any, + bl: any, + br: any, + xlen: number, + ylen: number, +) { + //corners + ctx.drawImage(tl, x - 2, y) + ctx.drawImage(tr, x + xlen - 14, y) + ctx.drawImage(bl, x - 2, y + ylen - 14 + 6) + ctx.drawImage(br, x + xlen - 14, y + ylen - 14 + 6) +} + +async function addRedBox( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + xlen: number, + ylen: number, +) { + const tl = await loadImage('src/assets/RedTL.png') + const tr = await loadImage('src/assets/RedTR.png') + const bl = await loadImage('src/assets/RedBL.png') + const br = await loadImage('src/assets/RedBR.png') + + //corners + drawBoxCorners(ctx, x, y, tl, tr, bl, br, xlen, ylen) + + //shadow + ctx.fillStyle = '#1E2E32' + ctx.fillRect(x - 2, y + 14, 2, ylen - 22) + ctx.fillRect(x + 12, y + ylen + 6, xlen - 26, -6) + + //fill + ctx.fillStyle = config.colors.mult + ctx.fillRect(x, y + 14, xlen, ylen - 22) + ctx.fillRect(x + 12, y, xlen - 24, ylen) +} + +async function addGrayBox( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + xlen: number, + ylen: number, +) { + const tl = await loadImage('src/assets/GrayTL.png') + const tr = await loadImage('src/assets/GrayTR.png') + const bl = await loadImage('src/assets/GrayBL.png') + const br = await loadImage('src/assets/GrayBR.png') + + drawBoxCorners(ctx, x, y, tl, tr, bl, br, xlen, ylen) + + //shadow + ctx.fillStyle = '#1E2E32' + ctx.fillRect(x - 2, y + 14, 2, ylen - 22) + ctx.fillRect(x + 12, y + ylen + 6, xlen - 26, -6) + + //fill + ctx.fillStyle = '#545454' + ctx.fillRect(x, y + 14, xlen, ylen - 22) + ctx.fillRect(x + 12, y, xlen - 24, ylen) + + ctx.save + ctx.restore +} + +async function addBlueBox( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + xlen: number, + ylen: number, +) { + const tl = await loadImage('src/assets/BlueTL.png') + const tr = await loadImage('src/assets/BlueTR.png') + const bl = await loadImage('src/assets/BlueBL.png') + const br = await loadImage('src/assets/BlueBR.png') + + //corners + drawBoxCorners(ctx, x, y, tl, tr, bl, br, xlen, ylen) + + //shadow + ctx.fillStyle = '#1E2E32' + ctx.fillRect(x - 2, y + 14, 2, ylen - 22) + ctx.fillRect(x + 12, y + ylen + 6, xlen - 26, -6) + + //fill + ctx.fillStyle = config.colors.chips + ctx.fillRect(x, y + 14, xlen, ylen - 22) + ctx.fillRect(x + 12, y, xlen - 24, ylen) +} + +async function addBlackBox( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + xlen: number, + ylen: number, +) { + const tl = await loadImage('src/assets/BlackTL.png') + const tr = await loadImage('src/assets/BlackTR.png') + const bl = await loadImage('src/assets/BlackBL.png') + const br = await loadImage('src/assets/BlackBR.png') + + //corners + drawBoxCorners(ctx, x, y, tl, tr, bl, br, xlen, ylen) + + //shadow + ctx.fillStyle = '#0B1415' + ctx.fillRect(x - 2, y + 14, 2, ylen - 22) + ctx.fillRect(x + 12, y + ylen + 6, xlen - 26, -6) + + //fill + ctx.fillStyle = '#1E2B2D' + ctx.fillRect(x, y + 14, xlen, ylen - 22) + ctx.fillRect(x + 12, y, xlen - 24, ylen) +} + +async function addBackBox( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + xlen: number, + ylen: number, +) { + const tl = await loadImage('src/assets/bgTL.png') + const tr = await loadImage('src/assets/bgTR.png') + const bl = await loadImage('src/assets/bgBL.png') + const br = await loadImage('src/assets/bgBR.png') + + //fill + ctx.fillStyle = '#3A5055' + ctx.fillRect(x + 32, y + 9, xlen - 44, ylen - 32) + ctx.fillRect(x + 9, y + 32, xlen - 18, ylen - 36) + + //corners + ctx.drawImage(tl, x, y) + ctx.drawImage(tr, x + xlen - 32, y) + ctx.drawImage(bl, x - 6, y + ylen - 32) + ctx.drawImage(br, x - 6 + xlen - 38, y + ylen - 32) + + //border + ctx.fillStyle = '#BAC2D2' + ctx.fillRect(x + 32, y, xlen - 64, 9) + ctx.fillRect(x, y + 32, 9, ylen - 64) + ctx.fillRect(x + xlen, y + 32, -9, ylen - 64) + ctx.fillRect(x + 38, y + ylen, xlen - 82, -9) + + //shadows + ctx.fillStyle = '#1b3233' + ctx.fillRect(x + 38, y + ylen, xlen - 76, 12) + ctx.fillRect(x, y + 48, -6, ylen - 58) +} + +function normalizeDataPosition( + playerData: StatsCanvasPlayerData, + byDate: boolean, +) { + const data = playerData.elo_graph_data + + if (!data || data.length === 0) { + return [] } - // Current rank label - ctx.fillStyle = rankColor - ctx.font = config.fonts.small - ctx.fillText(rankName, rankNameX, barY + barHeight - 12) + // Sort chronologically (oldest first) + const sortedData = [...data].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ) - // Next rank label and requirement - if (playerData.next_rank_name && playerData.next_rank_mmr) { - // MMR-based next rank - const mmrNeeded = playerData.next_rank_mmr - playerData.mmr - ctx.fillStyle = config.colors.textPrimary + let filteredData = sortedData + + if (byDate) { + // Group by date (ignoring time) + const grouped: Record = {} + + for (const point of sortedData) { + const day = new Date(point.date).toISOString().split('T')[0] // e.g., "2025-09-08" + + // Keep only the max rating for that day + if (!grouped[day] || point.rating > grouped[day].rating) { + grouped[day] = point + } + } + + filteredData = Object.values(grouped) + } + + // Sort again after grouping (important!) + const finalData = filteredData.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ) + + const startDate = new Date(finalData[0].date) + + // Add xVar (and keep rating as yVar) + return finalData.map((point, index) => { + const date = new Date(point.date) + const xVar = byDate + ? (date.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) // days since start + : index + + return { + ...point, + xVar, + yVar: point.rating, + } + }) +} + +function createGraph( + ctx: CanvasRenderingContext2D, + playerData: StatsCanvasPlayerData, + x: number, + y: number, + xlen: number, + ylen: number, + byDate: boolean = false, +) { + /** + * Draws normalizedPoints to ctx. The top left of the graph is at x,y, and the graph should be xlen across and ylen down + */ + const normalizedPoints = normalizeDataPosition(playerData, byDate) + const graphX = x + const graphY = y + 15 + const graphXLen = xlen + const graphYLen = ylen - 30 + + // Find min/max ratings for scaling + const ratings = normalizedPoints.map((p) => p.rating) + const minRating = Math.round(Math.min(...ratings)) + const maxRating = playerData.peak_mmr + const ratingRange = maxRating - minRating || 1 + + // Find min/max x values for scaling (in case byDate gives non-integer xVar) + const xValues = normalizedPoints.map((p) => p.xVar) + const minX = Math.min(...xValues) + const maxX = Math.max(...xValues) + const xRange = maxX - minX || 1 + + //draw divides + ctx.save() + const guideRatings = [ + 0, + 200, + 250, + 320, + 460, + 620, + 800, + 1000, + 1200, + 1400, + 1600, + 1800, + 2000, + maxRating, + minRating, + ] + const eloSplits = config.eloSplits + const eloColors = [ + config.colors.stone, + config.colors.steel, + config.colors.gold, + config.colors.lucky, + config.colors.glass, + ] + ctx.lineWidth = 0.5 + + function convertToCanvasSpace(y: number) { + return graphY + graphYLen - ((y - minRating) / ratingRange) * graphYLen + } + + // Build rating bands (from minRating to maxRating) + const bands = [minRating, ...eloSplits, maxRating] + + for (let i = 0; i < bands.length - 1; i++) { + const bandMin = bands[i] + const bandMax = bands[i + 1] + + // Convert rating band to canvas-space Y + const yTop = convertToCanvasSpace(bandMax) + const yBottom = convertToCanvasSpace(bandMin) + + // Create the graph path clipped to this band + ctx.save() + + // Clip only the area between bandTop and bandBottom + ctx.beginPath() + ctx.rect(graphX, yTop, graphXLen, yBottom - yTop) + ctx.clip() + + // Draw the filled shape under the line + ctx.beginPath() + const firstX = + graphX + ((normalizedPoints[0].xVar - minX) / xRange) * graphXLen + const firstY = convertToCanvasSpace(normalizedPoints[0].rating) + ctx.moveTo(firstX, firstY) + + for (let j = 1; j < normalizedPoints.length; j++) { + const p = normalizedPoints[j] + const x = graphX + ((p.xVar - minX) / xRange) * graphXLen + const y = convertToCanvasSpace(p.rating) + ctx.lineTo(x, y) + } + + // Close path down to the bottom of the graph area + const lastX = + graphX + + ((normalizedPoints[normalizedPoints.length - 1].xVar - minX) / xRange) * + graphXLen + ctx.lineTo(lastX, graphY + graphYLen) + ctx.lineTo(firstX, graphY + graphYLen) + ctx.closePath() + + // Fill with semi-transparent band color + const hex = eloColors[i] || eloColors[eloColors.length - 1] + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.12)` // translucent + ctx.fill() + ctx.restore() + } + + //Draw Horizontal Lines + + guideRatings.forEach((r) => { + // Skip if the line is outside of the current visible range + if (r < minRating || r > maxRating) return + + const yPos = + graphY + graphYLen - ((r - minRating) / ratingRange) * graphYLen + + if (r == maxRating) { + ctx.strokeStyle = config.colors.win + } else if (r == 620) { + ctx.strokeStyle = config.colors.glass + } else if (r == 460) { + ctx.strokeStyle = config.colors.lucky + } else if (r == 320) { + ctx.strokeStyle = config.colors.gold + } else if (r == 250) { + ctx.strokeStyle = config.colors.steel + } else if (r == 200 && maxRating > 185 && maxRating > 215) { + ctx.strokeStyle = config.colors.stone + } else if ( + !( + (r < maxRating + 15 && r > maxRating - 15) || + (r < minRating + 15 && r > minRating - 15) + ) + ) { + ctx.strokeStyle = config.colors.stone + } + + ctx.beginPath() + ctx.moveTo(x, yPos) + ctx.lineTo(x + xlen, yPos) + ctx.stroke() + }) + + //DRAW LINE + + ctx.save() + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.lineWidth = 3 + + // Helper: get color by rating + function getColor(rating: number) { + let idx = 0 + for (let i = 0; i < eloSplits.length; i++) { + if (rating >= eloSplits[i]) idx = i + 1 + } + return eloColors[idx] + } + + //Actually draw line for loop + for (let i = 0; i < normalizedPoints.length - 1; i++) { + const p1 = normalizedPoints[i] + const p2 = normalizedPoints[i + 1] + + const x1 = graphX + ((p1.xVar - minX) / xRange) * graphXLen + const y1 = convertToCanvasSpace(p1.rating) + const x2 = graphX + ((p2.xVar - minX) / xRange) * graphXLen + const y2 = convertToCanvasSpace(p2.rating) + + const color1 = getColor(p1.rating) + const color2 = getColor(p2.rating) + + //if the points have the same color, then draw the line, otherwise do some linear algebra + if (color1 === color2) { + ctx.beginPath() + ctx.strokeStyle = color1 + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + } else { + // y = n + const crossedSplit = eloSplits.find( + (split) => + split >= Math.min(p1.rating, p2.rating) && + split <= Math.max(p1.rating, p2.rating), + ) + + // Convert rating split → canvas y + const y3Canvas = convertToCanvasSpace(crossedSplit ?? 0) + + // line equation + const m = (y2 - y1) / (x2 - x1) + const b = y1 - m * x1 + const x3 = (y3Canvas - b) / m + + ctx.beginPath() + ctx.strokeStyle = color1 + ctx.moveTo(x1, y1) + ctx.lineTo(x3, y3Canvas) + ctx.stroke() + + ctx.beginPath() + ctx.strokeStyle = color2 + ctx.moveTo(x3, y3Canvas) + ctx.lineTo(x2, y2) + ctx.stroke() + } + } + + ctx.restore() + + // Add y level labels + + guideRatings.forEach((r) => { + // Skip if the line is outside of the current visible range + if (r < minRating || r > maxRating) return + + const yPos = + graphY + graphYLen - ((r - minRating) / ratingRange) * graphYLen + + ctx.fillStyle = config.colors.textSecondary ctx.textAlign = 'left' - ctx.font = config.fonts.graphSmall + ctx.font = config.fonts.tiny + + if (r == maxRating) { + ctx.fillText(`${Math.round(r)}`, x, yPos - 16) + ctx.font = config.fonts.tiny + ctx.fillText( + ' · Peak MMR', + x + ctx.measureText(`${Math.round(r)}`).width - 3, + yPos - 16, + ) + } else if (r == 620) { + ctx.fillText(r.toString(), x, yPos - 16) + } else if (r == 460) { + ctx.fillText(r.toString(), x, yPos - 16) + } else if (r == 320) { + ctx.fillText(r.toString(), x, yPos - 16) + } else if (r == 250) { + ctx.fillText(r.toString(), x, yPos - 16) + } else { + ctx.fillText(r.toString(), x, yPos - 16) + } + }) - ctx.letterSpacing = '1px' - ctx.shadowColor = 'rgba(0, 0, 0, 1)' - ctx.shadowBlur = 4 + // Add x axis + function addXAxisLabel(str: string, xVar: number) { ctx.fillText( - `+${mmrNeeded.toFixed(1)} MMR`, - barX + 10, - barY + barHeight / 2, + str.toString(), + graphX + ((xVar - minX) / xRange) * graphXLen, + graphY + graphYLen + 4, ) - ctx.shadowColor = 'rgba(0, 0, 0, 0)' - ctx.shadowBlur = 0 - ctx.letterSpacing = '0px' + } + + if (byDate) { + function formatDateToMMDD(dateString: string): string { + const date = new Date(dateString) + const day = date.getUTCDate().toString().padStart(2, '0') + const month = (date.getUTCMonth() + 1).toString().padStart(2, '0') + return `${month}/${day}` + } + + ctx.fillStyle = config.colors.textSecondary + ctx.textAlign = 'left' + ctx.font = config.fonts.tiny - ctx.font = config.fonts.small - ctx.fillStyle = nextRankColor ctx.fillText( - `${playerData.next_rank_name.toUpperCase()}`, - barX + barWidth + 10, - barY + barHeight - 12, + formatDateToMMDD(normalizedPoints[0].date.toString()) + ' · Date', + graphX, + graphY + graphYLen + 4, ) - } else if (playerData.next_rank_name && playerData.next_rank_position) { - // Leaderboard-based next rank - const nextRankPos = playerData.next_rank_position - const currentPos = playerData.leaderboard_position || 999 - const positionsNeeded = Math.max(0, currentPos - nextRankPos) - ctx.fillStyle = config.colors.textPrimary + ctx.textAlign = 'right' + if ( + (normalizedPoints[Math.floor(normalizedPoints.length * 0.5)].xVar - + minX) / + xRange >= + (normalizedPoints[Math.floor(normalizedPoints.length * 0.75)].xVar - + minX) / + xRange - + 0.1 + ) { + addXAxisLabel( + formatDateToMMDD( + normalizedPoints[ + Math.floor(normalizedPoints.length * 0.5) + ].date.toString(), + ), + normalizedPoints[Math.floor(normalizedPoints.length * 0.5)].xVar, + ) + } else { + addXAxisLabel( + formatDateToMMDD( + normalizedPoints[ + Math.floor(normalizedPoints.length * 0.5) + ].date.toString(), + ), + normalizedPoints[Math.floor(normalizedPoints.length * 0.5)].xVar, + ) + addXAxisLabel( + formatDateToMMDD( + normalizedPoints[ + Math.floor(normalizedPoints.length * 0.75) + ].date.toString(), + ), + normalizedPoints[Math.floor(normalizedPoints.length * 0.75)].xVar, + ) + } + + addXAxisLabel( + formatDateToMMDD( + normalizedPoints[normalizedPoints.length - 1].date.toString(), + ), + normalizedPoints[normalizedPoints.length - 1].xVar, + ) + } else { + ctx.fillStyle = config.colors.textSecondary ctx.textAlign = 'left' - ctx.font = config.fonts.graphSmall + ctx.font = config.fonts.tiny - ctx.shadowColor = 'rgba(0, 0, 0, 1)' - ctx.shadowBlur = 4 ctx.fillText( - `↑ ${positionsNeeded} ${positionsNeeded === 1 ? 'RANK' : 'RANKS'}`, - barX + 10, - barY + barHeight / 2, + normalizedPoints[0].xVar.toString() + ' · Games', + graphX, + graphY + graphYLen + 4, ) - ctx.shadowColor = 'rgba(0, 0, 0, 0)' - ctx.shadowBlur = 0 - ctx.font = config.fonts.small - ctx.fillStyle = nextRankColor - ctx.fillText( - `${playerData.next_rank_name.toUpperCase()}`, - barX + barWidth + 10, - barY + barHeight - 12, + ctx.textAlign = 'right' + addXAxisLabel( + normalizedPoints[ + Math.floor(normalizedPoints.length * 0.25) + ].xVar.toString(), + normalizedPoints[Math.floor(normalizedPoints.length * 0.25)].xVar, + ) + addXAxisLabel( + normalizedPoints[ + Math.floor(normalizedPoints.length * 0.5) + ].xVar.toString(), + normalizedPoints[Math.floor(normalizedPoints.length * 0.5)].xVar, + ) + addXAxisLabel( + normalizedPoints[ + Math.floor(normalizedPoints.length * 0.75) + ].xVar.toString(), + normalizedPoints[Math.floor(normalizedPoints.length * 0.75)].xVar, + ) + addXAxisLabel( + normalizedPoints[normalizedPoints.length - 1].xVar.toString(), + normalizedPoints[normalizedPoints.length - 1].xVar, ) } - - // Current MMR and peak - ctx.textAlign = 'right' - ctx.font = config.fonts.label - ctx.fillStyle = config.colors.textSecondary - ctx.fillText('MMR', config.width - padding - 20, 40) - - ctx.font = config.fonts.title - ctx.fillStyle = config.colors.textPrimary - ctx.fillText(`${playerData.mmr}`, config.width - padding - 20, 80) - - ctx.font = config.fonts.small - ctx.fillStyle = config.colors.textTertiary - ctx.fillText( - `PEAK: ${playerData.peak_mmr}`, - config.width - padding - 20, - barY + barHeight - 12, - ) - - ctx.textAlign = 'left' } -function drawStats( +function addSideData( ctx: CanvasRenderingContext2D, playerData: StatsCanvasPlayerData, + x: number, + y: number, + xlen: number, + ylen: number, ) { - const { padding } = config - const startX = padding - const startY = 170 - const panelWidth = 450 - const cellWidth = panelWidth / 3.5 - const valueOffsetY = 64 - - playerData.stats.forEach((stat, i) => { - const cx = startX + i * cellWidth + cellWidth / 2 - const y = startY + 35 - + const winsData = playerData.stats[0] + const lossesData = playerData.stats[1] + const gamesData = playerData.stats[2] + const winrateData = playerData.stats[3] + + addData(winrateData, x + 10, y, xlen / 2 - 25, ylen) + addData(gamesData, x + 15 + xlen / 2, y, xlen / 2 - 25, ylen) + addData(winsData, x + 10, y + 95, xlen / 2 - 25, ylen) + addData(lossesData, x + 15 + xlen / 2, y + 95, xlen / 2 - 25, ylen) + + function addData( + data: { label: string; value: string; percentile: number; isTop: boolean }, + x: number, + y: number, + xlen: number, + ylen: number, + ) { ctx.textAlign = 'center' ctx.font = config.fonts.statLabel - ctx.fillStyle = config.colors.textSecondary - ctx.fillText(stat.label, cx, y) - - ctx.font = config.fonts.value ctx.fillStyle = config.colors.textPrimary - // Format numeric values (but not percentages) - const displayValue = stat.value.includes('%') - ? stat.value - : isNaN(Number(stat.value)) - ? stat.value - : formatNumber(Number(stat.value)) - ctx.fillText(displayValue, cx, y + valueOffsetY) - - if (stat.percentile !== undefined) { - ctx.font = config.fonts.percentile - ctx.fillStyle = config.colors.textSecondary - const prefix = stat.isTop ? 'TOP' : 'BOTTOM' - ctx.fillText(`${prefix} ${stat.percentile}%`, cx, y + valueOffsetY + 69) - } - }) + ctx.fillText(data.value, x + xlen / 2, y + ylen / 2 + 5) - ctx.textAlign = 'left' - ctx.textBaseline = 'top' + ctx.textAlign = 'left' + ctx.font = config.fonts.small + ctx.fillStyle = config.colors.textTertiary + + ctx.fillText(data.label, x, y + 15) + + ctx.textAlign = 'right' + ctx.font = config.fonts.tiny + ctx.fillStyle = config.colors.textTertiary + if (data.isTop) { + ctx.fillText( + 'Top ' + data.percentile.toString() + '%', + x + xlen, + y + ylen - 10, + ) + } else { + ctx.fillText( + 'Bottom ' + data.percentile.toString() + '%', + x + xlen, + y + ylen - 10, + ) + } + } } function drawPreviousGames( ctx: CanvasRenderingContext2D, playerData: StatsCanvasPlayerData, + x: number, + y: number, + xlen: number, + ylen: number, ) { - const statsPanelWidth = 550 - const spacing = 135 - const startX = config.padding + statsPanelWidth + spacing - const startY = 170 - const panelWidth = config.width - startX - config.padding + const startX = x + 10 + const panelWidth = xlen const lineHeight = 22 - const maxGames = 4 + const maxGames = 5 ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.font = config.fonts.statLabel - ctx.fillStyle = config.colors.textSecondary - ctx.fillText('PREVIOUS GAMES', startX, startY + 35) + ctx.font = config.fonts.prevgameLabel + ctx.fillStyle = config.colors.textTertiary + ctx.fillText('PREVIOUS GAMES', x + xlen / 2, y + 20) - ctx.font = config.fonts.gameList ctx.textAlign = 'left' - // Display up to 4 recent games + // Display up to maxGames recent games for (let i = 0; i < maxGames; i++) { - const y = startY + 65 + i * lineHeight + const lineY = y + 70 + i * lineHeight if (i < playerData.previous_games.length) { const game = playerData.previous_games[i] @@ -390,33 +849,35 @@ function drawPreviousGames( const resultText = game.change > 0 ? 'WIN' : 'LOSS' const changeText = `${game.change > 0 ? '+' : ''}${game.change.toFixed(1)}` + ctx.font = config.fonts.gameList + ctx.textAlign = 'left' + ctx.fillStyle = config.colors.textPrimary - ctx.fillText(numberText, startX - 120, y) + ctx.fillText(numberText, startX, lineY) ctx.fillStyle = game.change > 0 ? config.colors.win : config.colors.lose const numberWidth = ctx.measureText(numberText).width - ctx.fillText( - resultText, - i == 0 ? startX + numberWidth - 112 : startX + numberWidth - 115, - y, - ) + ctx.fillText(resultText, startX + numberWidth + 5, lineY) - const resultWidth = ctx.measureText(resultText).width - ctx.letterSpacing = '2px' - ctx.fillText(changeText, startX + resultWidth + numberWidth - 108, y) - ctx.letterSpacing = '0px' + ctx.fillText(changeText, startX + numberWidth + 45, lineY) ctx.fillStyle = config.colors.textSecondary ctx.textAlign = 'right' - ctx.fillText(timeAgo(new Date(game.time)), startX + panelWidth - 20, y) - ctx.textAlign = 'left' + ctx.font = config.fonts.tiny + ctx.fillText( + timeAgo(new Date(game.time)), + startX + panelWidth - 20, + lineY, + ) } } // Current win/loss streak - const streakY = startY + 80 + maxGames * lineHeight + const streakY = y + 45 ctx.fillStyle = config.colors.textSecondary - ctx.fillText('CURRENT STREAK: ', startX - 120, streakY) + ctx.textAlign = 'left' + + ctx.fillText('CURRENT STREAK: ', startX + 30, streakY) ctx.fillStyle = playerData.win_streak > 0 @@ -424,258 +885,237 @@ function drawPreviousGames( : playerData.win_streak < 0 ? config.colors.lose : config.colors.textSecondary - ctx.fillText(`${playerData.win_streak}`, startX + 50, streakY) + ctx.fillText(`${playerData.win_streak}`, startX + 135, streakY) ctx.textBaseline = 'top' } -function drawGraph( +async function rankupBar( ctx: CanvasRenderingContext2D, playerData: StatsCanvasPlayerData, + x: number, + y: number, + xlen: number, + ylen: number, + xlen2: number, ) { - const { padding } = config - const area = { - x: padding + 75, - y: 395, - width: config.width - padding * 2 - 100, - height: config.height - 400 - 50, - } - - const graphPadding = 4 // Padding for the line within the graph area - - const data = playerData.elo_graph_data + const rankColor = playerData.rank_color || config.colors.textTertiary + const nextRankColor = playerData.next_rank_color || config.colors.textPrimary + const rankName = (playerData.rank_name || 'UNRANKED').toUpperCase() + const nextRankName = playerData.next_rank_name + ? playerData.next_rank_name.toUpperCase() + : '' - // Handle edge case: no data - if (data.length === 0) { - ctx.fillStyle = config.colors.textSecondary - ctx.font = config.fonts.label - ctx.textAlign = 'center' - ctx.fillText( - 'No match history', - area.x + area.width / 2, - area.y + area.height / 2, - ) - return - } + const nextRankMRR = playerData.next_rank_mmr + const rankMMR = playerData.rank_mmr + const MMR = playerData.mmr - // Calculate actual data range from the graph data - const dataMinRating = Math.min(...data.map((d) => d.rating)) - const dataMaxRating = Math.max(...data.map((d) => d.rating)) + const nextRankPosition = playerData.next_rank_position + const rankPosition = playerData.rank_position + const position = playerData.leaderboard_position - // Use the larger of peak_mmr or actual max data point - // This ensures all data points are visible even if they exceed peak - const maxRating = Math.max(playerData.peak_mmr, dataMaxRating) + async function corner(x: number, y: number, xlen: number, ylen: number) { + const tl = await loadImage('src/assets/antiTL.png') + const tr = await loadImage('src/assets/antiTR.png') + const bl = await loadImage('src/assets/antiBL.png') + const br = await loadImage('src/assets/antiBR.png') - ctx.strokeStyle = config.colors.gridLines - ctx.lineWidth = 1 - ctx.font = config.fonts.graphSmall - ctx.fillStyle = config.colors.textSecondary - ctx.textAlign = 'right' - ctx.textBaseline = 'middle' + drawBoxCorners(ctx, x, y, tl, tr, bl, br, xlen, ylen) - // Y-axis grid lines and labels - const targetGridLines = 6 - const tempRange = maxRating - dataMinRating - const rawInterval = tempRange / targetGridLines - - // Calculate nice interval based on range - let niceInterval = 5 - if (rawInterval > 100) niceInterval = 100 - else if (rawInterval > 50) niceInterval = 50 - else if (rawInterval > 25) niceInterval = 25 - else if (rawInterval > 10) niceInterval = 10 - else if (rawInterval > 5) niceInterval = 5 - else if (rawInterval > 2) niceInterval = 2 - else niceInterval = 1 - - // Start from 0 only if the player actually has a rating at or near 0 - const startValue = - dataMinRating <= 5 - ? 0 - : Math.floor(dataMinRating / niceInterval) * niceInterval - const minRating = startValue - - // Calculate range - extend maxRating to next nice interval for padding - const extendedMax = Math.ceil(maxRating / niceInterval) * niceInterval - const ratingRange = extendedMax - minRating - - // If range is 0 (all points same value), add small padding - const effectiveRange = ratingRange === 0 ? 10 : ratingRange - const effectiveMax = minRating + effectiveRange - - for (let value = startValue; value <= effectiveMax; value += niceInterval) { - const y = - area.y + - area.height - - ((value - minRating) / effectiveRange) * area.height - ctx.beginPath() - ctx.moveTo(area.x, y) - ctx.lineTo(area.x + area.width, y) - ctx.stroke() - ctx.fillText(value.toString(), area.x - 10, y) + //shadow + ctx.fillStyle = '#1E2E32' + ctx.fillRect(x - 2, y + 14, 2, ylen - 22) + ctx.fillRect(x + 12, y + ylen + 6, xlen - 26, -6) } - // Y-axis title - ctx.save() - ctx.translate(padding + 15, area.y + area.height / 2) - ctx.rotate(-Math.PI / 2) - ctx.font = config.fonts.label - ctx.textAlign = 'center' - ctx.fillText('RATING', 0, 0) - ctx.restore() - - // X-axis grid lines and labels - ctx.textAlign = 'center' - const maxLabels = 15 - - data.forEach((_point, i) => { - // Determine label visibility - let shouldShowLabel = data.length <= maxLabels - if (!shouldShowLabel) { - const gameNumber = i + 1 - const interval = data.length > 100 ? 10 : 5 - shouldShowLabel = - gameNumber === 1 || (gameNumber % interval === 0 && gameNumber !== 1) - } - - if (shouldShowLabel) { - // Handle single data point case - const x = - data.length === 1 - ? area.x + area.width / 2 - : area.x + (i / (data.length - 1)) * area.width + async function drawBar( + x: number, + y: number, + xlen: number, + ylen: number, + xmin: number, + xmax: number, + xact: number, + ) { + ctx.fillStyle = nextRankColor + ctx.fillRect(x, y, xlen, ylen) - // Draw grid line only where label is shown - ctx.beginPath() - ctx.moveTo(x, area.y) - ctx.lineTo(x, area.y + area.height) - ctx.stroke() + const xfill = xlen * ((xact - xmin) / (xmax - xmin)) - ctx.fillText((i + 1).toString(), x, area.y + area.height + 18) + ctx.fillStyle = rankColor + if (rankName == 'STONE') { + ctx.fillStyle = '#868692ff' } - }) - - // Graph border - ctx.strokeStyle = '#ffffff' - ctx.lineWidth = 1.5 - ctx.strokeRect(area.x, area.y, area.width, area.height) - const shadowOffset = 3 + ctx.fillRect(x, y, xfill, ylen) - // Draw shadow (black, transparent, offset) - ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)' - ctx.fillStyle = 'rgba(0, 0, 0, 0.3)' - ctx.lineWidth = 2 + await corner(x, y, xlen, ylen) - // Helper to calculate X position (handles single data point case) - const getXPosition = (index: number) => { - if (data.length === 1) { - return area.x + graphPadding + (area.width - graphPadding * 2) / 2 - } - return ( - area.x + - graphPadding + - (index / (data.length - 1)) * (area.width - graphPadding * 2) - ) + ctx.fillStyle = config.colors.textPrimary } - // Shadow lines - for (let i = 0; i < data.length - 1; i++) { - const x1 = getXPosition(i) + shadowOffset - const y1 = - area.y + - graphPadding + - (area.height - graphPadding * 2) - - ((data[i].rating - minRating) / effectiveRange) * - (area.height - graphPadding * 2) + - shadowOffset - const x2 = getXPosition(i + 1) + shadowOffset - const y2 = - area.y + - graphPadding + - (area.height - graphPadding * 2) - - ((data[i + 1].rating - minRating) / effectiveRange) * - (area.height - graphPadding * 2) + - shadowOffset + function toProperCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() + } - ctx.beginPath() - ctx.moveTo(x1, y1) - ctx.lineTo(x2, y2) - ctx.stroke() + function borderText( + str: string, + x: number, + y: number, + fill: string, + border: string, + ) { + ctx.fillStyle = border + ctx.fillText(str, x + 1, y + 1) + ctx.fillText(str, x + 1, y - 1) + ctx.fillText(str, x - 1, y + 1) + ctx.fillText(str, x - 1, y - 1) + + ctx.fillStyle = fill + ctx.fillText(str, x, y) } - // Shadow points - data.forEach((point, i) => { - const x = getXPosition(i) + shadowOffset - const y = - area.y + - graphPadding + - (area.height - graphPadding * 2) - - ((point.rating - minRating) / effectiveRange) * - (area.height - graphPadding * 2) + - shadowOffset + ctx.fillStyle = nextRankColor + ctx.fillRect(x + xlen + 10, y, xlen2, ylen) + await corner(x + xlen + 10, y, xlen2, ylen) - ctx.beginPath() - ctx.arc(x, y, 3, 0, Math.PI * 2) - ctx.fill() - }) + ctx.textAlign = 'center' + ctx.font = config.fonts.tiny - // Draw actual graph - ctx.strokeStyle = config.colors.graphLine - ctx.fillStyle = config.colors.graphLine - ctx.lineWidth = 2 - - // Actual lines - for (let i = 0; i < data.length - 1; i++) { - const x1 = getXPosition(i) - const y1 = - area.y + - graphPadding + - (area.height - graphPadding * 2) - - ((data[i].rating - minRating) / effectiveRange) * - (area.height - graphPadding * 2) - const x2 = getXPosition(i + 1) - const y2 = - area.y + - graphPadding + - (area.height - graphPadding * 2) - - ((data[i + 1].rating - minRating) / effectiveRange) * - (area.height - graphPadding * 2) + let nextRankNameConcat = nextRankName - ctx.beginPath() - ctx.moveTo(x1, y1) - ctx.lineTo(x2, y2) - ctx.stroke() + if (nextRankName == 'HOLOGRAPHIC' || nextRankName == 'POLYCHROME') { + nextRankNameConcat = nextRankName.slice(0, 4) + } + + if (nextRankMRR != null && rankMMR != null && MMR != null) { + await drawBar(x, y, xlen, ylen, rankMMR, nextRankMRR, MMR) + + borderText( + Math.round(nextRankMRR - MMR).toString() + + ' MMR to ' + + toProperCase(nextRankName), + x + xlen + 10 + xlen2 / 2, + y + ylen - 9, + config.colors.textPrimary, + config.colors.textQuaternary, + ) + } else if (position && rankPosition && nextRankPosition && position != 1) { + await drawBar(x, y, xlen, ylen, rankPosition, nextRankPosition, position) + + if (position - nextRankPosition == 1) { + borderText( + (position - nextRankPosition).toString() + + ' rank to ' + + toProperCase(nextRankNameConcat), + x + xlen + 10 + xlen2 / 2, + y + ylen - 9, + config.colors.textPrimary, + config.colors.textQuaternary, + ) + } else { + borderText( + (position - nextRankPosition).toString() + + ' ranks to ' + + toProperCase(nextRankNameConcat), + x + xlen + 10 + xlen2 / 2, + y + ylen - 9, + config.colors.textPrimary, + config.colors.textQuaternary, + ) + } + } else { + await drawBar(x, y, xlen, ylen, 0, 1, 1) + borderText( + '₍^. .^₎/', + x + xlen + 10 + xlen2 / 2, + y + ylen - 9, + config.colors.textPrimary, + config.colors.textQuaternary, + ) } - // Actual points - data.forEach((point, i) => { - const x = getXPosition(i) - const normalizedPosition = (point.rating - minRating) / effectiveRange - const y = - area.y + - graphPadding + - (area.height - graphPadding * 2) - - normalizedPosition * (area.height - graphPadding * 2) + ctx.textAlign = 'left' + borderText( + rankName, + x + 10, + y + ylen - 9, + config.colors.textPrimary, + config.colors.textQuaternary, + ) - ctx.beginPath() - ctx.arc(x, y, 3, 0, Math.PI * 2) - ctx.fill() - }) + ctx.textAlign = 'right' + borderText( + nextRankName, + x + xlen - 10, + y + ylen - 9, + config.colors.textPrimary, + config.colors.textQuaternary, + ) } export async function drawPlayerStatsCanvas( queueName: string, playerData: StatsCanvasPlayerData, + byDate: boolean, ) { - const canvas = new Canvas(config.width, config.height) + // Render at higher resolution for sharper text (2x, 4x, etc.) + // Higher scale = more anti-aliasing but larger file size + const scale = 2 + const canvas = new Canvas(config.width * scale, config.height * scale) const ctx = canvas.getContext('2d') - drawBackground(ctx) - await drawHeader(ctx, playerData, queueName) - drawStats(ctx, playerData) - drawPreviousGames(ctx, playerData) - drawGraph(ctx, playerData) + ctx.imageSmoothingEnabled = false + ctx.scale(2, 2) + + //back elements + await addBackground(ctx, playerData.stat_background) + ctx.imageSmoothingEnabled = false + await addBackBox( + ctx, + config.width / 32, + config.height / 32, + config.width - config.width / 16, + config.height - config.height / 12, + ) + ctx.imageSmoothingEnabled = true + + //top elements + await drawAvatar(ctx, 60, 50, 110, playerData) + + ctx.textBaseline = 'middle' + + //change the gray and red boxes depending on if mmr is 5 digits or >5 + if (`${playerData.peak_mmr}`.length > 5) { + await addGrayBox(ctx, 180, 50, 385, 80) + await addRedBox(ctx, 575, 50, 166, 80) + await rankupBar(ctx, playerData, 180, 140, 385, 20, 166) + } else { + await addGrayBox(ctx, 180, 50, 415, 80) + await addRedBox(ctx, 605, 50, 136, 80) + await rankupBar(ctx, playerData, 180, 140, 415, 20, 136) + } + + await addTopText(ctx, playerData, queueName) - return await canvas.png + //side elements + await addBlackBox(ctx, 60, 170, 95, 85) + await addBlackBox(ctx, 165, 170, 95, 85) + await addBlackBox(ctx, 60, 265, 95, 85) + await addBlackBox(ctx, 165, 265, 95, 85) + + await addBlackBox(ctx, 60, 360, 200, 180) + + addSideData(ctx, playerData, 60, 170, 200, 85) + drawPreviousGames(ctx, playerData, 60, 360, 200, 180) + + //graph + await addBlackBox(ctx, 270, 170, 470, 370) + createGraph(ctx, playerData, 280, 180, 450, 350, byDate) + + // Export with high quality settings + return await canvas.toBuffer('png', { + quality: 1.0, + density: scale, + }) } diff --git a/src/utils/queryDB.ts b/src/utils/queryDB.ts index c7a7773e..ffa831a6 100644 --- a/src/utils/queryDB.ts +++ b/src/utils/queryDB.ts @@ -1483,17 +1483,18 @@ export async function getStatsCanvasUserData( ` SELECT mu.elo_change AS change, - m.created_at AS time + m.created_at AS time, + m.stake AS stake, + m.deck AS deck FROM match_users mu JOIN matches m ON m.id = mu.match_id WHERE mu.user_id = $1 AND m.queue_id = $2 AND m.winning_team IS NOT NULL ORDER BY m.created_at DESC - LIMIT 4 `, [userId, queueId], ) - const previous_games = previousRes.rows as { change: number; time: Date }[] + const previous_games = previousRes.rows as { change: number; time: Date; deck: string; stake: string }[] const eloRes = await pool.query( ` @@ -1647,12 +1648,20 @@ export async function getStatsCanvasUserData( const leaderboardPos = await getLeaderboardPosition(queueId, userId) + // Fetch user's selected background + const bgRes = await pool.query( + 'SELECT stat_background FROM users WHERE user_id = $1', + [userId], + ) + const statBackground = bgRes.rows[0]?.stat_background || 'bgMain.png' + const data: StatsCanvasPlayerData = { user_id: p.user_id, name: '', mmr: p.elo, peak_mmr: p.peak_elo, win_streak: p.win_streak, + stat_background: statBackground, stats, previous_games, elo_graph_data,