mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
Merge branch 'v25'
This commit is contained in:
@@ -123,6 +123,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }}
|
||||
SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
|
||||
@@ -77,6 +77,7 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
run: |
|
||||
@@ -108,13 +109,13 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 🔑 Create SSH private key
|
||||
env:
|
||||
SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa
|
||||
test -n "$SERVER_HOST_NBG1" && ssh-keyscan -H "$SERVER_HOST_NBG1" >> ~/.ssh/known_hosts
|
||||
test -n "$SERVER_HOST_FALK1" && ssh-keyscan -H "$SERVER_HOST_FALK1" >> ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
- name: 🚀 Deploy image
|
||||
env:
|
||||
@@ -133,11 +134,12 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
./deploy.sh prod nbg1 "${IMAGE_ID}" beta
|
||||
./deploy.sh prod falk1 "${IMAGE_ID}" beta
|
||||
- name: ⏳ Wait for deployment to start
|
||||
env:
|
||||
FQDN: beta.${{ vars.DOMAIN }}
|
||||
@@ -164,13 +166,13 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 🔑 Create SSH private key
|
||||
env:
|
||||
SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa
|
||||
test -n "$SERVER_HOST_NBG1" && ssh-keyscan -H "$SERVER_HOST_NBG1" >> ~/.ssh/known_hosts
|
||||
test -n "$SERVER_HOST_FALK1" && ssh-keyscan -H "$SERVER_HOST_FALK1" >> ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
- name: 🚀 Deploy image
|
||||
env:
|
||||
@@ -189,11 +191,12 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
./deploy.sh prod nbg1 "${IMAGE_ID}" blue
|
||||
./deploy.sh prod falk1 "${IMAGE_ID}" blue
|
||||
- name: ⏳ Wait for deployment to start
|
||||
env:
|
||||
FQDN: blue.${{ vars.DOMAIN }}
|
||||
@@ -220,13 +223,13 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 🔑 Create SSH private key
|
||||
env:
|
||||
SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa
|
||||
test -n "$SERVER_HOST_NBG1" && ssh-keyscan -H "$SERVER_HOST_NBG1" >> ~/.ssh/known_hosts
|
||||
test -n "$SERVER_HOST_FALK1" && ssh-keyscan -H "$SERVER_HOST_FALK1" >> ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
- name: 🚀 Deploy image
|
||||
env:
|
||||
@@ -245,11 +248,12 @@ jobs:
|
||||
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||
SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
|
||||
SSH_KEY: ~/.ssh/id_rsa
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
./deploy.sh prod nbg1 "${IMAGE_ID}" green
|
||||
./deploy.sh prod falk1 "${IMAGE_ID}" green
|
||||
- name: ⏳ Wait for deployment to start
|
||||
env:
|
||||
FQDN: green.${{ vars.DOMAIN }}
|
||||
|
||||
@@ -76,6 +76,22 @@ To run just the server with development settings:
|
||||
npm run start:server-dev
|
||||
```
|
||||
|
||||
### Connecting to staging or production backends
|
||||
|
||||
Sometimes it's useful to connect to production servers when replaying a game, testing user profiles, purchases, or login flow.
|
||||
|
||||
To connect to staging api servers:
|
||||
|
||||
```bash
|
||||
npm run dev:staging
|
||||
```
|
||||
|
||||
To connect to production api servers:
|
||||
|
||||
```bash
|
||||
npm run dev:prod
|
||||
```
|
||||
|
||||
## 🛠️ Development Tools
|
||||
|
||||
- **Format code**:
|
||||
|
||||
+8
-8
@@ -15,34 +15,34 @@ print_header "BUILD AND DEPLOY WRAPPER"
|
||||
echo "This script will run build.sh and deploy.sh in sequence."
|
||||
echo "You can also run them separately:"
|
||||
echo " ./build.sh [prod|staging] [version_tag]"
|
||||
echo " ./deploy.sh [prod|staging] [eu|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]"
|
||||
echo " ./deploy.sh [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]"
|
||||
echo ""
|
||||
|
||||
# Check command line arguments
|
||||
if [ $# -lt 3 ] || [ $# -gt 5 ]; then
|
||||
echo "Error: Please specify environment, host, and subdomain"
|
||||
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate first argument (environment)
|
||||
if [ "$1" != "prod" ] && [ "$1" != "staging" ]; then
|
||||
echo "Error: First argument must be either 'prod' or 'staging'"
|
||||
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate second argument (host)
|
||||
if [ "$2" != "eu" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then
|
||||
echo "Error: Second argument must be either 'eu', 'nbg1', 'staging', or 'masters'"
|
||||
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
if [ "$2" != "falk1" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then
|
||||
echo "Error: Second argument must be either 'falk1', 'nbg1', 'staging', or 'masters'"
|
||||
echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate third argument (subdomain)
|
||||
if [ -z "$3" ]; then
|
||||
echo "Error: Subdomain is required"
|
||||
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -66,7 +66,7 @@ while [[ $# -gt 0 ]]; do
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown argument: $1"
|
||||
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -37,21 +37,21 @@ print_header() {
|
||||
# Check command line arguments
|
||||
if [ $# -ne 4 ]; then
|
||||
echo "Error: Please specify environment, host, version tag, and subdomain"
|
||||
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]"
|
||||
echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate first argument (environment)
|
||||
if [ "$1" != "prod" ] && [ "$1" != "staging" ]; then
|
||||
echo "Error: First argument must be either 'prod' or 'staging'"
|
||||
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]"
|
||||
echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate second argument (host)
|
||||
if [ "$2" != "eu" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then
|
||||
echo "Error: Second argument must be either 'eu', 'nbg1', 'staging', or 'masters'"
|
||||
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]"
|
||||
if [ "$2" != "falk1" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then
|
||||
echo "Error: Second argument must be either 'falk1', 'nbg1', 'staging', or 'masters'"
|
||||
echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -97,8 +97,8 @@ elif [ "$HOST" == "masters" ]; then
|
||||
print_header "DEPLOYING TO MASTERS HOST"
|
||||
SERVER_HOST=$SERVER_HOST_MASTERS
|
||||
else
|
||||
print_header "DEPLOYING TO EU HOST"
|
||||
SERVER_HOST=$SERVER_HOST_EU
|
||||
print_header "DEPLOYING TO FALK1 HOST"
|
||||
SERVER_HOST=$SERVER_HOST_FALK1
|
||||
fi
|
||||
|
||||
# Check required environment variables
|
||||
@@ -176,6 +176,7 @@ R2_ACCESS_KEY=$R2_ACCESS_KEY
|
||||
R2_SECRET_KEY=$R2_SECRET_KEY
|
||||
R2_BUCKET=$R2_BUCKET
|
||||
CF_API_TOKEN=$CF_API_TOKEN
|
||||
API_KEY=$API_KEY
|
||||
DOMAIN=$DOMAIN
|
||||
SUBDOMAIN=$SUBDOMAIN
|
||||
OTEL_USERNAME=$OTEL_USERNAME
|
||||
|
||||
+5
-2
@@ -19,10 +19,13 @@ R2_ACCESS_KEY=your_r2_access_key
|
||||
R2_SECRET_KEY=your_r2_secret_key
|
||||
R2_BUCKET=your-bucket-name
|
||||
|
||||
# API Key
|
||||
API_KEY=your_api_key_here
|
||||
|
||||
# Server Hosts
|
||||
SERVER_HOST_STAGING=123.456.78.90
|
||||
SERVER_HOST_EU=123.456.78.91
|
||||
SERVER_HOST_US=123.456.78.92
|
||||
SERVER_HOST_FALK1=123.456.78.91
|
||||
SERVER_HOST_NBG1=123.456.78.92
|
||||
|
||||
# Version
|
||||
VERSION_TAG="latest"
|
||||
|
||||
+19
@@ -132,6 +132,25 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# /commit.txt endpoint - Cache for 5 seconds
|
||||
location = /commit.txt {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Cache configuration
|
||||
proxy_cache API_CACHE;
|
||||
proxy_cache_valid 200 5s; # Cache successful responses for 5 seconds
|
||||
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
|
||||
proxy_cache_lock on;
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
|
||||
# Standard proxy headers
|
||||
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;
|
||||
}
|
||||
|
||||
# Binary files caching
|
||||
location ~* \.(bin|dat|exe|dll|so|dylib)$ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
|
||||
Generated
+12
-14
@@ -16,8 +16,10 @@
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.32.0",
|
||||
"@opentelemetry/winston-transport": "^0.11.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"colord": "^2.9.3",
|
||||
"colorjs.io": "^0.5.2",
|
||||
"compression": "^1.8.1",
|
||||
"dompurify": "^3.1.7",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^4.21.1",
|
||||
@@ -6697,7 +6699,6 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@@ -6721,11 +6722,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -7073,7 +7083,6 @@
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz",
|
||||
"integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
@@ -7086,7 +7095,6 @@
|
||||
"version": "4.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
|
||||
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -7134,7 +7142,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-proxy": {
|
||||
@@ -7225,7 +7232,6 @@
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/msgpack5": {
|
||||
@@ -7275,14 +7281,12 @@
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/readable-stream": {
|
||||
@@ -7313,7 +7317,6 @@
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
@@ -7334,7 +7337,6 @@
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
|
||||
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@@ -9495,7 +9497,6 @@
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
@@ -9527,7 +9528,6 @@
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
@@ -9537,14 +9537,12 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compression/node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"start:server": "node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts",
|
||||
"start:server-dev": "cross-env GAME_ENV=dev node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts",
|
||||
"dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
|
||||
"dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
|
||||
"dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"",
|
||||
"tunnel": "npm run build-prod && npm run start:server",
|
||||
"test": "jest",
|
||||
"perf": "npx tsx tests/perf/*.ts",
|
||||
@@ -108,8 +110,10 @@
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.32.0",
|
||||
"@opentelemetry/winston-transport": "^0.11.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"colord": "^2.9.3",
|
||||
"colorjs.io": "^0.5.2",
|
||||
"compression": "^1.8.1",
|
||||
"dompurify": "^3.1.7",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^4.21.1",
|
||||
|
||||
@@ -196,8 +196,9 @@
|
||||
"join_lobby": "Join Lobby",
|
||||
"checking": "Checking lobby...",
|
||||
"not_found": "Lobby not found. Please check the ID and try again.",
|
||||
"error": "An error occurred. Please try again.",
|
||||
"joined_waiting": "Joined successfully! Waiting for game to start..."
|
||||
"error": "An error occurred. Please try again or contact support.",
|
||||
"joined_waiting": "Joined successfully! Waiting for game to start...",
|
||||
"version_mismatch": "This game was created with a different version. Cannot join."
|
||||
},
|
||||
"public_lobby": {
|
||||
"join": "Join next Game",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
PlayerRecord,
|
||||
ServerMessage,
|
||||
} from "../core/Schemas";
|
||||
import { createGameRecord } from "../core/Util";
|
||||
import { createPartialGameRecord, replacer } from "../core/Util";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getConfig } from "../core/configuration/ConfigLoader";
|
||||
import { PlayerActions, UnitType } from "../core/game/Game";
|
||||
@@ -84,14 +84,18 @@ export function joinLobby(
|
||||
|
||||
const onmessage = (message: ServerMessage) => {
|
||||
if (message.type === "prestart") {
|
||||
console.log(`lobby: game prestarting: ${JSON.stringify(message)}`);
|
||||
console.log(
|
||||
`lobby: game prestarting: ${JSON.stringify(message, replacer)}`,
|
||||
);
|
||||
terrainLoad = loadTerrainMap(message.gameMap, terrainMapFileLoader);
|
||||
onPrestart();
|
||||
}
|
||||
if (message.type === "start") {
|
||||
// Trigger prestart for singleplayer games
|
||||
onPrestart();
|
||||
console.log(`lobby: game started: ${JSON.stringify(message, null, 2)}`);
|
||||
console.log(
|
||||
`lobby: game started: ${JSON.stringify(message, replacer, 2)}`,
|
||||
);
|
||||
onJoin();
|
||||
// For multiplayer games, GameStartInfo is not known until game starts.
|
||||
lobbyConfig.gameStartInfo = message.gameStartInfo;
|
||||
@@ -223,7 +227,7 @@ export class ClientGameRunner {
|
||||
if (this.lobby.gameStartInfo === undefined) {
|
||||
throw new Error("missing gameStartInfo");
|
||||
}
|
||||
const record = createGameRecord(
|
||||
const record = createPartialGameRecord(
|
||||
this.lobby.gameStartInfo.gameID,
|
||||
this.lobby.gameStartInfo.config,
|
||||
players,
|
||||
@@ -232,7 +236,6 @@ export class ClientGameRunner {
|
||||
startTime(),
|
||||
Date.now(),
|
||||
update.winner,
|
||||
this.lobby.serverConfig,
|
||||
);
|
||||
endGame(record);
|
||||
}
|
||||
@@ -274,7 +277,7 @@ export class ClientGameRunner {
|
||||
this.lobby.clientID,
|
||||
);
|
||||
console.error(gu.stack);
|
||||
this.stop(true);
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
this.transport.turnComplete();
|
||||
@@ -364,12 +367,12 @@ export class ClientGameRunner {
|
||||
this.transport.connect(onconnect, onmessage);
|
||||
}
|
||||
|
||||
public stop(saveFullGame: boolean = false) {
|
||||
public stop() {
|
||||
if (!this.isActive) return;
|
||||
|
||||
this.isActive = false;
|
||||
this.worker.cleanup();
|
||||
this.transport.leaveGame(saveFullGame);
|
||||
this.transport.leaveGame();
|
||||
if (this.connectionCheckInterval) {
|
||||
clearInterval(this.connectionCheckInterval);
|
||||
this.connectionCheckInterval = null;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { GameInfo, GameRecord } from "../core/Schemas";
|
||||
import { GameInfo, GameRecordSchema } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { getApiBase } from "./jwt";
|
||||
@customElement("join-private-lobby-modal")
|
||||
export class JoinPrivateLobbyModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
@@ -179,10 +180,19 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
if (gameExists) return;
|
||||
|
||||
// If not active, check archived games
|
||||
const archivedGame = await this.checkArchivedGame(lobbyId);
|
||||
if (archivedGame) return;
|
||||
|
||||
this.message = `${translateText("private_lobby.not_found")}`;
|
||||
switch (await this.checkArchivedGame(lobbyId)) {
|
||||
case "success":
|
||||
return;
|
||||
case "not_found":
|
||||
this.message = `${translateText("private_lobby.not_found")}`;
|
||||
return;
|
||||
case "version_mismatch":
|
||||
this.message = `${translateText("private_lobby.version_mismatch")}`;
|
||||
return;
|
||||
case "error":
|
||||
this.message = `${translateText("private_lobby.error")}`;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking lobby existence:", error);
|
||||
this.message = `${translateText("private_lobby.error")}`;
|
||||
@@ -222,49 +232,71 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkArchivedGame(lobbyId: string): Promise<boolean> {
|
||||
const config = await getServerConfigFromClient();
|
||||
const archiveUrl = `/${config.workerPath(lobbyId)}/api/archived_game/${lobbyId}`;
|
||||
|
||||
const archiveResponse = await fetch(archiveUrl, {
|
||||
private async checkArchivedGame(
|
||||
lobbyId: string,
|
||||
): Promise<"success" | "not_found" | "version_mismatch" | "error"> {
|
||||
const archivePromise = fetch(`${getApiBase()}/game/${lobbyId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const gitCommitPromise = fetch(`/commit.txt`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-cache",
|
||||
});
|
||||
|
||||
const archiveData = await archiveResponse.json();
|
||||
const [archiveResponse, gitCommitResponse] = await Promise.all([
|
||||
archivePromise,
|
||||
gitCommitPromise,
|
||||
]);
|
||||
|
||||
if (
|
||||
archiveData.success === false &&
|
||||
archiveData.error === "Version mismatch"
|
||||
) {
|
||||
if (archiveResponse.status === 404) {
|
||||
return "not_found";
|
||||
}
|
||||
if (archiveResponse.status !== 200) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
const archiveData = await archiveResponse.json();
|
||||
const parsed = GameRecordSchema.safeParse(archiveData);
|
||||
if (!parsed.success) {
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
let myGitCommit = "";
|
||||
if (gitCommitResponse.status === 404) {
|
||||
// commit.txt is not found when running locally
|
||||
myGitCommit = "DEV";
|
||||
} else if (gitCommitResponse.status === 200) {
|
||||
myGitCommit = (await gitCommitResponse.text()).trim();
|
||||
} else {
|
||||
console.error("Error getting git commit:", gitCommitResponse.status);
|
||||
return "error";
|
||||
}
|
||||
|
||||
// Allow DEV to join games created with a different version for debugging.
|
||||
if (myGitCommit !== "DEV" && parsed.data.gitCommit !== myGitCommit) {
|
||||
console.warn(
|
||||
`Git commit hash mismatch for game ${lobbyId}`,
|
||||
archiveData.details,
|
||||
);
|
||||
this.message =
|
||||
"This game was created with a different version. Cannot join.";
|
||||
return true;
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
if (archiveData.exists) {
|
||||
const gameRecord = archiveData.gameRecord as GameRecord;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
gameRecord: gameRecord,
|
||||
clientID: generateID(),
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
gameRecord: parsed.data,
|
||||
clientID: generateID(),
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
return "success";
|
||||
}
|
||||
|
||||
private async pollPlayers() {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { GameConfig, GameID, GameRecord } from "../core/Schemas";
|
||||
import { GameConfig, GameID, PartialGameRecord } from "../core/Schemas";
|
||||
import { replacer } from "../core/Util";
|
||||
|
||||
export interface LocalStatsData {
|
||||
[key: GameID]: {
|
||||
lobby: Partial<GameConfig>;
|
||||
// Only once the game is over
|
||||
gameRecord?: GameRecord;
|
||||
gameRecord?: PartialGameRecord;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function startTime() {
|
||||
return _startTime;
|
||||
}
|
||||
|
||||
export function endGame(gameRecord: GameRecord) {
|
||||
export function endGame(gameRecord: PartialGameRecord) {
|
||||
if (localStorage === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
+64
-17
@@ -4,14 +4,18 @@ import {
|
||||
AllPlayersStats,
|
||||
ClientMessage,
|
||||
ClientSendWinnerMessage,
|
||||
GameRecordSchema,
|
||||
Intent,
|
||||
PartialGameRecordSchema,
|
||||
PlayerRecord,
|
||||
ServerMessage,
|
||||
ServerStartGameMessage,
|
||||
Turn,
|
||||
} from "../core/Schemas";
|
||||
import { createGameRecord, decompressGameRecord, replacer } from "../core/Util";
|
||||
import {
|
||||
createPartialGameRecord,
|
||||
decompressGameRecord,
|
||||
replacer,
|
||||
} from "../core/Util";
|
||||
import { LobbyConfig } from "./ClientGameRunner";
|
||||
import { ReplaySpeedChangeEvent } from "./InputHandler";
|
||||
import { getPersistentID } from "./Main";
|
||||
@@ -103,8 +107,10 @@ export class LocalServer {
|
||||
}
|
||||
if (clientMsg.type === "hash") {
|
||||
if (!this.lobbyConfig.gameRecord) {
|
||||
// If we are playing a singleplayer then store hash.
|
||||
this.turns[clientMsg.turnNumber].hash = clientMsg.hash;
|
||||
if (clientMsg.turnNumber % 100 === 0) {
|
||||
// In singleplayer, only store hash every 100 turns to reduce size of game record.
|
||||
this.turns[clientMsg.turnNumber].hash = clientMsg.hash;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// If we are replaying a game then verify hash.
|
||||
@@ -169,7 +175,7 @@ export class LocalServer {
|
||||
});
|
||||
}
|
||||
|
||||
public endGame(saveFullGame: boolean = false) {
|
||||
public endGame() {
|
||||
console.log("local server ending game");
|
||||
clearInterval(this.turnCheckInterval);
|
||||
if (this.isReplay) {
|
||||
@@ -186,7 +192,7 @@ export class LocalServer {
|
||||
if (this.lobbyConfig.gameStartInfo === undefined) {
|
||||
throw new Error("missing gameStartInfo");
|
||||
}
|
||||
const record = createGameRecord(
|
||||
const record = createPartialGameRecord(
|
||||
this.lobbyConfig.gameStartInfo.gameID,
|
||||
this.lobbyConfig.gameStartInfo.config,
|
||||
players,
|
||||
@@ -194,25 +200,66 @@ export class LocalServer {
|
||||
this.startedAt,
|
||||
Date.now(),
|
||||
this.winner?.winner,
|
||||
this.lobbyConfig.serverConfig,
|
||||
);
|
||||
if (!saveFullGame) {
|
||||
// Clear turns because beacon only supports up to 64kb
|
||||
record.turns = [];
|
||||
}
|
||||
// For unload events, sendBeacon is the only reliable method
|
||||
const result = GameRecordSchema.safeParse(record);
|
||||
|
||||
const result = PartialGameRecordSchema.safeParse(record);
|
||||
if (!result.success) {
|
||||
const error = z.prettifyError(result.error);
|
||||
console.error("Error parsing game record", error);
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(result.data, replacer)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const workerPath = this.lobbyConfig.serverConfig.workerPath(
|
||||
this.lobbyConfig.gameStartInfo.gameID,
|
||||
);
|
||||
navigator.sendBeacon(`/${workerPath}/api/archive_singleplayer_game`, blob);
|
||||
|
||||
const jsonString = JSON.stringify(result.data, replacer);
|
||||
|
||||
compress(jsonString)
|
||||
.then((compressedData) => {
|
||||
return fetch(`/${workerPath}/api/archive_singleplayer_game`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
},
|
||||
body: compressedData,
|
||||
keepalive: true, // Ensures request completes even if page unloads
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to archive singleplayer game:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function compress(data: string): Promise<Uint8Array> {
|
||||
const stream = new CompressionStream("gzip");
|
||||
const writer = stream.writable.getWriter();
|
||||
const reader = stream.readable.getReader();
|
||||
|
||||
// Write the data to the compression stream
|
||||
writer.write(new TextEncoder().encode(data));
|
||||
writer.close();
|
||||
|
||||
// Read the compressed data
|
||||
const chunks: Uint8Array[] = [];
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const { value, done: readerDone } = await reader.read();
|
||||
done = readerDone;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all chunks into a single Uint8Array
|
||||
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||
const compressedData = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
compressedData.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return compressedData;
|
||||
}
|
||||
|
||||
+14
-3
@@ -4,7 +4,6 @@ import { EventBus } from "../core/EventBus";
|
||||
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./AccountModal";
|
||||
import { joinLobby } from "./ClientGameRunner";
|
||||
@@ -483,6 +482,16 @@ class Client {
|
||||
console.log(`joining lobby ${lobbyId}`);
|
||||
}
|
||||
}
|
||||
if (decodedHash.startsWith("#affiliate=")) {
|
||||
const affiliateCode = decodedHash.replace("#affiliate=", "");
|
||||
strip();
|
||||
if (affiliateCode) {
|
||||
this.patternsModal.open(affiliateCode);
|
||||
}
|
||||
}
|
||||
if (decodedHash.startsWith("#refresh")) {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
|
||||
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
|
||||
@@ -568,9 +577,11 @@ class Client {
|
||||
(ad as HTMLElement).style.display = "none";
|
||||
});
|
||||
|
||||
if (lobby.gameStartInfo?.config.gameType !== GameType.Singleplayer) {
|
||||
history.pushState(null, "", `#join=${lobby.gameID}`);
|
||||
// Ensure there's a homepage entry in history before adding the lobby entry
|
||||
if (window.location.hash === "" || window.location.hash === "#") {
|
||||
history.pushState(null, "", window.location.origin + "#refresh");
|
||||
}
|
||||
history.pushState(null, "", `#join=${lobby.gameID}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
private isActive = false;
|
||||
|
||||
private affiliateCode: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
@@ -51,6 +53,17 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
for (const [name, pattern] of this.patterns) {
|
||||
if (this.affiliateCode === null) {
|
||||
if (pattern.affiliateCode !== null && pattern.product !== null) {
|
||||
// Patterns with affiliate code are not for sale by default.
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (pattern.affiliateCode !== this.affiliateCode) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
@@ -65,10 +78,14 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
class="flex flex-wrap gap-4 p-2"
|
||||
style="justify-content: center; align-items: flex-start;"
|
||||
>
|
||||
<pattern-button
|
||||
.pattern=${null}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
|
||||
></pattern-button>
|
||||
${this.affiliateCode === null
|
||||
? html`
|
||||
<pattern-button
|
||||
.pattern=${null}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
|
||||
></pattern-button>
|
||||
`
|
||||
: html``}
|
||||
${buttons}
|
||||
</div>
|
||||
`;
|
||||
@@ -86,13 +103,15 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public async open() {
|
||||
public async open(affiliateCode?: string) {
|
||||
this.isActive = true;
|
||||
this.affiliateCode = affiliateCode ?? null;
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.isActive = false;
|
||||
this.affiliateCode = null;
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -382,9 +382,9 @@ export class Transport {
|
||||
} satisfies ClientJoinMessage);
|
||||
}
|
||||
|
||||
leaveGame(saveFullGame: boolean = false) {
|
||||
leaveGame() {
|
||||
if (this.isLocal) {
|
||||
this.localServer.endGame(saveFullGame);
|
||||
this.localServer.endGame();
|
||||
return;
|
||||
}
|
||||
this.stopPing();
|
||||
|
||||
+12
-4
@@ -19,9 +19,16 @@ function getAudience() {
|
||||
|
||||
export function getApiBase() {
|
||||
const domainname = getAudience();
|
||||
return domainname === "localhost"
|
||||
? (localStorage.getItem("apiHost") ?? "http://localhost:8787")
|
||||
: `https://api.${domainname}`;
|
||||
|
||||
if (domainname === "localhost") {
|
||||
const apiDomain = process?.env?.API_DOMAIN;
|
||||
if (apiDomain) {
|
||||
return `https://${apiDomain}`;
|
||||
}
|
||||
return localStorage.getItem("apiHost") ?? "http://localhost:8787";
|
||||
}
|
||||
|
||||
return `https://api.${domainname}`;
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
@@ -161,7 +168,8 @@ function _isLoggedIn(): IsLoggedInResponse {
|
||||
logOut();
|
||||
return false;
|
||||
}
|
||||
if (aud !== getAudience()) {
|
||||
const myAud = getAudience();
|
||||
if (myAud !== "localhost" && aud !== myAud) {
|
||||
// JWT was not issued for this website
|
||||
console.error(
|
||||
'unexpected "aud" claim value',
|
||||
|
||||
@@ -44,6 +44,7 @@ export const PatternSchema = z
|
||||
export const PatternInfoSchema = z.object({
|
||||
name: PatternNameSchema,
|
||||
pattern: PatternSchema,
|
||||
affiliateCode: z.string().nullable(),
|
||||
product: ProductSchema.nullable(),
|
||||
});
|
||||
|
||||
|
||||
+20
-3
@@ -502,7 +502,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||
//
|
||||
|
||||
export const PlayerRecordSchema = PlayerSchema.extend({
|
||||
persistentID: PersistentIdSchema, // WARNING: PII
|
||||
persistentID: PersistentIdSchema.nullable(), // WARNING: PII
|
||||
stats: PlayerStatsSchema,
|
||||
});
|
||||
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
|
||||
@@ -517,18 +517,35 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({
|
||||
});
|
||||
export type GameEndInfo = z.infer<typeof GameEndInfoSchema>;
|
||||
|
||||
const GitCommitSchema = z.string().regex(/^[0-9a-fA-F]{40}$/);
|
||||
const GitCommitSchema = z
|
||||
.string()
|
||||
.regex(/^[0-9a-fA-F]{40}$/)
|
||||
.or(z.literal("DEV"));
|
||||
|
||||
export const AnalyticsRecordSchema = z.object({
|
||||
export const PartialAnalyticsRecordSchema = z.object({
|
||||
info: GameEndInfoSchema,
|
||||
version: z.literal("v0.0.2"),
|
||||
});
|
||||
export type ClientAnalyticsRecord = z.infer<
|
||||
typeof PartialAnalyticsRecordSchema
|
||||
>;
|
||||
|
||||
export const AnalyticsRecordSchema = PartialAnalyticsRecordSchema.extend({
|
||||
gitCommit: GitCommitSchema,
|
||||
subdomain: z.string(),
|
||||
domain: z.string(),
|
||||
});
|
||||
|
||||
export type AnalyticsRecord = z.infer<typeof AnalyticsRecordSchema>;
|
||||
|
||||
export const GameRecordSchema = AnalyticsRecordSchema.extend({
|
||||
turns: TurnSchema.array(),
|
||||
});
|
||||
|
||||
export const PartialGameRecordSchema = PartialAnalyticsRecordSchema.extend({
|
||||
turns: TurnSchema.array(),
|
||||
});
|
||||
|
||||
export type PartialGameRecord = z.infer<typeof PartialGameRecordSchema>;
|
||||
|
||||
export type GameRecord = z.infer<typeof GameRecordSchema>;
|
||||
|
||||
+5
-13
@@ -6,12 +6,12 @@ import {
|
||||
GameConfig,
|
||||
GameID,
|
||||
GameRecord,
|
||||
PartialGameRecord,
|
||||
PlayerRecord,
|
||||
Turn,
|
||||
Winner,
|
||||
} from "./Schemas";
|
||||
|
||||
import { ServerConfig } from "./configuration/Config";
|
||||
import {
|
||||
BOT_NAME_PREFIXES,
|
||||
BOT_NAME_SUFFIXES,
|
||||
@@ -150,7 +150,7 @@ export function onlyImages(html: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createGameRecord(
|
||||
export function createPartialGameRecord(
|
||||
gameID: GameID,
|
||||
config: GameConfig,
|
||||
// username does not need to be set.
|
||||
@@ -159,18 +159,13 @@ export function createGameRecord(
|
||||
start: number,
|
||||
end: number,
|
||||
winner: Winner,
|
||||
serverConfig: ServerConfig,
|
||||
): GameRecord {
|
||||
): PartialGameRecord {
|
||||
const duration = Math.floor((end - start) / 1000);
|
||||
const version = "v0.0.2";
|
||||
const gitCommit = serverConfig.gitCommit();
|
||||
const subdomain = serverConfig.subdomain();
|
||||
const domain = serverConfig.domain();
|
||||
const num_turns = allTurns.length;
|
||||
const turns = allTurns.filter(
|
||||
(t) => t.intents.length !== 0 || t.hash !== undefined,
|
||||
);
|
||||
const record: GameRecord = {
|
||||
const record: PartialGameRecord = {
|
||||
info: {
|
||||
gameID,
|
||||
config,
|
||||
@@ -181,10 +176,7 @@ export function createGameRecord(
|
||||
num_turns,
|
||||
winner,
|
||||
},
|
||||
version,
|
||||
gitCommit,
|
||||
subdomain,
|
||||
domain,
|
||||
version: "v0.0.2",
|
||||
turns,
|
||||
};
|
||||
return record;
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface ServerConfig {
|
||||
r2Endpoint(): string;
|
||||
r2AccessKey(): string;
|
||||
r2SecretKey(): string;
|
||||
apiKey(): string;
|
||||
otelEndpoint(): string;
|
||||
otelAuthHeader(): string;
|
||||
otelEnabled(): boolean;
|
||||
|
||||
@@ -153,6 +153,10 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
return process.env.R2_BUCKET ?? "";
|
||||
}
|
||||
|
||||
apiKey(): string {
|
||||
return process.env.API_KEY ?? "";
|
||||
}
|
||||
|
||||
adminHeader(): string {
|
||||
return "x-admin-key";
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ export class DevServerConfig extends DefaultServerConfig {
|
||||
return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION";
|
||||
}
|
||||
|
||||
apiKey(): string {
|
||||
return "WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION";
|
||||
}
|
||||
|
||||
env(): GameEnv {
|
||||
return GameEnv.Dev;
|
||||
}
|
||||
|
||||
@@ -447,6 +447,9 @@ export class GameView implements GameMap {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this._myPlayer ??= this.playerByClientID(this._myClientID);
|
||||
|
||||
for (const unit of this._units.values()) {
|
||||
unit._wasUpdated = false;
|
||||
unit.lastPos = unit.lastPos.slice(-1);
|
||||
@@ -507,7 +510,6 @@ export class GameView implements GameMap {
|
||||
}
|
||||
|
||||
myPlayer(): PlayerView | null {
|
||||
this._myPlayer ??= this.playerByClientID(this._myClientID);
|
||||
return this._myPlayer;
|
||||
}
|
||||
|
||||
|
||||
+58
-155
@@ -1,6 +1,12 @@
|
||||
import { S3 } from "@aws-sdk/client-s3";
|
||||
import z from "zod";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas";
|
||||
import {
|
||||
GameID,
|
||||
GameRecord,
|
||||
GameRecordSchema,
|
||||
ID,
|
||||
PartialGameRecord,
|
||||
} from "../core/Schemas";
|
||||
import { replacer } from "../core/Util";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
@@ -8,179 +14,76 @@ const config = getServerConfigFromServer();
|
||||
|
||||
const log = logger.child({ component: "Archive" });
|
||||
|
||||
// R2 client configuration
|
||||
const r2 = new S3({
|
||||
region: "auto", // R2 ignores region, but it's required by the SDK
|
||||
endpoint: config.r2Endpoint(),
|
||||
credentials: {
|
||||
accessKeyId: config.r2AccessKey(),
|
||||
secretAccessKey: config.r2SecretKey(),
|
||||
},
|
||||
});
|
||||
|
||||
const bucket = config.r2Bucket();
|
||||
const gameFolder = "games";
|
||||
const analyticsFolder = "analytics";
|
||||
|
||||
export async function archive(gameRecord: GameRecord) {
|
||||
try {
|
||||
gameRecord.gitCommit = config.gitCommit();
|
||||
// Archive to R2
|
||||
await archiveAnalyticsToR2(gameRecord);
|
||||
|
||||
// Archive full game if there are turns
|
||||
if (gameRecord.turns.length > 0) {
|
||||
log.info(
|
||||
`${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`,
|
||||
);
|
||||
await archiveFullGameToR2(gameRecord);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// If the error is not an instance of Error, log it as a string
|
||||
if (!(error instanceof Error)) {
|
||||
log.error(
|
||||
`${gameRecord.info.gameID}: Final archive error. Non-Error type: ${String(error)}`,
|
||||
);
|
||||
const parsed = GameRecordSchema.safeParse(gameRecord);
|
||||
if (!parsed.success) {
|
||||
log.error(`invalid game record: ${z.prettifyError(parsed.error)}`, {
|
||||
gameID: gameRecord.info.gameID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { message, stack, name } = error;
|
||||
log.error(`${gameRecord.info.gameID}: Final archive error: ${error}`, {
|
||||
message: message,
|
||||
stack: stack,
|
||||
name: name,
|
||||
...(error && typeof error === "object" ? error : {}),
|
||||
const url = `${config.jwtIssuer()}/game/${gameRecord.info.gameID}`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(gameRecord, replacer),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": config.apiKey(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveAnalyticsToR2(gameRecord: GameRecord) {
|
||||
// Create analytics data object
|
||||
const { info, version, gitCommit, subdomain, domain } = gameRecord;
|
||||
const analyticsData: AnalyticsRecord = {
|
||||
info,
|
||||
version,
|
||||
gitCommit,
|
||||
subdomain,
|
||||
domain,
|
||||
};
|
||||
|
||||
try {
|
||||
// Store analytics data using just the game ID as the key
|
||||
const analyticsKey = `${info.gameID}.json`;
|
||||
|
||||
await r2.putObject({
|
||||
Bucket: bucket,
|
||||
Key: `${analyticsFolder}/${analyticsKey}`,
|
||||
Body: JSON.stringify(analyticsData, replacer),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
|
||||
log.info(`${info.gameID}: successfully wrote game analytics to R2`);
|
||||
} catch (error: unknown) {
|
||||
// If the error is not an instance of Error, log it as a string
|
||||
if (!(error instanceof Error)) {
|
||||
log.error(
|
||||
`${gameRecord.info.gameID}: Error writing game analytics to R2. Non-Error type: ${String(error)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
log.error(`error archiving game record: ${response.statusText}`, {
|
||||
gameID: gameRecord.info.gameID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { message, stack, name } = error;
|
||||
log.error(`${info.gameID}: Error writing game analytics to R2: ${error}`, {
|
||||
message: message,
|
||||
stack: stack,
|
||||
name: name,
|
||||
...(error && typeof error === "object" ? error : {}),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveFullGameToR2(gameRecord: GameRecord) {
|
||||
// Create a deep copy to avoid modifying the original
|
||||
const recordCopy = structuredClone(gameRecord);
|
||||
|
||||
// Players may see this so make sure to clear PII
|
||||
recordCopy.info.players.forEach((p) => {
|
||||
p.persistentID = "REDACTED";
|
||||
});
|
||||
|
||||
try {
|
||||
await r2.putObject({
|
||||
Bucket: bucket,
|
||||
Key: `${gameFolder}/${recordCopy.info.gameID}`,
|
||||
Body: JSON.stringify(recordCopy, replacer),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`error saving game ${gameRecord.info.gameID}`);
|
||||
throw error;
|
||||
log.error(`error archiving game record: ${error}`, {
|
||||
gameID: gameRecord.info.gameID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`${gameRecord.info.gameID}: game record successfully written to R2`);
|
||||
}
|
||||
|
||||
export async function readGameRecord(
|
||||
gameId: GameID,
|
||||
): Promise<GameRecord | null> {
|
||||
try {
|
||||
// Check if file exists and download in one operation
|
||||
const response = await r2.getObject({
|
||||
Bucket: bucket,
|
||||
Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder
|
||||
});
|
||||
// Parse the response body
|
||||
if (response.Body === undefined) return null;
|
||||
const bodyContents = await response.Body.transformToString();
|
||||
return JSON.parse(bodyContents) as GameRecord;
|
||||
} catch (error: unknown) {
|
||||
// If the error is not an instance of Error, log it as a string
|
||||
if (!(error instanceof Error)) {
|
||||
log.error(
|
||||
`${gameId}: Error reading game record from R2. Non-Error type: ${String(error)}`,
|
||||
);
|
||||
if (!ID.safeParse(gameId).success) {
|
||||
log.error(`invalid game ID: ${gameId}`);
|
||||
return null;
|
||||
}
|
||||
const { message, stack, name } = error;
|
||||
// Log the error for monitoring purposes
|
||||
log.error(`${gameId}: Error reading game record from R2: ${error}`, {
|
||||
message: message,
|
||||
stack: stack,
|
||||
name: name,
|
||||
...(error && typeof error === "object" ? error : {}),
|
||||
const url = `${config.jwtIssuer()}/game/${gameId}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const record = await response.json();
|
||||
if (!response.ok) {
|
||||
log.error(`error reading game record: ${response.statusText}`, {
|
||||
gameID: gameId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return GameRecordSchema.parse(record);
|
||||
} catch (error) {
|
||||
log.error(`error reading game record: ${error}`, {
|
||||
gameID: gameId,
|
||||
});
|
||||
|
||||
// Return null instead of throwing the error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function gameRecordExists(gameId: GameID): Promise<boolean> {
|
||||
try {
|
||||
await r2.headObject({
|
||||
Bucket: bucket,
|
||||
Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder
|
||||
});
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
// If the error is not an instance of Error, log it as a string
|
||||
if (!(error instanceof Error)) {
|
||||
log.error(
|
||||
`${gameId}: Error checking archive existence. Non-Error type: ${String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const { message, stack, name } = error;
|
||||
if (name === "NotFound") {
|
||||
return false;
|
||||
}
|
||||
log.error(`${gameId}: Error checking archive existence: ${error}`, {
|
||||
message: message,
|
||||
stack: stack,
|
||||
name: name,
|
||||
...(error && typeof error === "object" ? error : {}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
export function finalizeGameRecord(
|
||||
clientRecord: PartialGameRecord,
|
||||
): GameRecord {
|
||||
return {
|
||||
...clientRecord,
|
||||
gitCommit: config.gitCommit(),
|
||||
subdomain: config.subdomain(),
|
||||
domain: config.domain(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import WebSocket from "ws";
|
||||
import { TokenPayload } from "../core/ApiSchemas";
|
||||
import { Tick } from "../core/game/Game";
|
||||
import { ClientID } from "../core/Schemas";
|
||||
import { ClientID, Winner } from "../core/Schemas";
|
||||
|
||||
export class Client {
|
||||
public lastPing: number = Date.now();
|
||||
|
||||
public hashes: Map<Tick, number> = new Map();
|
||||
|
||||
public reportedWinner: Winner | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly clientID: ClientID,
|
||||
public readonly persistentID: string,
|
||||
|
||||
+65
-22
@@ -2,6 +2,8 @@ import ipAnonymize from "ip-anonymize";
|
||||
import { Logger } from "winston";
|
||||
import WebSocket from "ws";
|
||||
import { z } from "zod";
|
||||
import { GameEnv, ServerConfig } from "../core/configuration/Config";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientID,
|
||||
ClientMessageSchema,
|
||||
@@ -19,10 +21,8 @@ import {
|
||||
ServerTurnMessage,
|
||||
Turn,
|
||||
} from "../core/Schemas";
|
||||
import { createGameRecord } from "../core/Util";
|
||||
import { GameEnv, ServerConfig } from "../core/configuration/Config";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import { archive } from "./Archive";
|
||||
import { createPartialGameRecord } from "../core/Util";
|
||||
import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
export enum GamePhase {
|
||||
Lobby = "LOBBY",
|
||||
@@ -64,6 +64,11 @@ export class GameServer {
|
||||
|
||||
private websockets: Set<WebSocket> = new Set();
|
||||
|
||||
private winnerVotes: Map<
|
||||
string,
|
||||
{ winner: ClientSendWinnerMessage; ips: Set<string> }
|
||||
> = new Map();
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
readonly log_: Logger,
|
||||
@@ -190,6 +195,7 @@ export class GameServer {
|
||||
}
|
||||
|
||||
client.lastPing = existing.lastPing;
|
||||
client.reportedWinner = existing.reportedWinner;
|
||||
|
||||
this.activeClients = this.activeClients.filter((c) => c !== existing);
|
||||
}
|
||||
@@ -289,15 +295,7 @@ export class GameServer {
|
||||
break;
|
||||
}
|
||||
case "winner": {
|
||||
if (
|
||||
this.outOfSyncClients.has(client.clientID) ||
|
||||
this.kickedClients.has(client.clientID) ||
|
||||
this.winner !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.winner = clientMsg;
|
||||
this.archiveGame();
|
||||
this.handleWinner(client, clientMsg);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -688,15 +686,16 @@ export class GameServer {
|
||||
},
|
||||
);
|
||||
archive(
|
||||
createGameRecord(
|
||||
this.id,
|
||||
this.gameStartInfo.config,
|
||||
playerRecords,
|
||||
this.turns,
|
||||
this._startTime ?? 0,
|
||||
Date.now(),
|
||||
this.winner?.winner,
|
||||
this.config,
|
||||
finalizeGameRecord(
|
||||
createPartialGameRecord(
|
||||
this.id,
|
||||
this.gameStartInfo.config,
|
||||
playerRecords,
|
||||
this.turns,
|
||||
this._startTime ?? 0,
|
||||
Date.now(),
|
||||
this.winner?.winner,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -799,4 +798,48 @@ export class GameServer {
|
||||
outOfSyncClients,
|
||||
};
|
||||
}
|
||||
|
||||
private handleWinner(client: Client, clientMsg: ClientSendWinnerMessage) {
|
||||
if (
|
||||
this.outOfSyncClients.has(client.clientID) ||
|
||||
this.kickedClients.has(client.clientID) ||
|
||||
this.winner !== null ||
|
||||
client.reportedWinner !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
client.reportedWinner = clientMsg.winner;
|
||||
|
||||
// Add client vote
|
||||
const winnerKey = JSON.stringify(clientMsg.winner);
|
||||
if (!this.winnerVotes.has(winnerKey)) {
|
||||
this.winnerVotes.set(winnerKey, { ips: new Set(), winner: clientMsg });
|
||||
}
|
||||
const potentialWinner = this.winnerVotes.get(winnerKey)!;
|
||||
potentialWinner.ips.add(client.ip);
|
||||
|
||||
const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip));
|
||||
|
||||
const ratio = `${potentialWinner.ips.size}/${activeUniqueIPs.size}`;
|
||||
this.log.info(
|
||||
`recieved winner vote ${clientMsg.winner}, ${ratio} votes for this winner`,
|
||||
{
|
||||
clientID: client.clientID,
|
||||
},
|
||||
);
|
||||
|
||||
if (potentialWinner.ips.size * 2 < activeUniqueIPs.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Vote succeeded
|
||||
this.winner = potentialWinner.winner;
|
||||
this.log.info(
|
||||
`Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs`,
|
||||
{
|
||||
winnerKey: winnerKey,
|
||||
},
|
||||
);
|
||||
this.archiveGame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ if (config.otelEnabled()) {
|
||||
console.log("OTEL enabled");
|
||||
// Configure OpenTelemetry endpoint with basic auth (if provided)
|
||||
const headers: Record<string, string> = {};
|
||||
headers["Authorization"] = config.otelAuthHeader();
|
||||
|
||||
headers["Authorization"] = "Basic " + config.otelAuthHeader();
|
||||
// Add OTLP exporter for logs
|
||||
const logExporter = new OTLPLogExporter({
|
||||
url: `${config.otelEndpoint()}/v1/logs`,
|
||||
|
||||
+47
-52
@@ -1,3 +1,4 @@
|
||||
import compression from "compression";
|
||||
import express, { NextFunction, Request, Response } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import http from "http";
|
||||
@@ -6,18 +7,17 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { z } from "zod";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
GameRecord,
|
||||
GameRecordSchema,
|
||||
ID,
|
||||
PartialGameRecordSchema,
|
||||
ServerErrorMessage,
|
||||
} from "../core/Schemas";
|
||||
import { replacer } from "../core/Util";
|
||||
import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
|
||||
import { archive, readGameRecord } from "./Archive";
|
||||
import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
import { GameManager } from "./GameManager";
|
||||
import { getUserMe, verifyClientToken } from "./jwt";
|
||||
@@ -81,6 +81,7 @@ export async function startWorker() {
|
||||
});
|
||||
|
||||
app.set("trust proxy", 3);
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, "../../out")));
|
||||
app.use(
|
||||
@@ -210,55 +211,47 @@ export async function startWorker() {
|
||||
res.json(game.gameInfo());
|
||||
});
|
||||
|
||||
app.get("/api/archived_game/:id", async (req, res) => {
|
||||
const gameRecord = await readGameRecord(req.params.id);
|
||||
|
||||
if (!gameRecord) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Game not found",
|
||||
exists: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
config.env() !== GameEnv.Dev &&
|
||||
gameRecord.gitCommit !== config.gitCommit()
|
||||
) {
|
||||
log.warn(
|
||||
`git commit mismatch for game ${req.params.id}, expected ${config.gitCommit()}, got ${gameRecord.gitCommit}`,
|
||||
);
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: "Version mismatch",
|
||||
exists: true,
|
||||
details: {
|
||||
expectedCommit: config.gitCommit(),
|
||||
actualCommit: gameRecord.gitCommit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
exists: true,
|
||||
gameRecord: gameRecord,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/archive_singleplayer_game", async (req, res) => {
|
||||
const result = GameRecordSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
const error = z.prettifyError(result.error);
|
||||
log.info(error);
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
try {
|
||||
const record = req.body;
|
||||
|
||||
const gameRecord: GameRecord = result.data;
|
||||
archive(gameRecord);
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
const result = PartialGameRecordSchema.safeParse(record);
|
||||
if (!result.success) {
|
||||
const error = z.prettifyError(result.error);
|
||||
log.info(error);
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const gameRecord = result.data;
|
||||
|
||||
if (gameRecord.info.config.gameType !== GameType.Singleplayer) {
|
||||
log.warn(
|
||||
`cannot archive singleplayer with game type ${gameRecord.info.config.gameType}`,
|
||||
{
|
||||
gameID: gameRecord.info.gameID,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
if (result.data.info.players.length !== 1) {
|
||||
log.warn(`cannot archive singleplayer game multiple players`, {
|
||||
gameID: gameRecord.info.gameID,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
log.info("archiving singleplayer game", {
|
||||
gameID: gameRecord.info.gameID,
|
||||
});
|
||||
|
||||
archive(finalizeGameRecord(gameRecord));
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error processing archive request:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/kick_player/:gameID/:clientID", async (req, res) => {
|
||||
@@ -311,7 +304,9 @@ export async function startWorker() {
|
||||
// Ignore ping
|
||||
return;
|
||||
} else if (clientMsg.type !== "join") {
|
||||
log.warn(`Invalid message before join: ${JSON.stringify(clientMsg)}`);
|
||||
log.warn(
|
||||
`Invalid message before join: ${JSON.stringify(clientMsg, replacer)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function initWorkerMetrics(gameManager: GameManager): void {
|
||||
// Configure auth headers
|
||||
const headers: Record<string, string> = {};
|
||||
if (config.otelEnabled()) {
|
||||
headers["Authorization"] = config.otelAuthHeader();
|
||||
headers["Authorization"] = "Basic " + config.otelAuthHeader();
|
||||
}
|
||||
|
||||
// Create metrics exporter
|
||||
|
||||
@@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game";
|
||||
import { GameID } from "../../src/core/Schemas";
|
||||
|
||||
export class TestServerConfig implements ServerConfig {
|
||||
apiKey(): string {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
allowedFlares(): string[] | undefined {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ export default async (env, argv) => {
|
||||
"process.env.STRIPE_PUBLISHABLE_KEY": JSON.stringify(
|
||||
process.env.STRIPE_PUBLISHABLE_KEY,
|
||||
),
|
||||
"process.env.API_DOMAIN": JSON.stringify(process.env.API_DOMAIN),
|
||||
}),
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
|
||||
Reference in New Issue
Block a user