Commit 5f90cc89 authored by nanahira's avatar nanahira

random duel things

parent aff3079d
Pipeline #43255 failed with stages
in 74 minutes and 37 seconds
FROM node:lts-trixie-slim as base
LABEL Author="Nanahira <nanahira@momobako.com>"
RUN apt update && apt -y install python3 build-essential && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/*
RUN apt update && apt -y install python3 build-essential libpq-dev && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/*
WORKDIR /usr/src/app
COPY ./package*.json ./
......@@ -12,7 +12,8 @@ RUN npm run build
FROM base
ENV NODE_ENV production
RUN npm ci && npm cache clean --force
RUN npm ci && npm install --no-save pg-native && npm cache clean --force
COPY --from=builder /usr/src/app/dist ./dist
ENV NODE_PG_FORCE_NATIVE=true
CMD [ "npm", "start" ]
host: "::"
port: 7911
dbHost: ""
dbPort: 5432
dbUser: postgres
dbPass: ""
dbName: srvpro2
dbNoInit: 0
redisUrl: ""
logLevel: info
wsPort: 0
......@@ -33,6 +39,15 @@ windbotEndpoint: http://127.0.0.1:2399
windbotMyIp: 127.0.0.1
enableReconnect: 1
reconnectTimeout: 180000
enableRandomDuel: 1
randomDuelBlankPassModes:
- S
- M
randomDuelNoRematchCheck: 0
randomDuelRecordMatchScores: 1
randomDuelDisableChat: 0
randomDuelReadyTime: 20
randomDuelHangTimeout: 90
sideTimeoutMinutes: 3
hostinfoLflist: 0
hostinfoRule: 0
......
......@@ -17,13 +17,15 @@
"https-proxy-agent": "^7.0.6",
"ipaddr.js": "^2.3.0",
"koishipro-core.js": "^1.3.4",
"nfkit": "^1.0.31",
"nfkit": "^1.0.32",
"p-queue": "6.6.2",
"pg": "^8.18.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"rxjs": "^7.8.2",
"typed-reflector": "^1.0.14",
"typed-struct": "^2.7.1",
"typeorm": "^0.3.28",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"ygopro-cdb-encode": "^1.0.2",
......@@ -768,7 +770,6 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
......@@ -786,7 +787,6 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
......@@ -799,7 +799,6 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
......@@ -1390,7 +1389,6 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
......@@ -1449,6 +1447,12 @@
"@sinonjs/commons": "^3.0.1"
}
},
"node_modules/@sqltools/formatter": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
......@@ -2195,7 +2199,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
......@@ -2205,7 +2208,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
......@@ -2217,6 +2219,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ansis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
"integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==",
"license": "ISC",
"engines": {
"node": ">=14"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
......@@ -2231,6 +2242,15 @@
"node": ">= 8"
}
},
"node_modules/app-root-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz",
"integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/aragami": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/aragami/-/aragami-1.2.10.tgz",
......@@ -2413,7 +2433,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
......@@ -2456,7 +2475,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
......@@ -2712,7 +2730,6 @@
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
......@@ -2727,14 +2744,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
......@@ -2749,7 +2764,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
......@@ -2794,7 +2808,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
......@@ -2807,7 +2820,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
......@@ -2852,7 +2864,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
......@@ -2893,6 +2904,12 @@
"node": "*"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
......@@ -2914,7 +2931,6 @@
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
"integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
......@@ -3000,6 +3016,18 @@
"node": ">=6.0.0"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
......@@ -3018,7 +3046,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": {
......@@ -3045,7 +3072,6 @@
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/encoded-buffer": {
......@@ -3134,7 +3160,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
......@@ -3630,7 +3655,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
......@@ -3713,7 +3737,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
......@@ -3784,7 +3807,6 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
......@@ -4156,7 +4178,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
......@@ -4243,7 +4264,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
......@@ -4321,7 +4341,6 @@
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
......@@ -5245,7 +5264,6 @@
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
......@@ -5270,7 +5288,6 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
......@@ -5313,9 +5330,9 @@
"license": "MIT"
},
"node_modules/nfkit": {
"version": "1.0.31",
"resolved": "https://registry.npmjs.org/nfkit/-/nfkit-1.0.31.tgz",
"integrity": "sha512-Gj/WaJK5fFrhmIAcdpzG4P6gtgsIU+4uQPjza+2fhtgNj2RbOAdcMKWbSAcSgYt0wqzUqu4uVoNhsiJThDTVmQ==",
"version": "1.0.32",
"resolved": "https://registry.npmjs.org/nfkit/-/nfkit-1.0.32.tgz",
"integrity": "sha512-y+UoxDBs6JV4CSBZkidBGK4GfzJ1Qev8uU4m4oClWGs09oxOCh6TQqnOGRaZY1yCmD8yzYcED+8waSMU4WS5fg==",
"license": "MIT"
},
"node_modules/node-int64": {
......@@ -5490,7 +5507,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
......@@ -5555,7 +5571,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
......@@ -5565,7 +5580,6 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
......@@ -5582,9 +5596,98 @@
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pg": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
......@@ -5766,6 +5869,45 @@
"node": ">= 0.4"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
......@@ -5988,7 +6130,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
......@@ -6201,11 +6342,50 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sha.js": {
"version": "2.4.12",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz",
"integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
"license": "(MIT AND BSD-3-Clause)",
"dependencies": {
"inherits": "^2.0.4",
"safe-buffer": "^5.2.1",
"to-buffer": "^1.2.0"
},
"bin": {
"sha.js": "bin.js"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sha.js/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
......@@ -6218,7 +6398,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
......@@ -6228,7 +6407,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
......@@ -6293,6 +6471,22 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/sql-highlight": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz",
"integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==",
"funding": [
"https://github.com/scriptcoded/sql-highlight?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/scriptcoded"
}
],
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/sql.js": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.0.tgz",
......@@ -6355,7 +6549,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
......@@ -6374,7 +6567,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
......@@ -6389,14 +6581,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
......@@ -6409,7 +6599,6 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
......@@ -6425,7 +6614,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
......@@ -6439,7 +6627,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
......@@ -6869,6 +7056,114 @@
}
}
},
"node_modules/typeorm": {
"version": "0.3.28",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz",
"integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==",
"license": "MIT",
"dependencies": {
"@sqltools/formatter": "^1.2.5",
"ansis": "^4.2.0",
"app-root-path": "^3.1.0",
"buffer": "^6.0.3",
"dayjs": "^1.11.19",
"debug": "^4.4.3",
"dedent": "^1.7.0",
"dotenv": "^16.6.1",
"glob": "^10.5.0",
"reflect-metadata": "^0.2.2",
"sha.js": "^2.4.12",
"sql-highlight": "^6.1.0",
"tslib": "^2.8.1",
"uuid": "^11.1.0",
"yargs": "^17.7.2"
},
"bin": {
"typeorm": "cli.js",
"typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js",
"typeorm-ts-node-esm": "cli-ts-node-esm.js"
},
"engines": {
"node": ">=16.13.0"
},
"funding": {
"url": "https://opencollective.com/typeorm"
},
"peerDependencies": {
"@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@sap/hana-client": "^2.14.22",
"better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
"ioredis": "^5.0.4",
"mongodb": "^5.8.0 || ^6.0.0",
"mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0",
"mysql2": "^2.2.5 || ^3.0.1",
"oracledb": "^6.3.0",
"pg": "^8.5.1",
"pg-native": "^3.0.0",
"pg-query-stream": "^4.0.0",
"redis": "^3.1.1 || ^4.0.0 || ^5.0.14",
"sql.js": "^1.4.0",
"sqlite3": "^5.0.3",
"ts-node": "^10.7.0",
"typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0"
},
"peerDependenciesMeta": {
"@google-cloud/spanner": {
"optional": true
},
"@sap/hana-client": {
"optional": true
},
"better-sqlite3": {
"optional": true
},
"ioredis": {
"optional": true
},
"mongodb": {
"optional": true
},
"mssql": {
"optional": true
},
"mysql2": {
"optional": true
},
"oracledb": {
"optional": true
},
"pg": {
"optional": true
},
"pg-native": {
"optional": true
},
"pg-query-stream": {
"optional": true
},
"redis": {
"optional": true
},
"sql.js": {
"optional": true
},
"sqlite3": {
"optional": true
},
"ts-node": {
"optional": true
},
"typeorm-aurora-data-api-driver": {
"optional": true
}
}
},
"node_modules/typeorm/node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
......@@ -6986,6 +7281,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
......@@ -7015,7 +7323,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
......@@ -7069,7 +7376,6 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
......@@ -7088,7 +7394,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
......@@ -7106,14 +7411,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
......@@ -7128,7 +7431,6 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
......@@ -7141,7 +7443,6 @@
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
......@@ -7154,7 +7455,6 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
......@@ -7207,11 +7507,19 @@
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
......@@ -7243,7 +7551,6 @@
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
......@@ -7262,7 +7569,6 @@
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
......@@ -7272,14 +7578,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
......
......@@ -10,6 +10,7 @@ import { RoomModule } from './room/room-module';
import { SqljsFactory, SqljsLoader } from './services/sqljs';
import { FeatsModule } from './feats/feats-module';
import { MiddlewareRx } from './services/middleware-rx';
import { TypeormFactory, TypeormLoader } from './services/typeorm';
const core = createAppContext()
.provide(ConfigService, {
......@@ -24,6 +25,10 @@ const core = createAppContext()
useFactory: SqljsFactory,
merge: ['SQL'],
})
.provide(TypeormLoader, {
useFactory: TypeormFactory,
merge: ['database'],
})
.define();
export type Context = typeof core;
......
......@@ -27,18 +27,16 @@ export class ClientHandler {
// ws/reverse-ws should already have IP from connection metadata, skip overwrite
return next();
}
this.ctx
.get(() => IpResolver)
.setClientIp(
client,
msg.real_ip === '0.0.0.0' ? undefined : msg.real_ip,
);
await this.ctx.get(() => IpResolver).setClientIp(
client,
msg.real_ip === '0.0.0.0' ? undefined : msg.real_ip,
);
client.hostname = msg.hostname?.split(':')[0] || '';
return next();
})
.middleware(YGOProCtosPlayerInfo, async (msg, client, next) => {
if (!client.ip) {
this.ctx.get(() => IpResolver).setClientIp(client);
await this.ctx.get(() => IpResolver).setClientIp(client);
}
const [name, vpass] = msg.name.split('$');
client.name = name;
......
import { CacheKey } from 'aragami';
import { Context } from '../app';
import { Client } from './client';
import * as ipaddr from 'ipaddr.js';
import { YGOProCtosDisconnect } from '../utility/ygopro-ctos-disconnect';
const IP_RESOLVER_TTL = 24 * 60 * 60 * 1000;
class ConnectedIpCountCache {
@CacheKey()
ip!: string;
count = 0;
}
class BadIpCountCache {
@CacheKey()
ip!: string;
count = 0;
}
export class IpResolver {
private logger = this.ctx.createLogger('IpResolver');
private connectedIpCount = new Map<string, number>();
private badIpCount = new Map<string, number>();
private trustedProxies: Array<[ipaddr.IPv4 | ipaddr.IPv6, number]> = [];
constructor(private ctx: Context) {
......@@ -26,6 +42,11 @@ export class IpResolver {
{ count: this.trustedProxies.length },
'Trusted proxies initialized',
);
this.ctx.middleware(YGOProCtosDisconnect, async (_msg, client, next) => {
await this.releaseClientIp(client);
return next();
});
}
toIpv4(ip: string): string {
......@@ -73,7 +94,7 @@ export class IpResolver {
* @param xffIp Optional X-Forwarded-For IP
* @returns true if client should be rejected (bad IP or too many connections)
*/
setClientIp(client: Client, xffIp?: string): boolean {
async setClientIp(client: Client, xffIp?: string): Promise<boolean> {
const prevIp = client.ip;
// Priority: passed xffIp > client.xffIp() > client.physicalIp()
......@@ -89,13 +110,9 @@ export class IpResolver {
// Decrement count for previous IP
if (prevIp) {
const prevCount = this.connectedIpCount.get(prevIp) || 0;
const prevCount = await this.getConnectedIpCount(prevIp);
if (prevCount > 0) {
if (prevCount === 1) {
this.connectedIpCount.delete(prevIp);
} else {
this.connectedIpCount.set(prevIp, prevCount - 1);
}
await this.setConnectedIpCount(prevIp, prevCount - 1);
}
}
......@@ -110,7 +127,7 @@ export class IpResolver {
const noConnectCountLimit = this.ctx.config.getBoolean(
'NO_CONNECT_COUNT_LIMIT',
);
let connectCount = this.connectedIpCount.get(newIp) || 0;
let connectCount = await this.getConnectedIpCount(newIp);
if (
!noConnectCountLimit &&
......@@ -119,13 +136,11 @@ export class IpResolver {
!this.isTrustedProxy(newIp)
) {
connectCount++;
this.connectedIpCount.set(newIp, connectCount);
} else {
this.connectedIpCount.set(newIp, connectCount);
}
await this.setConnectedIpCount(newIp, connectCount);
// Check if IP should be rejected
const badCount = this.badIpCount.get(newIp) || 0;
const badCount = await this.getBadIpCount(newIp);
if (badCount > 5 || connectCount > 10) {
this.logger.info(
{ ip: newIp, badCount, connectCount },
......@@ -142,9 +157,9 @@ export class IpResolver {
* Mark an IP as bad (increment bad count)
* @param ip The IP address to mark as bad
*/
addBadIp(ip: string): void {
const currentCount = this.badIpCount.get(ip) || 0;
this.badIpCount.set(ip, currentCount + 1);
async addBadIp(ip: string): Promise<void> {
const currentCount = await this.getBadIpCount(ip);
await this.setBadIpCount(ip, currentCount + 1);
this.logger.warn(
{ ip, count: currentCount + 1 },
'Bad IP count incremented',
......@@ -154,30 +169,80 @@ export class IpResolver {
/**
* Get the current connection count for an IP
*/
getConnectedIpCount(ip: string): number {
return this.connectedIpCount.get(ip) || 0;
async getConnectedIpCount(ip: string): Promise<number> {
const data = await this.ctx.aragami.get(ConnectedIpCountCache, ip);
return data?.count || 0;
}
/**
* Get the bad count for an IP
*/
getBadIpCount(ip: string): number {
return this.badIpCount.get(ip) || 0;
async getBadIpCount(ip: string): Promise<number> {
const data = await this.ctx.aragami.get(BadIpCountCache, ip);
return data?.count || 0;
}
/**
* Clear all connection counts (useful for testing or maintenance)
*/
clearConnectionCounts(): void {
this.connectedIpCount.clear();
async clearConnectionCounts(): Promise<void> {
await this.ctx.aragami.clear(ConnectedIpCountCache);
this.logger.debug('Connection counts cleared');
}
/**
* Clear all bad IP counts (useful for testing or maintenance)
*/
clearBadIpCounts(): void {
this.badIpCount.clear();
async clearBadIpCounts(): Promise<void> {
await this.ctx.aragami.clear(BadIpCountCache);
this.logger.debug('Bad IP counts cleared');
}
private async setConnectedIpCount(ip: string, count: number) {
if (count <= 0) {
await this.ctx.aragami.del(ConnectedIpCountCache, ip);
return;
}
await this.ctx.aragami.set(
ConnectedIpCountCache,
{
ip,
count,
},
{
key: ip,
ttl: IP_RESOLVER_TTL,
},
);
}
private async setBadIpCount(ip: string, count: number) {
if (count <= 0) {
await this.ctx.aragami.del(BadIpCountCache, ip);
return;
}
await this.ctx.aragami.set(
BadIpCountCache,
{
ip,
count,
},
{
key: ip,
ttl: IP_RESOLVER_TTL,
},
);
}
private async releaseClientIp(client: Client) {
const ip = client.ip;
if (!ip) {
return;
}
const currentCount = await this.getConnectedIpCount(ip);
if (currentCount <= 0) {
return;
}
await this.setConnectedIpCount(ip, currentCount - 1);
}
}
......@@ -45,7 +45,9 @@ export class WsServer {
this.wss = new WebSocketServer({ server: this.httpServer });
this.wss.on('connection', (ws, req) => {
this.handleConnection(ws, req);
this.handleConnection(ws, req).catch((err) => {
this.logger.error({ err }, 'Error handling WebSocket connection');
});
});
await new Promise<void>((resolve, reject) => {
......@@ -64,13 +66,17 @@ export class WsServer {
});
}
private handleConnection(ws: WebSocket, req: IncomingMessage): void {
private async handleConnection(
ws: WebSocket,
req: IncomingMessage,
): Promise<void> {
const client = new WsClient(this.ctx, ws, req);
if (this.ctx.get(() => IpResolver).setClientIp(client, client.xffIp()))
if (await this.ctx.get(() => IpResolver).setClientIp(client, client.xffIp())) {
return;
}
client.hostname = req.headers.host?.split(':')[0] || '';
const handler = this.ctx.get(() => ClientHandler);
handler.handleClient(client).catch((err) => {
await handler.handleClient(client).catch((err) => {
this.logger.error({ err }, 'Error handling client');
});
}
......
......@@ -12,6 +12,19 @@ export const defaultConfig = {
HOST: '::',
// Main server port for YGOPro clients. Format: integer string.
PORT: '7911',
// PostgreSQL host. Empty means database disabled.
DB_HOST: '',
// PostgreSQL port. Format: integer string.
DB_PORT: '5432',
// PostgreSQL username.
DB_USER: 'postgres',
// PostgreSQL password.
DB_PASS: '',
// PostgreSQL database name.
DB_NAME: 'srvpro2',
// Skip schema initialization/synchronize when set to '1'.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
DB_NO_INIT: '0',
// Redis connection URL. Format: URL string. Empty means disabled.
REDIS_URL: '',
// Log level. Format: lowercase string (e.g. info/debug/warn/error).
......@@ -79,6 +92,27 @@ export const defaultConfig = {
ENABLE_RECONNECT: '1',
// Reconnect timeout after disconnect. Format: integer string in milliseconds (ms).
RECONNECT_TIMEOUT: '180000',
// Enable random duel feature.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
ENABLE_RANDOM_DUEL: '1',
// Random duel modes that can be matched by blank pass.
// Format: comma-separated mode names. The first item is used as default type.
RANDOM_DUEL_BLANK_PASS_MODES: 'S,M',
// Disable rematch checking for random duel.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
RANDOM_DUEL_NO_REMATCH_CHECK: '0',
// Record random match scores (effective only when database is enabled).
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
RANDOM_DUEL_RECORD_MATCH_SCORES: '1',
// Disable chat in random duel rooms.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
RANDOM_DUEL_DISABLE_CHAT: '0',
// Random duel ready countdown before kicking the only unready player in Begin stage.
// Format: integer string in seconds (s). '0' or negative disables the feature.
RANDOM_DUEL_READY_TIME: '20',
// Random duel AFK timeout while waiting for player action.
// Format: integer string in seconds (s). '0' or negative disables the feature.
RANDOM_DUEL_HANG_TIMEOUT: '90',
// Side deck timeout in minutes during siding stage.
// Format: integer string. '0' or negative disables the feature.
SIDE_TIMEOUT_MINUTES: '3',
......
export const MAX_ROOM_NAME_LENGTH = 19;
......@@ -35,6 +35,20 @@ export const TRANSLATIONS = {
side_remain_part2: ' minutes.',
side_overtime: 'You exceeded side changing time and were kicked by system.',
side_overtime_room: ' exceeded side changing time and was kicked by system.',
kicked_by_system: 'was evicted from the game by server.',
kick_count_down:
' seconds later this player will be evicted for not getting ready or starting the game.',
afk_warn_part1: 'no operation too long, will be disconnected after ',
afk_warn_part2: ' seconds',
random_duel_enter_room_waiting: 'Your opponent is ready, start now!',
random_duel_enter_room_new: 'Game created, waiting for random opponent.',
random_duel_enter_room_single:
'Single mode room. Password M for match mode, T for tag mode.',
random_duel_enter_room_match:
'Match mode room. Password S for single mode, T for tag mode.',
random_duel_enter_room_tag:
'Tag mode room. Password S for single mode, M for match mode.',
chat_disabled: 'Chat is disabled in this room.',
},
'zh-CN': {
update_required: '请更新你的客户端版本',
......@@ -69,5 +83,18 @@ export const TRANSLATIONS = {
side_remain_part2: '分钟。',
side_overtime: '你更换副卡组超时,已被系统踢出。',
side_overtime_room: '更换副卡组超时,已被系统踢出。',
kicked_by_system: '被系统请出了房间',
kick_count_down: '秒后若不准备或开始游戏将被请出房间',
afk_warn_part1: '已经很久没有操作了,若继续挂机,将于',
afk_warn_part2: '秒后被请出房间',
random_duel_enter_room_waiting: '对手已经在等你了,开始决斗吧!',
random_duel_enter_room_new: '已建立随机对战房间,正在等待对手!',
random_duel_enter_room_single:
'您进入了单局模式房间,密码输入 M 进入比赛模式,输入 T 进入双打模式。',
random_duel_enter_room_match:
'您进入了比赛模式房间,密码输入 S 进入单局模式,输入 T 进入双打模式。',
random_duel_enter_room_tag:
'您进入了双打模式房间,密码输入 S 进入单局模式,输入 M 进入比赛模式。',
chat_disabled: '本房间禁止聊天。',
},
};
......@@ -4,14 +4,18 @@ import { ContextState } from '../app';
import { Welcome } from './welcome';
import { PlayerStatusNotify } from './player-status-notify';
import { Reconnect } from './reconnect';
import { WindbotModule } from '../windbot';
import { WindbotModule } from './windbot';
import { SideTimeout } from './side-timeout';
import { RandomDuelModule } from './random-duel';
import { WaitForPlayerProvider } from './wait-for-player-provider';
export const FeatsModule = createAppContext<ContextState>()
.provide(ClientVersionCheck)
.use(WindbotModule)
.provide(Welcome)
.provide(PlayerStatusNotify)
.provide(Reconnect)
.provide(WaitForPlayerProvider)
.provide(SideTimeout)
.use(RandomDuelModule)
.use(WindbotModule)
.define();
export * from './client-version-check';
export * from './random-duel';
export * from './reconnect';
export * from './wait-for-player-provider';
export * from './module';
export * from './provider';
export * from './score.entity';
import { createAppContext } from 'nfkit';
import { ContextState } from '../../app';
import { RandomDuelProvider } from './provider';
export const RandomDuelModule = createAppContext<ContextState>()
.provide(RandomDuelProvider)
.define();
import { CacheKey } from 'aragami';
import { ChatColor, YGOProCtosChat } from 'ygopro-msg-encode';
import { Context } from '../../app';
import { Client } from '../../client';
import { MAX_ROOM_NAME_LENGTH } from '../../constants/room';
import {
DuelStage,
OnRoomFinalize,
OnRoomJoinPlayer,
Room,
RoomManager,
} from '../../room';
import { fillRandomString } from '../../utility/fill-random-string';
import { CanReconnectCheck } from '../reconnect';
import { WaitForPlayerProvider } from '../wait-for-player-provider';
import { RandomDuelScore } from './score.entity';
const RANDOM_DUEL_TTL = 24 * 60 * 60 * 1000;
const BUILTIN_RANDOM_TYPES = [
'S',
'M',
'T',
'TOR',
'TR',
'OOR',
'OR',
'TOMR',
'TMR',
'OOMR',
'OMR',
'CR',
'CMR',
];
class RandomDuelOpponentCache {
@CacheKey()
ip!: string;
opponentIp = '';
}
declare module '../../room' {
interface Room {
randomType?: string;
randomDuelMaxPlayer?: number;
}
}
export class RandomDuelProvider {
private logger = this.ctx.createLogger(this.constructor.name);
private roomManager = this.ctx.get(() => RoomManager);
private waitForPlayerProvider = this.ctx.get(() => WaitForPlayerProvider);
enabled = this.ctx.config.getBoolean('ENABLE_RANDOM_DUEL');
noRematchCheck = this.ctx.config.getBoolean('RANDOM_DUEL_NO_REMATCH_CHECK');
disableChat = this.ctx.config.getBoolean('RANDOM_DUEL_DISABLE_CHAT');
private recordMatchScoresConfigured = this.ctx.config.getBoolean(
'RANDOM_DUEL_RECORD_MATCH_SCORES',
);
private waitForPlayerReadyTimeoutMs =
Math.max(0, this.ctx.config.getInt('RANDOM_DUEL_READY_TIME') || 0) * 1000;
private waitForPlayerHangTimeoutMs =
Math.max(0, this.ctx.config.getInt('RANDOM_DUEL_HANG_TIMEOUT') || 0) *
1000;
private waitForPlayerLongAgoBackoffMs = Math.max(
0,
this.waitForPlayerHangTimeoutMs - 19_000,
);
private blankPassModes = this.resolveBlankPassModes();
private supportedTypes = this.resolveSupportedTypes();
constructor(private ctx: Context) {
if (!this.enabled) {
return;
}
this.waitForPlayerProvider.registerTick({
roomFilter: (room) => !!room.randomType,
raadyTimeoutMs: this.waitForPlayerReadyTimeoutMs,
hangTimeoutMs: this.waitForPlayerHangTimeoutMs,
longAgoBackoffMs: this.waitForPlayerLongAgoBackoffMs,
});
if (this.recordMatchScoresConfigured && !this.ctx.database) {
this.logger.warn(
'RANDOM_DUEL_RECORD_MATCH_SCORES is enabled but database is unavailable',
);
}
this.ctx.middleware(CanReconnectCheck, async (msg, _client, next) => {
if (msg.room.randomType && this.getDisconnectedCount(msg.room) > 1) {
return msg.no();
}
return next();
});
this.ctx.middleware(OnRoomJoinPlayer, async (event, client, next) => {
await this.updateOpponentRelation(event.room, client);
return next();
});
this.ctx.middleware(OnRoomFinalize, async (event, _client, next) => {
await this.recordMatchResult(event.room);
return next();
});
this.ctx.middleware(YGOProCtosChat, async (msg, client, next) => {
if (!this.disableChat || !client.roomName) {
return next();
}
const room = this.roomManager.findByName(client.roomName);
if (!room?.randomType) {
return next();
}
await client.sendChat('#{chat_disabled}', ChatColor.BABYBLUE);
return;
});
}
get defaultType() {
return this.blankPassModes[0] || 'S';
}
resolveRandomType(pass: string): string | undefined {
if (!this.enabled) {
return undefined;
}
const type = pass.trim().toUpperCase();
if (!type) {
return '';
}
if (this.supportedTypes.has(type)) {
return type;
}
return undefined;
}
async findOrCreateRandomRoom(type: string, playerIp: string) {
const found = await this.findRandomRoom(type, playerIp);
if (found) {
const foundType = found.randomType || type || this.defaultType;
found.randomType = foundType;
found.noHost = true;
found.randomDuelMaxPlayer = this.resolveRandomDuelMaxPlayer(foundType);
found.welcome = '#{random_duel_enter_room_waiting}';
this.applyWelcomeType(found, foundType);
return found;
}
const randomType = type || this.defaultType;
const roomName = this.generateRandomRoomName(randomType);
if (!roomName) {
return undefined;
}
const room = await this.roomManager.findOrCreateByName(roomName);
room.randomType = randomType;
room.noHost = true;
room.randomDuelMaxPlayer = this.resolveRandomDuelMaxPlayer(randomType);
room.welcome = '#{random_duel_enter_room_new}';
this.applyWelcomeType(room, randomType);
return room;
}
private resolveBlankPassModes() {
const modes = this.ctx.config
.getStringArray('RANDOM_DUEL_BLANK_PASS_MODES')
.map((s) => s.trim().toUpperCase())
.filter((s) => !!s);
const uniqModes = Array.from(new Set(modes));
if (!uniqModes.length) {
return ['S', 'M'];
}
return uniqModes;
}
private resolveSupportedTypes() {
return new Set([...BUILTIN_RANDOM_TYPES, ...this.blankPassModes]);
}
private canMatchType(roomType: string, targetType: string) {
if (!targetType) {
return (
roomType === this.defaultType || this.blankPassModes.includes(roomType)
);
}
return roomType === targetType;
}
private resolveRandomDuelMaxPlayer(type: string) {
return type === 'T' ? 4 : 2;
}
private getDisconnectedCount(room: Room) {
return room.playingPlayers.filter((player) => !!player.disconnected).length;
}
private async findRandomRoom(type: string, playerIp: string) {
for (const room of this.roomManager.allRooms()) {
if (
!room.randomType ||
room.finalizing ||
room.duelStage !== DuelStage.Begin ||
room.windbot
) {
continue;
}
if (!this.canMatchType(room.randomType, type)) {
continue;
}
const maxPlayer =
room.randomDuelMaxPlayer ||
this.resolveRandomDuelMaxPlayer(room.randomType);
const playingCount = room.playingPlayers.length;
if (playingCount <= 0 || playingCount >= maxPlayer) {
continue;
}
if (!this.noRematchCheck) {
const host = room.playingPlayers.find((p) => p.isHost);
if (host?.ip) {
const lastOpponentIp = await this.getLastOpponent(playerIp);
if (lastOpponentIp && lastOpponentIp === host.ip) {
continue;
}
}
}
return room;
}
return undefined;
}
private generateRandomRoomName(type: string) {
const prefix = `${type},RANDOM#`;
for (let i = 0; i < 1000; i += 1) {
const name = fillRandomString(prefix, MAX_ROOM_NAME_LENGTH);
if (!this.roomManager.findByName(name)) {
return name;
}
}
return undefined;
}
private applyWelcomeType(room: Room, type: string) {
if (type === 'S') {
room.welcome2 = '#{random_duel_enter_room_single}';
return;
}
if (type === 'M') {
room.welcome2 = '#{random_duel_enter_room_match}';
return;
}
if (type === 'T') {
room.welcome2 = '#{random_duel_enter_room_tag}';
return;
}
room.welcome2 = '';
}
private async updateOpponentRelation(room: Room, client: Client) {
if (!room.randomType || !client.ip) {
return;
}
const host = room.playingPlayers.find((player) => player.isHost);
if (host && host !== client && host.ip) {
await this.setLastOpponent(host.ip, client.ip);
await this.setLastOpponent(client.ip, host.ip);
return;
}
await this.setLastOpponent(client.ip, '');
}
private async getLastOpponent(ip: string) {
const data = await this.ctx.aragami.get(RandomDuelOpponentCache, ip);
return data?.opponentIp || '';
}
private async setLastOpponent(ip: string, opponentIp: string) {
await this.ctx.aragami.set(
RandomDuelOpponentCache,
{
ip,
opponentIp,
},
{
key: ip,
ttl: RANDOM_DUEL_TTL,
},
);
}
private get recordMatchScoresEnabled() {
return this.recordMatchScoresConfigured && !!this.ctx.database;
}
private async recordMatchResult(room: Room) {
if (!this.recordMatchScoresEnabled || room.randomType !== 'M') {
return;
}
const duelPos0Player = room.getDuelPosPlayers(0)[0];
const duelPos1Player = room.getDuelPosPlayers(1)[0];
if (!duelPos0Player || !duelPos1Player) {
return;
}
const [score0, score1] = room.score;
if (score0 === score1) {
return;
}
if (score0 > score1) {
await this.recordWin(duelPos0Player.name_vpass || duelPos0Player.name);
await this.recordLose(duelPos1Player.name_vpass || duelPos1Player.name);
return;
}
await this.recordWin(duelPos1Player.name_vpass || duelPos1Player.name);
await this.recordLose(duelPos0Player.name_vpass || duelPos0Player.name);
}
private async getOrCreateScore(name: string) {
const repo = this.ctx.database?.getRepository(RandomDuelScore);
if (!repo) {
return undefined;
}
let score = await repo.findOneBy({ name });
if (!score) {
score = repo.create({ name });
}
return score;
}
private async recordWin(name: string) {
if (!name) {
return;
}
const repo = this.ctx.database?.getRepository(RandomDuelScore);
if (!repo) {
return;
}
const score = await this.getOrCreateScore(name);
if (!score) {
return;
}
score.win();
await repo.save(score);
}
private async recordLose(name: string) {
if (!name) {
return;
}
const repo = this.ctx.database?.getRepository(RandomDuelScore);
if (!repo) {
return;
}
const score = await this.getOrCreateScore(name);
if (!score) {
return;
}
score.lose();
await repo.save(score);
}
}
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity('random_duel_score')
export class RandomDuelScore {
@PrimaryColumn({ type: 'varchar', length: 64 })
name!: string;
@Index()
@Column('int', { default: 0 })
winCount = 0;
@Index()
@Column('int', { default: 0 })
loseCount = 0;
@Index()
@Column('int', { default: 0 })
fleeCount = 0;
@Column('int', { default: 0 })
winCombo = 0;
win() {
this.winCount += 1;
this.winCombo += 1;
}
lose() {
this.loseCount += 1;
this.winCombo = 0;
}
flee() {
this.fleeCount += 1;
this.lose();
}
}
import { Client } from '../../client';
import { Room } from '../../room';
import { ValueContainer } from '../../utility/value-container';
export class CanReconnectCheck extends ValueContainer<boolean> {
constructor(
public client: Client,
public room: Room,
) {
super(true);
}
get canReconnect() {
return this.value;
}
no() {
return this.use(false);
}
}
......@@ -22,16 +22,16 @@ import {
ErrorMessageType,
YGOProStocErrorMsg,
} from 'ygopro-msg-encode';
import { Context } from '../app';
import { Client } from '../client';
import { DuelStage } from '../room/duel-stage';
import { Room } from '../room';
import { RoomManager } from '../room/room-manager';
import { getSpecificFields } from '../utility/metadata';
import { YGOProCtosDisconnect } from '../utility/ygopro-ctos-disconnect';
import { isUpdateDeckPayloadEqual } from '../utility/deck-compare';
import { Context } from '../../app';
import { Client } from '../../client';
import { DuelStage, Room, RoomManager } from '../../room';
import { getSpecificFields } from '../../utility/metadata';
import { YGOProCtosDisconnect } from '../../utility/ygopro-ctos-disconnect';
import { isUpdateDeckPayloadEqual } from '../../utility/deck-compare';
import { CanReconnectCheck } from './can-reconnect-check';
interface DisconnectInfo {
key: string;
roomName: string;
clientPos: number;
playerName: string;
......@@ -42,23 +42,24 @@ interface DisconnectInfo {
type ReconnectType = 'normal' | 'kick';
declare module '../client' {
declare module '../../client' {
interface Client {
preReconnecting?: boolean;
reconnectType?: ReconnectType;
preReconnectRoomName?: string; // 临时保存重连的目标房间名
preReconnectDisconnectKey?: string;
}
}
declare module '../room' {
declare module '../../room' {
interface Room {
noReconnect?: boolean;
isLooseReconnectRule?: boolean;
}
}
export class Reconnect {
private disconnectList = new Map<string, DisconnectInfo>();
private isLooseReconnectRule = false; // 宽松匹配模式,日后可能配置支持
private reconnectTimeout = this.ctx.config.getInt('RECONNECT_TIMEOUT'); // 超时时间,单位:毫秒(默认 180000ms = 3分钟)
constructor(private ctx: Context) {
......@@ -93,11 +94,16 @@ export class Reconnect {
return next(); // 正常断线处理
}
if (!this.canReconnect(client)) {
const room = this.getClientRoom(client);
if (!room) {
return next();
}
if (!(await this.canReconnect(client, room))) {
return next(); // 正常断线处理
}
await this.registerDisconnect(client);
await this.registerDisconnect(client, room);
// 不调用 next(),阻止踢人
});
......@@ -119,23 +125,24 @@ export class Reconnect {
});
}
private canReconnect(client: Client): boolean {
const room = this.getClientRoom(client);
if (!room) {
return false;
}
return (
private async canReconnect(client: Client, room: Room): Promise<boolean> {
const canReconnect =
!client.isInternal && // 不是内部虚拟客户端
!room.noReconnect &&
client.pos < NetPlayerType.OBSERVER && // 是玩家
room.duelStage !== DuelStage.Begin // 游戏已开始
room.duelStage !== DuelStage.Begin; // 游戏已开始
if (!canReconnect) {
return false;
}
const check = await this.ctx.dispatch(
new CanReconnectCheck(client, room),
client,
);
return !!check?.canReconnect;
}
private async registerDisconnect(client: Client) {
const room = this.getClientRoom(client)!;
const key = this.getAuthorizeKey(client);
private async registerDisconnect(client: Client, room: Room) {
const key = this.getAuthorizeKey(client, room);
// 通知房间
await room.sendChat(
......@@ -149,6 +156,7 @@ export class Reconnect {
}, this.reconnectTimeout);
this.disconnectList.set(key, {
key,
roomName: room.name,
clientPos: client.pos,
playerName: client.name,
......@@ -162,20 +170,14 @@ export class Reconnect {
newClient: Client,
msg: YGOProCtosJoinGame,
): Promise<boolean> {
const key = this.getAuthorizeKey(newClient);
const disconnectInfo = this.disconnectList.get(key);
let room: Room | undefined;
let oldClient: Client | undefined;
let reconnectType: ReconnectType | undefined;
let disconnectInfo: DisconnectInfo | undefined;
// 1. 尝试正常断线重连
disconnectInfo = this.findDisconnectInfo(newClient, msg.pass);
if (disconnectInfo) {
// 验证房间名(msg.pass 就是房间名)
if (msg.pass !== disconnectInfo.roomName) {
return false;
}
// 获取房间
const roomManager = this.ctx.get(() => RoomManager);
room = roomManager.findByName(disconnectInfo.roomName);
......@@ -191,7 +193,7 @@ export class Reconnect {
// 2. 尝试踢人重连
if (!room) {
const kickTarget = this.findKickReconnectTarget(newClient);
const kickTarget = await this.findKickReconnectTarget(newClient);
if (kickTarget) {
room = this.getClientRoom(kickTarget)!;
oldClient = kickTarget;
......@@ -204,7 +206,13 @@ export class Reconnect {
}
// 进入 pre_reconnect 阶段
await this.sendPreReconnectInfo(newClient, room, oldClient, reconnectType);
await this.sendPreReconnectInfo(
newClient,
room,
oldClient,
reconnectType,
disconnectInfo?.key,
);
return true;
}
......@@ -253,16 +261,20 @@ export class Reconnect {
client.preReconnecting = false;
client.reconnectType = undefined;
client.preReconnectRoomName = undefined;
client.preReconnectDisconnectKey = undefined;
return client.disconnect();
}
client.preReconnecting = false;
client.reconnectType = undefined;
client.preReconnectRoomName = undefined;
const preReconnectDisconnectKey = client.preReconnectDisconnectKey;
client.preReconnectDisconnectKey = undefined;
if (reconnectType === 'normal') {
const key = this.getAuthorizeKey(client);
const disconnectInfo = this.disconnectList.get(key);
const disconnectInfo = preReconnectDisconnectKey
? this.disconnectList.get(preReconnectDisconnectKey)
: undefined;
if (!disconnectInfo) {
await client.sendChat('#{reconnect_failed}', ChatColor.RED);
return client.disconnect();
......@@ -317,11 +329,13 @@ export class Reconnect {
room: Room,
oldClient: Client,
reconnectType: ReconnectType,
disconnectKey?: string,
) {
// 设置 pre_reconnecting 状态
client.preReconnecting = true;
client.reconnectType = reconnectType;
client.preReconnectRoomName = room.name; // 保存目标房间名
client.preReconnectDisconnectKey = disconnectKey;
client.pos = oldClient.pos;
// 发送房间信息
......@@ -358,8 +372,8 @@ export class Reconnect {
): Promise<boolean> {
if (reconnectType === 'normal') {
// 正常重连:验证 disconnectInfo 中的 startDeck
const key = this.getAuthorizeKey(client);
const disconnectInfo = this.disconnectList.get(key);
const key = client.preReconnectDisconnectKey;
const disconnectInfo = key ? this.disconnectList.get(key) : undefined;
if (!disconnectInfo) {
return false;
}
......@@ -750,15 +764,15 @@ export class Reconnect {
return undefined;
}
private getAuthorizeKey(client: Client): string {
private getAuthorizeKey(client: Client, room?: Room): string {
// 参考 srvpro 逻辑
// 如果有 vpass 且不是宽松匹配模式,优先用 name_vpass
if (!this.isLooseReconnectRule && client.vpass) {
if (!room?.isLooseReconnectRule && client.vpass) {
return client.name_vpass;
}
// 宽松匹配模式或内部客户端
if (this.isLooseReconnectRule) {
if (room?.isLooseReconnectRule) {
return client.name || client.ip || 'undefined';
}
......@@ -791,11 +805,12 @@ export class Reconnect {
private clearDisconnectInfo(disconnectInfo: DisconnectInfo) {
clearTimeout(disconnectInfo.timeout);
const key = this.getAuthorizeKey(disconnectInfo.oldClient);
this.disconnectList.delete(key);
this.disconnectList.delete(disconnectInfo.key);
}
private findKickReconnectTarget(newClient: Client): Client | undefined {
private async findKickReconnectTarget(
newClient: Client,
): Promise<Client | undefined> {
const roomManager = this.ctx.get(() => RoomManager);
const allRooms = roomManager.allRooms();
......@@ -807,6 +822,9 @@ export class Reconnect {
// 查找符合条件的在线玩家
for (const player of room.playingPlayers) {
if (!(await this.canReconnect(player, room))) {
continue;
}
// if (player.disconnected) {
// continue; // 跳过已断线的玩家
// }
......@@ -818,7 +836,7 @@ export class Reconnect {
// 宽松模式或匹配条件
const matchCondition =
this.isLooseReconnectRule ||
room.isLooseReconnectRule ||
player.ip === newClient.ip ||
(newClient.vpass && newClient.vpass === player.vpass);
......@@ -830,4 +848,29 @@ export class Reconnect {
return undefined;
}
private findDisconnectInfo(
newClient: Client,
roomName: string,
): DisconnectInfo | undefined {
const roomManager = this.ctx.get(() => RoomManager);
for (const disconnectInfo of this.disconnectList.values()) {
if (disconnectInfo.roomName !== roomName) {
continue;
}
const room = roomManager.findByName(disconnectInfo.roomName);
if (!room) {
this.clearDisconnectInfo(disconnectInfo);
continue;
}
const key = this.getAuthorizeKey(newClient, room);
if (key !== disconnectInfo.key) {
continue;
}
return disconnectInfo;
}
return undefined;
}
}
export * from './can-reconnect-check';
import {
ChatColor,
NetPlayerType,
YGOProCtosChat,
YGOProCtosHandResult,
YGOProCtosResponse,
YGOProCtosTpResult,
YGOProCtosUpdateDeck,
YGOProMsgResponseBase,
} from 'ygopro-msg-encode';
import { Context } from '../app';
import { Client } from '../client';
import {
DuelStage,
OnRoomFinger,
OnRoomFinalize,
OnRoomLeavePlayer,
OnRoomSelectTp,
Room,
RoomManager,
} from '../room';
export interface WaitForPlayerConfig {
roomFilter: (room: Room) => boolean;
raadyTimeoutMs?: number;
hangTimeoutMs?: number;
longAgoBackoffMs: number;
}
declare module '../room' {
interface Room {
waitForPlayerPos?: number;
waitingForPlayerOther?: number[];
lastActiveTime?: Date;
waitForPlayerTickRuntimeId?: number;
waitForPlayerReadyDeadlineMs?: number;
waitForPlayerReadyWarnRemain?: number;
waitForPlayerReadyTargetPos?: number;
waitForPlayerHangWarnElapsed?: number;
}
}
interface WaitForPlayerTickRuntime {
id: number;
options: Required<WaitForPlayerConfig>;
ticking: boolean;
timer: ReturnType<typeof setInterval>;
}
export class WaitForPlayerProvider {
private logger = this.ctx.createLogger(this.constructor.name);
private roomManager = this.ctx.get(() => RoomManager);
private tickRuntimes = new Map<number, WaitForPlayerTickRuntime>();
private nextTickId = 1;
constructor(private ctx: Context) {
this.ctx.middleware(
YGOProMsgResponseBase,
async (_msg, client, next) => {
const room = this.getRoom(client);
if (!room || !this.hasTickForRoom(room)) {
return next();
}
try {
return await next();
} finally {
this.setWaitForPlayer(room, client);
this.refreshLastActiveTime(room);
}
},
true,
);
this.ctx.middleware(
YGOProCtosResponse,
async (_msg, client, next) => {
const room = this.getRoom(client);
if (!room || !this.hasTickForRoom(room)) {
return next();
}
try {
return await next();
} finally {
this.refreshLastActiveTime(room);
}
},
true,
);
this.ctx.middleware(
YGOProCtosHandResult,
async (_msg, client, next) => {
const room = this.getRoom(client);
if (!room || !this.hasTickForRoom(room)) {
return next();
}
try {
return await next();
} finally {
if (room.duelStage === DuelStage.Finger) {
this.resolveWaitForPlayer(room, client);
}
this.refreshLastActiveTime(room, this.getLongAgoBackoffMs(room));
}
},
true,
);
this.ctx.middleware(
YGOProCtosTpResult,
async (_msg, client, next) => {
const room = this.getRoom(client);
if (!room || !this.hasTickForRoom(room)) {
return next();
}
try {
return await next();
} finally {
if (room.duelStage === DuelStage.FirstGo) {
this.resolveWaitForPlayer(room, client);
}
this.refreshLastActiveTime(room);
}
},
true,
);
this.ctx.middleware(
YGOProCtosUpdateDeck,
async (_msg, client, next) => {
const room = this.getRoom(client);
if (!room || !this.hasTickForRoom(room) || room.duelStage !== DuelStage.Begin) {
return next();
}
try {
return await next();
} finally {
this.resolveWaitForPlayer(room, client);
this.refreshLastActiveTime(room);
}
},
true,
);
this.ctx.middleware(
YGOProCtosChat,
async (msg, client, next) => {
const room = this.getRoom(client);
if (!room || !this.hasTickForRoom(room)) {
return next();
}
const text = (msg.msg || '').trim();
if (text.startsWith('/')) {
return next();
}
if (
[DuelStage.Finger, DuelStage.FirstGo, DuelStage.Siding].includes(
room.duelStage,
)
) {
return next();
}
try {
return await next();
} finally {
this.refreshLastActiveTime(room);
}
},
true,
);
this.ctx.middleware(OnRoomFinger, async (event, _client, next) => {
const room = event.room;
if (!this.hasTickForRoom(room)) {
return next();
}
this.setWaitForPlayer(room, ...event.fingerPlayers);
this.refreshLastActiveTime(room, this.getLongAgoBackoffMs(room));
return next();
});
this.ctx.middleware(OnRoomSelectTp, async (event, _client, next) => {
const room = event.room;
if (!this.hasTickForRoom(room)) {
return next();
}
this.setWaitForPlayer(room, event.selector);
this.refreshLastActiveTime(room);
return next();
});
this.ctx.middleware(OnRoomLeavePlayer, async (event, _client, next) => {
const room = event.room;
if (!this.hasTickForRoom(room)) {
return next();
}
if (room.waitForPlayerPos === event.oldPos) {
room.waitForPlayerPos = undefined;
}
room.waitingForPlayerOther = (room.waitingForPlayerOther || []).filter(
(pos) => pos !== event.oldPos,
);
return next();
});
this.ctx.middleware(OnRoomFinalize, async (event, _client, next) => {
const room = event.room;
this.clearTickRoomState(room);
room.waitForPlayerPos = undefined;
room.waitingForPlayerOther = undefined;
room.lastActiveTime = undefined;
return next();
});
}
registerTick(options: WaitForPlayerConfig) {
const runtimeOptions: Required<WaitForPlayerConfig> = {
roomFilter: options.roomFilter,
raadyTimeoutMs: Math.max(0, options.raadyTimeoutMs || 0),
hangTimeoutMs: Math.max(0, options.hangTimeoutMs || 0),
longAgoBackoffMs: Math.max(0, options.longAgoBackoffMs || 0),
};
const id = this.nextTickId;
this.nextTickId += 1;
const runtime: WaitForPlayerTickRuntime = {
id,
options: runtimeOptions,
ticking: false,
timer: setInterval(() => {
void this.tickRuntime(id).catch((error) => {
this.logger.warn({ error, id }, 'Failed to tick wait-for-player');
});
}, 1000),
};
this.tickRuntimes.set(id, runtime);
return id;
}
private getRoom(client: Client): Room | undefined {
if (!client.roomName) {
return undefined;
}
return this.roomManager.findByName(client.roomName);
}
private getLongAgoBackoffMs(room: Room) {
return this.getFirstMatchedRuntime(room)?.options.longAgoBackoffMs || 0;
}
private getMatchedTickRuntimes(room: Room) {
return [...this.tickRuntimes.values()].filter((runtime) =>
runtime.options.roomFilter(room),
);
}
private getFirstMatchedRuntime(room: Room) {
return this.getMatchedTickRuntimes(room)[0];
}
private hasTickForRoom(room: Room) {
return !!this.getFirstMatchedRuntime(room);
}
private async tickRuntime(id: number) {
const runtime = this.tickRuntimes.get(id);
if (!runtime || runtime.ticking) {
return;
}
runtime.ticking = true;
try {
const nowMs = Date.now();
for (const room of this.roomManager.allRooms()) {
const firstMatchedRuntime = this.getFirstMatchedRuntime(room);
if (firstMatchedRuntime?.id !== id) {
if (room.waitForPlayerTickRuntimeId === id) {
this.clearRoomState(room);
}
continue;
}
if (room.waitForPlayerTickRuntimeId !== id) {
this.clearRoomState(room);
room.waitForPlayerTickRuntimeId = id;
}
await this.tickReadyTimeout(runtime, room, nowMs);
await this.tickHangTimeout(runtime, room, nowMs);
}
} finally {
runtime.ticking = false;
}
}
private clearTickRoomState(room: Room) {
this.clearRoomState(room);
}
private clearRoomState(room: Room) {
this.clearReadyState(room);
room.waitForPlayerHangWarnElapsed = undefined;
room.waitForPlayerTickRuntimeId = undefined;
}
private clearReadyState(room: Room) {
room.waitForPlayerReadyDeadlineMs = undefined;
room.waitForPlayerReadyWarnRemain = undefined;
room.waitForPlayerReadyTargetPos = undefined;
}
private getDisconnectedCount(room: Room) {
return room.playingPlayers.filter((player) => !!player.disconnected).length;
}
private getReadyTimeoutTarget(room: Room) {
const players = room.playingPlayers.filter(
(player) =>
!player.disconnected &&
player.roomName === room.name &&
player.pos < NetPlayerType.OBSERVER,
);
const requiredPlayerCount = room.players.length;
if (players.length < requiredPlayerCount) {
return undefined;
}
const unreadyPlayers = players.filter((player) => !player.deck);
if (unreadyPlayers.length !== 1) {
return undefined;
}
return unreadyPlayers[0];
}
private async tickReadyTimeout(
runtime: WaitForPlayerTickRuntime,
room: Room,
nowMs: number,
) {
if (
runtime.options.raadyTimeoutMs <= 0 ||
room.duelStage !== DuelStage.Begin ||
this.getDisconnectedCount(room) > 0
) {
this.clearReadyState(room);
return;
}
const target = this.getReadyTimeoutTarget(room);
if (!target) {
this.clearReadyState(room);
return;
}
if (room.waitForPlayerReadyTargetPos !== target.pos) {
room.waitForPlayerReadyTargetPos = target.pos;
room.waitForPlayerReadyDeadlineMs = nowMs + runtime.options.raadyTimeoutMs;
room.waitForPlayerReadyWarnRemain = undefined;
}
const readyDeadlineMs = room.waitForPlayerReadyDeadlineMs;
if (!readyDeadlineMs) {
this.clearReadyState(room);
return;
}
const remainSeconds = Math.ceil((readyDeadlineMs - nowMs) / 1000);
if (remainSeconds > 0) {
if (
remainSeconds % 5 === 0 &&
room.waitForPlayerReadyWarnRemain !== remainSeconds
) {
room.waitForPlayerReadyWarnRemain = remainSeconds;
await room.sendChat(
`${target.name} ${remainSeconds} #{kick_count_down}`,
remainSeconds <= 9 ? ChatColor.RED : ChatColor.LIGHTBLUE,
);
}
return;
}
const latestTarget = this.getReadyTimeoutTarget(room);
this.clearReadyState(room);
if (
!latestTarget ||
latestTarget.pos !== target.pos ||
latestTarget.disconnected ||
latestTarget.roomName !== room.name ||
!!latestTarget.deck
) {
return;
}
await room.sendChat(`${latestTarget.name} #{kicked_by_system}`, ChatColor.RED);
latestTarget.disconnect();
}
private async tickHangTimeout(
runtime: WaitForPlayerTickRuntime,
room: Room,
nowMs: number,
) {
if (
runtime.options.hangTimeoutMs <= 0 ||
room.duelStage === DuelStage.Begin ||
room.duelStage === DuelStage.Siding
) {
room.waitForPlayerHangWarnElapsed = undefined;
return;
}
if (this.getDisconnectedCount(room) > 0) {
return;
}
const waitingPos = room.waitForPlayerPos;
if (waitingPos == null) {
room.waitForPlayerHangWarnElapsed = undefined;
return;
}
const waitingPlayer = room.players[waitingPos];
if (
!waitingPlayer ||
waitingPlayer.pos !== waitingPos ||
waitingPlayer.pos >= NetPlayerType.OBSERVER ||
waitingPlayer.disconnected ||
waitingPlayer.roomName !== room.name
) {
room.waitForPlayerHangWarnElapsed = undefined;
return;
}
if (!room.lastActiveTime) {
return;
}
const elapsedMs = nowMs - room.lastActiveTime.getTime();
if (elapsedMs >= runtime.options.hangTimeoutMs) {
room.lastActiveTime = new Date(nowMs);
room.waitForPlayerHangWarnElapsed = undefined;
await room.sendChat(
`${waitingPlayer.name} #{kicked_by_system}`,
ChatColor.RED,
);
waitingPlayer.disconnect();
return;
}
const elapsedSeconds = Math.floor(elapsedMs / 1000);
if (
elapsedMs >= runtime.options.hangTimeoutMs - 20_000 &&
elapsedSeconds % 10 === 0 &&
room.waitForPlayerHangWarnElapsed !== elapsedSeconds
) {
room.waitForPlayerHangWarnElapsed = elapsedSeconds;
const remainSeconds = Math.ceil(
(runtime.options.hangTimeoutMs - elapsedMs) / 1000,
);
if (remainSeconds > 0) {
await room.sendChat(
`${waitingPlayer.name} #{afk_warn_part1}${remainSeconds}#{afk_warn_part2}`,
ChatColor.RED,
);
}
}
}
private setWaitForPlayer(room: Room, ...clients: (Client | undefined)[]) {
const playerPoses = clients
.filter(
(client): client is Client =>
!!client && client.pos < NetPlayerType.OBSERVER,
)
.map((client) => client.pos);
const uniquePoses = Array.from(new Set(playerPoses));
room.waitForPlayerPos = uniquePoses[0];
room.waitingForPlayerOther = uniquePoses.slice(1);
this.logger.debug(
{
roomName: room.name,
waitForPlayerPos: room.waitForPlayerPos,
waitingForPlayerOther: room.waitingForPlayerOther,
},
'Set wait for player',
);
}
private resolveWaitForPlayer(room: Room, client: Client) {
const waitingOthers = [...(room.waitingForPlayerOther || [])];
if (client.pos === room.waitForPlayerPos) {
room.waitForPlayerPos = waitingOthers.shift();
room.waitingForPlayerOther = waitingOthers;
return;
}
const otherIndex = waitingOthers.indexOf(client.pos);
if (otherIndex >= 0) {
waitingOthers.splice(otherIndex, 1);
room.waitingForPlayerOther = waitingOthers;
}
this.logger.debug(
{
roomName: room.name,
waitForPlayerPos: room.waitForPlayerPos,
waitingForPlayerOther: room.waitingForPlayerOther,
},
'Resolved wait for player',
);
}
private refreshLastActiveTime(room: Room, backoffMs = 0) {
room.lastActiveTime = new Date(Date.now() - Math.max(0, backoffMs));
this.logger.debug(
{
roomName: room.name,
lastActiveTime: room.lastActiveTime,
},
'Refreshed last active time for wait for player',
);
}
}
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { Context } from '../../app';
import { WindBotProvider } from './windbot-provider';
import { RoomManager } from '../room';
import { fillRandomString } from '../utility/fill-random-string';
import { RoomManager } from '../../room';
import { MAX_ROOM_NAME_LENGTH } from '../../constants/room';
import { fillRandomString } from '../../utility/fill-random-string';
import { parseWindbotOptions } from './utility';
const getDisplayLength = (text: string) =>
......@@ -25,6 +26,7 @@ export class JoinWindbotAi {
const existingRoom = this.roomManager.findByName(msg.pass);
if (existingRoom) {
existingRoom.noHost = true;
return existingRoom.join(client);
}
......@@ -49,6 +51,7 @@ export class JoinWindbotAi {
lflist: -1,
time_limit: 0,
});
room.noHost = true;
room.noReconnect = true;
room.windbot = {
name: '',
......@@ -102,7 +105,7 @@ export class JoinWindbotAi {
} else {
prefix = `${pass}#`;
}
const roomName = fillRandomString(prefix, 19);
const roomName = fillRandomString(prefix, MAX_ROOM_NAME_LENGTH);
if (!this.roomManager.findByName(roomName)) {
return roomName;
}
......
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { Context } from '../../app';
import { WindBotProvider } from './windbot-provider';
import { RoomManager } from '../room';
import { RoomManager } from '../../room';
export class JoinWindbotToken {
private windbotProvider = this.ctx.get(() => WindBotProvider);
......
import { Observable, fromEvent, merge } from 'rxjs';
import { map, take } from 'rxjs/operators';
import WebSocket, { RawData } from 'ws';
import { Context } from '../app';
import { Client } from '../client';
import { Context } from '../../app';
import { Client } from '../../client';
export class ReverseWsClient extends Client {
constructor(
......
import { createAppContext } from 'nfkit';
import { ContextState } from '../app';
import { ContextState } from '../../app';
import { WindBotProvider } from './windbot-provider';
import { WindbotSpawner } from './windbot-spawner';
......
......@@ -2,9 +2,9 @@ import cryptoRandomString from 'crypto-random-string';
import * as fs from 'node:fs/promises';
import { ChatColor } from 'ygopro-msg-encode';
import WebSocket from 'ws';
import { Context } from '../app';
import { ClientHandler } from '../client';
import { OnRoomFinalize, Room } from '../room';
import { Context } from '../../app';
import { ClientHandler } from '../../client';
import { OnRoomFinalize, Room } from '../../room';
import type {
RequestWindbotJoinOptions,
WindbotData,
......@@ -12,13 +12,13 @@ import type {
} from './utility';
import { ReverseWsClient } from './reverse-ws-client';
declare module '../client' {
declare module '../../client' {
interface Client {
windbot?: WindbotData;
}
}
declare module '../room' {
declare module '../../room' {
interface Room {
windbot?: WindbotData;
}
......
import { ChildProcess, spawn } from 'node:child_process';
import { Context } from '../app';
import { Context } from '../../app';
import { WindBotProvider } from './windbot-provider';
export class WindbotSpawner {
......
import { createAppContext } from 'nfkit';
import { ContextState } from '../app';
import { ClientVersionCheck } from '../feats/client-version-check';
import { JoinWindbotAi, JoinWindbotToken } from '../windbot';
import { ClientVersionCheck } from '../feats';
import { JoinWindbotAi, JoinWindbotToken } from '../feats/windbot';
import { JoinRoom } from './join-room';
import { JoinFallback } from './fallback';
import { JoinPrechecks } from './join-prechecks';
import { RandomDuelJoinHandler } from './random-duel-join-handler';
export const JoinHandlerModule = createAppContext<ContextState>()
.provide(ClientVersionCheck)
.provide(JoinPrechecks)
.provide(JoinWindbotToken)
.provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi)
.provide(JoinRoom)
.provide(JoinFallback)
......
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { RandomDuelProvider } from '../feats';
export class RandomDuelJoinHandler {
private randomDuelProvider = this.ctx.get(() => RandomDuelProvider);
constructor(private ctx: Context) {
if (!this.randomDuelProvider.enabled) {
return;
}
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim();
const type = this.randomDuelProvider.resolveRandomType(msg.pass);
if (type == null) {
return next();
}
const room = await this.randomDuelProvider.findOrCreateRandomRoom(
type,
client.ip,
);
if (!room) {
return client.die('#{create_room_failed}', ChatColor.RED);
}
return room.join(client);
});
}
}
......@@ -2,8 +2,11 @@ export * from './room';
export * from './room-manager';
export * from './duel-stage';
export * from './room-event/on-room-finalize';
export * from './room-event/on-room-finger';
export * from './room-event/on-room-game-start';
export * from './room-event/on-room-join-player';
export * from './room-event/on-room-leave-player';
export * from './room-event/on-room-select-tp';
export * from './room-event/on-room-siding-ready';
export * from './room-event/on-room-siding-start';
export * from './room-event/on-room-win';
......
import { Client } from '../../client';
import { Room } from '../room';
import { RoomEvent } from './room-event';
export class OnRoomFinger extends RoomEvent {
constructor(room: Room, public fingerPlayers: [Client, Client]) {
super(room);
}
}
import { Client } from '../../client';
import { Room } from '../room';
import { RoomEvent } from './room-event';
export class OnRoomSelectTp extends RoomEvent {
constructor(room: Room, public selector: Client) {
super(room);
}
}
......@@ -52,6 +52,7 @@ import {
YGOProCtosTimeConfirm,
YGOProMsgWaiting,
YGOProStocTimeLimit,
YGOProMsgMatchKill,
} from 'ygopro-msg-encode';
import { DefaultHostInfoProvider } from './default-hostinfo-provder';
import {
......@@ -96,6 +97,8 @@ import { OnRoomCreate } from './room-event/on-room-create';
import { OnRoomFinalize } from './room-event/on-room-finalize';
import { OnRoomSidingStart } from './room-event/on-room-siding-start';
import { OnRoomSidingReady } from './room-event/on-room-siding-ready';
import { OnRoomFinger } from './room-event/on-room-finger';
import { OnRoomSelectTp } from './room-event/on-room-select-tp';
const { OcgcoreScriptConstants } = _OcgcoreConstants;
......@@ -124,6 +127,7 @@ export class Room {
return (this.hostinfo.mode & 0x2) !== 0;
}
noHost = false;
players = new Array<Client | undefined>(this.isTag ? 4 : 2);
watchers = new Set<Client>();
get playingPlayers() {
......@@ -335,17 +339,17 @@ export class Room {
) || [];
for (const message of observerMessages) {
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: message.observerView(),
}),
);
new YGOProStocGameMsg().fromPartial({
msg: message.observerView(),
}),
);
}
}
}
async join(client: Client) {
client.roomName = this.name;
client.isHost = !this.allPlayers.length;
client.isHost = this.noHost ? false : !this.allPlayers.length;
const firstEmptyPlayerSlot = this.players.findIndex((p) => !p);
const isPlayer =
firstEmptyPlayerSlot >= 0 && this.duelStage === DuelStage.Begin;
......@@ -401,10 +405,27 @@ export class Room {
duelStage = DuelStage.Begin;
duelRecords: DuelRecord[] = [];
private overrideScore?: [number | undefined, number | undefined];
setOverrideScore(duelPos: 0 | 1, value: number) {
this.overrideScore = this.overrideScore || [undefined, undefined];
this.overrideScore[duelPos] = value;
}
get score() {
return [0, 1].map(
(p) => this.duelRecords.filter((d) => d.winPosition === p).length,
);
const score: [number, number] = [0, 0];
for (const duelRecord of this.duelRecords) {
if (duelRecord.winPosition === 0 || duelRecord.winPosition === 1) {
score[duelRecord.winPosition] += 1;
}
}
for (const duelPos of [0, 1] as const) {
const override = this.overrideScore?.[duelPos];
if (override != null) {
score[duelPos] = override;
}
}
return score;
}
private async sendReplays(client: Client) {
......@@ -437,7 +458,10 @@ export class Room {
for (const p of this.watchers) {
p.send(new YGOProStocWaitingSide());
}
await this.ctx.dispatch(new OnRoomSidingStart(this), this.playingPlayers[0]);
await this.ctx.dispatch(
new OnRoomSidingStart(this),
this.playingPlayers[0],
);
}
get lastDuelRecord() {
......@@ -451,7 +475,7 @@ export class Room {
} catch {}
}
async win(winMsg: Partial<YGOProMsgWin>, forceWinMatch = false) {
async win(winMsg: Partial<YGOProMsgWin>, forceWinMatch?: number) {
this.resetResponseState();
this.disposeOcgcore();
this.ocgcore = undefined;
......@@ -481,10 +505,16 @@ export class Room {
if (lastDuelRecord) {
lastDuelRecord.winPosition = duelPos;
}
if (typeof forceWinMatch === 'number') {
const loseDuelPos = (1 - duelPos) as 0 | 1;
this.setOverrideScore(loseDuelPos, -Math.abs(forceWinMatch));
}
const score = this.score;
this.logger.debug(
`Player ${duelPos} wins the duel. Current score: ${this.score.join('-')}`,
`Player ${duelPos} wins the duel. Current score: ${score.join('-')}`,
);
const winMatch = forceWinMatch || this.score[duelPos] >= this.winMatchCount;
const winMatch =
forceWinMatch != null || score[duelPos] >= this.winMatchCount;
if (!winMatch) {
await this.changeSide();
}
......@@ -533,10 +563,9 @@ export class Room {
p.send(client.prepareChangePacket(PlayerChangeState.LEAVE));
});
} else {
this.score[this.getDuelPos(client)] = -9;
await this.win(
{ player: 1 - this.getIngameDuelPos(client), type: 0x4 },
true,
9,
);
}
if (client.isHost) {
......@@ -805,6 +834,12 @@ export class Room {
// Auto-ready: send PlayerChange READY to all players (client.deck 已设置,自动为 READY)
const changeMsg = client.prepareChangePacket();
this.allPlayers.forEach((p) => p.send(changeMsg));
if (this.noHost) {
const allReadyAndFull = this.players.every((player) => !!player?.deck);
if (allReadyAndFull) {
await this.startGame();
}
}
} else if (this.duelStage === DuelStage.Siding) {
// In Siding stage, send DUEL_START to the player who submitted deck
// Siding 阶段不发 DeckCount
......@@ -910,16 +945,27 @@ export class Room {
this.firstgoPos = firstgoPos;
this.duelStage = DuelStage.FirstGo;
const firstgoPlayer = this.getDuelPosPlayers(firstgoPos)[0];
if (!firstgoPlayer) {
return;
}
firstgoPlayer.send(new YGOProStocSelectTp());
await this.ctx.dispatch(
new OnRoomSelectTp(this, firstgoPlayer),
firstgoPlayer,
);
}
private async toFinger() {
this.duelStage = DuelStage.Finger;
// 只有每方的第一个玩家猜拳
const fingerPlayers = [0, 1].map((p) => this.getDuelPosPlayers(p)[0]);
fingerPlayers.forEach((p) => {
p.send(new YGOProStocSelectHand());
});
const duelPos0 = this.getDuelPosPlayers(0)[0];
const duelPos1 = this.getDuelPosPlayers(1)[0];
if (!duelPos0 || !duelPos1) {
return;
}
duelPos0.send(new YGOProStocSelectHand());
duelPos1.send(new YGOProStocSelectHand());
await this.ctx.dispatch(new OnRoomFinger(this, [duelPos0, duelPos1]), duelPos0);
}
@RoomMethod({ allowInDuelStages: DuelStage.Finger })
......@@ -1618,8 +1664,13 @@ export class Room {
);
}
return next();
})
.middleware(YGOProMsgMatchKill, async (message, next) => {
this.matchKilled = true;
return next();
});
private matchKilled = false;
private responsePos?: number;
private async advance() {
......@@ -1660,7 +1711,7 @@ export class Room {
const handled = await this.dispatchGameMsg(message);
if (handled instanceof YGOProMsgWin) {
return this.win(handled);
return this.win(handled, this.matchKilled ? 1 : undefined);
}
await this.routeGameMsg(handled);
}
......
import { AppContext } from 'nfkit';
import { DataSource } from 'typeorm';
import { ConfigService } from './config';
import { Logger } from './logger';
import { RandomDuelScore } from '../feats/random-duel';
export class TypeormLoader {
constructor(private ctx: AppContext) {}
database: DataSource | undefined;
setDatabase(database: DataSource | undefined) {
this.database = database;
return this;
}
}
export const TypeormFactory = async (ctx: AppContext) => {
const loader = new TypeormLoader(ctx);
const config = ctx.get(ConfigService).config;
const logger = ctx.get(Logger).createLogger('TypeormLoader');
const host = config.getString('DB_HOST');
if (!host) {
logger.info('database disabled because DB_HOST is empty');
return loader.setDatabase(undefined);
}
const port = config.getInt('DB_PORT') || 5432;
const username = config.getString('DB_USER');
const password = config.getString('DB_PASS');
const database = config.getString('DB_NAME');
const synchronize = !config.getBoolean('DB_NO_INIT');
const dataSource = new DataSource({
type: 'postgres',
host,
port,
username,
password,
database,
synchronize,
entities: [RandomDuelScore],
});
try {
await dataSource.initialize();
logger.info(
{
host,
port,
database,
synchronize,
},
'Database initialized',
);
return loader.setDatabase(dataSource);
} catch (error) {
logger.error(
{
host,
port,
database,
err: error,
},
'Database initialization failed',
);
throw error;
}
};
export class ValueContainer<T> {
constructor(public value: T) {}
use(value: T) {
this.value = value;
return this;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment