server for refapp and refbot and other stuff
0

Configure Feed

Select the types of activity you want to include in your feed.

Implement phase 4 match engine, generated-pools persistence, and audit logging

Port state/match.js into src/engine/match.ts keyed by matchId with plain
player data (no Discord objects). Add GeneratedPools model, event singleton
service, pools persistence layer with getPoolByTier/getMatchPool, and
audit logging service with query/filter support. All 228 tests pass,
typecheck clean.

alpine (Jul 15, 2026, 2:00 AM +0200) 1c272cdc 8d3d32c1

+2305 -2
+463
package-lock.json
··· 24 24 "@types/express": "^5.0.2", 25 25 "@types/node": "^22.0.0", 26 26 "@types/ws": "^8.5.10", 27 + "mongodb-memory-server": "^11.2.0", 27 28 "pino-pretty": "^13.1.3", 28 29 "tsx": "^4.0.0", 29 30 "typescript": "^5.6.0" ··· 657 658 "node": ">=14.0" 658 659 } 659 660 }, 661 + "node_modules/agent-base": { 662 + "version": "7.1.4", 663 + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", 664 + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", 665 + "dev": true, 666 + "license": "MIT", 667 + "engines": { 668 + "node": ">= 14" 669 + } 670 + }, 671 + "node_modules/async-mutex": { 672 + "version": "0.5.0", 673 + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", 674 + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", 675 + "dev": true, 676 + "license": "MIT", 677 + "dependencies": { 678 + "tslib": "^2.4.0" 679 + } 680 + }, 660 681 "node_modules/atomic-sleep": { 661 682 "version": "1.0.0", 662 683 "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", ··· 666 687 "node": ">=8.0.0" 667 688 } 668 689 }, 690 + "node_modules/b4a": { 691 + "version": "1.8.1", 692 + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", 693 + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", 694 + "dev": true, 695 + "license": "Apache-2.0", 696 + "peerDependencies": { 697 + "react-native-b4a": "*" 698 + }, 699 + "peerDependenciesMeta": { 700 + "react-native-b4a": { 701 + "optional": true 702 + } 703 + } 704 + }, 705 + "node_modules/bare-events": { 706 + "version": "2.9.1", 707 + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", 708 + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", 709 + "dev": true, 710 + "license": "Apache-2.0", 711 + "peerDependencies": { 712 + "bare-abort-controller": "*" 713 + }, 714 + "peerDependenciesMeta": { 715 + "bare-abort-controller": { 716 + "optional": true 717 + } 718 + } 719 + }, 720 + "node_modules/bare-fs": { 721 + "version": "4.7.4", 722 + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", 723 + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", 724 + "dev": true, 725 + "license": "Apache-2.0", 726 + "dependencies": { 727 + "bare-events": "^2.5.4", 728 + "bare-path": "^3.0.0", 729 + "bare-stream": "^2.6.4", 730 + "bare-url": "^2.2.2", 731 + "fast-fifo": "^1.3.2" 732 + }, 733 + "engines": { 734 + "bare": ">=1.16.0" 735 + }, 736 + "peerDependencies": { 737 + "bare-buffer": "*" 738 + }, 739 + "peerDependenciesMeta": { 740 + "bare-buffer": { 741 + "optional": true 742 + } 743 + } 744 + }, 745 + "node_modules/bare-path": { 746 + "version": "3.1.1", 747 + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", 748 + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", 749 + "dev": true, 750 + "license": "Apache-2.0" 751 + }, 752 + "node_modules/bare-stream": { 753 + "version": "2.13.3", 754 + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", 755 + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", 756 + "dev": true, 757 + "license": "Apache-2.0", 758 + "dependencies": { 759 + "b4a": "^1.8.1", 760 + "streamx": "^2.25.0", 761 + "teex": "^1.0.1" 762 + }, 763 + "peerDependencies": { 764 + "bare-abort-controller": "*", 765 + "bare-buffer": "*", 766 + "bare-events": "*" 767 + }, 768 + "peerDependenciesMeta": { 769 + "bare-abort-controller": { 770 + "optional": true 771 + }, 772 + "bare-buffer": { 773 + "optional": true 774 + }, 775 + "bare-events": { 776 + "optional": true 777 + } 778 + } 779 + }, 780 + "node_modules/bare-url": { 781 + "version": "2.4.5", 782 + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", 783 + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", 784 + "dev": true, 785 + "license": "Apache-2.0", 786 + "dependencies": { 787 + "bare-path": "^3.0.0" 788 + } 789 + }, 669 790 "node_modules/body-parser": { 670 791 "version": "2.3.0", 671 792 "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", ··· 750 871 "url": "https://github.com/sponsors/ljharb" 751 872 } 752 873 }, 874 + "node_modules/camelcase": { 875 + "version": "6.3.0", 876 + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", 877 + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", 878 + "dev": true, 879 + "license": "MIT", 880 + "engines": { 881 + "node": ">=10" 882 + }, 883 + "funding": { 884 + "url": "https://github.com/sponsors/sindresorhus" 885 + } 886 + }, 753 887 "node_modules/colorette": { 754 888 "version": "2.0.20", 755 889 "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", 756 890 "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", 891 + "dev": true, 892 + "license": "MIT" 893 + }, 894 + "node_modules/commondir": { 895 + "version": "1.0.1", 896 + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", 897 + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", 757 898 "dev": true, 758 899 "license": "MIT" 759 900 }, ··· 976 1117 "node": ">= 0.6" 977 1118 } 978 1119 }, 1120 + "node_modules/events-universal": { 1121 + "version": "1.0.1", 1122 + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", 1123 + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", 1124 + "dev": true, 1125 + "license": "Apache-2.0", 1126 + "dependencies": { 1127 + "bare-events": "^2.7.0" 1128 + } 1129 + }, 979 1130 "node_modules/express": { 980 1131 "version": "5.2.1", 981 1132 "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", ··· 1041 1192 "dev": true, 1042 1193 "license": "MIT" 1043 1194 }, 1195 + "node_modules/fast-fifo": { 1196 + "version": "1.3.2", 1197 + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", 1198 + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", 1199 + "dev": true, 1200 + "license": "MIT" 1201 + }, 1044 1202 "node_modules/fast-safe-stringify": { 1045 1203 "version": "2.1.1", 1046 1204 "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", ··· 1069 1227 "url": "https://opencollective.com/express" 1070 1228 } 1071 1229 }, 1230 + "node_modules/find-cache-dir": { 1231 + "version": "3.3.2", 1232 + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", 1233 + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", 1234 + "dev": true, 1235 + "license": "MIT", 1236 + "dependencies": { 1237 + "commondir": "^1.0.1", 1238 + "make-dir": "^3.0.2", 1239 + "pkg-dir": "^4.1.0" 1240 + }, 1241 + "engines": { 1242 + "node": ">=8" 1243 + }, 1244 + "funding": { 1245 + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" 1246 + } 1247 + }, 1248 + "node_modules/find-up": { 1249 + "version": "4.1.0", 1250 + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", 1251 + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", 1252 + "dev": true, 1253 + "license": "MIT", 1254 + "dependencies": { 1255 + "locate-path": "^5.0.0", 1256 + "path-exists": "^4.0.0" 1257 + }, 1258 + "engines": { 1259 + "node": ">=8" 1260 + } 1261 + }, 1262 + "node_modules/follow-redirects": { 1263 + "version": "1.16.0", 1264 + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", 1265 + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", 1266 + "dev": true, 1267 + "funding": [ 1268 + { 1269 + "type": "individual", 1270 + "url": "https://github.com/sponsors/RubenVerborgh" 1271 + } 1272 + ], 1273 + "license": "MIT", 1274 + "engines": { 1275 + "node": ">=4.0" 1276 + }, 1277 + "peerDependenciesMeta": { 1278 + "debug": { 1279 + "optional": true 1280 + } 1281 + } 1282 + }, 1072 1283 "node_modules/forwarded": { 1073 1284 "version": "0.2.0", 1074 1285 "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", ··· 1223 1434 "url": "https://opencollective.com/express" 1224 1435 } 1225 1436 }, 1437 + "node_modules/https-proxy-agent": { 1438 + "version": "7.0.6", 1439 + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", 1440 + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", 1441 + "dev": true, 1442 + "license": "MIT", 1443 + "dependencies": { 1444 + "agent-base": "^7.1.2", 1445 + "debug": "4" 1446 + }, 1447 + "engines": { 1448 + "node": ">= 14" 1449 + } 1450 + }, 1226 1451 "node_modules/iconv-lite": { 1227 1452 "version": "0.7.3", 1228 1453 "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", ··· 1279 1504 "node": ">=18.0.0" 1280 1505 } 1281 1506 }, 1507 + "node_modules/locate-path": { 1508 + "version": "5.0.0", 1509 + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", 1510 + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", 1511 + "dev": true, 1512 + "license": "MIT", 1513 + "dependencies": { 1514 + "p-locate": "^4.1.0" 1515 + }, 1516 + "engines": { 1517 + "node": ">=8" 1518 + } 1519 + }, 1520 + "node_modules/make-dir": { 1521 + "version": "3.1.0", 1522 + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", 1523 + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", 1524 + "dev": true, 1525 + "license": "MIT", 1526 + "dependencies": { 1527 + "semver": "^6.0.0" 1528 + }, 1529 + "engines": { 1530 + "node": ">=8" 1531 + }, 1532 + "funding": { 1533 + "url": "https://github.com/sponsors/sindresorhus" 1534 + } 1535 + }, 1536 + "node_modules/make-dir/node_modules/semver": { 1537 + "version": "6.3.1", 1538 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 1539 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 1540 + "dev": true, 1541 + "license": "ISC", 1542 + "bin": { 1543 + "semver": "bin/semver.js" 1544 + } 1545 + }, 1282 1546 "node_modules/math-intrinsics": { 1283 1547 "version": "1.1.0", 1284 1548 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", ··· 1409 1673 "node": ">=20.19.0" 1410 1674 } 1411 1675 }, 1676 + "node_modules/mongodb-memory-server": { 1677 + "version": "11.2.0", 1678 + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.2.0.tgz", 1679 + "integrity": "sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==", 1680 + "dev": true, 1681 + "hasInstallScript": true, 1682 + "license": "MIT", 1683 + "dependencies": { 1684 + "mongodb-memory-server-core": "11.2.0", 1685 + "tslib": "^2.8.1" 1686 + }, 1687 + "engines": { 1688 + "node": ">=20.19.0" 1689 + } 1690 + }, 1691 + "node_modules/mongodb-memory-server-core": { 1692 + "version": "11.2.0", 1693 + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.2.0.tgz", 1694 + "integrity": "sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==", 1695 + "dev": true, 1696 + "license": "MIT", 1697 + "dependencies": { 1698 + "async-mutex": "^0.5.0", 1699 + "camelcase": "^6.3.0", 1700 + "debug": "^4.4.3", 1701 + "find-cache-dir": "^3.3.2", 1702 + "follow-redirects": "^1.16.0", 1703 + "https-proxy-agent": "^7.0.6", 1704 + "mongodb": "^7.2.0", 1705 + "new-find-package-json": "^2.0.0", 1706 + "semver": "^7.7.3", 1707 + "tar-stream": "^3.1.8", 1708 + "tslib": "^2.8.1", 1709 + "yauzl": "^3.3.1" 1710 + }, 1711 + "engines": { 1712 + "node": ">=20.19.0" 1713 + } 1714 + }, 1412 1715 "node_modules/mongoose": { 1413 1716 "version": "9.7.4", 1414 1717 "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.7.4.tgz", ··· 1464 1767 "node": ">= 0.6" 1465 1768 } 1466 1769 }, 1770 + "node_modules/new-find-package-json": { 1771 + "version": "2.0.0", 1772 + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", 1773 + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", 1774 + "dev": true, 1775 + "license": "MIT", 1776 + "dependencies": { 1777 + "debug": "^4.3.4" 1778 + }, 1779 + "engines": { 1780 + "node": ">=12.22.0" 1781 + } 1782 + }, 1467 1783 "node_modules/object-assign": { 1468 1784 "version": "4.1.1", 1469 1785 "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", ··· 1515 1831 "wrappy": "1" 1516 1832 } 1517 1833 }, 1834 + "node_modules/p-limit": { 1835 + "version": "2.3.0", 1836 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", 1837 + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", 1838 + "dev": true, 1839 + "license": "MIT", 1840 + "dependencies": { 1841 + "p-try": "^2.0.0" 1842 + }, 1843 + "engines": { 1844 + "node": ">=6" 1845 + }, 1846 + "funding": { 1847 + "url": "https://github.com/sponsors/sindresorhus" 1848 + } 1849 + }, 1850 + "node_modules/p-locate": { 1851 + "version": "4.1.0", 1852 + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", 1853 + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", 1854 + "dev": true, 1855 + "license": "MIT", 1856 + "dependencies": { 1857 + "p-limit": "^2.2.0" 1858 + }, 1859 + "engines": { 1860 + "node": ">=8" 1861 + } 1862 + }, 1863 + "node_modules/p-try": { 1864 + "version": "2.2.0", 1865 + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", 1866 + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", 1867 + "dev": true, 1868 + "license": "MIT", 1869 + "engines": { 1870 + "node": ">=6" 1871 + } 1872 + }, 1518 1873 "node_modules/parseurl": { 1519 1874 "version": "1.3.3", 1520 1875 "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", ··· 1524 1879 "node": ">= 0.8" 1525 1880 } 1526 1881 }, 1882 + "node_modules/path-exists": { 1883 + "version": "4.0.0", 1884 + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 1885 + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 1886 + "dev": true, 1887 + "license": "MIT", 1888 + "engines": { 1889 + "node": ">=8" 1890 + } 1891 + }, 1527 1892 "node_modules/path-to-regexp": { 1528 1893 "version": "8.4.2", 1529 1894 "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", ··· 1533 1898 "type": "opencollective", 1534 1899 "url": "https://opencollective.com/express" 1535 1900 } 1901 + }, 1902 + "node_modules/pend": { 1903 + "version": "1.2.0", 1904 + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", 1905 + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", 1906 + "dev": true, 1907 + "license": "MIT" 1536 1908 }, 1537 1909 "node_modules/pino": { 1538 1910 "version": "10.3.1", ··· 1596 1968 "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", 1597 1969 "license": "MIT" 1598 1970 }, 1971 + "node_modules/pkg-dir": { 1972 + "version": "4.2.0", 1973 + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", 1974 + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", 1975 + "dev": true, 1976 + "license": "MIT", 1977 + "dependencies": { 1978 + "find-up": "^4.0.0" 1979 + }, 1980 + "engines": { 1981 + "node": ">=8" 1982 + } 1983 + }, 1599 1984 "node_modules/process-warning": { 1600 1985 "version": "5.0.0", 1601 1986 "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", ··· 1752 2137 ], 1753 2138 "license": "BSD-3-Clause" 1754 2139 }, 2140 + "node_modules/semver": { 2141 + "version": "7.8.5", 2142 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", 2143 + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", 2144 + "dev": true, 2145 + "license": "ISC", 2146 + "bin": { 2147 + "semver": "bin/semver.js" 2148 + }, 2149 + "engines": { 2150 + "node": ">=10" 2151 + } 2152 + }, 1755 2153 "node_modules/send": { 1756 2154 "version": "1.2.1", 1757 2155 "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", ··· 1917 2315 "node": ">= 0.8" 1918 2316 } 1919 2317 }, 2318 + "node_modules/streamx": { 2319 + "version": "2.28.0", 2320 + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", 2321 + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", 2322 + "dev": true, 2323 + "license": "MIT", 2324 + "dependencies": { 2325 + "events-universal": "^1.0.0", 2326 + "fast-fifo": "^1.3.2", 2327 + "text-decoder": "^1.1.0" 2328 + } 2329 + }, 1920 2330 "node_modules/strip-json-comments": { 1921 2331 "version": "5.0.3", 1922 2332 "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", ··· 1930 2340 "url": "https://github.com/sponsors/sindresorhus" 1931 2341 } 1932 2342 }, 2343 + "node_modules/tar-stream": { 2344 + "version": "3.2.0", 2345 + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", 2346 + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", 2347 + "dev": true, 2348 + "license": "MIT", 2349 + "dependencies": { 2350 + "b4a": "^1.6.4", 2351 + "bare-fs": "^4.5.5", 2352 + "fast-fifo": "^1.2.0", 2353 + "streamx": "^2.15.0" 2354 + } 2355 + }, 2356 + "node_modules/teex": { 2357 + "version": "1.0.1", 2358 + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", 2359 + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", 2360 + "dev": true, 2361 + "license": "MIT", 2362 + "dependencies": { 2363 + "streamx": "^2.12.5" 2364 + } 2365 + }, 2366 + "node_modules/text-decoder": { 2367 + "version": "1.2.7", 2368 + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", 2369 + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", 2370 + "dev": true, 2371 + "license": "Apache-2.0", 2372 + "dependencies": { 2373 + "b4a": "^1.6.4" 2374 + } 2375 + }, 1933 2376 "node_modules/thread-stream": { 1934 2377 "version": "4.2.0", 1935 2378 "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", ··· 1968 2411 "engines": { 1969 2412 "node": ">=18" 1970 2413 } 2414 + }, 2415 + "node_modules/tslib": { 2416 + "version": "2.8.1", 2417 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 2418 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 2419 + "dev": true, 2420 + "license": "0BSD" 1971 2421 }, 1972 2422 "node_modules/tsx": { 1973 2423 "version": "4.23.1", ··· 2105 2555 "utf-8-validate": { 2106 2556 "optional": true 2107 2557 } 2558 + } 2559 + }, 2560 + "node_modules/yauzl": { 2561 + "version": "3.4.0", 2562 + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", 2563 + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", 2564 + "dev": true, 2565 + "license": "MIT", 2566 + "dependencies": { 2567 + "pend": "~1.2.0" 2568 + }, 2569 + "engines": { 2570 + "node": ">=12" 2108 2571 } 2109 2572 } 2110 2573 }
+1
package.json
··· 33 33 "@types/express": "^5.0.2", 34 34 "@types/node": "^22.0.0", 35 35 "@types/ws": "^8.5.10", 36 + "mongodb-memory-server": "^11.2.0", 36 37 "pino-pretty": "^13.1.3", 37 38 "tsx": "^4.0.0", 38 39 "typescript": "^5.6.0"
+143
src/engine/audit.test.ts
··· 1 + import { test, before, after, beforeEach } from 'node:test'; 2 + import assert from 'node:assert/strict'; 3 + import mongoose, { Types } from 'mongoose'; 4 + import { MongoMemoryServer } from 'mongodb-memory-server'; 5 + import { logAction, queryAuditLogs, getAuditLogsForMatch } from './audit.js'; 6 + 7 + let mongod: MongoMemoryServer; 8 + 9 + before(async () => { 10 + mongod = await MongoMemoryServer.create(); 11 + await mongoose.connect(mongod.getUri()); 12 + }); 13 + 14 + after(async () => { 15 + await mongoose.disconnect(); 16 + await mongod.stop(); 17 + }); 18 + 19 + beforeEach(async () => { 20 + await mongoose.connection.db!.dropCollection('auditlogs').catch(() => {}); 21 + }); 22 + 23 + test('logAction creates an audit log entry', async () => { 24 + const doc = await logAction({ 25 + actorId: 'ref-123', 26 + actorType: 'discord', 27 + action: 'match.start', 28 + }); 29 + 30 + assert.ok(doc._id); 31 + assert.equal(doc.actorId, 'ref-123'); 32 + assert.equal(doc.actorType, 'discord'); 33 + assert.equal(doc.action, 'match.start'); 34 + assert.ok(doc.timestamp); 35 + }); 36 + 37 + test('logAction stores before/after snapshots', async () => { 38 + const doc = await logAction({ 39 + actorId: 'api-key-1', 40 + actorType: 'api-key', 41 + action: 'match.ban', 42 + before: { bansCompleted: 0 }, 43 + after: { bansCompleted: 1 }, 44 + }); 45 + 46 + assert.deepEqual(doc.before, { bansCompleted: 0 }); 47 + assert.deepEqual(doc.after, { bansCompleted: 1 }); 48 + }); 49 + 50 + test('logAction stores matchId as ObjectId', async () => { 51 + const matchId = new Types.ObjectId(); 52 + const doc = await logAction({ 53 + actorId: 'ref-123', 54 + actorType: 'discord', 55 + action: 'match.result', 56 + matchId, 57 + }); 58 + 59 + assert.ok(doc.matchId); 60 + assert.equal(doc.matchId.toString(), matchId.toString()); 61 + }); 62 + 63 + test('logAction accepts matchId as string', async () => { 64 + const matchId = new Types.ObjectId(); 65 + const doc = await logAction({ 66 + actorId: 'system', 67 + actorType: 'system', 68 + action: 'match.abandoned', 69 + matchId: matchId.toString(), 70 + }); 71 + 72 + assert.ok(doc.matchId); 73 + assert.equal(doc.matchId.toString(), matchId.toString()); 74 + }); 75 + 76 + test('queryAuditLogs returns all logs sorted by timestamp desc', async () => { 77 + await logAction({ actorId: 'a', actorType: 'discord', action: 'action1' }); 78 + await logAction({ actorId: 'b', actorType: 'discord', action: 'action2' }); 79 + await logAction({ actorId: 'c', actorType: 'discord', action: 'action3' }); 80 + 81 + const logs = await queryAuditLogs(); 82 + assert.equal(logs.length, 3); 83 + assert.equal(logs[0]!.action, 'action3'); 84 + assert.equal(logs[2]!.action, 'action1'); 85 + }); 86 + 87 + test('queryAuditLogs filters by matchId', async () => { 88 + const matchId1 = new Types.ObjectId(); 89 + const matchId2 = new Types.ObjectId(); 90 + 91 + await logAction({ actorId: 'a', actorType: 'discord', action: 'a1', matchId: matchId1 }); 92 + await logAction({ actorId: 'b', actorType: 'discord', action: 'a2', matchId: matchId1 }); 93 + await logAction({ actorId: 'c', actorType: 'discord', action: 'a3', matchId: matchId2 }); 94 + 95 + const logs = await queryAuditLogs({ matchId: matchId1 }); 96 + assert.equal(logs.length, 2); 97 + }); 98 + 99 + test('queryAuditLogs filters by actorId', async () => { 100 + await logAction({ actorId: 'ref-1', actorType: 'discord', action: 'a1' }); 101 + await logAction({ actorId: 'ref-1', actorType: 'discord', action: 'a2' }); 102 + await logAction({ actorId: 'ref-2', actorType: 'discord', action: 'a3' }); 103 + 104 + const logs = await queryAuditLogs({ actorId: 'ref-1' }); 105 + assert.equal(logs.length, 2); 106 + }); 107 + 108 + test('queryAuditLogs filters by action', async () => { 109 + await logAction({ actorId: 'a', actorType: 'discord', action: 'match.start' }); 110 + await logAction({ actorId: 'b', actorType: 'discord', action: 'match.ban' }); 111 + await logAction({ actorId: 'c', actorType: 'discord', action: 'match.start' }); 112 + 113 + const logs = await queryAuditLogs({ action: 'match.start' }); 114 + assert.equal(logs.length, 2); 115 + }); 116 + 117 + test('queryAuditLogs respects limit', async () => { 118 + for (let i = 0; i < 10; i++) { 119 + await logAction({ actorId: 'a', actorType: 'discord', action: `action${i}` }); 120 + } 121 + 122 + const logs = await queryAuditLogs({ limit: 3 }); 123 + assert.equal(logs.length, 3); 124 + }); 125 + 126 + test('queryAuditLogs respects offset', async () => { 127 + for (let i = 0; i < 5; i++) { 128 + await logAction({ actorId: 'a', actorType: 'discord', action: `action${i}` }); 129 + } 130 + 131 + const logs = await queryAuditLogs({ offset: 2, limit: 2 }); 132 + assert.equal(logs.length, 2); 133 + }); 134 + 135 + test('getAuditLogsForMatch returns logs for a specific match', async () => { 136 + const matchId = new Types.ObjectId(); 137 + await logAction({ actorId: 'a', actorType: 'discord', action: 'match.start', matchId }); 138 + await logAction({ actorId: 'b', actorType: 'discord', action: 'match.ban', matchId }); 139 + await logAction({ actorId: 'c', actorType: 'discord', action: 'other' }); 140 + 141 + const logs = await getAuditLogsForMatch(matchId); 142 + assert.equal(logs.length, 2); 143 + });
+67
src/engine/audit.ts
··· 1 + import { Types } from 'mongoose'; 2 + import { AuditLog, type AuditLogDoc } from '../models/index.js'; 3 + import { logger } from '../logger.js'; 4 + 5 + export type ActorType = 'api-key' | 'discord' | 'system'; 6 + 7 + export interface LogActionParams { 8 + actorId: string; 9 + actorType: ActorType; 10 + action: string; 11 + matchId?: Types.ObjectId | string | null; 12 + before?: Record<string, unknown>; 13 + after?: Record<string, unknown>; 14 + } 15 + 16 + export async function logAction(params: LogActionParams): Promise<AuditLogDoc> { 17 + const matchObjectId = params.matchId 18 + ? (params.matchId instanceof Types.ObjectId ? params.matchId : new Types.ObjectId(params.matchId)) 19 + : null; 20 + 21 + const doc = await AuditLog.create({ 22 + actorId: params.actorId, 23 + actorType: params.actorType, 24 + action: params.action, 25 + matchId: matchObjectId, 26 + before: params.before ?? undefined, 27 + after: params.after ?? undefined, 28 + timestamp: new Date(), 29 + }); 30 + 31 + logger.debug({ action: params.action, actorId: params.actorId, actorType: params.actorType, matchId: matchObjectId }, 'audit log created'); 32 + return doc; 33 + } 34 + 35 + export interface QueryAuditParams { 36 + matchId?: Types.ObjectId | string | null; 37 + actorId?: string; 38 + action?: string; 39 + limit?: number; 40 + offset?: number; 41 + } 42 + 43 + export async function queryAuditLogs(params: QueryAuditParams = {}): Promise<AuditLogDoc[]> { 44 + const filter: Record<string, unknown> = {}; 45 + 46 + if (params.matchId) { 47 + filter.matchId = params.matchId instanceof Types.ObjectId ? params.matchId : new Types.ObjectId(params.matchId); 48 + } 49 + if (params.actorId) { 50 + filter.actorId = params.actorId; 51 + } 52 + if (params.action) { 53 + filter.action = params.action; 54 + } 55 + 56 + const limit = Math.min(Math.max(params.limit ?? 50, 1), 200); 57 + const offset = Math.max(params.offset ?? 0, 0); 58 + 59 + return AuditLog.find(filter) 60 + .sort({ timestamp: -1 }) 61 + .skip(offset) 62 + .limit(limit); 63 + } 64 + 65 + export async function getAuditLogsForMatch(matchId: Types.ObjectId | string): Promise<AuditLogDoc[]> { 66 + return queryAuditLogs({ matchId, limit: 100 }); 67 + }
+106
src/engine/event.test.ts
··· 1 + import { test, before, after } from 'node:test'; 2 + import assert from 'node:assert/strict'; 3 + import mongoose from 'mongoose'; 4 + import { MongoMemoryServer } from 'mongodb-memory-server'; 5 + import { 6 + loadActiveEvent, 7 + getActiveEvent, 8 + createEvent, 9 + switchEvent, 10 + listEvents, 11 + deleteEvent, 12 + } from './event.js'; 13 + 14 + let mongod: MongoMemoryServer; 15 + 16 + before(async () => { 17 + mongod = await MongoMemoryServer.create(); 18 + await mongoose.connect(mongod.getUri()); 19 + }); 20 + 21 + after(async () => { 22 + await mongoose.disconnect(); 23 + await mongod.stop(); 24 + }); 25 + 26 + // clear all events between tests 27 + async function clearEvents() { 28 + await mongoose.connection.db!.dropCollection('events').catch(() => {}); 29 + } 30 + 31 + test('getActiveEvent returns null before any event is created', async () => { 32 + await clearEvents(); 33 + // loadActiveEvent queries DB and sets singleton 34 + const loaded = await loadActiveEvent(); 35 + assert.equal(loaded, null); 36 + assert.equal(getActiveEvent(), null); 37 + }); 38 + 39 + test('createEvent creates an event and sets it active', async () => { 40 + await clearEvents(); 41 + const event = await createEvent('Test Cup', 'test-cup'); 42 + assert.equal(event.name, 'Test Cup'); 43 + assert.equal(event.active, true); 44 + assert.equal(event.tournamentSlug, 'test-cup'); 45 + assert.equal(getActiveEvent()?.name, 'Test Cup'); 46 + }); 47 + 48 + test('createEvent deactivates previous events', async () => { 49 + await clearEvents(); 50 + await createEvent('First'); 51 + const second = await createEvent('Second'); 52 + assert.equal(second.active, true); 53 + const first = await mongoose.connection.db!.collection('events').findOne({ name: 'First' }); 54 + assert.equal(first?.active, false); 55 + }); 56 + 57 + test('switchEvent activates a different event', async () => { 58 + await clearEvents(); 59 + await createEvent('Cup A'); 60 + await createEvent('Cup B'); 61 + const switched = await switchEvent('Cup A'); 62 + assert.equal(switched?.active, true); 63 + assert.equal(switched?.name, 'Cup A'); 64 + assert.equal(getActiveEvent()?.name, 'Cup A'); 65 + }); 66 + 67 + test('switchEvent returns null for nonexistent event', async () => { 68 + await clearEvents(); 69 + const result = await switchEvent('Nonexistent'); 70 + assert.equal(result, null); 71 + }); 72 + 73 + test('listEvents returns events sorted by createdAt desc', async () => { 74 + await clearEvents(); 75 + await createEvent('Alpha'); 76 + await createEvent('Beta'); 77 + const events = await listEvents(); 78 + assert.equal(events.length, 2); 79 + assert.equal(events[0]!.name, 'Beta'); 80 + assert.equal(events[1]!.name, 'Alpha'); 81 + }); 82 + 83 + test('deleteEvent removes an event and clears singleton if active', async () => { 84 + await clearEvents(); 85 + const event = await createEvent('ToDelete'); 86 + assert.equal(getActiveEvent()?.name, 'ToDelete'); 87 + const deleted = await deleteEvent('ToDelete'); 88 + assert.equal(deleted?.name, 'ToDelete'); 89 + assert.equal(getActiveEvent(), null); 90 + }); 91 + 92 + test('deleteEvent returns null for nonexistent event', async () => { 93 + await clearEvents(); 94 + const result = await deleteEvent('Nonexistent'); 95 + assert.equal(result, null); 96 + }); 97 + 98 + test('loadActiveEvent hydrates singleton from DB', async () => { 99 + await clearEvents(); 100 + await createEvent('Persistent'); 101 + // simulate fresh start by clearing singleton — we can't access it directly, 102 + // but loadActiveEvent should re-hydrate from DB 103 + const loaded = await loadActiveEvent(); 104 + assert.equal(loaded?.name, 'Persistent'); 105 + assert.equal(getActiveEvent()?.name, 'Persistent'); 106 + });
+47
src/engine/event.ts
··· 1 + import { Event, type EventDoc } from '../models/index.js'; 2 + 3 + let activeEvent: EventDoc | null = null; 4 + 5 + export function _resetCacheForTesting() { 6 + activeEvent = null; 7 + } 8 + 9 + export async function loadActiveEvent(): Promise<EventDoc | null> { 10 + activeEvent = await Event.findOne({ active: true }); 11 + return activeEvent; 12 + } 13 + 14 + export function getActiveEvent(): EventDoc | null { 15 + return activeEvent; 16 + } 17 + 18 + export async function createEvent(name: string, tournamentSlug?: string | null): Promise<EventDoc> { 19 + await Event.updateMany({}, { active: false }); 20 + const event = await Event.create({ name, active: true, tournamentSlug: tournamentSlug ?? null }); 21 + activeEvent = event; 22 + return event; 23 + } 24 + 25 + export async function switchEvent(name: string): Promise<EventDoc | null> { 26 + const event = await Event.findOne({ name }); 27 + if (!event) return null; 28 + await Event.updateMany({}, { active: false }); 29 + event.active = true; 30 + await event.save(); 31 + activeEvent = event; 32 + return event; 33 + } 34 + 35 + export async function listEvents(): Promise<EventDoc[]> { 36 + return Event.find().sort({ createdAt: -1 }); 37 + } 38 + 39 + export async function deleteEvent(name: string): Promise<EventDoc | null> { 40 + const event = await Event.findOne({ name }); 41 + if (!event) return null; 42 + await event.deleteOne(); 43 + if (activeEvent?._id && event._id && activeEvent._id.equals(event._id)) { 44 + activeEvent = null; 45 + } 46 + return event; 47 + }
+473
src/engine/match.test.ts
··· 1 + import { test, before, after, beforeEach } from 'node:test'; 2 + import assert from 'node:assert/strict'; 3 + import mongoose from 'mongoose'; 4 + import { MongoMemoryServer } from 'mongodb-memory-server'; 5 + import { 6 + initMatchState, 7 + getMatchState, 8 + getAllMatchStates, 9 + resetMatchState, 10 + banChart, 11 + pickChart, 12 + submitResult, 13 + setPlayerReady, 14 + completeMatch, 15 + abandonMatch, 16 + restoreMatchState, 17 + loadAllInProgressMatches, 18 + _resetMatchStatesForTesting, 19 + type PlayerData, 20 + type MappoolInput, 21 + } from './match.js'; 22 + import { createEvent, loadActiveEvent, _resetCacheForTesting as resetEventCache } from './event.js'; 23 + 24 + let mongod: MongoMemoryServer; 25 + 26 + before(async () => { 27 + mongod = await MongoMemoryServer.create(); 28 + await mongoose.connect(mongod.getUri()); 29 + }); 30 + 31 + after(async () => { 32 + await mongoose.disconnect(); 33 + await mongod.stop(); 34 + }); 35 + 36 + beforeEach(async () => { 37 + _resetMatchStatesForTesting(); 38 + resetEventCache(); 39 + await mongoose.connection.db!.dropCollection('matches').catch(() => {}); 40 + await mongoose.connection.db!.dropCollection('events').catch(() => {}); 41 + await mongoose.connection.db!.dropCollection('generatedpools').catch(() => {}); 42 + }); 43 + 44 + function makePlayer(slot: number, name: string): PlayerData { 45 + return { 46 + slot, 47 + discordId: `discord_${name}`, 48 + displayName: name, 49 + discordUsername: name.toLowerCase(), 50 + discordDisplayName: name, 51 + }; 52 + } 53 + 54 + function makePool(count: number): MappoolInput[] { 55 + return Array.from({ length: count }, (_, i) => ({ 56 + songId: 1000 + i, 57 + displayName: `Chart ${i + 1}`, 58 + })); 59 + } 60 + 61 + async function createTestMatch(poolSize = 5) { 62 + const event = await createEvent('Test'); 63 + await loadActiveEvent(); 64 + return initMatchState({ 65 + players: [makePlayer(1, 'Alice'), makePlayer(2, 'Bob')], 66 + firstBannerDiscordId: 'discord_Alice', 67 + bestOf: 3, 68 + mapPool: makePool(poolSize), 69 + round: 'Winners Round 1', 70 + matchNumber: 1, 71 + }); 72 + } 73 + 74 + test('initMatchState creates state and persists to DB', async () => { 75 + const state = await createTestMatch(); 76 + assert.equal(state.progressLevel, 'ban-phase'); 77 + assert.equal(state.players.length, 2); 78 + assert.equal(state.players[0]!.displayName, 'Alice'); 79 + assert.equal(state.players[1]!.displayName, 'Bob'); 80 + assert.equal(state.mappool.length, 5); 81 + assert.equal(state.status, 'in_progress'); 82 + assert.equal(state.meta.winsNeeded, 2); 83 + assert.equal(state.banPhase.totalBans, 4); 84 + 85 + const cached = getMatchState(state.matchId!); 86 + assert.ok(cached); 87 + assert.equal(cached.progressLevel, 'ban-phase'); 88 + 89 + const doc = await mongoose.connection.db!.collection('matches').findOne({ matchId: state.matchId }); 90 + assert.ok(doc); 91 + }); 92 + 93 + test('initMatchState throws when no active event', async () => { 94 + await assert.rejects( 95 + () => initMatchState({ 96 + players: [makePlayer(1, 'Alice'), makePlayer(2, 'Bob')], 97 + firstBannerDiscordId: 'discord_Alice', 98 + bestOf: 3, 99 + mapPool: makePool(5), 100 + round: 'Winners Round 1', 101 + matchNumber: 1, 102 + }), 103 + /No active event/, 104 + ); 105 + }); 106 + 107 + test('initMatchState accepts custom matchId', async () => { 108 + const event = await createEvent('Test'); 109 + await loadActiveEvent(); 110 + const state = await initMatchState({ 111 + players: [makePlayer(1, 'Alice'), makePlayer(2, 'Bob')], 112 + firstBannerDiscordId: 'discord_Alice', 113 + bestOf: 3, 114 + mapPool: makePool(3), 115 + round: 'Winners Round 1', 116 + matchNumber: 1, 117 + matchId: 'custom-id-123', 118 + }); 119 + assert.equal(state.matchId, 'custom-id-123'); 120 + }); 121 + 122 + test('initMatchState sets ban order alternating from firstBanner', async () => { 123 + const state = await createTestMatch(5); 124 + // Alice is firstBanner, Bob is second 125 + // ban order: Alice, Bob, Alice, Bob (alternating) 126 + assert.equal(state.banPhase.banOrder.length, 4); 127 + assert.equal(state.banPhase.banOrder[0], 'discord_Alice'); 128 + assert.equal(state.banPhase.banOrder[1], 'discord_Bob'); 129 + assert.equal(state.banPhase.banOrder[2], 'discord_Alice'); 130 + assert.equal(state.banPhase.banOrder[3], 'discord_Bob'); 131 + assert.equal(state.banPhase.currentBannerDiscordId, 'discord_Alice'); 132 + }); 133 + 134 + test('getAllMatchStates returns all active matches', async () => { 135 + await createTestMatch(); 136 + await createTestMatch(); 137 + const all = getAllMatchStates(); 138 + assert.equal(all.length, 2); 139 + }); 140 + 141 + test('resetMatchState removes from cache', async () => { 142 + const state = await createTestMatch(); 143 + assert.ok(getMatchState(state.matchId!)); 144 + resetMatchState(state.matchId!); 145 + assert.equal(getMatchState(state.matchId!), null); 146 + }); 147 + 148 + test('banChart bans a chart and advances ban phase', async () => { 149 + const state = await createTestMatch(); 150 + const songId = state.mappool[0]!.songId!; 151 + await banChart(state.matchId!, songId, 'discord_Alice'); 152 + 153 + const updated = getMatchState(state.matchId!); 154 + const entry = updated!.mappool.find(c => c.songId === songId)!; 155 + assert.equal(entry.status.banned, true); 156 + assert.equal(entry.status.bannedByDiscordId, 'discord_Alice'); 157 + assert.equal(entry.status.bannedAt !== null, true); 158 + assert.equal(entry.status.inCurrentPool, false); 159 + assert.equal(updated!.banPhase.bansCompleted, 1); 160 + assert.equal(updated!.banPhase.currentBannerDiscordId, 'discord_Bob'); 161 + }); 162 + 163 + test('banChart pushes feed event', async () => { 164 + const state = await createTestMatch(); 165 + await banChart(state.matchId!, state.mappool[0]!.songId!, 'discord_Alice'); 166 + const updated = getMatchState(state.matchId!); 167 + const banEvents = updated!.feed.filter(f => f.type === 'ban'); 168 + assert.equal(banEvents.length, 1); 169 + assert.ok(banEvents[0]!.message!.includes('Alice')); 170 + assert.ok(banEvents[0]!.message!.includes('Chart 1')); 171 + }); 172 + 173 + test('banChart no-ops for nonexistent match', async () => { 174 + await banChart('nonexistent', 1234, 'discord_Alice'); 175 + // should not throw 176 + }); 177 + 178 + test('banChart no-ops for nonexistent chart', async () => { 179 + const state = await createTestMatch(); 180 + await banChart(state.matchId!, 9999, 'discord_Alice'); 181 + const updated = getMatchState(state.matchId!); 182 + assert.equal(updated!.banPhase.bansCompleted, 0); 183 + }); 184 + 185 + test('pickChart sets currentChart and playing state', async () => { 186 + const state = await createTestMatch(); 187 + const songId = state.mappool[2]!.songId!; 188 + await pickChart(state.matchId!, songId, 'discord_Alice'); 189 + 190 + const updated = getMatchState(state.matchId!); 191 + assert.equal(updated!.currentChart, songId); 192 + assert.equal(updated!.progressLevel, 'playing'); 193 + assert.equal(updated!.mappool[2]!.status.isBeingPlayed, true); 194 + // other charts should not be isBeingPlayed 195 + updated!.mappool.forEach((c, i) => { 196 + if (i !== 2) assert.equal(c.status.isBeingPlayed, false); 197 + }); 198 + // all players ready should be reset 199 + updated!.players.forEach(p => assert.equal(p.ready, false)); 200 + }); 201 + 202 + test('pickChart pushes feed event', async () => { 203 + const state = await createTestMatch(); 204 + await pickChart(state.matchId!, state.mappool[1]!.songId!, 'discord_Bob'); 205 + const updated = getMatchState(state.matchId!); 206 + const pickEvents = updated!.feed.filter(f => f.type === 'pick'); 207 + assert.equal(pickEvents.length, 1); 208 + assert.ok(pickEvents[0]!.message!.includes('Bob')); 209 + }); 210 + 211 + test('submitResult awards point to winner', async () => { 212 + const state = await createTestMatch(3); 213 + // pick a chart first 214 + await pickChart(state.matchId!, state.mappool[0]!.songId!, 'discord_Alice'); 215 + 216 + const outcome = await submitResult(state.matchId!, { 217 + songId: state.mappool[0]!.songId!, 218 + score1: 950000, 219 + score2: 900000, 220 + fc1: false, 221 + fc2: false, 222 + pfc1: false, 223 + pfc2: false, 224 + }); 225 + 226 + assert.ok(outcome); 227 + // winsNeeded is 2, so no winner after just 1 point 228 + assert.equal(outcome.winner, null); 229 + assert.equal(outcome.isTie, false); 230 + 231 + const updated = getMatchState(state.matchId!); 232 + assert.equal(updated!.players[0]!.points, 1); 233 + assert.equal(updated!.players[1]!.points, 0); 234 + assert.equal(updated!.progressLevel, 'picking-post-result'); 235 + assert.equal(updated!.currentPickerDiscordId, 'discord_Bob'); // loser picks 236 + }); 237 + 238 + test('submitResult with tie awards no points', async () => { 239 + const state = await createTestMatch(3); 240 + await pickChart(state.matchId!, state.mappool[0]!.songId!, 'discord_Alice'); 241 + 242 + const outcome = await submitResult(state.matchId!, { 243 + songId: state.mappool[0]!.songId!, 244 + score1: 950000, 245 + score2: 950000, 246 + fc1: false, 247 + fc2: false, 248 + pfc1: false, 249 + pfc2: false, 250 + }); 251 + 252 + assert.ok(outcome); 253 + assert.equal(outcome.isTie, true); 254 + assert.equal(outcome.winner, null); 255 + 256 + const updated = getMatchState(state.matchId!); 257 + assert.equal(updated!.players[0]!.points, 0); 258 + assert.equal(updated!.players[1]!.points, 0); 259 + }); 260 + 261 + test('submitResult with FC beats non-FC', async () => { 262 + const state = await createTestMatch(3); 263 + await pickChart(state.matchId!, state.mappool[0]!.songId!, 'discord_Alice'); 264 + 265 + const outcome = await submitResult(state.matchId!, { 266 + songId: state.mappool[0]!.songId!, 267 + score1: 900000, 268 + score2: 950000, 269 + fc1: true, 270 + fc2: false, 271 + pfc1: false, 272 + pfc2: false, 273 + }); 274 + 275 + assert.ok(outcome); 276 + assert.equal(outcome.winner, null); // winsNeeded=2 not reached 277 + const updated = getMatchState(state.matchId!); 278 + assert.equal(updated!.players[0]!.points, 1); 279 + }); 280 + 281 + test('submitResult with PFC beats FC', async () => { 282 + const state = await createTestMatch(3); 283 + await pickChart(state.matchId!, state.mappool[0]!.songId!, 'discord_Alice'); 284 + 285 + const outcome = await submitResult(state.matchId!, { 286 + songId: state.mappool[0]!.songId!, 287 + score1: 900000, 288 + score2: 950000, 289 + fc1: false, 290 + fc2: true, 291 + pfc1: true, 292 + pfc2: false, 293 + }); 294 + 295 + assert.ok(outcome); 296 + assert.equal(outcome.winner, null); // winsNeeded=2 not reached 297 + const updated = getMatchState(state.matchId!); 298 + assert.equal(updated!.players[0]!.points, 1); 299 + }); 300 + 301 + test('submitResult completes match when winsNeeded reached', async () => { 302 + const event = await createEvent('Test'); 303 + await loadActiveEvent(); 304 + const state = await initMatchState({ 305 + players: [makePlayer(1, 'Alice'), makePlayer(2, 'Bob')], 306 + firstBannerDiscordId: 'discord_Alice', 307 + bestOf: 3, 308 + mapPool: makePool(5), 309 + round: 'Winners Round 1', 310 + matchNumber: 1, 311 + }); 312 + 313 + // Alice wins first chart 314 + await pickChart(state.matchId!, state.mappool[0]!.songId!, 'discord_Alice'); 315 + await submitResult(state.matchId!, { 316 + songId: state.mappool[0]!.songId!, 317 + score1: 950000, 318 + score2: 900000, 319 + fc1: false, fc2: false, pfc1: false, pfc2: false, 320 + }); 321 + // Alice wins second chart 322 + await pickChart(state.matchId!, state.mappool[1]!.songId!, 'discord_Bob'); 323 + const outcome = await submitResult(state.matchId!, { 324 + songId: state.mappool[1]!.songId!, 325 + score1: 950000, 326 + score2: 900000, 327 + fc1: false, fc2: false, pfc1: false, pfc2: false, 328 + }); 329 + 330 + assert.ok(outcome); 331 + assert.equal(outcome.winner!.discordId, 'discord_Alice'); 332 + 333 + const updated = getMatchState(state.matchId!); 334 + assert.equal(updated!.progressLevel, 'finished'); 335 + assert.equal(updated!.status, 'completed'); 336 + assert.ok(updated!.meta.completedAt); 337 + assert.equal(updated!.players[0]!.winner, true); 338 + 339 + const endEvents = updated!.feed.filter(f => f.type === 'match.end'); 340 + assert.equal(endEvents.length, 1); 341 + }); 342 + 343 + test('submitResult guards against double-submit', async () => { 344 + const state = await createTestMatch(3); 345 + await pickChart(state.matchId!, state.mappool[0]!.songId!, 'discord_Alice'); 346 + await submitResult(state.matchId!, { 347 + songId: state.mappool[0]!.songId!, 348 + score1: 950000, score2: 900000, 349 + fc1: false, fc2: false, pfc1: false, pfc2: false, 350 + }); 351 + 352 + const secondResult = await submitResult(state.matchId!, { 353 + songId: state.mappool[0]!.songId!, 354 + score1: 990000, score2: 100000, 355 + fc1: false, fc2: false, pfc1: false, pfc2: false, 356 + }); 357 + assert.equal(secondResult, null); 358 + }); 359 + 360 + test('setPlayerReady marks player ready', async () => { 361 + const state = await createTestMatch(); 362 + const allReady = await setPlayerReady(state.matchId!, 'discord_Alice'); 363 + assert.equal(allReady, false); 364 + 365 + const updated = getMatchState(state.matchId!); 366 + assert.equal(updated!.players[0]!.ready, true); 367 + assert.equal(updated!.players[1]!.ready, false); 368 + }); 369 + 370 + test('setPlayerReady returns true when all ready', async () => { 371 + const state = await createTestMatch(); 372 + await setPlayerReady(state.matchId!, 'discord_Alice'); 373 + const allReady = await setPlayerReady(state.matchId!, 'discord_Bob'); 374 + assert.equal(allReady, true); 375 + }); 376 + 377 + test('setPlayerReady pushes feed event', async () => { 378 + const state = await createTestMatch(); 379 + await setPlayerReady(state.matchId!, 'discord_Alice'); 380 + const updated = getMatchState(state.matchId!); 381 + const readyEvents = updated!.feed.filter(f => f.type === 'ready'); 382 + assert.equal(readyEvents.length, 1); 383 + assert.ok(readyEvents[0]!.message!.includes('Alice')); 384 + }); 385 + 386 + test('setPlayerReady returns false for unknown player', async () => { 387 + const state = await createTestMatch(); 388 + const result = await setPlayerReady(state.matchId!, 'discord_Unknown'); 389 + assert.equal(result, false); 390 + }); 391 + 392 + test('setPlayerReady returns false for nonexistent match', async () => { 393 + const result = await setPlayerReady('nonexistent', 'discord_Alice'); 394 + assert.equal(result, false); 395 + }); 396 + 397 + test('completeMatch persists final state', async () => { 398 + const state = await createTestMatch(); 399 + await completeMatch(state.matchId!); 400 + const doc = await mongoose.connection.db!.collection('matches').findOne({ matchId: state.matchId }); 401 + assert.ok(doc); 402 + }); 403 + 404 + test('abandonMatch sets abandoned status', async () => { 405 + const state = await createTestMatch(); 406 + await abandonMatch(state.matchId!); 407 + const updated = getMatchState(state.matchId!); 408 + assert.equal(updated!.status, 'abandoned'); 409 + assert.ok(updated!.meta.completedAt); 410 + 411 + const abandonEvents = updated!.feed.filter(f => f.type === 'match.abandoned'); 412 + assert.equal(abandonEvents.length, 1); 413 + 414 + const doc = await mongoose.connection.db!.collection('matches').findOne({ matchId: state.matchId }); 415 + assert.equal(doc?.status, 'abandoned'); 416 + }); 417 + 418 + test('restoreMatchState hydrates from DB doc', async () => { 419 + const state = await createTestMatch(); 420 + const doc = await mongoose.connection.db!.collection('matches').findOne({ matchId: state.matchId }); 421 + assert.ok(doc); 422 + 423 + resetMatchState(state.matchId!); 424 + assert.equal(getMatchState(state.matchId!), null); 425 + 426 + const restored = await restoreMatchState(state.matchId!, doc as any); 427 + assert.equal(restored.matchId, state.matchId); 428 + assert.equal(restored.players[0]!.displayName, 'Alice'); 429 + assert.equal(restored.players[1]!.displayName, 'Bob'); 430 + assert.equal(restored.mappool.length, 5); 431 + assert.ok(getMatchState(state.matchId!)); 432 + }); 433 + 434 + test('loadAllInProgressMatches returns matches for active event', async () => { 435 + const event = await createEvent('Test'); 436 + await loadActiveEvent(); 437 + await initMatchState({ 438 + players: [makePlayer(1, 'Alice'), makePlayer(2, 'Bob')], 439 + firstBannerDiscordId: 'discord_Alice', 440 + bestOf: 3, 441 + mapPool: makePool(5), 442 + round: 'Winners Round 1', 443 + matchNumber: 1, 444 + }); 445 + await initMatchState({ 446 + players: [makePlayer(1, 'Charlie'), makePlayer(2, 'Dave')], 447 + firstBannerDiscordId: 'discord_Charlie', 448 + bestOf: 3, 449 + mapPool: makePool(5), 450 + round: 'Winners Round 1', 451 + matchNumber: 2, 452 + }); 453 + const matches = await loadAllInProgressMatches(); 454 + assert.equal(matches.length, 2); 455 + }); 456 + 457 + test('loadAllInProgressMatches returns empty when no event', async () => { 458 + const matches = await loadAllInProgressMatches(); 459 + assert.equal(matches.length, 0); 460 + }); 461 + 462 + test('feed events accumulate in order', async () => { 463 + const state = await createTestMatch(3); 464 + await banChart(state.matchId!, state.mappool[0]!.songId!, 'discord_Alice'); 465 + await banChart(state.matchId!, state.mappool[1]!.songId!, 'discord_Bob'); 466 + await pickChart(state.matchId!, state.mappool[2]!.songId!, 'discord_Alice'); 467 + 468 + const updated = getMatchState(state.matchId!); 469 + assert.equal(updated!.feed[0]!.type, 'match.start'); 470 + assert.equal(updated!.feed[1]!.type, 'ban'); 471 + assert.equal(updated!.feed[2]!.type, 'ban'); 472 + assert.equal(updated!.feed[3]!.type, 'pick'); 473 + });
+651
src/engine/match.ts
··· 1 + import crypto from 'node:crypto'; 2 + import { Match, type MatchDoc, type FeedEventDoc } from '../models/index.js'; 3 + import { getActiveEvent } from './event.js'; 4 + import { getPoolByTier } from './pools.js'; 5 + import { getBestName, type PlayerName } from '../util/names.js'; 6 + import { logger } from '../logger.js'; 7 + 8 + export interface PlayerData extends PlayerName { 9 + slot: number; 10 + discordId: string; 11 + discordUsername?: string | null; 12 + discordDisplayName?: string | null; 13 + avatarUrl?: string | null; 14 + spinshareId?: number | null; 15 + spinshareUsername?: string | null; 16 + spinshareAvatarUrl?: string | null; 17 + pronouns?: string | null; 18 + } 19 + 20 + export interface MappoolInput { 21 + songId?: number | null; 22 + title?: string | null; 23 + artist?: string | null; 24 + charter?: string | null; 25 + cover?: string | null; 26 + thumbnailUrl?: string | null; 27 + difficulty?: number | null; 28 + tags?: string[]; 29 + displayName?: string | null; 30 + } 31 + 32 + export interface InitMatchParams { 33 + players: [PlayerData, PlayerData]; 34 + firstBannerDiscordId: string; 35 + bestOf: number; 36 + mapPool: MappoolInput[]; 37 + round: string; 38 + matchNumber: number; 39 + tier?: number | null; 40 + refereeDiscordId?: string | null; 41 + startggSetId?: string | null; 42 + type?: string; 43 + matchId?: string; 44 + channelId?: string | null; 45 + eventId?: string | null; 46 + } 47 + 48 + export interface MatchState { 49 + _id?: import('mongoose').Types.ObjectId; 50 + matchId: string | null; 51 + progressLevel: string; 52 + meta: { 53 + name: string | null; 54 + round: string | null; 55 + matchNumber: number | null; 56 + bestOf: number; 57 + winsNeeded: number; 58 + tier: number | null; 59 + channelId: string | null; 60 + eventId: import('mongoose').Types.ObjectId; 61 + startedAt: string; 62 + completedAt: string | null; 63 + startggSetId: string | null; 64 + type: string; 65 + }; 66 + players: MatchPlayerState[]; 67 + mappool: MappoolEntryState[]; 68 + banPhase: { 69 + banOrder: string[]; 70 + currentBannerDiscordId: string | null; 71 + bansCompleted: number; 72 + totalBans: number; 73 + }; 74 + currentPickerDiscordId: string | null; 75 + currentChart: number | null; 76 + refereeDiscordId: string | null; 77 + feed: FeedEventState[]; 78 + status: string; 79 + } 80 + 81 + export interface MatchPlayerState { 82 + slot: number; 83 + displayName: string | null; 84 + discordId: string; 85 + discordUsername: string | null; 86 + discordDisplayName: string | null; 87 + serverNickname: string | null; 88 + avatarUrl: string | null; 89 + points: number; 90 + ready: boolean; 91 + winner: boolean; 92 + spinshareId: number | null; 93 + spinshareUsername: string | null; 94 + spinshareAvatarUrl: string | null; 95 + pronouns: string | null; 96 + } 97 + 98 + export interface MappoolEntryState { 99 + songId: number | null; 100 + title: string | null; 101 + artist: string | null; 102 + charter: string | null; 103 + cover: string | null; 104 + thumbnailUrl: string | null; 105 + difficulty: number | null; 106 + tags: string[]; 107 + displayName: string | null; 108 + status: ChartStatusState; 109 + result: ChartResultState | null; 110 + isTiebreaker: boolean; 111 + } 112 + 113 + export interface ChartStatusState { 114 + banned: boolean; 115 + bannedByDiscordId: string | null; 116 + bannedAt: string | null; 117 + played: boolean; 118 + playedAt: string | null; 119 + inCurrentPool: boolean; 120 + isBeingPlayed: boolean; 121 + } 122 + 123 + export interface ChartResultState { 124 + score1: number; 125 + score2: number; 126 + fc1: boolean; 127 + fc2: boolean; 128 + pfc1: boolean; 129 + pfc2: boolean; 130 + winnerDiscordId: string | null; 131 + winnerSlot: number | null; 132 + } 133 + 134 + export interface FeedEventState { 135 + type: string; 136 + timestamp: string; 137 + message: string | null; 138 + data: Record<string, unknown>; 139 + } 140 + 141 + export interface SubmitResultParams { 142 + songId: number; 143 + score1: number; 144 + score2: number; 145 + fc1: boolean; 146 + fc2: boolean; 147 + pfc1: boolean; 148 + pfc2: boolean; 149 + } 150 + 151 + export interface SubmitOutcome { 152 + winner: MatchPlayerState | null; 153 + tiebreakerChart: MappoolEntryState | null; 154 + isTie: boolean; 155 + } 156 + 157 + type BroadcastFn = (event: string, data: unknown) => void; 158 + 159 + let matchStates = new Map<string, MatchState>(); 160 + let broadcastFn: BroadcastFn | null = null; 161 + 162 + export function setBroadcast(fn: BroadcastFn) { 163 + broadcastFn = fn; 164 + } 165 + 166 + export function getMatchState(matchId: string): MatchState | null { 167 + return matchStates.get(matchId) ?? null; 168 + } 169 + 170 + export function getAllMatchStates(): MatchState[] { 171 + return Array.from(matchStates.values()); 172 + } 173 + 174 + export function resetMatchState(matchId: string) { 175 + matchStates.delete(matchId); 176 + } 177 + 178 + export function pushFeedEvent(matchId: string, type: string, message: string | null, data: Record<string, unknown> = {}) { 179 + const state = matchStates.get(matchId); 180 + if (!state) return; 181 + state.feed.push({ 182 + type, 183 + timestamp: new Date().toISOString(), 184 + message: message ?? null, 185 + data, 186 + }); 187 + } 188 + 189 + function generateMatchId(): string { 190 + return crypto.randomBytes(3).toString('hex'); 191 + } 192 + 193 + function buildPlayerEntry(data: PlayerData): MatchPlayerState { 194 + return { 195 + slot: data.slot, 196 + displayName: data.displayName ?? null, 197 + discordId: data.discordId, 198 + discordUsername: data.discordUsername ?? null, 199 + discordDisplayName: data.discordDisplayName ?? null, 200 + serverNickname: data.serverNickname ?? null, 201 + avatarUrl: data.avatarUrl ?? null, 202 + points: 0, 203 + ready: false, 204 + winner: false, 205 + spinshareId: data.spinshareId ?? null, 206 + spinshareUsername: data.spinshareUsername ?? null, 207 + spinshareAvatarUrl: data.spinshareAvatarUrl ?? null, 208 + pronouns: data.pronouns ?? null, 209 + }; 210 + } 211 + 212 + function buildMappoolEntry(entry: MappoolInput): MappoolEntryState { 213 + return { 214 + songId: entry.songId ?? null, 215 + title: entry.title ?? null, 216 + artist: entry.artist ?? null, 217 + charter: entry.charter ?? null, 218 + cover: entry.cover ?? null, 219 + thumbnailUrl: entry.thumbnailUrl ?? null, 220 + difficulty: entry.difficulty ?? null, 221 + tags: entry.tags ?? [], 222 + displayName: entry.displayName ?? (entry.songId != null ? String(entry.songId) : '?'), 223 + status: { 224 + banned: false, 225 + bannedByDiscordId: null, 226 + bannedAt: null, 227 + played: false, 228 + playedAt: null, 229 + inCurrentPool: true, 230 + isBeingPlayed: false, 231 + }, 232 + result: null, 233 + isTiebreaker: false, 234 + }; 235 + } 236 + 237 + export async function initMatchState(params: InitMatchParams): Promise<MatchState> { 238 + const event = getActiveEvent(); 239 + const resolvedEventId = params.eventId 240 + ? new (await import('mongoose')).default.Types.ObjectId(params.eventId) 241 + : event?._id; 242 + if (!resolvedEventId) throw new Error('No active event!'); 243 + 244 + const winsNeeded = Math.ceil(params.bestOf / 2); 245 + const numBans = params.mapPool.length - 1; 246 + 247 + const p1 = params.players[0]!; 248 + const p2 = params.players[1]!; 249 + const secondBanner = params.firstBannerDiscordId === p1.discordId ? p2 : p1; 250 + 251 + const banOrder = Array.from({ length: numBans }, (_, i) => { 252 + return (i % 2 === 0 ? params.firstBannerDiscordId : secondBanner.discordId); 253 + }); 254 + 255 + const builtMappool = params.mapPool.map(entry => buildMappoolEntry(entry)); 256 + 257 + const state: MatchState = { 258 + matchId: params.matchId ?? generateMatchId(), 259 + progressLevel: 'ban-phase', 260 + meta: { 261 + name: `${params.round} - Match ${params.matchNumber}`, 262 + round: params.round, 263 + matchNumber: params.matchNumber, 264 + bestOf: params.bestOf, 265 + winsNeeded, 266 + tier: params.tier ?? null, 267 + channelId: params.channelId ?? null, 268 + eventId: resolvedEventId, 269 + startedAt: new Date().toISOString(), 270 + completedAt: null, 271 + startggSetId: params.startggSetId ?? null, 272 + type: params.type ?? 'tournament', 273 + }, 274 + players: [buildPlayerEntry({ ...p1, slot: 1 }), buildPlayerEntry({ ...p2, slot: 2 })], 275 + mappool: builtMappool, 276 + banPhase: { 277 + banOrder, 278 + currentBannerDiscordId: params.firstBannerDiscordId, 279 + bansCompleted: 0, 280 + totalBans: numBans, 281 + }, 282 + currentPickerDiscordId: null, 283 + currentChart: null, 284 + refereeDiscordId: params.refereeDiscordId ?? null, 285 + feed: [], 286 + status: 'in_progress', 287 + }; 288 + 289 + const doc = await Match.create({ 290 + matchId: state.matchId, 291 + progressLevel: state.progressLevel, 292 + meta: state.meta, 293 + players: state.players, 294 + mappool: state.mappool, 295 + banPhase: state.banPhase, 296 + currentPickerDiscordId: state.currentPickerDiscordId, 297 + currentChart: state.currentChart, 298 + refereeDiscordId: state.refereeDiscordId, 299 + feed: state.feed, 300 + status: state.status, 301 + }); 302 + 303 + state._id = doc._id; 304 + state.matchId = doc.matchId ?? state.matchId; 305 + const matchId = state.matchId!; 306 + matchStates.set(matchId, state); 307 + 308 + pushFeedEvent(matchId, 'match.start', `Match started: ${state.meta.name}`); 309 + await saveMatchState(matchId); 310 + 311 + logger.info({ matchId, round: params.round, matchNumber: params.matchNumber }, 'match initialized'); 312 + return state; 313 + } 314 + 315 + export async function saveMatchState(matchId: string): Promise<void> { 316 + const state = matchStates.get(matchId); 317 + if (!state?._id) return; 318 + await Match.findByIdAndUpdate(state._id, { 319 + matchId: state.matchId ?? null, 320 + progressLevel: state.progressLevel, 321 + players: state.players, 322 + mappool: state.mappool, 323 + banPhase: state.banPhase, 324 + currentPickerDiscordId: state.currentPickerDiscordId, 325 + currentChart: state.currentChart, 326 + refereeDiscordId: state.refereeDiscordId, 327 + feed: state.feed, 328 + status: state.status, 329 + 'meta.completedAt': state.meta.completedAt, 330 + }); 331 + broadcastFn?.('match.snapshot', state); 332 + } 333 + 334 + export async function banChart(matchId: string, songId: number, bannerDiscordId: string): Promise<void> { 335 + const state = matchStates.get(matchId); 336 + if (!state) return; 337 + 338 + const entry = state.mappool.find(c => c.songId === songId); 339 + if (!entry) return; 340 + 341 + entry.status.banned = true; 342 + entry.status.bannedByDiscordId = bannerDiscordId; 343 + entry.status.bannedAt = new Date().toISOString(); 344 + entry.status.inCurrentPool = false; 345 + 346 + state.banPhase.bansCompleted++; 347 + const nextBannerDiscordId = state.banPhase.banOrder[state.banPhase.bansCompleted] ?? null; 348 + state.banPhase.currentBannerDiscordId = nextBannerDiscordId; 349 + 350 + const bannerPlayer = state.players.find(p => p.discordId === bannerDiscordId); 351 + pushFeedEvent(matchId, 'ban', `${getBestName(bannerPlayer)} banned ${entry.displayName}`, { songId, bannerDiscordId }); 352 + 353 + await saveMatchState(matchId); 354 + } 355 + 356 + export async function pickChart(matchId: string, songId: number, pickerDiscordId: string): Promise<void> { 357 + const state = matchStates.get(matchId); 358 + if (!state) return; 359 + 360 + const entry = state.mappool.find(c => c.songId === songId); 361 + if (!entry) return; 362 + 363 + state.currentChart = songId; 364 + state.progressLevel = 'playing'; 365 + 366 + state.mappool.forEach(c => { c.status.isBeingPlayed = false; }); 367 + entry.status.isBeingPlayed = true; 368 + 369 + state.players.forEach(p => { p.ready = false; }); 370 + 371 + const pickerPlayer = state.players.find(p => p.discordId === pickerDiscordId); 372 + pushFeedEvent(matchId, 'pick', `${getBestName(pickerPlayer)} picked ${entry.displayName}`, { songId, pickerDiscordId }); 373 + 374 + await saveMatchState(matchId); 375 + } 376 + 377 + export async function submitResult(matchId: string, params: SubmitResultParams): Promise<SubmitOutcome | null> { 378 + const state = matchStates.get(matchId); 379 + if (!state) return null; 380 + 381 + const entry = state.mappool.find(c => c.songId === params.songId); 382 + if (!entry) return null; 383 + 384 + // guard against double-submit 385 + if (entry.status.played) { 386 + logger.warn({ matchId, songId: params.songId }, 'submitResult called on already-played chart'); 387 + return null; 388 + } 389 + 390 + const p1 = state.players[0]!; 391 + const p2 = state.players[1]!; 392 + 393 + function getRank(fc: boolean, pfc: boolean): number { 394 + if (pfc) return 2; 395 + if (fc) return 1; 396 + return 0; 397 + } 398 + 399 + const rank1 = getRank(params.fc1, params.pfc1); 400 + const rank2 = getRank(params.fc2, params.pfc2); 401 + 402 + let p1Wins = false; 403 + let p2Wins = false; 404 + 405 + if (rank1 !== rank2) { 406 + p1Wins = rank1 > rank2; 407 + p2Wins = rank2 > rank1; 408 + } 409 + else { 410 + p1Wins = params.score1 > params.score2; 411 + p2Wins = params.score2 > params.score1; 412 + } 413 + 414 + let winnerDiscordId: string | null = null; 415 + let winnerSlot: number | null = null; 416 + 417 + if (p1Wins) { 418 + winnerDiscordId = p1.discordId; 419 + winnerSlot = 1; 420 + p1.points++; 421 + } 422 + else if (p2Wins) { 423 + winnerDiscordId = p2.discordId; 424 + winnerSlot = 2; 425 + p2.points++; 426 + } 427 + 428 + entry.status.isBeingPlayed = false; 429 + entry.status.played = true; 430 + entry.status.playedAt = new Date().toISOString(); 431 + entry.status.inCurrentPool = false; 432 + entry.result = { ...params, winnerDiscordId, winnerSlot }; 433 + 434 + state.currentChart = null; 435 + 436 + const isTie = !p1Wins && !p2Wins; 437 + 438 + if (isTie) { 439 + try { 440 + const tier = state.meta.tier; 441 + const existingSongIds = new Set(state.mappool.map(c => c.songId).filter(Boolean)); 442 + const tierCharts = getPoolByTier(tier).filter(c => !existingSongIds.has(c.songId)); 443 + if (tierCharts.length > 0) { 444 + const random = tierCharts[Math.floor(Math.random() * tierCharts.length)]; 445 + const tiebreakerEntry: MappoolEntryState = { 446 + songId: random.songId ?? null, 447 + title: null, 448 + artist: null, 449 + charter: null, 450 + cover: null, 451 + thumbnailUrl: null, 452 + difficulty: null, 453 + tags: [], 454 + displayName: random.songId != null ? String(random.songId) : '?', 455 + status: { 456 + banned: false, 457 + bannedByDiscordId: null, 458 + bannedAt: null, 459 + played: false, 460 + playedAt: null, 461 + inCurrentPool: true, 462 + isBeingPlayed: false, 463 + }, 464 + result: null, 465 + isTiebreaker: true, 466 + }; 467 + state.mappool.push(tiebreakerEntry); 468 + pushFeedEvent(matchId, 'tiebreaker', `Tie! Random chart added: ${tiebreakerEntry.displayName}`, { songId: random.songId }); 469 + } 470 + else { 471 + logger.warn({ matchId, tier }, 'tie but no eligible tiebreaker charts'); 472 + } 473 + } 474 + catch (err) { 475 + logger.warn({ matchId, err }, 'could not look up tier pool for tiebreaker'); 476 + } 477 + } 478 + 479 + const winsNeeded = state.meta.winsNeeded; 480 + const winner = state.players.find(p => p.points >= winsNeeded) ?? null; 481 + 482 + if (winner) { 483 + winner.winner = true; 484 + state.progressLevel = 'finished'; 485 + state.status = 'completed'; 486 + state.meta.completedAt = new Date().toISOString(); 487 + pushFeedEvent(matchId, 'match.end', `${getBestName(winner)} wins the match!`, { winnerDiscordId: winner.discordId }); 488 + } 489 + else if (isTie) { 490 + const tiebreakerEntry = state.mappool.find(c => c.isTiebreaker && !c.status.played); 491 + if (tiebreakerEntry) { 492 + state.progressLevel = 'ready-check'; 493 + state.currentChart = tiebreakerEntry.songId; 494 + tiebreakerEntry.status.isBeingPlayed = true; 495 + state.players.forEach(p => { p.ready = false; }); 496 + pushFeedEvent(matchId, 'tiebreaker-pick', `Tiebreaker chart: ${tiebreakerEntry.displayName}`); 497 + } 498 + else { 499 + state.progressLevel = 'picking-post-result'; 500 + state.currentPickerDiscordId = p1.points <= p2.points ? p1.discordId : p2.discordId; 501 + pushFeedEvent(matchId, 'result', `${entry.displayName} - Tie. Score: ${params.score1} - ${params.score2}`, { songId: params.songId, score1: params.score1, score2: params.score2 }); 502 + } 503 + } 504 + else { 505 + state.progressLevel = 'picking-post-result'; 506 + const loserDiscordId = state.players.find(p => p.discordId !== winnerDiscordId)?.discordId ?? null; 507 + state.currentPickerDiscordId = loserDiscordId; 508 + pushFeedEvent(matchId, 'result', `${entry.displayName} result submitted. Score: ${params.score1} - ${params.score2}`, { songId: params.songId, score1: params.score1, score2: params.score2, winnerDiscordId, winnerSlot }); 509 + } 510 + 511 + await saveMatchState(matchId); 512 + 513 + const updatedState = matchStates.get(matchId); 514 + const tiebreakerChart = isTie ? updatedState!.mappool.find(c => c.isTiebreaker && !c.status.played) ?? null : null; 515 + return { winner, tiebreakerChart, isTie }; 516 + } 517 + 518 + export async function setPlayerReady(matchId: string, discordId: string): Promise<boolean> { 519 + const state = matchStates.get(matchId); 520 + if (!state) return false; 521 + 522 + const player = state.players.find(p => p.discordId === discordId); 523 + if (!player) return false; 524 + 525 + player.ready = true; 526 + pushFeedEvent(matchId, 'ready', `${getBestName(player)} is ready`, { discordId }); 527 + await saveMatchState(matchId); 528 + 529 + return state.players.every(p => p.ready); 530 + } 531 + 532 + export async function completeMatch(matchId: string): Promise<void> { 533 + const state = matchStates.get(matchId); 534 + if (!state) return; 535 + await saveMatchState(matchId); 536 + logger.info({ matchId }, 'match completed'); 537 + } 538 + 539 + export async function abandonMatch(matchId: string): Promise<void> { 540 + const state = matchStates.get(matchId); 541 + if (!state) return; 542 + state.status = 'abandoned'; 543 + state.meta.completedAt = new Date().toISOString(); 544 + pushFeedEvent(matchId, 'match.abandoned', 'Match was abandoned.'); 545 + await Match.findByIdAndUpdate(state._id, { 546 + status: 'abandoned', 547 + 'meta.completedAt': state.meta.completedAt, 548 + feed: state.feed, 549 + }); 550 + broadcastFn?.('match.abandoned', { matchId: state.matchId }); 551 + logger.info({ matchId }, 'match abandoned'); 552 + } 553 + 554 + export async function loadAllInProgressMatches(): Promise<MatchDoc[]> { 555 + const event = getActiveEvent(); 556 + if (!event) return []; 557 + return Match.find({ 'meta.eventId': event._id, status: 'in_progress' }); 558 + } 559 + 560 + export async function restoreMatchState(matchId: string, doc: MatchDoc): Promise<MatchState> { 561 + const state: MatchState = { 562 + _id: doc._id, 563 + matchId: doc.matchId ?? null, 564 + progressLevel: doc.progressLevel ?? 'ban-phase', 565 + meta: { 566 + name: doc.meta?.name ?? null, 567 + round: doc.meta?.round ?? null, 568 + matchNumber: doc.meta?.matchNumber ?? null, 569 + bestOf: doc.meta?.bestOf ?? 5, 570 + winsNeeded: doc.meta?.winsNeeded ?? 3, 571 + tier: doc.meta?.tier ?? null, 572 + channelId: doc.meta?.channelId ?? null, 573 + eventId: doc.meta.eventId, 574 + startedAt: doc.meta?.startedAt?.toISOString() ?? new Date().toISOString(), 575 + completedAt: doc.meta?.completedAt?.toISOString() ?? null, 576 + startggSetId: doc.meta?.startggSetId ?? null, 577 + type: doc.meta?.type ?? 'tournament', 578 + }, 579 + players: (doc.players ?? []).map(p => ({ 580 + slot: p.slot, 581 + displayName: p.displayName ?? null, 582 + discordId: p.discordId ?? '', 583 + discordUsername: p.discordUsername ?? null, 584 + discordDisplayName: p.discordDisplayName ?? null, 585 + serverNickname: p.serverNickname ?? null, 586 + avatarUrl: p.avatarUrl ?? null, 587 + points: p.points ?? 0, 588 + ready: p.ready ?? false, 589 + winner: p.winner ?? false, 590 + spinshareId: p.spinshareId ?? null, 591 + spinshareUsername: p.spinshareUsername ?? null, 592 + spinshareAvatarUrl: p.spinshareAvatarUrl ?? null, 593 + pronouns: p.pronouns ?? null, 594 + })), 595 + mappool: (doc.mappool ?? []).map(m => ({ 596 + songId: m.songId ?? null, 597 + title: m.title ?? null, 598 + artist: m.artist ?? null, 599 + charter: m.charter ?? null, 600 + cover: m.cover ?? null, 601 + thumbnailUrl: m.thumbnailUrl ?? null, 602 + difficulty: m.difficulty ?? null, 603 + tags: m.tags ?? [], 604 + displayName: m.displayName ?? null, 605 + status: { 606 + banned: m.status?.banned ?? false, 607 + bannedByDiscordId: m.status?.bannedByDiscordId ?? null, 608 + bannedAt: m.status?.bannedAt?.toISOString() ?? null, 609 + played: m.status?.played ?? false, 610 + playedAt: m.status?.playedAt?.toISOString() ?? null, 611 + inCurrentPool: m.status?.inCurrentPool ?? true, 612 + isBeingPlayed: m.status?.isBeingPlayed ?? false, 613 + }, 614 + result: m.result ? { 615 + score1: m.result.score1 ?? 0, 616 + score2: m.result.score2 ?? 0, 617 + fc1: m.result.fc1 ?? false, 618 + fc2: m.result.fc2 ?? false, 619 + pfc1: m.result.pfc1 ?? false, 620 + pfc2: m.result.pfc2 ?? false, 621 + winnerDiscordId: m.result.winnerDiscordId ?? null, 622 + winnerSlot: m.result.winnerSlot ?? null, 623 + } : null, 624 + isTiebreaker: m.isTiebreaker ?? false, 625 + })), 626 + banPhase: { 627 + banOrder: doc.banPhase?.banOrder ?? [], 628 + currentBannerDiscordId: doc.banPhase?.currentBannerDiscordId ?? null, 629 + bansCompleted: doc.banPhase?.bansCompleted ?? 0, 630 + totalBans: doc.banPhase?.totalBans ?? 0, 631 + }, 632 + currentPickerDiscordId: doc.currentPickerDiscordId ?? null, 633 + currentChart: doc.currentChart ?? null, 634 + refereeDiscordId: doc.refereeDiscordId ?? null, 635 + feed: (doc.feed ?? []).map(f => ({ 636 + type: f.type, 637 + timestamp: f.timestamp?.toISOString() ?? new Date().toISOString(), 638 + message: f.message ?? null, 639 + data: (f.data as Record<string, unknown>) ?? {}, 640 + })), 641 + status: doc.status ?? 'in_progress', 642 + }; 643 + matchStates.set(state.matchId!, state); 644 + return state; 645 + } 646 + 647 + // for testing: clear the in-memory cache 648 + export function _resetMatchStatesForTesting() { 649 + matchStates = new Map(); 650 + broadcastFn = null; 651 + }
+166
src/engine/pools.test.ts
··· 1 + import { test, before, after, beforeEach } from 'node:test'; 2 + import assert from 'node:assert/strict'; 3 + import mongoose from 'mongoose'; 4 + import { MongoMemoryServer } from 'mongodb-memory-server'; 5 + import { 6 + setGeneratedPools, 7 + loadGeneratedPoolsFromDB, 8 + getGeneratedPools, 9 + getPoolByTier, 10 + getMatchPool, 11 + _resetCacheForTesting, 12 + } from './pools.js'; 13 + import { createEvent, loadActiveEvent } from './event.js'; 14 + 15 + let mongod: MongoMemoryServer; 16 + 17 + before(async () => { 18 + mongod = await MongoMemoryServer.create(); 19 + await mongoose.connect(mongod.getUri()); 20 + }); 21 + 22 + after(async () => { 23 + await mongoose.disconnect(); 24 + await mongod.stop(); 25 + }); 26 + 27 + beforeEach(async () => { 28 + await mongoose.connection.db!.dropCollection('generatedpools').catch(() => {}); 29 + await mongoose.connection.db!.dropCollection('events').catch(() => {}); 30 + }); 31 + 32 + const sampleBrackets = [ 33 + { 34 + name: 'Open', 35 + rounds: [ 36 + { 37 + name: 'Winners Round 1', 38 + tier: 1, 39 + matches: [ 40 + { charts: [{ songId: 1 }, { songId: 2 }, { songId: 3 }, { songId: 4 }, { songId: 5 }] }, 41 + { charts: [{ songId: 6 }, { songId: 7 }, { songId: 8 }, { songId: 9 }, { songId: 10 }] }, 42 + ], 43 + }, 44 + ], 45 + }, 46 + ]; 47 + 48 + const sampleTierPools: Record<string, { songId: number | null }[]> = { 49 + '1': [{ songId: 1 }, { songId: 2 }, { songId: 3 }], 50 + '2': [{ songId: 4 }, { songId: 5 }], 51 + }; 52 + 53 + test('setGeneratedPools saves to DB and populates cache', async () => { 54 + const event = await createEvent('Test'); 55 + await loadActiveEvent(); 56 + await setGeneratedPools(sampleBrackets, sampleTierPools); 57 + const pools = getGeneratedPools(); 58 + assert.ok(pools); 59 + assert.equal(pools.length, 1); 60 + assert.equal(pools[0]!.name, 'Open'); 61 + assert.equal(pools[0]!.rounds[0]!.matches.length, 2); 62 + }); 63 + 64 + test('getPoolByTier returns a copy of the tier pool', async () => { 65 + const event = await createEvent('Test'); 66 + await loadActiveEvent(); 67 + await setGeneratedPools(sampleBrackets, sampleTierPools); 68 + const pool = getPoolByTier(1); 69 + assert.equal(pool.length, 3); 70 + assert.equal(pool[0]!.songId, 1); 71 + // verify it's a copy (mutation doesn't affect cache) 72 + pool.push({ songId: 999 }); 73 + assert.equal(getPoolByTier(1).length, 3); 74 + }); 75 + 76 + test('getPoolByTier throws for unknown tier', async () => { 77 + const event = await createEvent('Test'); 78 + await loadActiveEvent(); 79 + await setGeneratedPools(sampleBrackets, sampleTierPools); 80 + assert.throws(() => getPoolByTier(99), /No tier pool found/); 81 + }); 82 + 83 + test('getMatchPool returns charts for a specific match slot', async () => { 84 + const event = await createEvent('Test'); 85 + await loadActiveEvent(); 86 + await setGeneratedPools(sampleBrackets, sampleTierPools); 87 + const charts = getMatchPool('Open', 'Winners Round 1', null, 0); 88 + assert.equal(charts.length, 5); 89 + assert.equal(charts[0]!.songId, 1); 90 + }); 91 + 92 + test('getMatchPool returns second match charts', async () => { 93 + const event = await createEvent('Test'); 94 + await loadActiveEvent(); 95 + await setGeneratedPools(sampleBrackets, sampleTierPools); 96 + const charts = getMatchPool('Open', 'Winners Round 1', null, 1); 97 + assert.equal(charts.length, 5); 98 + assert.equal(charts[0]!.songId, 6); 99 + }); 100 + 101 + test('getMatchPool throws for nonexistent bracket', async () => { 102 + const event = await createEvent('Test'); 103 + await loadActiveEvent(); 104 + await setGeneratedPools(sampleBrackets, sampleTierPools); 105 + assert.throws(() => getMatchPool('Nonexistent', 'Winners Round 1', null, 0), /Bracket "Nonexistent" not found/); 106 + }); 107 + 108 + test('getMatchPool throws for nonexistent round', async () => { 109 + const event = await createEvent('Test'); 110 + await loadActiveEvent(); 111 + await setGeneratedPools(sampleBrackets, sampleTierPools); 112 + assert.throws(() => getMatchPool('Open', 'Nonexistent', null, 0), /Round "Nonexistent" .* not found/); 113 + }); 114 + 115 + test('getMatchPool throws for out-of-range index', async () => { 116 + const event = await createEvent('Test'); 117 + await loadActiveEvent(); 118 + await setGeneratedPools(sampleBrackets, sampleTierPools); 119 + assert.throws(() => getMatchPool('Open', 'Winners Round 1', null, 99), /Match index 99 out of range/); 120 + }); 121 + 122 + test('getMatchPool throws for non-numeric index', async () => { 123 + const event = await createEvent('Test'); 124 + await loadActiveEvent(); 125 + await setGeneratedPools(sampleBrackets, sampleTierPools); 126 + assert.throws(() => getMatchPool('Open', 'Winners Round 1', null, NaN as unknown as number), /Invalid match index/); 127 + }); 128 + 129 + test('getMatchPool throws when pools not generated', async () => { 130 + _resetCacheForTesting(); 131 + assert.throws(() => getMatchPool('Open', 'Winners Round 1', null, 0), /Pools have not been generated/); 132 + }); 133 + 134 + test('loadGeneratedPoolsFromDB restores cache from DB', async () => { 135 + const event = await createEvent('Test'); 136 + await loadActiveEvent(); 137 + await setGeneratedPools(sampleBrackets, sampleTierPools); 138 + // simulate fresh process — clear in-memory cache by re-importing would be complex, 139 + // so just verify loadGeneratedPoolsFromDB works on the same event 140 + await loadGeneratedPoolsFromDB(); 141 + const pools = getGeneratedPools(); 142 + assert.ok(pools); 143 + assert.equal(pools.length, 1); 144 + }); 145 + 146 + test('setGeneratedPools handles brackets with poolId', async () => { 147 + const event = await createEvent('Test'); 148 + await loadActiveEvent(); 149 + const bracketsWithPool = [ 150 + { 151 + name: 'Swiss', 152 + rounds: [ 153 + { 154 + name: 'Round 1', 155 + tier: 1, 156 + matches: [ 157 + { charts: [{ songId: 1 }, { songId: 2 }] }, 158 + ], 159 + }, 160 + ], 161 + }, 162 + ]; 163 + await setGeneratedPools(bracketsWithPool); 164 + const charts = getMatchPool('Swiss', 'Round 1', null, 0); 165 + assert.equal(charts.length, 2); 166 + });
+162
src/engine/pools.ts
··· 1 + import { GeneratedPools } from '../models/index.js'; 2 + import { getActiveEvent } from './event.js'; 3 + import { logger } from '../logger.js'; 4 + 5 + let generatedPools: { 6 + name: string; 7 + rounds: { 8 + name: string; 9 + poolId: string | null; 10 + tier: number | null; 11 + matches: { charts: { songId: number | null }[] }[]; 12 + }[]; 13 + }[] | null = null; 14 + 15 + let tierPools: Record<string, { songId: number | null }[]> | null = null; 16 + 17 + function serializePools(pools: { 18 + name: string; 19 + rounds: { 20 + name: string; 21 + poolId?: string | null; 22 + tier?: number | null; 23 + matches: { charts: { songId?: number | null; name?: string | null; index?: number }[] }[]; 24 + }[]; 25 + }[]) { 26 + return pools.map((bracket) => ({ 27 + name: bracket.name, 28 + rounds: bracket.rounds.map((round) => ({ 29 + name: round.name, 30 + poolId: round.poolId ?? null, 31 + tier: round.tier ?? null, 32 + matches: round.matches.map((match) => ({ 33 + charts: match.charts.map((c) => ({ 34 + name: c.name ?? null, 35 + index: c.index, 36 + songId: c.songId ?? null, 37 + })), 38 + })), 39 + })), 40 + })); 41 + } 42 + 43 + function serializeTierPools(tp: Record<string, { songId: number | null }[]>) { 44 + const result: Record<string, { songId: number | null }[]> = {}; 45 + for (const [tier, charts] of Object.entries(tp)) { 46 + result[tier] = charts.map((c) => ({ songId: c.songId ?? null })); 47 + } 48 + return result; 49 + } 50 + 51 + export async function setGeneratedPools( 52 + pools: Parameters<typeof serializePools>[0], 53 + tp?: Record<string, { songId: number | null }[]> | null, 54 + eventId?: string | null, 55 + ): Promise<void> { 56 + const event = eventId ? null : getActiveEvent(); 57 + const resolvedEventId = eventId ?? event?._id; 58 + if (!resolvedEventId) throw new Error('No active event!'); 59 + 60 + const serialized = serializePools(pools); 61 + const serializedTierPools = tp ? serializeTierPools(tp) : undefined; 62 + 63 + await GeneratedPools.findOneAndUpdate( 64 + { event: resolvedEventId }, 65 + { 66 + event: resolvedEventId, 67 + brackets: serialized, 68 + ...(serializedTierPools ? { tierPools: serializedTierPools } : {}), 69 + generatedAt: new Date(), 70 + }, 71 + { upsert: true, new: true }, 72 + ); 73 + 74 + generatedPools = serialized.map(bracket => ({ 75 + name: bracket.name, 76 + rounds: bracket.rounds.map(round => ({ 77 + name: round.name, 78 + poolId: round.poolId ?? null, 79 + tier: round.tier ?? null, 80 + matches: round.matches.map(match => ({ charts: match.charts })), 81 + })), 82 + })); 83 + 84 + if (tp) tierPools = tp; 85 + 86 + logger.info({ eventId: resolvedEventId, bracketCount: serialized.length }, 'generated pools saved'); 87 + } 88 + 89 + export async function loadGeneratedPoolsFromDB(eventId?: string | null): Promise<void> { 90 + const event = eventId ? null : getActiveEvent(); 91 + const resolvedEventId = eventId ?? event?._id; 92 + if (!resolvedEventId) return; 93 + 94 + const doc = await GeneratedPools.findOne({ event: resolvedEventId }); 95 + if (!doc) return; 96 + 97 + generatedPools = doc.brackets as typeof generatedPools; 98 + 99 + if (doc.tierPools) { 100 + const tp: Record<string, { songId: number | null }[]> = {}; 101 + for (const [tier, charts] of Object.entries(doc.tierPools)) { 102 + tp[tier] = (Array.isArray(charts) ? charts : []).map((c: { songId?: number | null }) => ({ songId: c?.songId ?? null })); 103 + } 104 + tierPools = tp; 105 + } 106 + 107 + logger.info({ eventId: resolvedEventId, bracketCount: generatedPools?.length }, 'generated pools loaded from DB'); 108 + } 109 + 110 + export function getGeneratedPools() { 111 + return generatedPools; 112 + } 113 + 114 + export function _resetCacheForTesting() { 115 + generatedPools = null; 116 + tierPools = null; 117 + } 118 + 119 + export function getPoolByTier(tier: number | null): { songId: number | null }[] { 120 + if (tier != null && tierPools?.[String(tier)]) return [...tierPools[String(tier)]!]; 121 + throw new Error(`No tier pool found for tier ${tier}. Re-run /generate.`); 122 + } 123 + 124 + export function getMatchPool( 125 + bracketName: string, 126 + roundName: string, 127 + poolId: string | null, 128 + matchIndex: number, 129 + ): { songId: number | null }[] { 130 + if (!generatedPools) throw new Error('Pools have not been generated yet! Ask a referee to run /generate.'); 131 + 132 + if (typeof matchIndex !== 'number' || Number.isNaN(matchIndex)) { 133 + throw new Error(`Invalid match index: ${matchIndex} must be a number`); 134 + } 135 + 136 + const bracket = generatedPools.find((b) => b.name === bracketName); 137 + if (!bracket) throw new Error(`Bracket "${bracketName}" not found.`); 138 + 139 + const round = bracket.rounds.find((r) => r.name === roundName && (r.poolId ?? null) === (poolId ?? null)); 140 + if (!round) throw new Error(`Round "${roundName}" (pool: ${poolId ?? 'none'}) not found in bracket "${bracketName}".`); 141 + 142 + if (!round.matches || !Array.isArray(round.matches)) { 143 + throw new Error(`No matches array found for round "${roundName}"`); 144 + } 145 + 146 + if (matchIndex < 0 || matchIndex >= round.matches.length) { 147 + throw new Error(`Match index ${matchIndex} out of range (0-${round.matches.length - 1}).`); 148 + } 149 + 150 + const match = round.matches[matchIndex]; 151 + if (!match) throw new Error(`Match at index ${matchIndex} is undefined.`); 152 + 153 + const charts = match.charts || []; 154 + if (!Array.isArray(charts)) { 155 + logger.warn({ matchIndex }, 'charts is not an array'); 156 + return []; 157 + } 158 + 159 + return charts.map((c) => ({ 160 + songId: c?.songId ?? null, 161 + })); 162 + }
+2 -1
src/models/Event.ts
··· 1 - import { Schema, model } from 'mongoose'; 1 + import { Schema, model, type Types } from 'mongoose'; 2 2 3 3 export interface EventDoc { 4 + _id?: Types.ObjectId; 4 5 name: string; 5 6 createdAt?: Date; 6 7 active?: boolean;
+17
src/models/GeneratedPools.ts
··· 1 + import { Schema, model, type Types } from 'mongoose'; 2 + 3 + export interface GeneratedPoolsDoc { 4 + event: Types.ObjectId; 5 + brackets: unknown[]; 6 + tierPools?: Record<string, { songId: number | null }[]>; 7 + generatedAt?: Date; 8 + } 9 + 10 + const generatedPoolsSchema = new Schema<GeneratedPoolsDoc>({ 11 + event: { type: Schema.Types.ObjectId, ref: 'Event', required: true, unique: true }, 12 + brackets: { type: Schema.Types.Mixed, default: [] }, 13 + tierPools: { type: Schema.Types.Mixed, default: null }, 14 + generatedAt: { type: Date, default: Date.now }, 15 + }); 16 + 17 + export const GeneratedPools = model<GeneratedPoolsDoc>('GeneratedPools', generatedPoolsSchema);
+1
src/models/Match.ts
··· 72 72 } 73 73 74 74 export interface MatchDoc { 75 + _id?: Types.ObjectId; 75 76 matchId?: string | null; 76 77 progressLevel?: string; 77 78 meta: {
+1
src/models/System.ts
··· 23 23 export const ApiKey = model<ApiKeyDoc>('ApiKey', apiKeySchema); 24 24 25 25 export interface AuditLogDoc { 26 + _id?: Types.ObjectId; 26 27 actorId: string; 27 28 actorType: 'api-key' | 'discord' | 'system'; 28 29 action: string;
+5 -1
src/models/index.ts
··· 19 19 type ApiKeyScope, 20 20 type AuditLogDoc, 21 21 type BotMetaDoc, 22 - } from './System.js'; 22 + } from './System.js'; 23 + export { 24 + GeneratedPools, 25 + type GeneratedPoolsDoc, 26 + } from './GeneratedPools.js';