mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
Discord(et al.) embedded URLs (#2740)
## Description: Changes URL embeds within other platforms, e.g. Discord, WhatsApp & X. Updates game URLs to `/game/<code>` instead of `/#join=<code>` (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: <img width="556" height="487" alt="image" src="https://github.com/user-attachments/assets/efd4a06d-506c-4036-9403-ee7c9a669e21" /> WhatsApp: <img width="353" height="339" alt="image" src="https://github.com/user-attachments/assets/3b2d0c69-988c-424f-9dee-f4e6a6868f6b" /> x.com: <img width="588" height="325" alt="image" src="https://github.com/user-attachments/assets/d9e78169-20be-4a3e-8df4-8ad41d08a750" /> ------------------------- ### **Game Win Details** Discord: <img width="506" height="468" alt="image" src="https://github.com/user-attachments/assets/69947774-c943-4a50-b470-5634ed3bf3d7" /> WhatsApp: <img width="770" height="132" alt="image" src="https://github.com/user-attachments/assets/eec28bf8-bf64-4ab8-954e-03dfdd1aae40" /> x.com <img width="584" height="350" alt="image" src="https://github.com/user-attachments/assets/168063e2-b707-422b-b7a1-0025f3ebeb92" /> ## 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
This commit is contained in:
+61
-109
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+114
-156
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
</h3>
|
||||
<game-list
|
||||
.games=${this.recentGames}
|
||||
.onViewGame=${(id: string) => this.viewGame(id)}
|
||||
.onViewGame=${(id: string) => void this.viewGame(id)}
|
||||
></game-list>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,15 +230,16 @@ export class AccountModal extends BaseModal {
|
||||
return html``;
|
||||
}
|
||||
|
||||
private viewGame(gameId: string): void {
|
||||
private async viewGame(gameId: string): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<string> {
|
||||
const config = await getServerConfigFromClient();
|
||||
return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`;
|
||||
}
|
||||
|
||||
private async constructUrl(): Promise<string> {
|
||||
this.lobbyUrlSuffix = this.getRandomString();
|
||||
return await this.buildLobbyUrl();
|
||||
}
|
||||
|
||||
private updateHistory(url: string): void {
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div
|
||||
@@ -109,7 +135,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
${modalHeader({
|
||||
title: translateText("host_modal.title"),
|
||||
onBack: () => {
|
||||
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),
|
||||
);
|
||||
|
||||
@@ -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<string | number | symbol, unknown>) {
|
||||
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: {
|
||||
|
||||
+36
-14
@@ -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<JoinLobbyEvent>;
|
||||
"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<ReturnType<typeof getServerConfigFromClient>>,
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -73,7 +73,7 @@ export class OModal extends LitElement {
|
||||
? html`
|
||||
<aside
|
||||
class="${backdropClass}"
|
||||
@click=${this.inline ? null : this.close}
|
||||
@click=${this.inline ? null : () => this.close()}
|
||||
>
|
||||
<div
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
@@ -83,7 +83,7 @@ export class OModal extends LitElement {
|
||||
? html``
|
||||
: html`<div
|
||||
class="absolute top-4 right-4 z-10 text-white cursor-pointer"
|
||||
@click=${this.close}
|
||||
@click=${() => this.close()}
|
||||
>
|
||||
✕
|
||||
</div>`}
|
||||
|
||||
@@ -302,6 +302,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
});
|
||||
this.isWin = false;
|
||||
}
|
||||
history.replaceState(null, "", `${window.location.pathname}?replay`);
|
||||
this.show();
|
||||
} else if (wu.winner[0] === "nation") {
|
||||
this._title = translateText("win_modal.nation_won", {
|
||||
@@ -331,6 +332,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
});
|
||||
this.isWin = false;
|
||||
}
|
||||
history.replaceState(null, "", `${window.location.pathname}?replay`);
|
||||
this.show();
|
||||
}
|
||||
});
|
||||
|
||||
+20
-4
@@ -131,6 +131,19 @@ export type PlayerColor = z.infer<typeof PlayerColorSchema>;
|
||||
export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
|
||||
|
||||
const ClientInfoSchema = z.object({
|
||||
clientID: z.string(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
export const GameInfoSchema = z.object({
|
||||
gameID: z.string(),
|
||||
clients: z.array(ClientInfoSchema).optional(),
|
||||
numClients: z.number().optional(),
|
||||
msUntilStart: z.number().optional(),
|
||||
gameConfig: z.lazy(() => GameConfigSchema).optional(),
|
||||
});
|
||||
|
||||
export interface GameInfo {
|
||||
gameID: GameID;
|
||||
clients?: ClientInfo[];
|
||||
@@ -218,10 +231,13 @@ const EmojiSchema = z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.max(flattenedEmojiTable.length - 1);
|
||||
export const ID = z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9]+$/)
|
||||
.length(8);
|
||||
|
||||
export const GAME_ID_REGEX = /^[A-Za-z0-9]{8}$/;
|
||||
|
||||
export const isValidGameID = (value: string): boolean =>
|
||||
GAME_ID_REGEX.test(value);
|
||||
|
||||
export const ID = z.string().regex(GAME_ID_REGEX);
|
||||
|
||||
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { z } from "zod";
|
||||
import { GameInfo } from "../core/Schemas";
|
||||
import { GameMode } from "../core/game/Game";
|
||||
|
||||
export const PlayerInfoSchema = z.object({
|
||||
clientID: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
stats: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type PlayerInfo = z.infer<typeof PlayerInfoSchema>;
|
||||
|
||||
export const ExternalGameInfoSchema = z.object({
|
||||
info: z
|
||||
.object({
|
||||
config: z
|
||||
.object({
|
||||
gameMap: z.string().optional(),
|
||||
gameMode: z.string().optional(),
|
||||
gameType: z.string().optional(),
|
||||
maxPlayers: z.number().optional(),
|
||||
playerTeams: z.union([z.number(), z.string()]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
players: z.array(PlayerInfoSchema).optional(),
|
||||
winner: z.array(z.string()).optional(),
|
||||
duration: z.number().optional(),
|
||||
start: z.number().optional(),
|
||||
end: z.number().optional(),
|
||||
lobbyCreatedAt: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ExternalGameInfo = z.infer<typeof ExternalGameInfoSchema>;
|
||||
|
||||
export type PreviewMeta = {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
joinUrl: string;
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "Unknown";
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const minutes = mins % 60;
|
||||
if (hours) return `${hours}h ${minutes}m ${secs}s`;
|
||||
if (minutes) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(timestamp: number): number {
|
||||
return timestamp < 1e12 ? timestamp * 1000 : timestamp;
|
||||
}
|
||||
|
||||
function formatDateTimeParts(timestamp: number): {
|
||||
date: string;
|
||||
time: string;
|
||||
} {
|
||||
const date = new Date(normalizeTimestamp(timestamp));
|
||||
const dateLabel = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(date);
|
||||
const timeLabel = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "UTC",
|
||||
}).format(date);
|
||||
return { date: dateLabel, time: `${timeLabel} UTC` };
|
||||
}
|
||||
|
||||
type WinnerInfo = { names: string; count: number };
|
||||
|
||||
function parseWinner(
|
||||
winnerArray: string[] | undefined,
|
||||
players: PlayerInfo[] | undefined,
|
||||
): WinnerInfo | undefined {
|
||||
if (!winnerArray || winnerArray.length < 2) return undefined;
|
||||
|
||||
const idToName = new Map(
|
||||
(players ?? []).map((p) => [p.clientID, p.username]),
|
||||
);
|
||||
|
||||
if (winnerArray[0] === "team" && winnerArray.length >= 3) {
|
||||
const playerIds = winnerArray.slice(2);
|
||||
const names = playerIds.map((id) => idToName.get(id) ?? id).filter(Boolean);
|
||||
return names.length > 0
|
||||
? { names: names.join(", "), count: names.length }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (winnerArray[0] === "player" && winnerArray.length >= 2) {
|
||||
const clientId = winnerArray[1];
|
||||
const name = idToName.get(clientId) ?? clientId;
|
||||
return { names: name, count: 1 };
|
||||
}
|
||||
|
||||
// Unknown winner format - don't display confusing output
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function countActivePlayers(players: PlayerInfo[] | undefined): number {
|
||||
return (players ?? []).filter((p) => {
|
||||
if (!p || p.stats === null || p.stats === undefined) return false;
|
||||
// Count only when `stats` has at least one property.
|
||||
if (typeof p.stats === "object") {
|
||||
return Object.keys(p.stats as Record<string, unknown>).length > 0;
|
||||
}
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function buildPreview(
|
||||
gameID: string,
|
||||
origin: string,
|
||||
workerPath: string,
|
||||
lobby: GameInfo | null,
|
||||
publicInfo: ExternalGameInfo | null,
|
||||
): PreviewMeta {
|
||||
const isFinished = !!publicInfo?.info?.end;
|
||||
const isPrivate = lobby?.gameConfig?.gameType === "Private";
|
||||
|
||||
// route directly to the correct worker.
|
||||
const joinUrl = `${origin}/${workerPath}/game/${gameID}`;
|
||||
|
||||
const config = publicInfo?.info?.config ?? {};
|
||||
const players = publicInfo?.info?.players ?? [];
|
||||
|
||||
let activePlayers: number;
|
||||
if (isFinished) {
|
||||
activePlayers = countActivePlayers(players);
|
||||
} else {
|
||||
activePlayers =
|
||||
countActivePlayers(players) ||
|
||||
(lobby?.numClients ?? lobby?.clients?.length ?? 0);
|
||||
}
|
||||
const map = lobby?.gameConfig?.gameMap ?? config.gameMap;
|
||||
let mode = lobby?.gameConfig?.gameMode ?? config.gameMode ?? GameMode.FFA;
|
||||
const playerTeams = lobby?.gameConfig?.playerTeams ?? config.playerTeams;
|
||||
const numericTeamCount =
|
||||
typeof playerTeams === "number" && playerTeams > 0
|
||||
? playerTeams
|
||||
: undefined;
|
||||
|
||||
// For finished games, show "x teams of y". For lobbies, just show "x teams"
|
||||
const teamBreakdownLabel = numericTeamCount
|
||||
? isFinished
|
||||
? `${numericTeamCount} teams of ${Math.max(
|
||||
1,
|
||||
Math.ceil(activePlayers / numericTeamCount),
|
||||
)}`
|
||||
: `${numericTeamCount} teams`
|
||||
: undefined;
|
||||
|
||||
// Format team mode display
|
||||
if (mode === "Team" && playerTeams) {
|
||||
if (typeof playerTeams === "string") {
|
||||
mode = playerTeams; // e.g., "Quads"
|
||||
} else if (typeof playerTeams === "number") {
|
||||
mode = teamBreakdownLabel ?? `${playerTeams} Teams`;
|
||||
}
|
||||
}
|
||||
|
||||
const winner = parseWinner(publicInfo?.info?.winner, players);
|
||||
const duration = publicInfo?.info?.duration;
|
||||
|
||||
// Normalize map name to match filesystem (lowercase, no spaces or special chars)
|
||||
const normalizedMap = map ? map.toLowerCase().replace(/[\s.()]+/g, "") : null;
|
||||
|
||||
const mapThumbnail = normalizedMap
|
||||
? `${origin}/maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`
|
||||
: null;
|
||||
const image = mapThumbnail ?? `${origin}/images/GameplayScreenshot.png`;
|
||||
|
||||
const gameType = lobby?.gameConfig?.gameType ?? config.gameType;
|
||||
const gameTypeLabel = gameType ? ` (${gameType})` : "";
|
||||
|
||||
const title = isFinished
|
||||
? `${mode ?? "Game"} on ${map ?? "Unknown Map"}${gameTypeLabel}`
|
||||
: mode && map
|
||||
? `${mode} on ${map}${gameTypeLabel}`
|
||||
: "OpenFront Game";
|
||||
|
||||
let description = "";
|
||||
if (isFinished) {
|
||||
const parts: string[] = [];
|
||||
if (winner) {
|
||||
parts.push(`${winner.count > 1 ? "Winners" : "Winner"}: ${winner.names}`);
|
||||
parts.push(""); // Extra line break after winner
|
||||
}
|
||||
const matchTimestamp =
|
||||
publicInfo?.info?.start ??
|
||||
publicInfo?.info?.end ??
|
||||
publicInfo?.info?.lobbyCreatedAt;
|
||||
const detailParts: string[] = [];
|
||||
const playerCountLabel = `${activePlayers} ${activePlayers === 1 ? "player" : "players"}`;
|
||||
detailParts.push(playerCountLabel);
|
||||
if (duration !== undefined) detailParts.push(`${formatDuration(duration)}`);
|
||||
if (matchTimestamp !== undefined) {
|
||||
const dateTime = formatDateTimeParts(matchTimestamp);
|
||||
detailParts.push(`${dateTime.date}`);
|
||||
detailParts.push(`${dateTime.time}`);
|
||||
}
|
||||
parts.push(detailParts.join(" • "));
|
||||
description = parts.join("\n");
|
||||
} else if (lobby) {
|
||||
const gc = lobby.gameConfig;
|
||||
|
||||
if (isPrivate) {
|
||||
// Private lobby: show detailed game settings
|
||||
const sections: string[] = [];
|
||||
|
||||
// Show host
|
||||
const hostClient = lobby.clients?.[0];
|
||||
if (hostClient?.username) {
|
||||
sections.push(`Host: ${hostClient.username}`);
|
||||
}
|
||||
|
||||
const gameOptions: string[] = [];
|
||||
|
||||
if (gc?.gameMapSize && gc.gameMapSize !== "Normal") {
|
||||
gameOptions.push(`${gc.gameMapSize} Map`);
|
||||
}
|
||||
if (gc?.infiniteGold) gameOptions.push("Infinite Gold");
|
||||
if (gc?.infiniteTroops) gameOptions.push("Infinite Troops");
|
||||
if (gc?.instantBuild) gameOptions.push("Instant Build");
|
||||
if (gc?.randomSpawn) gameOptions.push("Random Spawn");
|
||||
if (gc?.disableNations) gameOptions.push("Nations Disabled");
|
||||
if (gc?.donateTroops) gameOptions.push("Troop Donations Enabled");
|
||||
|
||||
if (gameOptions.length > 0) {
|
||||
sections.push(`Game Options: ${gameOptions.join(" | ")}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(gc?.disabledUnits) && gc.disabledUnits.length > 0) {
|
||||
sections.push(
|
||||
`Disabled Units: ${gc.disabledUnits.map(String).join(" | ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
description = sections.join("\n\n");
|
||||
} else {
|
||||
// Public lobby: basic info
|
||||
description = "";
|
||||
}
|
||||
} else {
|
||||
description = `Game ${gameID}`;
|
||||
}
|
||||
|
||||
return { title, description, image, joinUrl };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { Express, Request } from "express";
|
||||
import fsPromises from "fs/promises";
|
||||
import { parse } from "node-html-parser";
|
||||
import path from "path";
|
||||
import type { Logger } from "winston";
|
||||
import { z } from "zod";
|
||||
import type { ServerConfig } from "../core/configuration/Config";
|
||||
import { GAME_ID_REGEX, GameInfo } from "../core/Schemas";
|
||||
import { replacer } from "../core/Util";
|
||||
import type { GameManager } from "./GameManager";
|
||||
import {
|
||||
buildPreview,
|
||||
escapeHtml,
|
||||
ExternalGameInfo,
|
||||
ExternalGameInfoSchema,
|
||||
} from "./GamePreviewBuilder";
|
||||
import { renderHtmlContent, setHtmlNoCacheHeaders } from "./RenderHtml";
|
||||
|
||||
const requestOrigin = (req: Request, config: ServerConfig): string => {
|
||||
const protoHeader = (req.headers["x-forwarded-proto"] as string) ?? "";
|
||||
const proto = protoHeader.split(",")[0]?.trim() || req.protocol || "https";
|
||||
const host = req.get("host") ?? `${config.subdomain()}.${config.domain()}`;
|
||||
|
||||
// Force https only for the configured public domain (and its subdomains).
|
||||
// This avoids hardcoding hostnames while ensuring we don't force https on
|
||||
// localhost or arbitrary custom hosts.
|
||||
const hostname = host.split(":")[0].toLowerCase();
|
||||
const domain = config.domain().toLowerCase();
|
||||
const forceHttps = hostname === domain || hostname.endsWith(`.${domain}`);
|
||||
|
||||
return `${forceHttps ? "https" : proto}://${host}`;
|
||||
};
|
||||
|
||||
export function registerGamePreviewRoute(opts: {
|
||||
app: Express;
|
||||
gm: GameManager;
|
||||
config: ServerConfig;
|
||||
workerId: number;
|
||||
log: Logger;
|
||||
baseDir: string;
|
||||
}) {
|
||||
const { app, gm, config, log, baseDir } = opts;
|
||||
|
||||
const gameIDSchema = z.string().regex(GAME_ID_REGEX);
|
||||
|
||||
const fetchPublicGameInfo = async (
|
||||
gameID: string,
|
||||
): Promise<ExternalGameInfo | null> => {
|
||||
if (!gameIDSchema.safeParse(gameID).success) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||
try {
|
||||
const apiDomain = config.jwtIssuer();
|
||||
const encodedID = encodeURIComponent(gameID);
|
||||
const response = await fetch(`${apiDomain}/game/${encodedID}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
const parsed = ExternalGameInfoSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
log.warn("Invalid ExternalGameInfo from API", {
|
||||
gameID,
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch (error) {
|
||||
log.warn("failed to fetch public game info", { gameID, error });
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
app.get("/game/:id", async (req, res) => {
|
||||
const gameID = req.params.id;
|
||||
|
||||
// Validate gameID format
|
||||
if (!GAME_ID_REGEX.test(gameID)) {
|
||||
return res.status(400).json({ error: "Invalid game ID format" });
|
||||
}
|
||||
|
||||
const game = gm.game(gameID);
|
||||
|
||||
const lobby: GameInfo | null = game ? game.gameInfo() : null;
|
||||
|
||||
try {
|
||||
const publicInfo = await fetchPublicGameInfo(gameID); // Fetch from central API (DB/Auth)
|
||||
|
||||
// If we have neither live lobby info nor archived public info, we can't show anything
|
||||
if (!lobby && !publicInfo) {
|
||||
return res.redirect(302, "/");
|
||||
}
|
||||
|
||||
const origin = requestOrigin(req, config);
|
||||
const meta = buildPreview(
|
||||
gameID,
|
||||
origin,
|
||||
config.workerPath(gameID),
|
||||
lobby,
|
||||
publicInfo,
|
||||
);
|
||||
|
||||
// Always serve HTML with meta tags for /game/:id route
|
||||
const staticHtml = path.join(baseDir, "../../static/index.html");
|
||||
const rootHtml = path.join(baseDir, "../../index.html");
|
||||
let filePath: string | null = null;
|
||||
|
||||
try {
|
||||
await fsPromises.access(staticHtml);
|
||||
filePath = staticHtml;
|
||||
} catch {
|
||||
try {
|
||||
await fsPromises.access(rootHtml);
|
||||
filePath = rootHtml;
|
||||
} catch {
|
||||
// Neither file exists
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath) {
|
||||
const html = await renderHtmlContent(filePath);
|
||||
const root = parse(html);
|
||||
const head = root.querySelector("head");
|
||||
if (head) {
|
||||
head
|
||||
.querySelectorAll('meta[property^="og:"], meta[name^="twitter:"]')
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
const tagsToInject = [
|
||||
`<meta property="og:title" content="${escapeHtml(meta.title)}" />`,
|
||||
`<meta property="og:description" content="${escapeHtml(meta.description || meta.title)}" />`,
|
||||
`<meta property="og:url" content="${escapeHtml(meta.joinUrl)}" />`,
|
||||
`<meta property="og:image" content="${escapeHtml(meta.image)}" />`,
|
||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||
`<meta name="twitter:title" content="${escapeHtml(meta.title)}" />`,
|
||||
`<meta name="twitter:description" content="${escapeHtml(meta.description || meta.title)}" />`,
|
||||
`<meta name="twitter:image" content="${escapeHtml(meta.image)}" />`,
|
||||
];
|
||||
|
||||
tagsToInject.forEach((tag) =>
|
||||
head.insertAdjacentHTML("beforeend", tag),
|
||||
);
|
||||
}
|
||||
|
||||
setHtmlNoCacheHeaders(res);
|
||||
return res.status(200).send(root.toString());
|
||||
}
|
||||
|
||||
// Fallback to JSON if HTML file not found
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
return res.send(JSON.stringify(lobby ?? publicInfo, replacer));
|
||||
} catch (error) {
|
||||
log.error("failed to render join preview", { error });
|
||||
return res.status(500).send("Unable to render lobby preview");
|
||||
}
|
||||
});
|
||||
}
|
||||
+1
-24
@@ -1,9 +1,7 @@
|
||||
import cluster from "cluster";
|
||||
import crypto from "crypto";
|
||||
import ejs from "ejs";
|
||||
import express from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import fs from "fs/promises";
|
||||
import http from "http";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
@@ -14,6 +12,7 @@ import { GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { logger } from "./Logger";
|
||||
import { MapPlaylist } from "./MapPlaylist";
|
||||
import { renderHtml } from "./RenderHtml";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
const playlist = new MapPlaylist();
|
||||
@@ -348,25 +347,3 @@ app.get("*", async function (_req, res) {
|
||||
res.status(500).send("Internal Server Error");
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to render HTML with EJS templating
|
||||
async function renderHtml(
|
||||
res: express.Response,
|
||||
htmlPath: string,
|
||||
): Promise<void> {
|
||||
const htmlContent = await fs.readFile(htmlPath, "utf-8");
|
||||
const rendered = ejs.render(htmlContent, {
|
||||
gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"),
|
||||
instanceId: JSON.stringify(process.env.INSTANCE_ID ?? "undefined"),
|
||||
});
|
||||
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
);
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
res.setHeader("ETag", "");
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.send(rendered);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import ejs from "ejs";
|
||||
import type { Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
|
||||
export async function renderHtmlContent(htmlPath: string): Promise<string> {
|
||||
const htmlContent = await fs.readFile(htmlPath, "utf-8");
|
||||
return ejs.render(htmlContent, {
|
||||
gitCommit: JSON.stringify(process.env.GIT_COMMIT ?? "undefined"),
|
||||
instanceId: JSON.stringify(process.env.INSTANCE_ID ?? "undefined"),
|
||||
});
|
||||
}
|
||||
|
||||
export function setHtmlNoCacheHeaders(res: Response): void {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
);
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
res.setHeader("ETag", "");
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
}
|
||||
|
||||
export async function renderHtml(
|
||||
res: Response,
|
||||
htmlPath: string,
|
||||
): Promise<void> {
|
||||
const rendered = await renderHtmlContent(htmlPath);
|
||||
setHtmlNoCacheHeaders(res);
|
||||
res.send(rendered);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { CreateGameInputSchema } from "../core/WorkerSchemas";
|
||||
import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
import { GameManager } from "./GameManager";
|
||||
import { registerGamePreviewRoute } from "./GamePreviewRoute";
|
||||
import { getUserMe, verifyClientToken } from "./jwt";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
@@ -94,7 +95,22 @@ export async function startWorker() {
|
||||
app.set("trust proxy", 3);
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
|
||||
// Configure MIME types for webp files
|
||||
express.static.mime.define({ "image/webp": ["webp"] });
|
||||
|
||||
app.use(express.static(path.join(__dirname, "../../out")));
|
||||
app.use(
|
||||
"/maps",
|
||||
express.static(path.join(__dirname, "../../static/maps"), {
|
||||
maxAge: "1y",
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith(".webp")) {
|
||||
res.setHeader("Content-Type", "image/webp");
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 1000, // 1 second
|
||||
@@ -187,6 +203,15 @@ export async function startWorker() {
|
||||
res.json(game.gameInfo());
|
||||
});
|
||||
|
||||
registerGamePreviewRoute({
|
||||
app,
|
||||
gm,
|
||||
config,
|
||||
workerId,
|
||||
log,
|
||||
baseDir: __dirname,
|
||||
});
|
||||
|
||||
app.post("/api/archive_singleplayer_game", async (req, res) => {
|
||||
try {
|
||||
const record = req.body;
|
||||
|
||||
Reference in New Issue
Block a user