From 247c78151cae439dc9fe69d9141e227ddf8af512 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Jan 2026 03:48:00 +0000 Subject: [PATCH] Discord(et al.) embedded URLs (#2740) ## Description: Changes URL embeds within other platforms, e.g. Discord, WhatsApp & X. Updates game URLs to `/game/` instead of `/#join=` (required for embedded URLs). An added benefit of this is that you would be able to change a url from `openfront.io/game/RQDUy8nP?replay` to `api.openfront.io/game/RQDUy8nP?replay` (add api. In front) and be in the right place for the API data. Updates URLs when joining/leaving private lobbies Appends a random string to the end of the URL when inside a private lobby and options change - this is to force discord to update the embedded details. Updates URL in different game states to ?lobby / ?live and ?replay. These do nothing other than being used as a _cache-busting_ solution. ----------------------------------------------- ### **Lobby Info** Discord: image WhatsApp: image x.com: image ------------------------- ### **Game Win Details** Discord: image WhatsApp: image x.com image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- nginx.conf | 170 ++++------- package-lock.json | 270 ++++++++---------- package.json | 1 + src/client/AccountModal.ts | 16 +- src/client/HostLobbyModal.ts | 54 +++- src/client/JoinPrivateLobbyModal.ts | 77 +++-- src/client/Main.ts | 50 +++- src/client/components/baseComponents/Modal.ts | 4 +- src/client/graphics/layers/WinModal.ts | 2 + src/core/Schemas.ts | 24 +- src/server/GamePreviewBuilder.ts | 267 +++++++++++++++++ src/server/GamePreviewRoute.ts | 161 +++++++++++ src/server/Master.ts | 25 +- src/server/RenderHtml.ts | 31 ++ src/server/Worker.ts | 25 ++ 15 files changed, 832 insertions(+), 345 deletions(-) create mode 100644 src/server/GamePreviewBuilder.ts create mode 100644 src/server/GamePreviewRoute.ts create mode 100644 src/server/RenderHtml.ts diff --git a/nginx.conf b/nginx.conf index 59a1bc30a..12e7282f1 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,63 +1,12 @@ -# Map URI to ports -map $uri $port { - ~^/w0/ 3001; - ~^/w1/ 3002; - ~^/w2/ 3003; - ~^/w3/ 3004; - ~^/w4/ 3005; - ~^/w5/ 3006; - ~^/w6/ 3007; - ~^/w7/ 3008; - ~^/w8/ 3009; - ~^/w9/ 3010; - ~^/w10/ 3011; - ~^/w11/ 3012; - ~^/w12/ 3013; - ~^/w13/ 3014; - ~^/w14/ 3015; - ~^/w15/ 3016; - ~^/w16/ 3017; - ~^/w17/ 3018; - ~^/w18/ 3019; - ~^/w19/ 3020; - ~^/w20/ 3021; - ~^/w21/ 3022; - ~^/w22/ 3023; - ~^/w23/ 3024; - ~^/w24/ 3025; - ~^/w25/ 3026; - ~^/w26/ 3027; - ~^/w27/ 3028; - ~^/w28/ 3029; - ~^/w29/ 3030; - ~^/w30/ 3031; - ~^/w31/ 3032; - ~^/w32/ 3033; - ~^/w33/ 3034; - ~^/w34/ 3035; - ~^/w35/ 3036; - ~^/w36/ 3037; - ~^/w37/ 3038; - ~^/w38/ 3039; - ~^/w39/ 3040; - ~^/w40/ 3041; - default 3000; -} - # WebSocket settings map $http_upgrade $connection_upgrade { default upgrade; '' close; } -# WebSocket path handling -map $uri $uri_path { - ~^/w\d+(/.*)?$ $1; - default $uri; -} - # Cache configuration proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:10m inactive=24h max_size=1g; + # API cache for frequently requested endpoints proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=API_CACHE:10m inactive=60m max_size=100m; @@ -67,6 +16,65 @@ server { # Logging access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; + + # Worker locations - Processing this first so worker-specific requests are handled by workers + # This prevents static file regexes from capturing worker requests + location ~* ^/w(\d+)(/.*)?$ { + set $worker $1; + set $worker_port 3001; + + if ($worker = "0") { set $worker_port 3001; } + if ($worker = "1") { set $worker_port 3002; } + if ($worker = "2") { set $worker_port 3003; } + if ($worker = "3") { set $worker_port 3004; } + if ($worker = "4") { set $worker_port 3005; } + if ($worker = "5") { set $worker_port 3006; } + if ($worker = "6") { set $worker_port 3007; } + if ($worker = "7") { set $worker_port 3008; } + if ($worker = "8") { set $worker_port 3009; } + if ($worker = "9") { set $worker_port 3010; } + if ($worker = "10") { set $worker_port 3011; } + if ($worker = "11") { set $worker_port 3012; } + if ($worker = "12") { set $worker_port 3013; } + if ($worker = "13") { set $worker_port 3014; } + if ($worker = "14") { set $worker_port 3015; } + if ($worker = "15") { set $worker_port 3016; } + if ($worker = "16") { set $worker_port 3017; } + if ($worker = "17") { set $worker_port 3018; } + if ($worker = "18") { set $worker_port 3019; } + if ($worker = "19") { set $worker_port 3020; } + if ($worker = "20") { set $worker_port 3021; } + if ($worker = "21") { set $worker_port 3022; } + if ($worker = "22") { set $worker_port 3023; } + if ($worker = "23") { set $worker_port 3024; } + if ($worker = "24") { set $worker_port 3025; } + if ($worker = "25") { set $worker_port 3026; } + if ($worker = "26") { set $worker_port 3027; } + if ($worker = "27") { set $worker_port 3028; } + if ($worker = "28") { set $worker_port 3029; } + if ($worker = "29") { set $worker_port 3030; } + if ($worker = "30") { set $worker_port 3031; } + if ($worker = "31") { set $worker_port 3032; } + if ($worker = "32") { set $worker_port 3033; } + if ($worker = "33") { set $worker_port 3034; } + if ($worker = "34") { set $worker_port 3035; } + if ($worker = "35") { set $worker_port 3036; } + if ($worker = "36") { set $worker_port 3037; } + if ($worker = "37") { set $worker_port 3038; } + if ($worker = "38") { set $worker_port 3039; } + if ($worker = "39") { set $worker_port 3040; } + if ($worker = "40") { set $worker_port 3041; } + + # Preserve query string by appending $is_args$args + proxy_pass http://127.0.0.1:$worker_port$2$is_args$args; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } # Static file handling with proper MIME types and consistent caching location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|eot)$ { @@ -221,6 +229,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Root location with short Nginx cache but no browser cache location = / { proxy_pass http://127.0.0.1:3000; @@ -260,61 +269,4 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Worker locations - location ~* ^/w(\d+)(/.*)?$ { - set $worker $1; - set $worker_port 3001; - - if ($worker = "0") { set $worker_port 3001; } - if ($worker = "1") { set $worker_port 3002; } - if ($worker = "2") { set $worker_port 3003; } - if ($worker = "3") { set $worker_port 3004; } - if ($worker = "4") { set $worker_port 3005; } - if ($worker = "5") { set $worker_port 3006; } - if ($worker = "6") { set $worker_port 3007; } - if ($worker = "7") { set $worker_port 3008; } - if ($worker = "8") { set $worker_port 3009; } - if ($worker = "9") { set $worker_port 3010; } - if ($worker = "10") { set $worker_port 3011; } - if ($worker = "11") { set $worker_port 3012; } - if ($worker = "12") { set $worker_port 3013; } - if ($worker = "13") { set $worker_port 3014; } - if ($worker = "14") { set $worker_port 3015; } - if ($worker = "15") { set $worker_port 3016; } - if ($worker = "16") { set $worker_port 3017; } - if ($worker = "17") { set $worker_port 3018; } - if ($worker = "18") { set $worker_port 3019; } - if ($worker = "19") { set $worker_port 3020; } - if ($worker = "20") { set $worker_port 3021; } - if ($worker = "21") { set $worker_port 3022; } - if ($worker = "22") { set $worker_port 3023; } - if ($worker = "23") { set $worker_port 3024; } - if ($worker = "24") { set $worker_port 3025; } - if ($worker = "25") { set $worker_port 3026; } - if ($worker = "26") { set $worker_port 3027; } - if ($worker = "27") { set $worker_port 3028; } - if ($worker = "28") { set $worker_port 3029; } - if ($worker = "29") { set $worker_port 3030; } - if ($worker = "30") { set $worker_port 3031; } - if ($worker = "31") { set $worker_port 3032; } - if ($worker = "32") { set $worker_port 3033; } - if ($worker = "33") { set $worker_port 3034; } - if ($worker = "34") { set $worker_port 3035; } - if ($worker = "35") { set $worker_port 3036; } - if ($worker = "36") { set $worker_port 3037; } - if ($worker = "37") { set $worker_port 3038; } - if ($worker = "38") { set $worker_port 3039; } - if ($worker = "39") { set $worker_port 3040; } - if ($worker = "40") { set $worker_port 3041; } - - # Preserve query string by appending $is_args$args - proxy_pass http://127.0.0.1:$worker_port$2$is_args$args; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ae21c1e0f..446dd7af2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "jose": "^6.0.10", "js-yaml": "^4.1.1", "nanoid": "^3.3.6", + "node-html-parser": "^7.0.2", "obscenity": "^0.4.3", "seedrandom": "^3.0.5", "ts-node": "^10.9.2", @@ -3950,66 +3951,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.7.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.7.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -5544,7 +5485,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, "license": "ISC" }, "node_modules/bowser": { @@ -6271,16 +6211,15 @@ } }, "node_modules/css-select": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", - "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", - "dev": true, + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", - "css-what": "^6.0.1", - "domhandler": "^4.3.1", - "domutils": "^2.8.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", "nth-check": "^2.0.1" }, "funding": { @@ -6302,10 +6241,9 @@ } }, "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "dev": true, + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -6919,35 +6857,23 @@ } }, "node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "dev": true, + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "license": "MIT", "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" }, "funding": { "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/dom-serializer/node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "dev": true, - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, "funding": [ { "type": "github", @@ -6957,13 +6883,12 @@ "license": "BSD-2-Clause" }, "node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "dev": true, + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "license": "BSD-2-Clause", "dependencies": { - "domelementtype": "^2.2.0" + "domelementtype": "^2.3.0" }, "engines": { "node": ">= 4" @@ -6982,15 +6907,14 @@ } }, "node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "dev": true, + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "license": "BSD-2-Clause", "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" }, "funding": { "url": "https://github.com/fb55/domutils?sponsor=1" @@ -7121,7 +7045,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -8150,7 +8073,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, "license": "MIT", "bin": { "he": "bin/he" @@ -9800,13 +9722,12 @@ } }, "node_modules/node-html-parser": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.4.2.tgz", - "integrity": "sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw==", - "dev": true, + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.2.tgz", + "integrity": "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ==", "license": "MIT", "dependencies": { - "css-select": "^4.2.1", + "css-select": "^5.1.0", "he": "1.2.0" } }, @@ -9841,7 +9762,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" @@ -10846,52 +10766,6 @@ "postcss": "^8.3.11" } }, - "node_modules/sanitize-html/node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/sanitize-html/node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, "node_modules/sanitize-html/node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -12202,6 +12076,79 @@ "node": ">=0.8" } }, + "node_modules/vite-plugin-html/node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/vite-plugin-html/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/vite-plugin-html/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/vite-plugin-html/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/vite-plugin-html/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/vite-plugin-html/node_modules/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -12224,6 +12171,17 @@ "node": ">=12" } }, + "node_modules/vite-plugin-html/node_modules/node-html-parser": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.4.2.tgz", + "integrity": "sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^4.2.1", + "he": "1.2.0" + } + }, "node_modules/vite-plugin-static-copy": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz", diff --git a/package.json b/package.json index ee05b0b9f..5db065892 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "jose": "^6.0.10", "js-yaml": "^4.1.1", "nanoid": "^3.3.6", + "node-html-parser": "^7.0.2", "obscenity": "^0.4.3", "seedrandom": "^3.0.5", "ts-node": "^10.9.2", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 06fe524cf..505537be6 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -5,6 +5,7 @@ import { PlayerStatsTree, UserMeResponse, } from "../core/ApiSchemas"; +import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { fetchPlayerById, getUserMe } from "./Api"; import { discordLogin, logOut, sendMagicLink } from "./Auth"; import "./components/baseComponents/stats/DiscordUserHeader"; @@ -198,7 +199,7 @@ export class AccountModal extends BaseModal { this.viewGame(id)} + .onViewGame=${(id: string) => void this.viewGame(id)} > @@ -229,15 +230,16 @@ export class AccountModal extends BaseModal { return html``; } - private viewGame(gameId: string): void { + private async viewGame(gameId: string): Promise { this.close(); - const path = location.pathname; - const { search } = location; - const hash = `#join=${encodeURIComponent(gameId)}`; - const newUrl = `${path}${search}${hash}`; + const config = await getServerConfigFromClient(); + const encodedGameId = encodeURIComponent(gameId); + const newUrl = `/${config.workerPath(gameId)}/game/${encodedGameId}`; history.pushState({ join: gameId }, "", newUrl); - window.dispatchEvent(new HashChangeEvent("hashchange")); + window.dispatchEvent( + new CustomEvent("join-changed", { detail: { gameId: encodedGameId } }), + ); } private renderLogoutButton(): TemplateResult { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 03f7f1d68..74cfbeaac 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -21,6 +21,7 @@ import { GameConfig, GameInfo, TeamCountConfig, + isValidGameID, } from "../core/Schemas"; import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; @@ -61,6 +62,7 @@ export class HostLobbyModal extends BaseModal { @state() private compactMap: boolean = false; @state() private lobbyId = ""; @state() private copySuccess = false; + @state() private lobbyUrlSuffix = ""; @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @@ -74,6 +76,8 @@ export class HostLobbyModal extends BaseModal { private userSettings: UserSettings = new UserSettings(); private mapLoader = terrainMapFileLoader; + private leaveLobbyOnClose = true; + private renderOptionToggle( labelKey: string, checked: boolean, @@ -100,6 +104,28 @@ export class HostLobbyModal extends BaseModal { `; } + private getRandomString(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + return Array.from( + { length: 5 }, + () => chars[Math.floor(Math.random() * chars.length)], + ).join(""); + } + + private async buildLobbyUrl(): Promise { + const config = await getServerConfigFromClient(); + return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`; + } + + private async constructUrl(): Promise { + this.lobbyUrlSuffix = this.getRandomString(); + return await this.buildLobbyUrl(); + } + + private updateHistory(url: string): void { + history.replaceState(null, "", url); + } + render() { const content = html`
{ - this.leaveLobby(); + this.leaveLobbyOnClose = true; this.close(); }, ariaLabel: translateText("common.back"), @@ -821,9 +847,14 @@ export class HostLobbyModal extends BaseModal { ); createLobby(this.lobbyCreatorClientID) - .then((lobby) => { + .then(async (lobby) => { this.lobbyId = lobby.gameID; + if (!isValidGameID(this.lobbyId)) { + throw new Error(`Invalid lobby ID format: ${this.lobbyId}`); + } crazyGamesSDK.showInviteButton(this.lobbyId); + const url = await this.constructUrl(); + this.updateHistory(url); }) .then(() => { this.dispatchEvent( @@ -895,6 +926,10 @@ export class HostLobbyModal extends BaseModal { protected onClose(): void { console.log("Closing host lobby modal"); + if (this.leaveLobbyOnClose) { + this.leaveLobby(); + this.updateHistory("/"); // Reset URL to base + } crazyGamesSDK.hideInviteButton(); // Clean up timers and resources @@ -933,6 +968,8 @@ export class HostLobbyModal extends BaseModal { this.lobbyCreatorClientID = ""; this.lobbyIdVisible = true; this.nationCount = 0; + + this.leaveLobbyOnClose = true; } private async handleSelectRandomMap() { @@ -1075,6 +1112,8 @@ export class HostLobbyModal extends BaseModal { const spawnImmunityTicks = this.spawnImmunityDurationMinutes ? this.spawnImmunityDurationMinutes * 60 * 10 : 0; + const url = await this.constructUrl(); + this.updateHistory(url); this.dispatchEvent( new CustomEvent("update-game-config", { detail: { @@ -1134,6 +1173,10 @@ export class HostLobbyModal extends BaseModal { console.log( `Starting private game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]} ${this.useRandomMap ? " (Randomly selected)" : ""}`, ); + + // If the modal closes as part of starting the game, do not leave the lobby + this.leaveLobbyOnClose = false; + const config = await getServerConfigFromClient(); const response = await fetch( `${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`, @@ -1144,12 +1187,17 @@ export class HostLobbyModal extends BaseModal { }, }, ); + + if (!response.ok) { + this.leaveLobbyOnClose = true; + } return response; } private async copyToClipboard() { + const url = await this.buildLobbyUrl(); await copyToClipboard( - `${location.origin}/#join=${this.lobbyId}`, + url, () => (this.copySuccess = true), () => (this.copySuccess = false), ); diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index c61342cc8..cb65b428f 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -3,6 +3,7 @@ import { customElement, query, state } from "lit/decorators.js"; import { copyToClipboard, translateText } from "../client/Utils"; import { ClientInfo, + GAME_ID_REGEX, GameConfig, GameInfo, GameRecordSchema, @@ -32,6 +33,8 @@ export class JoinPrivateLobbyModal extends BaseModal { private playersInterval: NodeJS.Timeout | null = null; private userSettings: UserSettings = new UserSettings(); + private leaveLobbyOnClose = true; + updated(changedProperties: Map) { super.updated(changedProperties); } @@ -354,21 +357,10 @@ export class JoinPrivateLobbyModal extends BaseModal { } } - protected onClose(): void { - if (this.lobbyIdInput) this.lobbyIdInput.value = ""; - this.currentLobbyId = ""; - this.gameConfig = null; - this.players = []; - if (this.playersInterval) { - clearInterval(this.playersInterval); - this.playersInterval = null; + private leaveLobby() { + if (!this.currentLobbyId || !this.hasJoined) { + return; } - } - - public closeAndLeave() { - this.close(); - this.hasJoined = false; - this.message = ""; this.dispatchEvent( new CustomEvent("leave-lobby", { detail: { lobby: this.currentLobbyId }, @@ -378,16 +370,43 @@ export class JoinPrivateLobbyModal extends BaseModal { ); } + protected onClose(): void { + if (this.lobbyIdInput) this.lobbyIdInput.value = ""; + this.gameConfig = null; + this.players = []; + if (this.playersInterval) { + clearInterval(this.playersInterval); + this.playersInterval = null; + } + if (this.leaveLobbyOnClose) { + this.leaveLobby(); + // Reset URL to base when modal closes + history.replaceState(null, "", window.location.origin + "/"); + } + + this.hasJoined = false; + this.message = ""; + this.currentLobbyId = ""; + + this.leaveLobbyOnClose = true; + } + + public closeAndLeave() { + this.leaveLobbyOnClose = true; + this.close(); + } + private async copyToClipboard() { + const config = await getServerConfigFromClient(); await copyToClipboard( - `${location.origin}/#join=${this.currentLobbyId}`, + `${location.origin}/${config.workerPath(this.currentLobbyId)}/game/${this.currentLobbyId}`, () => (this.copySuccess = true), () => (this.copySuccess = false), ); } private isValidLobbyId(value: string): boolean { - return /^[a-zA-Z0-9]{8}$/.test(value); + return GAME_ID_REGEX.test(value); } private normalizeLobbyId(input: string): string | null { @@ -403,16 +422,19 @@ export class JoinPrivateLobbyModal extends BaseModal { } private extractLobbyIdFromUrl(input: string): string { - if (input.startsWith("http")) { - if (input.includes("#join=")) { - const params = new URLSearchParams(input.split("#")[1]); - return params.get("join") ?? input; - } else if (input.includes("join/")) { - return input.split("join/")[1]; - } else { - return input; - } - } else { + if (!input.startsWith("http")) { + return input; + } + + try { + const url = new URL(input); + const match = url.pathname.match(/game\/([^/]+)/); + const candidate = match?.[1]; + if (candidate && GAME_ID_REGEX.test(candidate)) return candidate; + + return input; + } catch (error) { + console.warn("Failed to parse lobby URL", error); return input; } } @@ -502,6 +524,9 @@ export class JoinPrivateLobbyModal extends BaseModal { this.message = ""; this.hasJoined = true; + // If the modal closes as part of joining the game, do not leave the lobby + this.leaveLobbyOnClose = false; + this.dispatchEvent( new CustomEvent("join-lobby", { detail: { diff --git a/src/client/Main.ts b/src/client/Main.ts index 732e0f37f..f2cdae39d 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1,7 +1,7 @@ import version from "resources/version.txt?raw"; import { UserMeResponse } from "../core/ApiSchemas"; import { EventBus } from "../core/EventBus"; -import { GameRecord, GameStartInfo, ID } from "../core/Schemas"; +import { GAME_ID_REGEX, GameRecord, GameStartInfo } from "../core/Schemas"; import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; @@ -193,6 +193,7 @@ declare global { interface DocumentEventMap { "join-lobby": CustomEvent; "kick-player": CustomEvent; + "join-changed": CustomEvent; } } @@ -607,6 +608,7 @@ class Client { onHashUpdate(); }); window.addEventListener("hashchange", onHashUpdate); + window.addEventListener("join-changed", onHashUpdate); function updateSliderProgress(slider: HTMLInputElement) { const percent = @@ -632,7 +634,7 @@ class Client { // Check if CrazyGames SDK is enabled first (no hash needed in CrazyGames) if (crazyGamesSDK.isOnCrazyGames()) { const lobbyId = crazyGamesSDK.getInviteGameId(); - if (lobbyId && ID.safeParse(lobbyId).success) { + if (lobbyId && GAME_ID_REGEX.test(lobbyId)) { window.showPage?.("page-join-private-lobby"); this.joinModal?.open(lobbyId); console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`); @@ -708,14 +710,16 @@ class Client { return; } - // Fallback to hash-based join for non-CrazyGames environments - if (decodedHash.startsWith("#join=")) { - const lobbyId = decodedHash.substring(6); // Remove "#join=" - if (lobbyId && ID.safeParse(lobbyId).success) { - window.showPage?.("page-join-private-lobby"); - this.joinModal?.open(lobbyId); - console.log(`joining lobby ${lobbyId}`); - } + const pathMatch = window.location.pathname.match( + /^\/(?:w\d+\/)?game\/([^/]+)/, + ); + const lobbyId = + pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null; + if (lobbyId) { + window.showPage?.("page-join-private-lobby"); + this.joinModal.open(lobbyId); + console.log(`joining lobby ${lobbyId}`); + return; } if (decodedHash.startsWith("#affiliate=")) { const affiliateCode = decodedHash.replace("#affiliate=", ""); @@ -738,6 +742,7 @@ class Client { document.body.classList.remove("in-game"); } const config = await getServerConfigFromClient(); + this.updateJoinUrlForShare(lobby.gameID, config); const pattern = this.userSettings.getSelectedPatternName( await fetchCosmetics(), @@ -778,15 +783,16 @@ class Client { "host-lobby-modal", "join-private-lobby-modal", "game-starting-modal", + "game-top-bar", "help-modal", "user-setting", - "territory-patterns-modal", "language-modal", "news-modal", "flag-input-modal", + "account-button", + "stats-button", "token-login", - "matchmaking-modal", "lang-selector", ].forEach((tag) => { @@ -817,7 +823,7 @@ class Client { this.gutterAds.hide(); }, () => { - this.joinModal?.close(); + this.joinModal.close(); this.publicLobby.stop(); incrementGamesPlayed(); @@ -833,11 +839,27 @@ class Client { if (window.location.hash === "" || window.location.hash === "#") { history.replaceState(null, "", window.location.origin + "#refresh"); } - history.pushState(null, "", `#join=${lobby.gameID}`); + history.pushState( + null, + "", + `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`, + ); }, ); } + private updateJoinUrlForShare( + lobbyId: string, + config: Awaited>, + ) { + const targetUrl = `/${config.workerPath(lobbyId)}/game/${lobbyId}`; + const currentUrl = window.location.pathname; + + if (currentUrl !== targetUrl) { + history.replaceState(null, "", targetUrl); + } + } + private async handleLeaveLobby(/* event: CustomEvent */) { if (this.gameStop === null) { return; diff --git a/src/client/components/baseComponents/Modal.ts b/src/client/components/baseComponents/Modal.ts index 47b948f87..baa877b4c 100644 --- a/src/client/components/baseComponents/Modal.ts +++ b/src/client/components/baseComponents/Modal.ts @@ -73,7 +73,7 @@ export class OModal extends LitElement { ? html`