Merge branch 'v25'

This commit is contained in:
evanpelle
2025-09-18 11:26:53 -07:00
33 changed files with 508 additions and 374 deletions
+1
View File
@@ -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 }}
+16 -12
View File
@@ -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 }}
+16
View File
@@ -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
View File
@@ -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
+8 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+12 -14
View File
@@ -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"
+4
View File
@@ -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",
+3 -2
View File
@@ -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",
+11 -8
View File
@@ -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;
+69 -37
View File
@@ -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() {
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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}`);
},
);
}
+24 -5
View File
@@ -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();
}
+2 -2
View File
@@ -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
View File
@@ -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',
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -48,6 +48,7 @@ export interface ServerConfig {
r2Endpoint(): string;
r2AccessKey(): string;
r2SecretKey(): string;
apiKey(): string;
otelEndpoint(): string;
otelAuthHeader(): string;
otelEnabled(): boolean;
+4
View File
@@ -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";
}
+4
View File
@@ -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;
}
+3 -1
View File
@@ -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
View File
@@ -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(),
};
}
+3 -1
View File
@@ -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
View File
@@ -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();
}
}
+1 -2
View File
@@ -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
View File
@@ -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;
}
+1 -1
View File
@@ -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
+3
View File
@@ -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.");
}
+1
View File
@@ -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: [