diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b04581640..4a26134e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1674e15f..527dc6eca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/README.md b/README.md index 89df8e833..0c620ad7e 100644 --- a/README.md +++ b/README.md @@ -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**: diff --git a/build-deploy.sh b/build-deploy.sh index c008be0df..370991b58 100755 --- a/build-deploy.sh +++ b/build-deploy.sh @@ -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 diff --git a/deploy.sh b/deploy.sh index 2d9eb8645..bbacb85a0 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/example.env b/example.env index d186dab02..4135026cf 100644 --- a/example.env +++ b/example.env @@ -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" diff --git a/nginx.conf b/nginx.conf index 25c09b3d3..4732c9f80 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; diff --git a/package-lock.json b/package-lock.json index 81cc356e0..cea861373 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index fcee0a1e8..7c48ce71e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/lang/en.json b/resources/lang/en.json index 48dde128e..abf64ec90 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 8723657e0..f1d7c0293 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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; diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 1c875a7e7..2c89e9804 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -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 { - 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() { diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index c6dd1df5a..f1e78b924 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -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; // 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; } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 24cf46782..b1aa7a100 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -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 { + 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; +} diff --git a/src/client/Main.ts b/src/client/Main.ts index 046becf7c..1d435eea6 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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) { @@ -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}`); }, ); } diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 995246f37..53357f792 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -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` - this.selectPattern(null)} - > + ${this.affiliateCode === null + ? html` + this.selectPattern(null)} + > + ` + : html``} ${buttons} `; @@ -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(); } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 76357fd42..f27030ca6 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -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(); diff --git a/src/client/jwt.ts b/src/client/jwt.ts index c3222cb5d..ae2fdb618 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -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', diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index f52c811c1..5ff3d23fc 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -44,6 +44,7 @@ export const PatternSchema = z export const PatternInfoSchema = z.object({ name: PatternNameSchema, pattern: PatternSchema, + affiliateCode: z.string().nullable(), product: ProductSchema.nullable(), }); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 04c3d8a17..e402da509 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -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; @@ -517,18 +517,35 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({ }); export type GameEndInfo = z.infer; -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; export const GameRecordSchema = AnalyticsRecordSchema.extend({ turns: TurnSchema.array(), }); + +export const PartialGameRecordSchema = PartialAnalyticsRecordSchema.extend({ + turns: TurnSchema.array(), +}); + +export type PartialGameRecord = z.infer; + export type GameRecord = z.infer; diff --git a/src/core/Util.ts b/src/core/Util.ts index 0ded7f5de..8d30c1a23 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -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; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 6ed0d83a3..642ea0cd1 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -48,6 +48,7 @@ export interface ServerConfig { r2Endpoint(): string; r2AccessKey(): string; r2SecretKey(): string; + apiKey(): string; otelEndpoint(): string; otelAuthHeader(): string; otelEnabled(): boolean; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index f4617ac6a..484f540f5 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -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"; } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 3a14b39bb..d530795c5 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -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; } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index a73efceb7..97e78cb2c 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -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; } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 6b3675d94..b540f9c8d 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -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 { 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 { - 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(), + }; } diff --git a/src/server/Client.ts b/src/server/Client.ts index 68f0a2bfb..ecac7f885 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -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 = new Map(); + public reportedWinner: Winner | null = null; + constructor( public readonly clientID: ClientID, public readonly persistentID: string, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 105cac554..803638719 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -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 = new Set(); + private winnerVotes: Map< + string, + { winner: ClientSendWinnerMessage; ips: Set } + > = 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(); + } } diff --git a/src/server/Logger.ts b/src/server/Logger.ts index 25be2683e..6a47b9ead 100644 --- a/src/server/Logger.ts +++ b/src/server/Logger.ts @@ -24,8 +24,7 @@ if (config.otelEnabled()) { console.log("OTEL enabled"); // Configure OpenTelemetry endpoint with basic auth (if provided) const headers: Record = {}; - headers["Authorization"] = config.otelAuthHeader(); - + headers["Authorization"] = "Basic " + config.otelAuthHeader(); // Add OTLP exporter for logs const logExporter = new OTLPLogExporter({ url: `${config.otelEndpoint()}/v1/logs`, diff --git a/src/server/Worker.ts b/src/server/Worker.ts index ae6e413e7..24d6cb2cf 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -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; } diff --git a/src/server/WorkerMetrics.ts b/src/server/WorkerMetrics.ts index 86d320e75..6e40fb6ee 100644 --- a/src/server/WorkerMetrics.ts +++ b/src/server/WorkerMetrics.ts @@ -20,7 +20,7 @@ export function initWorkerMetrics(gameManager: GameManager): void { // Configure auth headers const headers: Record = {}; if (config.otelEnabled()) { - headers["Authorization"] = config.otelAuthHeader(); + headers["Authorization"] = "Basic " + config.otelAuthHeader(); } // Create metrics exporter diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 86a277d65..6488dc99b 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -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."); } diff --git a/webpack.config.js b/webpack.config.js index 0820e8cd0..bf929b3fa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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: [