Merge branch 'main' into meta3

This commit is contained in:
1brucben
2025-05-06 01:13:38 +02:00
103 changed files with 4721 additions and 1613 deletions
@@ -0,0 +1,67 @@
---
name: "📝 API/DB Feature Request"
about: Suggest a new backend API or database feature
title: "[Feature] <short description here>"
labels: [feature, backend]
assignees: ""
---
## ✨ API/Database Feature Request
### Summary
Describe the feature being requested. What functionality does it add to the backend or database? What problem does it solve?
## 📘 Use Case
Explain the use case behind this feature. Who is it for, and why is it needed now?
## 📥 Example API Request
```
POST /api/example-endpoint
Content-Type: application/json
Authorization: Bearer <token>
{
"exampleField": "value",
"anotherField": 123
}
```
## 📤 Example API Response
```
// JSON response
{
"id": "abc123",
"status": "success",
"data": {
"result": true,
"timestamp": "2025-04-30T18:00:00Z"
}
}
```
## 📦 Database Considerations
- **New Tables**: Yes/No
If yes, describe the schema or include a rough layout.
- **Modified Tables**: Yes/No
Describe what changes are needed and why.
- **Migrations Required**: Yes/No
- **Indexes Required**: Yes/No
Include any thoughts on performance or query efficiency.
## 🔐 Security & Access Control
- Does the endpoint require authentication? Yes/No
- Should it be limited to specific roles (e.g. admin, worker, user)?
- Any sensitive data in the request or response?
## 📎 Additional Context
Add any screenshots, designs, relevant discussion links, or references to prior issues.
+93 -36
View File
@@ -1,16 +1,18 @@
name: 🚀 Deploy
on:
# Allow contributors to schedule manual deployments.
# Permission to deploy can be restricted by requiring approval in environment configuration.
workflow_dispatch:
inputs:
target_environment:
description: "Deployment Environment"
target_domain:
description: "Deployment Domain"
required: true
default: "staging"
default: "openfront.dev"
type: choice
options:
- prod
- staging
- openfront.io
- openfront.dev
target_host:
description: "Deployment Host"
required: true
@@ -25,53 +27,108 @@ on:
required: false
default: ""
type: string
# Automatic deployment on push
# See https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore
push:
branches:
- main
- "*"
jobs:
deploy:
# Don't deploy on push if this is a fork
if: ${{ github.event_name == 'workflow_dispatch' || github.repository == 'openfrontio/OpenFrontIO' }}
# Use different logic based on event type
name: Deploy to ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}
name: Deploy to ${{
github.event_name == 'push'
&& (github.ref_name == 'main' && 'openfront.dev'
|| format('{0}.openfront.dev', github.ref_name))
|| inputs.target_subdomain && format('{0}.{1}', inputs.target_subdomain, inputs.target_domain)
|| inputs.target_domain
|| 'openfront.dev'
}}
runs-on: ubuntu-latest
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}
environment: ${{
github.event_name == 'push'
&& (github.ref_name == 'main' && 'openfront.dev'
|| format('{0}.openfront.dev', github.ref_name))
|| inputs.target_subdomain && format('{0}.{1}', inputs.target_subdomain, inputs.target_domain)
|| inputs.target_domain
|| 'openfront.dev'
}}
env:
DOMAIN: ${{ inputs.target_domain || 'openfront.dev' }}
SUBDOMAIN: ${{ github.event_name == 'push' && github.ref_name || inputs.target_subdomain || 'main' }}
steps:
- uses: actions/checkout@v4
- name: Update deployment status
env:
FQDN: ${{ env.SUBDOMAIN && format('{0}.{1}', env.SUBDOMAIN, env.DOMAIN) || env.DOMAIN || 'openfront.dev' }}
run: |
echo "FQDN=$FQDN" >> $GITHUB_ENV
cat <<EOF >> $GITHUB_STEP_SUMMARY
### In progress :ship:
Deploying from $GITHUB_REF to $FQDN
EOF
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- run: |
- name: Create SSH private key
env:
SERVER_HOST_EU: ${{ secrets.SERVER_HOST_EU }}
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
SERVER_HOST_US: ${{ secrets.SERVER_HOST_US }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
set -euxo pipefail
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa
test -n "$SERVER_HOST_STAGING" && ssh-keyscan -H "$SERVER_HOST_STAGING" >> ~/.ssh/known_hosts
test -n "$SERVER_HOST_US" && ssh-keyscan -H "$SERVER_HOST_US" >> ~/.ssh/known_hosts
test -n "$SERVER_HOST_EU" && ssh-keyscan -H "$SERVER_HOST_EU" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SERVER_HOST_STAGING }} >> ~/.ssh/known_hosts
- name: Deploy
env:
ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
DOCKER_REPO: ${{ vars.DOCKERHUB_REPO }}
DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
ENV: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }}
HOST: ${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }}
MON_PASSWORD: ${{ secrets.MON_PASSWORD }}
MON_USERNAME: ${{ secrets.MON_USERNAME }}
OTEL_ENDPOINT: ${{ secrets.OTEL_ENDPOINT }}
OTEL_PASSWORD: ${{ secrets.OTEL_PASSWORD }}
OTEL_USERNAME: ${{ secrets.OTEL_USERNAME }}
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
SERVER_HOST_EU: ${{ secrets.SERVER_HOST_EU }}
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
SERVER_HOST_US: ${{ secrets.SERVER_HOST_US }}
SSH_KEY: ~/.ssh/id_rsa
VERSION_TAG: latest
run: |
echo "::group::deploy.sh"
./deploy.sh "$ENV" "$HOST" "$SUBDOMAIN"
echo "::endgroup::"
- name: Update deployment status ✅
if: success()
run: |
cat <<EOF >> $GITHUB_STEP_SUMMARY
### Success! :rocket:
# Determine environment based on trigger type
TARGET_ENV="${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}"
TARGET_HOST="${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }}"
TARGET_SUBDOMAIN="${{ github.event_name == 'workflow_dispatch' && inputs.target_subdomain || 'main' }}"
cat >.env.$TARGET_ENV <<EOF
ADMIN_TOKEN=${{ secrets.ADMIN_TOKEN }}
CF_ACCOUNT_ID=${{ secrets.CF_ACCOUNT_ID }}
CF_API_TOKEN=${{ secrets.CF_API_TOKEN }}
DOCKER_REPO=${{ vars.DOCKERHUB_REPO }}
DOCKER_USERNAME=${{ vars.DOCKERHUB_USERNAME }}
DOMAIN=${{ vars.DOMAIN }}
MON_PASSWORD=${{ secrets.MON_PASSWORD }}
MON_USERNAME=${{ secrets.MON_USERNAME }}
R2_ACCESS_KEY=${{ secrets.R2_ACCESS_KEY }}
R2_BUCKET=${{ secrets.R2_BUCKET }}
R2_SECRET_KEY=${{ secrets.R2_SECRET_KEY }}
SERVER_HOST_STAGING=${{ secrets.SERVER_HOST_STAGING }}
SERVER_HOST_US=${{ secrets.SERVER_HOST_US }}
SERVER_HOST_EU=${{ secrets.SERVER_HOST_EU }}
SSH_KEY=~/.ssh/id_rsa
VERSION_TAG="latest"
Deployed from $GITHUB_REF to $FQDN
EOF
- name: Update deployment status ❌
if: failure()
run: |
cat <<EOF >> $GITHUB_STEP_SUMMARY
### Failure! :fire:
./deploy.sh $TARGET_ENV $TARGET_HOST $TARGET_SUBDOMAIN
echo "Deployed to $TARGET_ENV environment on $TARGET_HOST host with subdomain $TARGET_SUBDOMAIN"
Unable to deploy from $GITHUB_REF to $FQDN
EOF
+19
View File
@@ -9,6 +9,25 @@
"runtimeArgs": ["run-script", "test"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Debug Server",
"runtimeExecutable": "node",
"runtimeArgs": [
"--loader",
"ts-node/esm",
"--experimental-specifier-resolution=node",
"${workspaceFolder}/src/server/Server.ts"
],
"env": {
"GAME_ENV": "dev"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"sourceMaps": true,
"restart": true
}
]
}
+17 -11
View File
@@ -1,9 +1,8 @@
# Use an official Node runtime as the base image
FROM node:18
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
FROM node:18 AS base
# Install Nginx, Supervisor, Git, jq, curl, and Node Exporter dependencies
# Create dependency layer
FROM base AS dependencies
RUN apt-get update && apt-get install -y \
nginx \
supervisor \
@@ -11,19 +10,22 @@ RUN apt-get update && apt-get install -y \
curl \
jq \
wget \
apache2-utils \
&& rm -rf /var/lib/apt/lists/*
# Install Node Exporter
RUN mkdir -p /opt/node_exporter && \
wget -qO- https://github.com/prometheus/node_exporter/releases/download/v1.7.0/node_exporter-1.7.0.linux-amd64.tar.gz | \
tar xvz --strip-components=1 -C /opt/node_exporter && \
ln -s /opt/node_exporter/node_exporter /usr/local/bin/
# Install cloudflared
RUN curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb > cloudflared.deb \
&& dpkg -i cloudflared.deb \
&& rm cloudflared.deb
# Final image
FROM base
# Copy installed packages from dependencies stage
COPY --from=dependencies / /
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
# Set the working directory in the container
WORKDIR /usr/src/app
@@ -41,6 +43,10 @@ COPY . .
# Build the client-side application
RUN npm run build-prod
# So we can see which commit was used to build the container
# https://openfront.io/commit.txt
RUN echo $GIT_COMMIT > static/commit.txt
# Copy Nginx configuration and ensure it's used instead of the default
COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN rm -f /etc/nginx/sites-enabled/default
+9 -7
View File
@@ -116,21 +116,23 @@ This project is licensed under the terms found in the [LICENSE](LICENSE) file.
Contributions are welcome! Please feel free to submit a Pull Request.
1. Request to join the development [Discord](https://discord.gg/K9zernJB5z).
1. Fork the repository
2. Create your feature branch (`git checkout -b amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin amazing-feature`)
5. Open a Pull Request
1. Create your feature branch (`git checkout -b amazing-feature`)
1. Commit your changes (`git commit -m 'Add some amazing feature'`)
1. Push to the branch (`git push origin amazing-feature`)
1. Open a Pull Request
## 🌐 Translation
Translators are welcome! Please feel free to help translate into your language.
How to help?
1. Request to join the translation [Discord](https://discord.gg/rUukAnz4Ww)
1. Go to the project's Crowdin translation page: [https://crowdin.com/project/openfront-mls](https://crowdin.com/project/openfront-mls)
2. Login if you already have an account/ Sign up if you don't have one
3. Select the language you want to translate in/ If your language isn't on the list, click the "Request New Language" button and enter the language you want added there.
4. Translate the strings
1. Login if you already have an account/ Sign up if you don't have one
1. Select the language you want to translate in/ If your language isn't on the list, click the "Request New Language" button and enter the language you want added there.
1. Translate the strings
### Project Governance
+55 -14
View File
@@ -7,24 +7,45 @@
set -e # Exit immediately if a command exits with a non-zero status
# Initialize variables
ENABLE_BASIC_AUTH=false
# Parse command line arguments
POSITIONAL_ARGS=()
while [[ $# -gt 0 ]]; do
case $1 in
--enable_basic_auth)
ENABLE_BASIC_AUTH=true
shift
;;
*)
POSITIONAL_ARGS+=("$1")
shift
;;
esac
done
# Restore positional parameters
set -- "${POSITIONAL_ARGS[@]}"
# Check command line arguments
if [ $# -lt 2 ] || [ $# -gt 3 ]; then
echo "Error: Please specify environment and host, with optional subdomain"
echo "Usage: $0 [prod|staging] [eu|us|staging] [subdomain]"
echo "Usage: $0 [prod|staging] [eu|us|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|us|staging] [subdomain]"
echo "Usage: $0 [prod|staging] [eu|us|staging|masters] [subdomain] [--enable_basic_auth]"
exit 1
fi
# Validate second argument (host)
if [ "$2" != "eu" ] && [ "$2" != "us" ] && [ "$2" != "staging" ]; then
echo "Error: Second argument must be either 'eu', 'us', or 'staging'"
echo "Usage: $0 [prod|staging] [eu|us|staging] [subdomain]"
if [ "$2" != "eu" ] && [ "$2" != "us" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then
echo "Error: Second argument must be either 'eu', 'us', 'staging', or 'masters'"
echo "Usage: $0 [prod|staging] [eu|us|staging|masters] [subdomain] [--enable_basic_auth]"
exit 1
fi
@@ -57,9 +78,6 @@ fi
if [ -f .env.$ENV ]; then
echo "Loading $ENV-specific configuration from .env.$ENV file..."
export $(grep -v '^#' .env.$ENV | xargs)
else
echo "Error: Environment file .env.$ENV not found"
exit 1
fi
if [ "$HOST" == "staging" ]; then
@@ -68,6 +86,9 @@ if [ "$HOST" == "staging" ]; then
elif [ "$HOST" == "us" ]; then
print_header "DEPLOYING TO US HOST"
SERVER_HOST=$SERVER_HOST_US
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
@@ -79,14 +100,29 @@ if [ -z "$SERVER_HOST" ]; then
exit 1
fi
# Check if basic auth is enabled and credentials are available
if [ "$ENABLE_BASIC_AUTH" = true ]; then
print_header "BASIC AUTH ENABLED"
if [ -z "$BASIC_AUTH_USER" ] || [ -z "$BASIC_AUTH_PASS" ]; then
echo "Error: Basic Auth is enabled but BASIC_AUTH_USER or BASIC_AUTH_PASS not defined in .env file or environment"
exit 1
fi
echo "Basic Authentication will be enabled with user: $BASIC_AUTH_USER"
else
# If basic auth is not enabled, set the variables to empty to ensure they don't get used
BASIC_AUTH_USER=""
BASIC_AUTH_PASS=""
echo "Basic Authentication is disabled"
fi
# Configuration
UPDATE_SCRIPT="./update.sh" # Path to your update script
REMOTE_USER="openfront"
REMOTE_UPDATE_PATH="/home/$REMOTE_USER"
REMOTE_UPDATE_SCRIPT="$REMOTE_UPDATE_PATH/update-openfront.sh" # Where to place the script on server
IMAGE_NAME="${DOCKER_USERNAME}/${DOCKER_REPO}"
DOCKER_IMAGE="${IMAGE_NAME}:${VERSION_TAG}"
VERSION_TAG=$(date +"%Y%m%d-%H%M%S")
DOCKER_IMAGE="${DOCKER_USERNAME}/${DOCKER_REPO}:${VERSION_TAG}"
# Check if update script exists
if [ ! -f "$UPDATE_SCRIPT" ]; then
@@ -109,7 +145,7 @@ echo "Git commit: $GIT_COMMIT"
docker buildx build \
--platform linux/amd64 \
--build-arg GIT_COMMIT=$GIT_COMMIT \
-t $DOCKER_USERNAME/$DOCKER_REPO:$VERSION_TAG \
-t $DOCKER_IMAGE \
--push \
.
@@ -140,7 +176,6 @@ cat > $REMOTE_UPDATE_PATH/.env << 'EOL'
GAME_ENV=$ENV
ENV=$ENV
HOST=$HOST
SUBDOMAIN=$SUBDOMAIN
DOCKER_IMAGE=$DOCKER_IMAGE
DOCKER_TOKEN=$DOCKER_TOKEN
ADMIN_TOKEN=$ADMIN_TOKEN
@@ -151,8 +186,11 @@ R2_BUCKET=$R2_BUCKET
CF_API_TOKEN=$CF_API_TOKEN
DOMAIN=$DOMAIN
SUBDOMAIN=$SUBDOMAIN
MON_USERNAME=$MON_USERNAME
MON_PASSWORD=$MON_PASSWORD
OTEL_USERNAME=$OTEL_USERNAME
OTEL_PASSWORD=$OTEL_PASSWORD
OTEL_ENDPOINT=$OTEL_ENDPOINT
BASIC_AUTH_USER=$BASIC_AUTH_USER
BASIC_AUTH_PASS=$BASIC_AUTH_PASS
EOL
chmod 600 $REMOTE_UPDATE_PATH/.env && \
$REMOTE_UPDATE_SCRIPT"
@@ -164,5 +202,8 @@ fi
print_header "DEPLOYMENT COMPLETED SUCCESSFULLY"
echo "✅ New version deployed to ${ENV} environment in ${HOST} with subdomain ${SUBDOMAIN}!"
if [ "$ENABLE_BASIC_AUTH" = true ]; then
echo "🔒 Basic authentication enabled with user: $BASIC_AUTH_USER"
fi
echo "🌐 Check your server to verify the deployment."
echo "======================================================="
-103
View File
@@ -1,103 +0,0 @@
#!/bin/bash
# Metric Collector for Prometheus Pushgateway
# This script collects metrics from Node Exporter and application sources
# and pushes them to a Prometheus Pushgateway with custom labels.
# Configuration
NODE_EXPORTER_URL="http://localhost:9100/metrics"
APP_METRICS_URL="http://localhost:9090/metrics"
PUSHGATEWAY_BASE_URL="https://mon.openfront.io/pushgateway/metrics"
AUTH=$MON_USERNAME:$MON_PASSWORD
INTERVAL=15 # seconds
# Function to fetch metrics from Node Exporter
fetch_node_exporter_metrics() {
curl -s --connect-timeout 5 --max-time 10 "$NODE_EXPORTER_URL" ||
echo "# Error fetching Node Exporter metrics"
}
# Function to fetch metrics from your application
fetch_app_metrics() {
curl -s --connect-timeout 5 --max-time 10 "$APP_METRICS_URL" ||
echo "# Error fetching application metrics"
}
# Function to push metrics to Pushgateway
push_metrics() {
local metrics=$1
local job_name=$2
echo "Pushing $job_name metrics to Pushgateway..."
# Create a temporary file for the metrics
TEMP_FILE=$(mktemp)
echo "$metrics" > "$TEMP_FILE"
# Push to Pushgateway with instance label
curl -s -u "$AUTH" --data-binary @"$TEMP_FILE" \
"$PUSHGATEWAY_BASE_URL/job/$job_name/instance/$HOST"
# Check if push was successful
if [ $? -eq 0 ]; then
echo "$job_name metrics pushed successfully"
else
echo "Error pushing $job_name metrics"
fi
# Remove temporary file
rm "$TEMP_FILE"
}
# Function to add labels to metrics
add_labels() {
local metrics=$1
# First, handle metrics with existing labels
metrics=$(echo "$metrics" | sed -E 's/(\{[^}]*)\}/\1,env="'$ENV'",host="'$HOST'",subdomain="'$SUBDOMAIN'"}/g')
# Then, handle metrics with no existing labels
metrics=$(echo "$metrics" | sed -E 's/^([a-zA-Z0-9_:]+)[ \t]+([0-9.e+-]+)$/\1{env="'$ENV'",host="'$HOST'",subdomain="'$SUBDOMAIN'"} \2/g')
echo "$metrics"
}
# Main function to collect and push metrics
collect_and_push_metrics() {
echo "Starting metrics collection cycle at $(date)"
# Get metrics from both sources
NODE_METRICS=$(fetch_node_exporter_metrics)
APP_METRICS=$(fetch_app_metrics)
# Clean up metrics (remove headers etc.)
NODE_METRICS=$(echo "$NODE_METRICS" | grep -v "^Fetching")
APP_METRICS=$(echo "$APP_METRICS" | grep -v "^Fetching")
# Add labels to metrics
NODE_METRICS=$(add_labels "$NODE_METRICS")
APP_METRICS=$(add_labels "$APP_METRICS")
# Push to Pushgateway separately
push_metrics "$NODE_METRICS" "node_exporter"
push_metrics "$APP_METRICS" "app_metrics"
echo "Metrics collection cycle completed at $(date)"
}
# Main execution
echo "===== Starting metrics collector ====="
echo "Environment: $ENV, HOST: $HOST, Subdomain: $SUBDOMAIN"
echo "Collecting and pushing metrics every $INTERVAL seconds"
echo "Node Exporter URL: $NODE_EXPORTER_URL"
echo "App Metrics URL: $APP_METRICS_URL"
echo "Pushgateway URL: $PUSHGATEWAY_BASE_URL"
# Wait for app to be ready.
sleep 30
# Then set up interval loop
while true; do
sleep $INTERVAL
collect_and_push_metrics
done
+1580 -13
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -80,6 +80,18 @@
"@aws-sdk/client-s3": "^3.758.0",
"@datastructures-js/priority-queue": "^6.3.1",
"@google-cloud/secret-manager": "^5.6.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.200.0",
"@opentelemetry/auto-instrumentations-node": "^0.58.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.200.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
"@opentelemetry/host-metrics": "^0.36.0",
"@opentelemetry/resources": "^2.0.0",
"@opentelemetry/sdk-logs": "^0.200.0",
"@opentelemetry/sdk-metrics": "^2.0.0",
"@opentelemetry/sdk-node": "^0.200.0",
"@opentelemetry/semantic-conventions": "^1.32.0",
"@opentelemetry/winston-transport": "^0.11.0",
"@types/dompurify": "^3.0.5",
"@types/express": "^4.17.21",
"@types/google-protobuf": "^3.15.12",
@@ -95,7 +107,7 @@
"d3": "^7.9.0",
"discord.js": "^14.16.3",
"dompurify": "^3.1.7",
"dotenv": "^16.4.7",
"dotenv": "^16.5.0",
"express": "^4.21.1",
"express-rate-limit": "^7.5.0",
"google-auth-library": "^9.14.0",
@@ -104,6 +116,7 @@
"html-webpack-plugin": "^5.6.3",
"ip-anonymize": "^0.1.0",
"jimp": "^0.22.12",
"jose": "^6.0.10",
"lit": "^3.2.1",
"msgpack5": "^6.0.2",
"nanoid": "^3.3.6",
@@ -127,6 +140,7 @@
"webpack-dev-server": "^5.0.4",
"wheelnav": "^1.7.1",
"winston": "^3.17.0",
"winston-transport": "^4.9.0",
"ws": "^8.18.0",
"zod": "^3.23.8"
},
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="150px" height="150px" viewBox="0 0 150 100" version="1.1">
<g id="surface1">
<rect x="0" y="0" width="150" height="100" style="fill:rgb(100%,100%,100%);fill-opacity:1;stroke:none;"/>
<path style="fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;stroke-width:120;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(93.333334%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 0 60 L 900 60 M 900 300 L 0 300 M 0 540 L 900 540 " transform="matrix(0.166667,0,0,0.166667,0,0)"/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,31.37255%,94.117647%);fill-opacity:1;" d="M 0 0 L 0 100 L 86.667969 50 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 19 63.667969 L 28.832031 33.167969 L 38.667969 63.667969 L 12.832031 44.832031 L 44.832031 44.832031 "/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 954 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 88 KiB

-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="150px" height="150px" viewBox="0 0 150 84" version="1.1">
<g id="surface1">
<rect x="0" y="0" width="150" height="84" style="fill:rgb(83.529413%,16.862746%,11.764706%);fill-opacity:1;stroke:none;"/>
<path style="fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;stroke-width:4.3;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,60.784316%,28.235295%);stroke-opacity:1;stroke-miterlimit:4;" d="M 0 0 L 50 28 M 50 0 L 0 28 " transform="matrix(3,0,0,3,0,0)"/>
<path style="fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;stroke-width:4.3;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 25 0 L 25 28 M 0 14 L 50 14 " transform="matrix(3,0,0,3,0,0)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 866 B

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 367 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 98 KiB

-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="150px" height="150px" viewBox="0 0 150 150" version="1.1">
<g id="surface1">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 0 30 L 150 30 L 150 120 L 0 120 Z M 0 30 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(80.784315%,6.666667%,14.117648%);fill-opacity:1;" d="M 66 30 L 84 30 L 84 120 L 66 120 Z M 66 30 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(80.784315%,6.666667%,14.117648%);fill-opacity:1;" d="M 0 66 L 150 66 L 150 84 L 0 84 Z M 0 66 "/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 659 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 53 KiB

-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="150px" height="150px" viewBox="0 0 150 150" version="1.1">
<g id="surface1">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,36.862746%,72.156864%);fill-opacity:1;" d="M 0 30 L 150 30 L 150 120 L 0 120 Z M 0 30 "/>
<path style="fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;stroke-width:120;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 0 0 L 999.99996 599.999976 M 0 599.999976 L 999.99996 0 " transform="matrix(0.15,0,0,0.15,0,30)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 658 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+46 -1
View File
@@ -8,6 +8,9 @@
"main": {
"title": "OpenFront (ALPHA)",
"join_discord": "Join the Discord!",
"login_discord": "Login with Discord",
"logged_in": "Logged in!",
"log_out": "Log out",
"create_lobby": "Create Lobby",
"join_lobby": "Join Lobby",
"single_player": "Single Player",
@@ -122,6 +125,7 @@
"betweentwoseas": "Between Two Seas",
"knownworld": "Known World",
"faroeislands": "Faroe Islands",
"deglaciatedantarctica": "Deglaciated Antarctica",
"europeclassic": "Europe (classic)"
},
"map_categories": {
@@ -142,7 +146,8 @@
},
"public_lobby": {
"join": "Join next Game",
"waiting": "players waiting"
"waiting": "players waiting",
"teams": "{num} teams"
},
"username": {
"enter_username": "Enter your username",
@@ -185,5 +190,45 @@
},
"select_lang": {
"title": "Select Language"
},
"user_setting": {
"title": "User Settings",
"tab_basic": "Basic Settings",
"tab_keybinds": "Keybinds",
"dark_mode_label": "🌙 Dark Mode",
"dark_mode_desc": "Toggle the sites appearance between light and dark themes",
"emojis_label": "😊 Emojis",
"emojis_desc": "Toggle whether emojis are shown in game",
"left_click_label": "🖱️ Left Click to Open Menu",
"left_click_desc": "When ON, left-click opens menu and sword button attacks. When OFF, left-click attacks directly.",
"attack_ratio_label": "⚔️ Attack Ratio",
"attack_ratio_desc": "What percentage of your troops to send in an attack (1100%)",
"troop_ratio_label": "🪖🛠️ Troops and Workers Ratio",
"troop_ratio_desc": "Adjust the balance between troops (for combat) and workers (for gold production) (1100%)",
"easter_writing_speed_label": "Writing Speed Multiplier",
"easter_writing_speed_desc": "Adjust how fast you pretend to code (x1x100)",
"easter_bug_count_label": "Bug Count",
"easter_bug_count_desc": "How many bugs you're okay with (01000, emotionally)",
"view_options": "View Options",
"toggle_view": "Toggle View",
"toggle_view_desc": "Alternate view (terrain/countries)",
"zoom_controls": "Zoom Controls",
"zoom_out": "Zoom Out",
"zoom_out_desc": "Zoom out the map",
"zoom_in": "Zoom In",
"zoom_in_desc": "Zoom in the map",
"camera_movement": "Camera Movement",
"center_camera": "Center Camera",
"center_camera_desc": "Center camera on player",
"move_up": "Move Camera Up",
"move_up_desc": "Move the camera upward",
"move_left": "Move Camera Left",
"move_left_desc": "Move the camera to the left",
"move_down": "Move Camera Down",
"move_down_desc": "Move the camera downward",
"move_right": "Move Camera Right",
"move_right_desc": "Move the camera to the right",
"reset": "Reset",
"unbind": "Unbind"
}
}
+40
View File
@@ -171,5 +171,45 @@
"game_mode": {
"ffa": "Todos contra Todos",
"teams": "Equipos"
},
"user_setting": {
"title": "Uzantparametroj",
"tab_basic": "Bazaj parametroj",
"tab_keybinds": "Fulmoklavoj",
"dark_mode_label": "🌙 Malhela Modo",
"dark_mode_desc": "Baskuli la retpaĝa aspekto inter hela kaj malhela temo",
"emojis_label": "😊 Emoĝioj",
"emojis_desc": "Montri/Maski la emoĝiojn en la ludo",
"left_click_label": "🖱️Maldekstra alklako por malfermi menuon",
"left_click_desc": "Kiam aktiviga, maldekstra alklako malfermas menuon kaj glava atakbutono. Kiam malaktiviga, maldekstra alklako atakas direkten.",
"attack_ratio_label": "⚔️ Atakkvociento",
"attack_ratio_desc": "Kian procenton de viaj trupoj sendi en atako (1100%)",
"troop_ratio_label": "🪖🛠️ Trupoj kaj Laboristoj kvociento",
"troop_ratio_desc": "Alĝustigu la ekvilibron inter soldatoj (por batalo) kaj laboristoj (por orproduktado) (1100%)",
"easter_writing_speed_label": "Rapidskriba multiganto",
"easter_writing_speed_desc": "Alĝustigu kiom rapide vi ŝajnigas kodi (x1x100)",
"easter_bug_count_label": "Nombro da cimoj",
"easter_bug_count_desc": "Kiom da cimoj vi bonfartas (01000, emocie)",
"view_options": "Vidi opciojn",
"toggle_view": "Baskuli vido",
"toggle_view_desc": "Alterna vido (tereno/landoj)",
"zoom_controls": "Zomaj kontroloj",
"zoom_out": "Malzomi",
"zoom_out_desc": "Malzomi la mapon",
"zoom_in": "Zomi",
"zoom_in_desc": "Zomi en la mapon",
"camera_movement": "Fotila movado",
"center_camera": "Centriĝi la fotilon",
"center_camera_desc": "Centriĝi la fotilon sur la ludanto",
"move_up": "Movi Fotilon Supren",
"move_up_desc": "Movi la fotilon supren",
"move_left": "Movi Fotilon Maldekstren",
"move_left_desc": "Movi la fotilon maldekstren",
"move_down": "Movi Fotilon Malsupren",
"move_down_desc": "Movi la fotilon malsupren",
"move_right": "Movi Fotilon Dekstren",
"move_right_desc": "Movi la fotilon dekstren",
"reset": "Rekomencigi",
"unbind": "Senligi"
}
}
+2 -1
View File
@@ -137,7 +137,8 @@
},
"public_lobby": {
"join": "次のゲームに参加",
"waiting": "人が参加しています..."
"waiting": "人が参加しています...",
"teams": "{num}チーム"
},
"username": {
"enter_username": "ユーザー名を入力",
+1 -1
View File
@@ -65,7 +65,7 @@
},
{
"coordinates": [208, 832],
"name": "Sierra Leon",
"name": "Sierra Leone",
"strength": 2,
"flag": "sl"
},
File diff suppressed because one or more lines are too long
+61
View File
@@ -0,0 +1,61 @@
{
"name": "Deglaciated Antarctica",
"width": 2300,
"height": 1840,
"nations": [
{
"coordinates": [1545, 785],
"name": "Penguin Empire",
"strength": 2,
"flag": "an_pe"
},
{
"coordinates": [1365, 155],
"name": "Norwegian Claim",
"strength": 2,
"flag": "no"
},
{
"coordinates": [1810, 450],
"name": "Upper Australian Claim",
"strength": 2,
"flag": "au"
},
{
"coordinates": [1980, 980],
"name": "Lower Australian Claim",
"strength": 1,
"flag": "au"
},
{
"coordinates": [495, 605],
"name": "Argentinian Claim",
"strength": 2,
"flag": "ar"
},
{
"coordinates": [1150, 715],
"name": "United Kingdom Claim",
"strength": 2,
"flag": "gb"
},
{
"coordinates": [1060, 935],
"name": "Chilean Claim",
"strength": 2,
"flag": "cl"
},
{
"coordinates": [1365, 1400],
"name": "New Zealand Claim",
"strength": 2,
"flag": "nz"
},
{
"coordinates": [1590, 1120],
"name": "French Claim",
"strength": 2,
"flag": "fr"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+608
View File
@@ -0,0 +1,608 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenFront - Privacy Policy</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #333;
}
h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 30px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
h2 {
color: #2980b9;
margin-top: 30px;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
h3 {
color: #3498db;
margin-top: 20px;
}
h4 {
color: #2980b9;
margin-top: 15px;
}
ul {
padding-left: 25px;
}
.updated-date {
text-align: center;
font-style: italic;
margin-bottom: 30px;
}
.contact {
margin-top: 40px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 5px;
}
.footer {
margin-top: 50px;
padding-top: 20px;
border-top: 1px solid #eee;
text-align: center;
font-style: italic;
}
</style>
</head>
<body>
<h1>Privacy Policy</h1>
<p class="updated-date"><strong>Last Updated: April 29, 2025</strong></p>
<p>
This Privacy Policy describes Our policies and procedures on the
collection, use and disclosure of Your information when You use the
Service and tells You about Your privacy rights and how the law protects
You.
</p>
<p>
We use Your Personal data to provide and improve the Service. By using the
Service, You agree to the collection and use of information in accordance
with this Privacy Policy.
</p>
<h2>Interpretation and Definitions</h2>
<h3>Interpretation</h3>
<p>
The words of which the initial letter is capitalized have meanings defined
under the following conditions. The following definitions shall have the
same meaning regardless of whether they appear in singular or in plural.
</p>
<h3>Definitions</h3>
<p>For the purposes of this Privacy Policy:</p>
<ul>
<li>
<p>
<strong>Account</strong> means a unique account created for You to
access our Service or parts of our Service.
</p>
</li>
<li>
<p>
<strong>Affiliate</strong> means an entity that controls, is
controlled by or is under common control with a party, where
&quot;control&quot; means ownership of 50% or more of the shares,
equity interest or other securities entitled to vote for election of
directors or other managing authority.
</p>
</li>
<li>
<p>
<strong>Company</strong> (referred to as either &quot;the
Company&quot;, &quot;We&quot;, &quot;Us&quot; or &quot;Our&quot; in
this Agreement) refers to OpenFront LLC, NORTHWEST REGISTERED AGENT,
INC. 2108 N ST STE N SACRAMENTO, CA 95816.
</p>
</li>
<li>
<p>
<strong>Cookies</strong> are small files that are placed on Your
computer, mobile device or any other device by a website, containing
the details of Your browsing history on that website among its many
uses.
</p>
</li>
<li>
<p><strong>Country</strong> refers to: California, United States</p>
</li>
<li>
<p>
<strong>Device</strong> means any device that can access the Service
such as a computer, a cellphone or a digital tablet.
</p>
</li>
<li>
<p>
<strong>Personal Data</strong> is any information that relates to an
identified or identifiable individual.
</p>
</li>
<li>
<p>
<strong>Service</strong> refers to both our Website and our Discord
Bot application.
</p>
</li>
<li>
<p>
<strong>Bot</strong> refers specifically to our Discord Bot
application that interacts with Discord users and servers.
</p>
</li>
<li>
<p>
<strong>Service Provider</strong> means any natural or legal person
who processes the data on behalf of the Company. It refers to
third-party companies or individuals employed by the Company to
facilitate the Service, to provide the Service on behalf of the
Company, to perform services related to the Service or to assist the
Company in analyzing how the Service is used.
</p>
</li>
<li>
<p>
<strong>Third-party Social Media Service</strong> refers to any
website or any social network website through which a User can log in
or create an account to use the Service.
</p>
</li>
<li>
<p>
<strong>Usage Data</strong> refers to data collected automatically,
either generated by the use of the Service or from the Service
infrastructure itself (for example, the duration of a page visit).
</p>
</li>
<li>
<p>
<strong>Website</strong> refers to OpenFront, accessible from
<a
href="https://openfront.io"
rel="external nofollow noopener"
target="_blank"
>https://openfront.io</a
>
</p>
</li>
<li>
<p>
<strong>You</strong> means the individual accessing or using the
Service, or the company, or other legal entity on behalf of which such
individual is accessing or using the Service, as applicable.
</p>
</li>
</ul>
<h2>Collecting and Using Your Personal Data</h2>
<h3>Types of Data Collected</h3>
<h4>Personal Data</h4>
<p>
While using Our Service, We may ask You to provide Us with certain
personally identifiable information that can be used to contact or
identify You. Personally identifiable information may include, but is not
limited to:
</p>
<ul>
<li>
<p>Email address</p>
</li>
<li>
<p>Discord user ID and username</p>
</li>
<li>
<p>Discord server information when using our Bot</p>
</li>
<li>
<p>Usage Data</p>
</li>
</ul>
<h4>Usage Data</h4>
<p>Usage Data is collected automatically when using the Service.</p>
<p>
Usage Data may include information such as Your Device's Internet Protocol
address (e.g. IP address), browser type, browser version, the pages of our
Website that You visit, interactions with our Discord Bot, the time and
date of Your visit or interaction, the time spent on those pages or
interactions, unique device identifiers and other diagnostic data.
</p>
<p>
When You access the Service by or through a mobile device, We may collect
certain information automatically, including, but not limited to, the type
of mobile device You use, Your mobile device unique ID, the IP address of
Your mobile device, Your mobile operating system, the type of mobile
Internet browser You use, unique device identifiers and other diagnostic
data.
</p>
<p>
We may also collect information that Your browser sends whenever You visit
our Website or when You access the Service by or through a mobile device.
</p>
<h4>Information from Third-Party Social Media Services</h4>
<p>
The Company allows You to create an account and log in to use the Service
through the following Third-party Social Media Services:
</p>
<ul>
<li>Discord</li>
</ul>
<p>
If You decide to register through or otherwise grant us access to a
Third-Party Social Media Service, We may collect Personal data that is
already associated with Your Third-Party Social Media Service's account,
such as Your name, Your email address, Your activities or Your contact
list associated with that account.
</p>
<p>
Our Discord Bot may request permissions to access certain information from
your Discord account. These permissions will be displayed to you when you
add the Bot to a server or authorize the application.
</p>
<p>
You may also have the option of sharing additional information with the
Company through Your Third-Party Social Media Service's account. If You
choose to provide such information and Personal Data, during registration
or otherwise, You are giving the Company permission to use, share, and
store it in a manner consistent with this Privacy Policy.
</p>
<h4>Tracking Technologies and Cookies</h4>
<p>
We use Cookies and similar tracking technologies to track the activity on
Our Service and store certain information. Tracking technologies used are
beacons, tags, and scripts to collect and track information and to improve
and analyze Our Service. The technologies We use may include:
</p>
<ul>
<li>
<strong>Cookies or Browser Cookies.</strong> A cookie is a small file
placed on Your Device. You can instruct Your browser to refuse all
Cookies or to indicate when a Cookie is being sent. However, if You do
not accept Cookies, You may not be able to use some parts of our
Service. Unless you have adjusted Your browser setting so that it will
refuse Cookies, our Service may use Cookies.
</li>
<li>
<strong>Web Beacons.</strong> Certain sections of our Service and our
emails may contain small electronic files known as web beacons (also
referred to as clear gifs, pixel tags, and single-pixel gifs) that
permit the Company, for example, to count users who have visited those
pages or opened an email and for other related website statistics (for
example, recording the popularity of a certain section and verifying
system and server integrity).
</li>
</ul>
<p>
Cookies can be &quot;Persistent&quot; or &quot;Session&quot; Cookies.
Persistent Cookies remain on Your personal computer or mobile device when
You go offline, while Session Cookies are deleted as soon as You close
Your web browser.
</p>
<p>
We use both Session and Persistent Cookies for the purposes set out below:
</p>
<ul>
<li>
<p><strong>Necessary / Essential Cookies</strong></p>
<p>Type: Session Cookies</p>
<p>Administered by: Us</p>
<p>
Purpose: These Cookies are essential to provide You with services
available through the Website and to enable You to use some of its
features. They help to authenticate users and prevent fraudulent use
of user accounts. Without these Cookies, the services that You have
asked for cannot be provided, and We only use these Cookies to provide
You with those services.
</p>
</li>
<li>
<p><strong>Cookies Policy / Notice Acceptance Cookies</strong></p>
<p>Type: Persistent Cookies</p>
<p>Administered by: Us</p>
<p>
Purpose: These Cookies identify if users have accepted the use of
cookies on the Website.
</p>
</li>
<li>
<p><strong>Functionality Cookies</strong></p>
<p>Type: Persistent Cookies</p>
<p>Administered by: Us</p>
<p>
Purpose: These Cookies allow us to remember choices You make when You
use the Website, such as remembering your login details or language
preference. The purpose of these Cookies is to provide You with a more
personal experience and to avoid You having to re-enter your
preferences every time You use the Website.
</p>
</li>
</ul>
<h3>Use of Your Personal Data</h3>
<p>The Company may use Personal Data for the following purposes:</p>
<ul>
<li>
<p>
<strong>To provide and maintain our Service</strong>, including to
monitor the usage of our Service.
</p>
</li>
<li>
<p>
<strong>To manage Your Account:</strong> to manage Your registration
as a user of the Service. The Personal Data You provide can give You
access to different functionalities of the Service that are available
to You as a registered user.
</p>
</li>
<li>
<p>
<strong>For the performance of a contract:</strong> the development,
compliance and undertaking of the purchase contract for the products,
items or services You have purchased or of any other contract with Us
through the Service.
</p>
</li>
<li>
<p>
<strong>To contact You:</strong> To contact You by email, telephone
calls, SMS, or other equivalent forms of electronic communication,
such as a mobile application's push notifications or Discord messages
regarding updates or informative communications related to the
functionalities, products or contracted services, including the
security updates, when necessary or reasonable for their
implementation.
</p>
</li>
<li>
<p>
<strong>To provide You</strong> with news, special offers and general
information about other goods, services and events which we offer that
are similar to those that you have already purchased or enquired about
unless You have opted not to receive such information.
</p>
</li>
<li>
<p>
<strong>To manage Your requests:</strong> To attend and manage Your
requests to Us.
</p>
</li>
<li>
<p>
<strong>For business transfers:</strong> We may use Your information
to evaluate or conduct a merger, divestiture, restructuring,
reorganization, dissolution, or other sale or transfer of some or all
of Our assets, whether as a going concern or as part of bankruptcy,
liquidation, or similar proceeding, in which Personal Data held by Us
about our Service users is among the assets transferred.
</p>
</li>
<li>
<p>
<strong>For other purposes</strong>: We may use Your information for
other purposes, such as data analysis, identifying usage trends,
determining the effectiveness of our promotional campaigns and to
evaluate and improve our Service, products, services, marketing and
your experience.
</p>
</li>
</ul>
<p>We may share Your personal information in the following situations:</p>
<ul>
<li>
<strong>With Service Providers:</strong> We may share Your personal
information with Service Providers to monitor and analyze the use of our
Service, to contact You.
</li>
<li>
<strong>For business transfers:</strong> We may share or transfer Your
personal information in connection with, or during negotiations of, any
merger, sale of Company assets, financing, or acquisition of all or a
portion of Our business to another company.
</li>
<li>
<strong>With Affiliates:</strong> We may share Your information with Our
affiliates, in which case we will require those affiliates to honor this
Privacy Policy. Affiliates include Our parent company and any other
subsidiaries, joint venture partners or other companies that We control
or that are under common control with Us.
</li>
<li>
<strong>With business partners:</strong> We may share Your information
with Our business partners to offer You certain products, services or
promotions.
</li>
<li>
<strong>With other users:</strong> when You share personal information
or otherwise interact in the public areas with other users, such
information may be viewed by all users and may be publicly distributed
outside. If You interact with other users or register through a
Third-Party Social Media Service, Your contacts on the Third-Party
Social Media Service may see Your name, profile, pictures and
description of Your activity. Similarly, other users will be able to
view descriptions of Your activity, communicate with You and view Your
profile.
</li>
<li>
<strong>With Discord:</strong> When you use our Discord Bot, certain
information may be shared with Discord as necessary for the Bot's
functionality. This is subject to Discord's own Privacy Policy.
</li>
<li>
<strong>With Your consent</strong>: We may disclose Your personal
information for any other purpose with Your consent.
</li>
</ul>
<h3>Retention of Your Personal Data</h3>
<p>
The Company will retain Your Personal Data only for as long as is
necessary for the purposes set out in this Privacy Policy. We will retain
and use Your Personal Data to the extent necessary to comply with our
legal obligations (for example, if we are required to retain your data to
comply with applicable laws), resolve disputes, and enforce our legal
agreements and policies.
</p>
<p>
The Company will also retain Usage Data for internal analysis purposes.
Usage Data is generally retained for a shorter period of time, except when
this data is used to strengthen the security or to improve the
functionality of Our Service, or We are legally obligated to retain this
data for longer time periods.
</p>
<h3>Transfer of Your Personal Data</h3>
<p>
Your information, including Personal Data, is processed at the Company's
operating offices and in any other places where the parties involved in
the processing are located. It means that this information may be
transferred to — and maintained on — computers located outside of Your
state, province, country or other governmental jurisdiction where the data
protection laws may differ than those from Your jurisdiction.
</p>
<p>
Your consent to this Privacy Policy followed by Your submission of such
information represents Your agreement to that transfer.
</p>
<p>
The Company will take all steps reasonably necessary to ensure that Your
data is treated securely and in accordance with this Privacy Policy and no
transfer of Your Personal Data will take place to an organization or a
country unless there are adequate controls in place including the security
of Your data and other personal information.
</p>
<h3>Delete Your Personal Data</h3>
<p>
You have the right to delete or request that We assist in deleting the
Personal Data that We have collected about You.
</p>
<p>
Our Service may give You the ability to delete certain information about
You from within the Service.
</p>
<p>
You may update, amend, or delete Your information at any time by signing
in to Your Account, if you have one, and visiting the account settings
section that allows you to manage Your personal information. You may also
contact Us to request access to, correct, or delete any personal
information that You have provided to Us.
</p>
<p>
Please note, however, that We may need to retain certain information when
we have a legal obligation or lawful basis to do so.
</p>
<h3>Disclosure of Your Personal Data</h3>
<h4>Business Transactions</h4>
<p>
If the Company is involved in a merger, acquisition or asset sale, Your
Personal Data may be transferred. We will provide notice before Your
Personal Data is transferred and becomes subject to a different Privacy
Policy.
</p>
<h4>Law enforcement</h4>
<p>
Under certain circumstances, the Company may be required to disclose Your
Personal Data if required to do so by law or in response to valid requests
by public authorities (e.g. a court or a government agency).
</p>
<h4>Other legal requirements</h4>
<p>
The Company may disclose Your Personal Data in the good faith belief that
such action is necessary to:
</p>
<ul>
<li>Comply with a legal obligation</li>
<li>Protect and defend the rights or property of the Company</li>
<li>
Prevent or investigate possible wrongdoing in connection with the
Service
</li>
<li>Protect the personal safety of Users of the Service or the public</li>
<li>Protect against legal liability</li>
</ul>
<h3>Security of Your Personal Data</h3>
<p>
The security of Your Personal Data is important to Us, but remember that
no method of transmission over the Internet, or method of electronic
storage is 100% secure. While We strive to use commercially acceptable
means to protect Your Personal Data, We cannot guarantee its absolute
security.
</p>
<h2>Children's Privacy</h2>
<p>
Our Service does not address anyone under the age of 13. We do not
knowingly collect personally identifiable information from anyone under
the age of 13. If You are a parent or guardian and You are aware that Your
child has provided Us with Personal Data, please contact Us. If We become
aware that We have collected Personal Data from anyone under the age of 13
without verification of parental consent, We take steps to remove that
information from Our servers.
</p>
<p>
If We need to rely on consent as a legal basis for processing Your
information and Your country requires consent from a parent, We may
require Your parent's consent before We collect and use that information.
</p>
<h2>Links to Other Websites</h2>
<p>
Our Service may contain links to other websites that are not operated by
Us. If You click on a third party link, You will be directed to that third
party's site. We strongly advise You to review the Privacy Policy of every
site You visit.
</p>
<p>
We have no control over and assume no responsibility for the content,
privacy policies or practices of any third party sites or services.
</p>
<h2>Changes to this Privacy Policy</h2>
<p>
We may update Our Privacy Policy from time to time. We will notify You of
any changes by posting the new Privacy Policy on this page.
</p>
<p>
We will let You know via email and/or a prominent notice on Our Service,
prior to the change becoming effective and update the &quot;Last
updated&quot; date at the top of this Privacy Policy.
</p>
<p>
You are advised to review this Privacy Policy periodically for any
changes. Changes to this Privacy Policy are effective when they are posted
on this page.
</p>
<h2>Contact Us</h2>
<p class="contact">
If you have any questions about this Privacy Policy, You can contact us:
</p>
<ul>
<li>By email: openfrontio@gmail.com</li>
</ul>
<div class="footer">
<p>
By using our Service, you acknowledge that you have read and understood
this Privacy Policy and agree to be bound by it.
</p>
</div>
</body>
</html>
+238
View File
@@ -0,0 +1,238 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenFront - Terms of Service</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #333;
}
h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 30px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
h2 {
color: #2980b9;
margin-top: 30px;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
ul {
padding-left: 25px;
}
.updated-date {
text-align: center;
font-style: italic;
margin-bottom: 30px;
}
.contact {
margin-top: 40px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 5px;
}
.footer {
margin-top: 50px;
padding-top: 20px;
border-top: 1px solid #eee;
text-align: center;
font-style: italic;
}
</style>
</head>
<body>
<h1>Terms of Service</h1>
<p class="updated-date"><strong>Last Updated: 4/29/2025</strong></p>
<h2>1. Introduction</h2>
<p>
Welcome to OpenFront ("we," "our," "us"). These Terms of Service ("Terms")
govern your access to and use of our website at https://openfront.io and
our Discord bot (collectively, the "Service"). By accessing or using our
Service, you agree to be bound by these Terms.
</p>
<h2>2. Definitions</h2>
<ul>
<li>
"Service" refers to both our website and Discord bot functionality
</li>
<li>"Bot" refers specifically to our Discord bot application</li>
<li>"Website" refers to https://openfront.io</li>
<li>
"User," "you," and "your" refers to individuals who access or use our
Service
</li>
<li>"Discord" refers to Discord Inc. and its services</li>
</ul>
<h2>3. Account Registration and Discord Integration</h2>
<p>
Our Service uses Discord for authentication. By accessing or using our
Service, you authorize us to access certain Discord account information,
including but not limited to your Discord ID, username, avatar, email
address, and Discord server memberships.
</p>
<p>
You are responsible for maintaining the confidentiality of your Discord
account credentials and for all activities that occur under your account.
You agree to immediately notify us of any unauthorized use of your
account.
</p>
<h2>4. User Conduct</h2>
<p>When using our Service, you agree not to:</p>
<ul>
<li>
Use the Service in any way that violates any applicable laws or
regulations
</li>
<li>Harass, abuse, or harm another person or group</li>
<li>Impersonate another user or person</li>
<li>Use our Service for unauthorized advertising or promotion</li>
<li>
Attempt to access areas of our Service that you are not authorized to
access
</li>
<li>
Interfere with or disrupt the Service or servers connected to the
Service
</li>
<li>
Exploit, distribute, or publicly inform other users of any error or bug
that gives an unintended advantage
</li>
<li>
Use any automated means or interface not provided by us to access our
Service
</li>
<li>Attempt to reverse engineer any portion of our Service</li>
</ul>
<h2>5. Data Collection and Privacy</h2>
<p>
We collect and process personal information as described in our Privacy
Policy, which is incorporated by reference into these Terms. By using our
Service, you consent to our data practices as described in our Privacy
Policy, including our collection, use, and sharing of your information.
</p>
<h2>6. Content and Intellectual Property</h2>
<p>
All content available through our Service, including but not limited to
text, graphics, logos, icons, images, audio clips, and software, is the
property of OpenFront or our licensors and is protected by copyright,
trademark, and other intellectual property laws.
</p>
<h2>7. User-Generated Content</h2>
<p>
You retain ownership of any content you submit, post, or display on or
through our Service ("User Content"). By submitting User Content, you
grant us a worldwide, non-exclusive, royalty-free license to use, copy,
modify, create derivative works based on, distribute, publicly display,
and publicly perform your User Content in connection with operating and
providing our Service.
</p>
<h2>8. Service Modifications and Availability</h2>
<p>
We reserve the right to modify, suspend, or discontinue the Service (or
any part thereof) at any time, with or without notice. We will not be
liable to you or to any third party for any modification, suspension, or
discontinuance of the Service.
</p>
<p>
We do not guarantee that our Service will be available at all times. We
may experience hardware, software, or other problems or need to perform
maintenance related to the Service, resulting in interruptions, delays, or
errors.
</p>
<h2>9. Limitation of Liability</h2>
<p>
To the maximum extent permitted by law, OpenFront and its affiliates,
officers, employees, agents, partners, and licensors will not be liable
for any indirect, incidental, special, consequential, or punitive damages,
including without limitation, loss of profits, data, use, goodwill, or
other intangible losses, resulting from:
</p>
<ul>
<li>
Your access to or use of or inability to access or use the Service
</li>
<li>Any conduct or content of any third party on the Service</li>
<li>Any content obtained from the Service</li>
<li>
Unauthorized access, use, or alteration of your transmissions or content
</li>
</ul>
<p>
In no event shall our aggregate liability for all claims relating to the
Service exceed one hundred dollars ($100).
</p>
<h2>10. Disclaimer of Warranties</h2>
<p>
The Service is provided on an "AS IS" and "AS AVAILABLE" basis without any
warranty of any kind, whether express or implied. We expressly disclaim
all warranties of any kind, whether express or implied, including but not
limited to the implied warranties of merchantability, fitness for a
particular purpose, and non-infringement.
</p>
<h2>11. Discord's Terms and Conditions</h2>
<p>
Your use of Discord's services is also governed by Discord's Terms of
Service and Privacy Policy. Our Service does not override or modify any
terms and conditions that govern your use of Discord's services.
</p>
<h2>12. Termination</h2>
<p>
We may terminate or suspend your access to the Service immediately,
without prior notice or liability, for any reason whatsoever, including
without limitation if you breach these Terms.
</p>
<h2>13. Changes to Terms</h2>
<p>
We reserve the right to modify or replace these Terms at any time. If a
revision is material, we will try to provide at least 30 days' notice
prior to any new terms taking effect. What constitutes a material change
will be determined at our sole discretion.
</p>
<p>
By continuing to access or use our Service after those revisions become
effective, you agree to be bound by the revised terms.
</p>
<h2>14. Governing Law</h2>
<p>
These Terms shall be governed and construed in accordance with the laws of
California, without regard to its conflict of law provisions.
</p>
<h2>15. Contact Us</h2>
<p class="contact">
If you have any questions about these Terms, please contact us at: <br />
openfrontio@gmail.com
</p>
<div class="footer">
<p>
By using our Service, you acknowledge that you have read and understood
these Terms and agree to be bound by them.
</p>
</div>
</body>
</html>
+124 -13
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# Comprehensive setup script for Hetzner server with Docker and user setup
# Comprehensive setup script for Hetzner server with Docker, user setup, Node Exporter, and OpenTelemetry
# Exit on error
set -e
@@ -7,6 +7,13 @@ echo "====================================================="
echo "🚀 STARTING SERVER SETUP"
echo "====================================================="
# Verify required environment variables
if [ -z "$OTEL_ENDPOINT" ] || [ -z "$OTEL_USERNAME" ] || [ -z "$OTEL_PASSWORD" ]; then
echo "❌ ERROR: Required environment variables are not set!"
echo "Please set OTEL_ENDPOINT, OTEL_USERNAME, and OTEL_PASSWORD"
exit 1
fi
echo "🔄 Updating system..."
apt update && apt upgrade -y
@@ -54,27 +61,131 @@ if [ -f /root/.ssh/authorized_keys ] && [ ! -f /home/openfront/.ssh/authorized_k
echo "SSH keys copied from root to openfront"
fi
# Check if node-exporter container already exists
if docker ps -a | grep -q "node-exporter"; then
echo "Node Exporter is already installed"
# Configure UDP buffer sizes for Cloudflare Tunnel
# https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes
echo "🔧 Configuring UDP buffer sizes..."
# Check if settings already exist in sysctl.conf
if grep -q "net.core.rmem_max" /etc/sysctl.conf && grep -q "net.core.wmem_max" /etc/sysctl.conf; then
echo "UDP buffer size settings already configured"
else
echo "🔄 Installing Node Exporter..."
docker run -d --name node-exporter --restart=unless-stopped \
--net="host" \
--pid="host" \
-v "/:/host:ro,rslave" \
prom/node-exporter:latest \
--path.rootfs=/host
echo "Node Exporter installed successfully"
# Add UDP buffer size settings to sysctl.conf
echo "# UDP buffer size settings for improved QUIC performance" >> /etc/sysctl.conf
echo "net.core.rmem_max=7500000" >> /etc/sysctl.conf
echo "net.core.wmem_max=7500000" >> /etc/sysctl.conf
# Apply the settings immediately
sysctl -p
echo "UDP buffer sizes configured and applied"
fi
# Set proper ownership for openfront's home directory
chown -R openfront:openfront /home/openfront
echo "Set proper ownership for openfront's home directory"
# Create directory for OpenTelemetry configuration
echo "📊 Setting up Node Exporter and OpenTelemetry Collector..."
OTEL_CONFIG_DIR="/home/openfront/otel"
if [ ! -d "$OTEL_CONFIG_DIR" ]; then
mkdir -p "$OTEL_CONFIG_DIR"
echo "Created OpenTelemetry configuration directory"
fi
# Generate Base64 auth string
BASE64_AUTH=$(echo -n "${OTEL_USERNAME}:${OTEL_PASSWORD}" | base64)
# Create OpenTelemetry Collector configuration
cat > "$OTEL_CONFIG_DIR/otel-collector-config.yaml" << EOF
receivers:
prometheus:
config:
scrape_configs:
- job_name: 'node'
scrape_interval: 10s
static_configs:
- targets: ['localhost:9100'] # Node Exporter endpoint
relabel_configs:
- source_labels: [__address__]
regex: '.*'
target_label: openfront.host
replacement: "\${HOSTNAME}"
processors:
batch:
# Batch metrics before sending
timeout: 10s
send_batch_size: 1000
exporters:
otlphttp:
endpoint: "${OTEL_ENDPOINT}"
headers:
Authorization: "Basic ${BASE64_AUTH}"
tls:
insecure: true # Set to false in production with proper certs
service:
pipelines:
metrics:
receivers: [prometheus]
processors: [batch]
exporters: [otlphttp]
EOF
# Set ownership of all files
chmod 600 "$OTEL_CONFIG_DIR/otel-collector-config.yaml"
chown -R openfront:openfront "$OTEL_CONFIG_DIR"
# Run Node Exporter
echo "🚀 Starting Node Exporter..."
docker pull prom/node-exporter:latest
docker rm -f node-exporter 2>/dev/null || true
docker run -d \
--name=node-exporter \
--restart=unless-stopped \
--net="host" \
--pid="host" \
-v "/:/host:ro,rslave" \
prom/node-exporter:latest \
--path.rootfs=/host
# Run OpenTelemetry Collector
echo "🚀 Starting OpenTelemetry Collector..."
docker pull otel/opentelemetry-collector-contrib:latest
docker rm -f otel-collector 2>/dev/null || true
# Run OpenTelemetry Collector with appropriate permissions
# Run OpenTelemetry Collector
echo "🚀 Starting OpenTelemetry Collector..."
docker pull otel/opentelemetry-collector-contrib:latest
docker rm -f otel-collector 2>/dev/null || true
docker run -d \
--name=otel-collector \
--restart=unless-stopped \
--network=host \
--user=0 \
-v "$OTEL_CONFIG_DIR/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro" \
-e OTEL_ENDPOINT="${OTEL_ENDPOINT}" \
otel/opentelemetry-collector-contrib:latest
# Check if containers are running
if docker ps | grep -q node-exporter && docker ps | grep -q otel-collector; then
echo "✅ Node Exporter and OpenTelemetry Collector started successfully!"
else
echo "❌ Failed to start containers. Check logs with: docker logs node-exporter or docker logs otel-collector"
exit 1
fi
echo "====================================================="
echo "🎉 SETUP COMPLETE!"
echo "====================================================="
echo "The openfront user has been set up and has Docker permissions."
echo "You can now deploy using the openfront user."
echo "UDP buffer sizes have been configured for optimal QUIC/WebSocket performance."
echo "Node Exporter is collecting system metrics."
echo "OpenTelemetry Collector is forwarding metrics to your endpoint."
echo ""
echo "📝 Configuration:"
echo " - Config Directory: $OTEL_CONFIG_DIR"
echo " - OpenTelemetry Endpoint: $OTEL_ENDPOINT"
echo " - Username: $OTEL_USERNAME"
echo "====================================================="
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
export const RefreshResponseSchema = z.object({
token: z.string(),
});
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
export const TokenPayloadSchema = z.object({
jti: z.string(),
sub: z
.string()
.refine(
(val) => {
const uuid = base64urlToUuid(val);
return uuid != null;
},
{
message: "Invalid base64-encoded UUID",
},
)
.transform((val) => {
const uuid = base64urlToUuid(val);
if (!uuid) throw new Error("Invalid base64 UUID");
return uuid;
}),
iat: z.number(),
iss: z.string(),
aud: z.string(),
exp: z.number(),
rol: z
.string()
.optional()
.transform((val) => val.split(",")),
});
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
export const UserMeResponseSchema = z.object({
user: z.object({
id: z.string(),
avatar: z.string(),
username: z.string(),
global_name: z.string(),
discriminator: z.string(),
locale: z.string(),
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
+37
View File
@@ -0,0 +1,37 @@
import { base64url } from "jose";
/**
* Converts a UUID string to a base64url-encoded binary representation.
* @param uuid - The UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
* @returns base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
*/
export function uuidToBase64url(uuid: string): string {
const hex = uuid.replace(/-/g, "");
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return base64url.encode(bytes);
}
/**
* Converts a base64url-encoded binary UUID back to its canonical UUID string.
* @param encoded - base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
* @returns UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
*/
export function base64urlToUuid(encoded: string): string {
const bytes = base64url.decode(encoded);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
+6 -5
View File
@@ -6,6 +6,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
mapCategories,
@@ -28,7 +29,7 @@ export class HostLobbyModal extends LitElement {
@state() private selectedDifficulty: Difficulty = Difficulty.Medium;
@state() private disableNPCs = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: number = 2;
@state() private teamCount: number | typeof Duos = 2;
@state() private disableNukes: boolean = false;
@state() private bots: number = 400;
@state() private infiniteGold: boolean = false;
@@ -194,7 +195,7 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
${[Duos, 2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -465,8 +466,8 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private async handleTeamCountSelection(value: number) {
this.teamCount = value;
private async handleTeamCountSelection(value: number | typeof Duos) {
this.teamCount = value === Duos ? Duos : Number(value);
this.putGameConfig();
}
@@ -489,7 +490,7 @@ export class HostLobbyModal extends LitElement {
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
playerTeams: this.teamCount,
} as GameConfig),
},
);
+56 -63
View File
@@ -113,6 +113,17 @@ export class InputHandler {
) {}
initialize() {
const keybinds = {
toggleView: "Space",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
moveLeft: "KeyA",
moveRight: "KeyD",
zoomOut: "KeyQ",
zoomIn: "KeyE",
...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"),
};
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
this.canvas.addEventListener(
@@ -122,59 +133,65 @@ export class InputHandler {
this.onShiftScroll(e);
e.preventDefault();
},
{
passive: false,
},
{ passive: false },
);
window.addEventListener("pointermove", this.onPointerMove.bind(this));
this.canvas.addEventListener("contextmenu", (e: MouseEvent) => {
this.onContextMenu(e);
});
this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e));
window.addEventListener("mousemove", (e) => {
if (e.movementX == 0 && e.movementY == 0) {
return;
if (e.movementX || e.movementY) {
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
}
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
});
this.pointers.clear();
// Initialize the combined movement interval
this.moveInterval = setInterval(() => {
let deltaX = 0;
let deltaY = 0;
// Handle both WASD and arrow keys
if (this.activeKeys.has("KeyW") || this.activeKeys.has("ArrowUp"))
if (
this.activeKeys.has(keybinds.moveUp) ||
this.activeKeys.has("ArrowUp")
)
deltaY += this.PAN_SPEED;
if (this.activeKeys.has("KeyS") || this.activeKeys.has("ArrowDown"))
if (
this.activeKeys.has(keybinds.moveDown) ||
this.activeKeys.has("ArrowDown")
)
deltaY -= this.PAN_SPEED;
if (this.activeKeys.has("KeyA") || this.activeKeys.has("ArrowLeft"))
if (
this.activeKeys.has(keybinds.moveLeft) ||
this.activeKeys.has("ArrowLeft")
)
deltaX += this.PAN_SPEED;
if (this.activeKeys.has("KeyD") || this.activeKeys.has("ArrowRight"))
if (
this.activeKeys.has(keybinds.moveRight) ||
this.activeKeys.has("ArrowRight")
)
deltaX -= this.PAN_SPEED;
if (deltaX !== 0 || deltaY !== 0) {
if (deltaX || deltaY) {
this.eventBus.emit(new DragEvent(deltaX, deltaY));
}
// Handle zooming
const screenCenterX = window.innerWidth / 2;
const screenCenterY = window.innerHeight / 2;
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
if (this.activeKeys.has("Minus") || this.activeKeys.has("KeyQ")) {
this.eventBus.emit(
new ZoomEvent(screenCenterX, screenCenterY, this.ZOOM_SPEED),
);
if (
this.activeKeys.has(keybinds.zoomOut) ||
this.activeKeys.has("Minus")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED));
}
if (this.activeKeys.has("Equal") || this.activeKeys.has("KeyE")) {
this.eventBus.emit(
new ZoomEvent(screenCenterX, screenCenterY, -this.ZOOM_SPEED),
);
if (
this.activeKeys.has(keybinds.zoomIn) ||
this.activeKeys.has("Equal")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED));
}
}, 1);
window.addEventListener("keydown", (e) => {
if (e.code === "Space") {
if (e.code === keybinds.toggleView) {
e.preventDefault();
if (!this.alternateView) {
this.alternateView = true;
@@ -187,24 +204,23 @@ export class InputHandler {
this.eventBus.emit(new CloseViewEvent());
}
// Add all movement keys to activeKeys
if (
[
"KeyW",
"KeyA",
"KeyS",
"KeyD",
keybinds.moveUp,
keybinds.moveDown,
keybinds.moveLeft,
keybinds.moveRight,
keybinds.zoomOut,
keybinds.zoomIn,
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"ArrowRight",
"Minus",
"Equal",
"KeyE",
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
keybinds.centerCamera,
"ControlLeft",
"ControlRight",
].includes(e.code)
@@ -212,13 +228,13 @@ export class InputHandler {
this.activeKeys.add(e.code);
}
});
window.addEventListener("keyup", (e) => {
if (e.code === "Space") {
if (e.code === keybinds.toggleView) {
e.preventDefault();
this.alternateView = false;
this.eventBus.emit(new AlternateViewEvent(false));
}
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey) {
e.preventDefault();
this.eventBus.emit(new RefreshGraphicsEvent());
@@ -234,35 +250,12 @@ export class InputHandler {
this.eventBus.emit(new AttackRatioEvent(10));
}
if (e.code === "KeyC") {
if (e.code === keybinds.centerCamera) {
e.preventDefault();
this.eventBus.emit(new CenterCameraEvent());
}
// Remove all movement keys from activeKeys
if (
[
"KeyW",
"KeyA",
"KeyS",
"KeyD",
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"ArrowRight",
"Minus",
"Equal",
"KeyE",
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
"ControlLeft",
"ControlRight",
].includes(e.code)
) {
this.activeKeys.delete(e.code);
}
this.activeKeys.delete(e.code);
});
}
+1
View File
@@ -173,6 +173,7 @@ export class LangSelector extends LitElement {
"help-modal",
"username-input",
"public-lobby",
"user-setting",
"o-modal",
"o-button",
];
+49
View File
@@ -29,7 +29,9 @@ import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
import { generateCryptoRandomUUID } from "./Utils";
import "./components/baseComponents/Button";
import { OButton } from "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { discordLogin, getUserMe, isLoggedIn, logOut } from "./jwt";
import "./styles.css";
export interface JoinLobbyEvent {
@@ -90,6 +92,13 @@ class Client {
consolex.warn("Random name button element not found");
}
const loginDiscordButton = document.getElementById(
"login-discord",
) as OButton;
const logoutDiscordButton = document.getElementById(
"logout-discord",
) as OButton;
this.usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
@@ -129,6 +138,41 @@ class Client {
hlpModal.open();
});
const claims = isLoggedIn();
if (claims === false) {
// Not logged in
loginDiscordButton.disable = false;
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
} else {
// JWT appears to be valid, assume we are logged in
loginDiscordButton.disable = true;
loginDiscordButton.translationKey = "main.logged_in";
logoutDiscordButton.hidden = false;
logoutDiscordButton.addEventListener("click", () => {
// Log out
logOut();
loginDiscordButton.disable = false;
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
});
// Look up the discord user object.
// TODO: Add caching
getUserMe().then((userMeResponse) => {
if (userMeResponse === false) {
// Not logged in
loginDiscordButton.disable = false;
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
return;
}
// TODO: Update the page for logged in user
});
}
const settingsModal = document.querySelector(
"user-setting",
) as UserSettingModal;
@@ -293,6 +337,11 @@ function setFavicon(): void {
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentIDFromCookie(): string {
const claims = isLoggedIn();
if (claims !== false && claims.sub) {
return claims.sub;
}
const COOKIE_NAME = "player_persistent_id";
// Try to get existing cookie
+6 -1
View File
@@ -94,6 +94,11 @@ export class PublicLobby extends LitElement {
const playersRemainingBeforeMax =
lobby.gameConfig.maxPlayers - lobby.numClients;
const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? lobby.gameConfig.playerTeams || 0
: null;
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
@@ -127,7 +132,7 @@ export class PublicLobby extends LitElement {
</div>
<div class="text-md font-medium text-blue-100">
${lobby.gameConfig.gameMode == GameMode.Team
? translateText("game_mode.teams")
? translateText("public_lobby.teams", { num: teamCount })
: translateText("game_mode.ffa")}
</div>
</div>
+6 -5
View File
@@ -5,6 +5,7 @@ import { translateText } from "../client/Utils";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
GameType,
@@ -36,7 +37,7 @@ export class SinglePlayerModal extends LitElement {
@state() private instantBuild: boolean = false;
@state() private useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: number = 2;
@state() private teamCount: number | typeof Duos = 2;
render() {
return html`
@@ -165,7 +166,7 @@ export class SinglePlayerModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
${["Duos", 2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -355,8 +356,8 @@ export class SinglePlayerModal extends LitElement {
this.gameMode = value;
}
private handleTeamCountSelection(value: number) {
this.teamCount = value;
private handleTeamCountSelection(value: number | string) {
this.teamCount = value === "Duos" ? Duos : Number(value);
}
private getRandomMap(): GameMapType {
@@ -410,7 +411,7 @@ export class SinglePlayerModal extends LitElement {
gameMap: this.selectedMap,
gameType: GameType.Singleplayer,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
playerTeams: this.teamCount,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
disableNukes: this.disableNukes,
+1 -1
View File
@@ -88,7 +88,7 @@ export class SendTargetPlayerIntentEvent implements GameEvent {
export class SendEmojiIntentEvent implements GameEvent {
constructor(
public readonly recipient: PlayerView | typeof AllPlayers,
public readonly emoji: string,
public readonly emoji: number,
) {}
}
+253 -86
View File
@@ -1,6 +1,9 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { UserSettings } from "../core/game/UserSettings";
import "./components/baseComponents/setting/SettingKeybind";
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
import "./components/baseComponents/setting/SettingNumber";
import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
@@ -9,7 +12,8 @@ import "./components/baseComponents/setting/SettingToggle";
export class UserSettingModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
@state() private darkMode: boolean = this.userSettings.darkMode();
@state() private settingsMode: "basic" | "keybinds" = "basic";
@state() private keybinds: Record<string, string> = {};
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@@ -17,6 +21,15 @@ export class UserSettingModal extends LitElement {
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (savedKeybinds) {
try {
this.keybinds = JSON.parse(savedKeybinds);
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
}
}
@query("o-modal") private modalEl!: HTMLElement & {
@@ -119,96 +132,63 @@ export class UserSettingModal extends LitElement {
}
}
private handleKeybindChange(
e: CustomEvent<{ action: string; value: string }>,
) {
const { action, value } = e.detail;
const prevValue = this.keybinds[action] ?? "";
const values = Object.entries(this.keybinds)
.filter(([k]) => k !== action)
.map(([, v]) => v);
if (values.includes(value) && value !== "Null") {
const popup = document.createElement("div");
popup.className = "setting-popup";
popup.textContent = `The key "${value}" is already assigned to another action.`;
document.body.appendChild(popup);
const element = this.renderRoot.querySelector(
`setting-keybind[action="${action}"]`,
) as SettingKeybind;
if (element) {
element.value = prevValue;
element.requestUpdate();
}
return;
}
this.keybinds = { ...this.keybinds, [action]: value };
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
}
render() {
return html`
<o-modal title="User Settings">
<o-modal title="${translateText("user_setting.title")}">
<div class="modal-overlay">
<div class="modal-content user-setting-modal">
<div class="flex mb-4 w-full justify-center">
<button
class="w-1/2 text-center px-3 py-1 rounded-l
${this.settingsMode === "basic"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
@click=${() => (this.settingsMode = "basic")}
>
${translateText("user_setting.tab_basic")}
</button>
<button
class="w-1/2 text-center px-3 py-1 rounded-r
${this.settingsMode === "keybinds"
? "bg-white/10 text-white"
: "bg-transparent text-gray-400"}"
@click=${() => (this.settingsMode = "keybinds")}
>
${translateText("user_setting.tab_keybinds")}
</button>
</div>
<div class="settings-list">
<setting-toggle
label="🌙 Dark Mode"
description="Toggle the sites appearance between light and dark themes"
id="dark-mode-toggle"
.checked=${this.userSettings.darkMode()}
@change=${(e: CustomEvent<{ checked: boolean }>) =>
this.toggleDarkMode(e)}
></setting-toggle>
<setting-toggle
label="😊 Emojis"
description="Toggle whether emojis are shown in game"
id="emoji-toggle"
.checked=${this.userSettings.emojis()}
@change=${this.toggleEmojis}
></setting-toggle>
<setting-toggle
label="🖱️ Left Click to Open Menu"
description="When ON, left-click opens menu and sword button attacks. When OFF, right-click attacks directly."
id="left-click-toggle"
.checked=${this.userSettings.leftClickOpensMenu()}
@change=${this.toggleLeftClickOpensMenu}
></setting-toggle>
<setting-slider
label="⚔️ Attack Ratio"
description="What percentage of your troops to send in an attack (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
) * 100}
@change=${this.sliderAttackRatio}
></setting-slider>
<setting-slider
label="🪖🛠️ Troops and Workers Ratio"
description="Adjust the balance between troops (for combat) and workers (for gold production) (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.troopRatio") ?? "0.95",
) * 100}
@change=${this.sliderTroopRatio}
></setting-slider>
${this.showEasterEggSettings
? html`
<setting-slider
label="Writing Speed Multiplier"
description="Adjust how fast you pretend to code (x1x100)"
min="0"
max="100"
value="40"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-slider>
<setting-number
label="Bug Count"
description="How many bugs you're okay with (01000, emotionally)"
value="100"
min="0"
max="1000"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-number>
`
: null}
${this.settingsMode === "basic"
? this.renderBasicSettings()
: this.renderKeybindSettings()}
</div>
</div>
</div>
@@ -216,7 +196,194 @@ export class UserSettingModal extends LitElement {
`;
}
private renderBasicSettings() {
return html`
<!-- 🌙 Dark Mode -->
<setting-toggle
label="${translateText("user_setting.dark_mode_label")}"
description="${translateText("user_setting.dark_mode_desc")}"
id="dark-mode-toggle"
.checked=${this.userSettings.darkMode()}
@change=${(e: CustomEvent<{ checked: boolean }>) =>
this.toggleDarkMode(e)}
></setting-toggle>
<!-- 😊 Emojis -->
<setting-toggle
label="${translateText("user_setting.emojis_label")}"
description="${translateText("user_setting.emojis_desc")}"
id="emoji-toggle"
.checked=${this.userSettings.emojis()}
@change=${this.toggleEmojis}
></setting-toggle>
<!-- 🖱️ Left Click Menu -->
<setting-toggle
label="${translateText("user_setting.left_click_label")}"
description="${translateText("user_setting.left_click_desc")}"
id="left-click-toggle"
.checked=${this.userSettings.leftClickOpensMenu()}
@change=${this.toggleLeftClickOpensMenu}
></setting-toggle>
<!-- ⚔️ Attack Ratio -->
<setting-slider
label="${translateText("user_setting.attack_ratio_label")}"
description="${translateText("user_setting.attack_ratio_desc")}"
min="1"
max="100"
.value=${Number(localStorage.getItem("settings.attackRatio") ?? "0.2") *
100}
@change=${this.sliderAttackRatio}
></setting-slider>
<!-- 🪖🛠️ Troop Ratio -->
<setting-slider
label="${translateText("user_setting.troop_ratio_label")}"
description="${translateText("user_setting.troop_ratio_desc")}"
min="1"
max="100"
.value=${Number(localStorage.getItem("settings.troopRatio") ?? "0.95") *
100}
@change=${this.sliderTroopRatio}
></setting-slider>
${this.showEasterEggSettings
? html`
<setting-slider
label="${translateText(
"user_setting.easter_writing_speed_label",
)}"
description="${translateText(
"user_setting.easter_writing_speed_desc",
)}"
min="0"
max="100"
value="40"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-slider>
<setting-number
label="${translateText("user_setting.easter_bug_count_label")}"
description="${translateText(
"user_setting.easter_bug_count_desc",
)}"
value="100"
min="0"
max="1000"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-number>
`
: null}
`;
}
private renderKeybindSettings() {
return html`
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
${translateText("user_setting.view_options")}
</div>
<setting-keybind
action="toggleView"
label=${translateText("user_setting.toggle_view")}
description=${translateText("user_setting.toggle_view_desc")}
defaultKey="Space"
.value=${this.keybinds["toggleView"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
${translateText("user_setting.zoom_controls")}
</div>
<setting-keybind
action="zoomOut"
label=${translateText("user_setting.zoom_out")}
description=${translateText("user_setting.zoom_out_desc")}
defaultKey="KeyQ"
.value=${this.keybinds["zoomOut"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="zoomIn"
label=${translateText("user_setting.zoom_in")}
description=${translateText("user_setting.zoom_in_desc")}
defaultKey="KeyE"
.value=${this.keybinds["zoomIn"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
${translateText("user_setting.camera_movement")}
</div>
<setting-keybind
action="centerCamera"
label=${translateText("user_setting.center_camera")}
description=${translateText("user_setting.center_camera_desc")}
defaultKey="KeyC"
.value=${this.keybinds["centerCamera"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveUp"
label=${translateText("user_setting.move_up")}
description=${translateText("user_setting.move_up_desc")}
defaultKey="KeyW"
.value=${this.keybinds["moveUp"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveLeft"
label=${translateText("user_setting.move_left")}
description=${translateText("user_setting.move_left_desc")}
defaultKey="KeyA"
.value=${this.keybinds["moveLeft"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveDown"
label=${translateText("user_setting.move_down")}
description=${translateText("user_setting.move_down_desc")}
defaultKey="KeyS"
.value=${this.keybinds["moveDown"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="moveRight"
label=${translateText("user_setting.move_right")}
description=${translateText("user_setting.move_right_desc")}
defaultKey="KeyD"
.value=${this.keybinds["moveRight"] ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
`;
}
public open() {
this.requestUpdate();
this.modalEl?.open();
}
+1
View File
@@ -25,6 +25,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
BetweenTwoSeas: "Between Two Seas",
KnownWorld: "Known World",
FaroeIslands: "Faroe Islands",
DeglaciatedAntarctica: "Deglaciated Antarctica",
};
@customElement("map-display")
@@ -0,0 +1,115 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../../../../client/Utils";
@customElement("setting-keybind")
export class SettingKeybind extends LitElement {
@property() label = "Setting";
@property() description = "";
@property({ type: String, reflect: true }) action = "";
@property({ type: String }) defaultKey = "";
@property({ type: String }) value = "";
@property({ type: Boolean }) easter = false;
createRenderRoot() {
return this;
}
private listening = false;
render() {
return html`
<div class="setting-item column${this.easter ? " easter-egg" : ""}">
<div class="setting-label-group">
<label class="setting-label block mb-1">${this.label}</label>
<div class="setting-keybind-box">
<div class="setting-keybind-description">${this.description}</div>
<div class="flex items-center gap-2">
<span
class="setting-key"
tabindex="0"
@keydown=${this.handleKeydown}
@click=${this.startListening}
>
${this.displayKey(this.value || this.defaultKey)}
</span>
<button
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
@click=${this.resetToDefault}
>
${translateText("user_setting.reset")}
</button>
<button
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
@click=${this.unbindKey}
>
${translateText("user_setting.unbind")}
</button>
</div>
</div>
</div>
</div>
`;
}
private displayKey(key: string): string {
if (key === " ") return "Space";
if (key.startsWith("Key") && key.length === 4) {
return key.slice(3);
}
return key.length
? key.charAt(0).toUpperCase() + key.slice(1)
: "Press a key";
}
private startListening() {
this.listening = true;
this.requestUpdate();
}
private handleKeydown(e: KeyboardEvent) {
if (!this.listening) return;
e.preventDefault();
const code = e.code;
this.value = code;
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: code },
bubbles: true,
composed: true,
}),
);
this.listening = false;
this.requestUpdate();
}
private resetToDefault() {
this.value = this.defaultKey;
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: this.defaultKey },
bubbles: true,
composed: true,
}),
);
}
private unbindKey() {
this.value = "";
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: "Null" },
bubbles: true,
composed: true,
}),
);
this.requestUpdate();
}
}
+71 -72
View File
@@ -13,6 +13,11 @@
"continent": "Asia",
"name": "Achaemenid Empire"
},
{
"code": "ae",
"continent": "Asia",
"name": "United Arab Emirates"
},
{
"code": "af",
"continent": "Asia",
@@ -23,16 +28,21 @@
"continent": "Africa",
"name": "African Union"
},
{
"code": "ag",
"continent": "North America",
"name": "Antigua and Barbuda"
},
{
"code": "ai",
"continent": "North America",
"name": "Anguilla"
},
{
"code": "1_Airgialla",
"continent": "Europe",
"name": "Airgialla"
},
{
"code": "ax",
"continent": "Europe",
"name": "Aland Islands"
},
{
"code": "Alabama",
"continent": "North America",
@@ -48,16 +58,26 @@
"continent": "Europe",
"name": "Albania"
},
{
"code": "Alabama",
"continent": "North America",
"name": "Alabama"
},
{
"code": "ax",
"continent": "Europe",
"name": "Aland Islands"
},
{
"code": "Alaska",
"continent": "North America",
"name": "Alaska"
},
{
"code": "dz",
"continent": "Africa",
"name": "Algeria"
},
{
"code": "Alkebulan",
"continent": "Africa",
"name": "Alkebulan"
},
{
"code": "Amazigh flag",
"continent": "Africa",
@@ -92,19 +112,19 @@
"continent": "Africa",
"name": "Angola"
},
{
"code": "ai",
"continent": "North America",
"name": "Anguilla"
},
{
"code": "aq",
"name": "Antarctica"
},
{
"code": "ag",
"continent": "North America",
"name": "Antigua and Barbuda"
"code": "antipope",
"continent": "Europe",
"name": "Anti-Pope"
},
{
"code": "aquitaine",
"continent": "Europe",
"name": "Aquitaine"
},
{
"code": "antipope",
@@ -196,15 +216,6 @@
"continent": "North America",
"name": "Aztec Empire"
},
{
"code": "Babylonia",
"continent": "Asia",
"name": "Babylonia"
},
{
"code": "baguette",
"name": "Baguette"
},
{
"code": "bs",
"continent": "North America",
@@ -220,6 +231,10 @@
"continent": "Asia",
"name": "Bahrain"
},
{
"code": "baguette",
"name": "Baguette"
},
{
"code": "bd",
"continent": "Asia",
@@ -230,6 +245,11 @@
"continent": "North America",
"name": "Barbados"
},
{
"code": "Babylonia",
"continent": "Asia",
"name": "Babylonia"
},
{
"code": "es-pv",
"continent": "Europe",
@@ -486,11 +506,6 @@
"continent": "Europe",
"name": "Connacht"
},
{
"code": "Connecticut",
"continent": "North America",
"name": "Connecticut"
},
{
"code": "ck",
"continent": "Oceania",
@@ -545,31 +560,26 @@
"continent": "Europe",
"name": "Czech Republic"
},
{
"code": "1_Dalriata",
"continent": "Europe",
"name": "Dál Riata"
},
{
"code": "Danzig",
"continent": "Europe",
"name": "Danzig"
},
{
"code": "Delaware",
"continent": "North America",
"name": "Delaware"
},
{
"code": "cd",
"continent": "Africa",
"name": "Democratic Republic of the Congo"
"code": "1_Dalriata",
"continent": "Europe",
"name": "Dál Riata"
},
{
"code": "dk",
"continent": "Europe",
"name": "Denmark"
},
{
"code": "cd",
"continent": "Africa",
"name": "Democratic Republic of the Congo"
},
{
"code": "dg",
"continent": "Asia",
@@ -580,11 +590,6 @@
"continent": "Asia",
"name": "Dilmun"
},
{
"code": "District_of_Columbia",
"continent": "North America",
"name": "District of Columbia"
},
{
"code": "dj",
"continent": "Africa",
@@ -768,6 +773,10 @@
"continent": "Oceania",
"name": "French Polynesia"
},
{
"code": "frost_giant",
"name": "Frost Giant"
},
{
"code": "tf",
"continent": "Africa",
@@ -1006,6 +1015,11 @@
"continent": "Europe",
"name": "Italy"
},
{
"code": "italy",
"continent": "Europe",
"name": "Kingdom of Italy"
},
{
"code": "jm",
"continent": "North America",
@@ -1071,11 +1085,6 @@
"continent": "Asia",
"name": "Kingdom of Iraq"
},
{
"code": "italy",
"continent": "Europe",
"name": "Kingdom of Italy"
},
{
"code": "Kingdom of Jerusalem",
"continent": "Asia",
@@ -1183,11 +1192,6 @@
"continent": "Europe",
"name": "Liechtenstein"
},
{
"code": "Lihyan",
"continent": "Asia",
"name": "Lihyan"
},
{
"code": "Listenbourg",
"name": "Listenbourg"
@@ -1277,11 +1281,6 @@
"continent": "North America",
"name": "Maryland"
},
{
"code": "Massachusetts",
"continent": "North America",
"name": "Massachusetts"
},
{
"code": "mr",
"continent": "Africa",
@@ -1640,15 +1639,15 @@
"continent": "Europe",
"name": "Poland"
},
{
"code": "polar_bears",
"name": "Polar Bears"
},
{
"code": "PolishLithuanian Commonwealth",
"continent": "Europe",
"name": "PolishLithuanian Commonwealth"
},
{
"code": "polar_bears",
"name": "Polar Bears"
},
{
"code": "pt",
"continent": "Europe",
@@ -2151,7 +2150,7 @@
"name": "USA 1776"
},
{
"code": "USSR",
"code": "ussr",
"continent": "Europe",
"name": "USSR"
},
@@ -2176,9 +2175,9 @@
"name": "Umayyad Caliphate"
},
{
"code": "ae",
"code": "United Arab Republic",
"continent": "Asia",
"name": "United Arab Emirates"
"name": "United Arab Republic"
},
{
"code": "United Arab Republic",
+7 -15
View File
@@ -4,24 +4,11 @@ import { EventBus } from "../../../core/EventBus";
import { AllPlayers } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
import { emojiTable, flattenedEmojiTable } from "../../../core/Util";
import { ShowEmojiMenuEvent } from "../../InputHandler";
import { SendEmojiIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
const emojiTable: string[][] = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
["👋", "👏", "🤌", "💪", "🫡"],
["👍", "👎", "❓", "🐔", "🐀"],
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
["🔥", "💥", "💀", "☢️", "⚠️"],
["↖️", "⬆️", "↗️", "👑", "🥇"],
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
@customElement("emoji-table")
export class EmojiTable extends LitElement {
public eventBus: EventBus;
@@ -130,7 +117,12 @@ export class EmojiTable extends LitElement {
targetPlayer == this.game.myPlayer()
? AllPlayers
: (targetPlayer as PlayerView);
this.eventBus.emit(new SendEmojiIntentEvent(recipient, emoji));
this.eventBus.emit(
new SendEmojiIntentEvent(
recipient,
flattenedEmojiTable.indexOf(emoji),
),
);
this.hideTable();
});
});
+10 -1
View File
@@ -271,10 +271,19 @@ export class EventsDisplay extends LitElement implements Layer {
const malusPercent = Math.round(
(1 - this.game.config().traitorDefenseDebuff()) * 100,
);
const traitorDurationRaw =
Number(this.game.config().traitorDuration) / 10;
const traitorDurationSeconds = Math.floor(traitorDurationRaw);
const durationText =
traitorDurationSeconds === 1
? "1 second"
: `${traitorDurationSeconds} seconds`;
this.addEvent({
description:
`You broke your alliance with ${betrayed.name()}, making you a TRAITOR ` +
`(${malusPercent}% defense debuff)`,
`(${malusPercent}% defense debuff for ${durationText})`,
type: MessageType.ERROR,
highlight: true,
createdAt: this.game.ticks(),
@@ -207,6 +207,9 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
: ""}
${player.name()}
</div>
${player.team() != null
? html`<div class="text-sm opacity-80">Team: ${player.team()}</div>`
: ""}
<div class="text-sm opacity-80">Type: ${playerType}</div>
${player.troops() >= 1
? html`<div class="text-sm opacity-80" translate="no">
+10 -2
View File
@@ -15,6 +15,7 @@ import {
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { flattenedEmojiTable } from "../../../core/Util";
import { MouseUpEvent } from "../../InputHandler";
import {
SendAllianceRequestIntentEvent,
@@ -122,9 +123,16 @@ export class PlayerPanel extends LitElement implements Layer {
e.stopPropagation();
this.emojiTable.showTable((emoji: string) => {
if (myPlayer == other) {
this.eventBus.emit(new SendEmojiIntentEvent(AllPlayers, emoji));
this.eventBus.emit(
new SendEmojiIntentEvent(
AllPlayers,
flattenedEmojiTable.indexOf(emoji),
),
);
} else {
this.eventBus.emit(new SendEmojiIntentEvent(other, emoji));
this.eventBus.emit(
new SendEmojiIntentEvent(other, flattenedEmojiTable.indexOf(emoji)),
);
}
this.emojiTable.hideTable();
this.hide();
+9 -1
View File
@@ -119,6 +119,14 @@ export class TerritoryLayer implements Layer {
if (!centerTile) {
continue;
}
let color = this.theme.spawnHighlightColor();
if (
this.game.myPlayer() != null &&
this.game.myPlayer() != human &&
this.game.myPlayer().isFriendly(human)
) {
color = this.theme.selfColor();
}
for (const tile of this.game.bfs(
centerTile,
euclDistFN(centerTile, 9, true),
@@ -126,7 +134,7 @@ export class TerritoryLayer implements Layer {
if (!this.game.hasOwner(tile)) {
this.paintHighlightCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.spawnHighlightColor(),
color,
255,
);
}
+40 -27
View File
@@ -1,38 +1,24 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import mastersIcon from "../../../../resources/images/MastersIcon.png";
import { EventBus } from "../../../core/EventBus";
import { Team } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { simpleHash } from "../../../core/Util";
import { SendWinnerEvent } from "../../Transport";
import { Layer } from "./Layer";
// Add this at the top of your file
declare global {
interface Window {
adsbygoogle: unknown[];
}
}
// Add this at the top of your file
declare let adsbygoogle: unknown[];
@customElement("win-modal")
export class WinModal extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private rand: PseudoRandom;
private hasShownDeathModal = false;
@state()
isVisible = false;
private _title: string;
private won: boolean;
// Override to prevent shadow DOM creation
createRenderRoot() {
@@ -53,7 +39,7 @@ export class WinModal extends LitElement implements Layer {
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
color: white;
width: 300px;
width: 350px;
transition:
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out;
@@ -77,7 +63,7 @@ export class WinModal extends LitElement implements Layer {
.win-modal h2 {
margin: 0 0 15px 0;
font-size: 24px;
font-size: 26px;
text-align: center;
color: white;
}
@@ -127,7 +113,7 @@ export class WinModal extends LitElement implements Layer {
}
.win-modal h2 {
font-size: 20px;
font-size: 26px;
}
.win-modal button {
@@ -160,7 +146,41 @@ export class WinModal extends LitElement implements Layer {
innerHtml() {
return html`
<div style="text-align: center; margin: 15px 0; line-height: 1.5;"></div>
<div
style="
text-align: center;
margin: 10px 0;
line-height: 1.5;
background-image: url(${mastersIcon});
background-size: 100px;
background-position: center;
background-repeat: no-repeat;
background-blend-mode: overlay;
position: relative;
"
>
<div
style="
margin: 10px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.76);
border-radius: 5px;
position: relative;
z-index: 1;
font-size: 22px;
"
>
Watch the best compete in the
<br />
<a
href="https://openfrontmaster.com/"
target="_blank"
rel="noopener noreferrer"
style="color: #00bfff; font-weight: bold; text-decoration: underline;"
>OpenFront Masters</a
>
</div>
</div>
`;
}
@@ -179,9 +199,7 @@ export class WinModal extends LitElement implements Layer {
window.location.href = "/";
}
init() {
this.rand = new PseudoRandom(simpleHash(this.game.myClientID()));
}
init() {}
tick() {
const myPlayer = this.game.myPlayer();
@@ -194,7 +212,6 @@ export class WinModal extends LitElement implements Layer {
) {
this.hasShownDeathModal = true;
this._title = "You died";
this.won = false;
this.show();
}
this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => {
@@ -204,10 +221,8 @@ export class WinModal extends LitElement implements Layer {
);
if (wu.winner == this.game.myPlayer()?.team()) {
this._title = "Your team won!";
this.won = true;
} else {
this._title = `${wu.winner} team has won!`;
this.won = false;
}
this.show();
} else {
@@ -219,10 +234,8 @@ export class WinModal extends LitElement implements Layer {
);
if (winner == this.game.myPlayer()) {
this._title = "You Won!";
this.won = true;
} else {
this._title = `${winner.name()} has won!`;
this.won = false;
}
this.show();
}
+22 -2
View File
@@ -212,6 +212,21 @@
<!-- Main container with responsive padding -->
<main class="flex justify-center flex-grow">
<div class="container pt-12">
<o-button
id="login-discord"
title="Initializing..."
disable="true"
block
></o-button>
<o-button
id="logout-discord"
title="Log out"
translationKey="main.log_out"
visible="false"
block
></o-button>
<div class="container__row">
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
<username-input class="w-full"></username-input>
@@ -328,13 +343,18 @@
</a>
</div>
<div class="l-footer__col t-text-white">
© 2025
<a
href="https://github.com/openfrontio/OpenFrontIO"
class="t-link"
target="_blank"
>
OpenFront™
©2025 OpenFront™
</a>
<a href="/privacy-policy.html" class="t-link" target="_blank">
Privacy Policy
</a>
<a href="/terms-of-service.html" class="t-link" target="_blank">
Terms of Service
</a>
</div>
</div>
+210
View File
@@ -0,0 +1,210 @@
import { decodeJwt } from "jose";
import {
RefreshResponseSchema,
TokenPayload,
TokenPayloadSchema,
UserMeResponse,
UserMeResponseSchema,
} from "./ApiSchemas";
function getAudience() {
const { hostname } = new URL(window.location.href);
const domainname = hostname.split(".").slice(-2).join(".");
return domainname;
}
function getApiBase() {
const domainname = getAudience();
return domainname === "localhost"
? (localStorage.getItem("apiHost") ?? "http://localhost:8787")
: `https://api.${domainname}`;
}
function getToken(): string | null {
const { hash } = window.location;
if (hash.startsWith("#")) {
const params = new URLSearchParams(hash.slice(1));
const token = params.get("token");
if (token) {
localStorage.setItem("token", token);
}
// Clean the URL
history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
return localStorage.getItem("token");
}
export function discordLogin() {
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
}
export async function logOut(allSessions: boolean = false) {
const token = localStorage.getItem("token");
if (token === null) return;
localStorage.removeItem("token");
__isLoggedIn = false;
const response = await fetch(
getApiBase() + allSessions ? "/revoke" : "/logout",
{
method: "POST",
headers: {
authorization: `Bearer ${token}`,
},
},
);
if (response.ok === false) {
console.error("Logout failed", response);
return false;
}
return true;
}
let __isLoggedIn: TokenPayload | false | undefined = undefined;
export function isLoggedIn(): TokenPayload | false {
if (__isLoggedIn === undefined) {
__isLoggedIn = _isLoggedIn();
}
return __isLoggedIn;
}
export function _isLoggedIn(): TokenPayload | false {
try {
const token = getToken();
if (!token) {
// console.log("No token found");
return false;
}
// Verify the JWT (requires browser support)
// const jwks = createRemoteJWKSet(
// new URL(getApiBase() + "/.well-known/jwks.json"),
// );
// const { payload, protectedHeader } = await jwtVerify(token, jwks, {
// issuer: getApiBase(),
// audience: getAudience(),
// });
// Decode the JWT
const payload = decodeJwt(token);
const { iss, aud, exp, iat } = payload;
if (iss !== getApiBase()) {
// JWT was not issued by the correct server
console.error(
'unexpected "iss" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
if (aud !== getAudience()) {
// JWT was not issued for this website
console.error(
'unexpected "aud" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
const now = Math.floor(Date.now() / 1000);
if (exp !== undefined && now >= exp) {
// JWT expired
console.error(
'after "exp" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
return false;
}
const refreshAge: number = 6 * 3600; // 6 hours
if (iat !== undefined && now >= iat + refreshAge) {
console.log("Refreshing access token...");
postRefresh().then((success) => {
if (success) {
console.log("Refreshed access token successfully.");
} else {
console.error("Failed to refresh access token.");
}
});
}
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
// Invalid response
console.error(
"Invalid payload",
// JSON.stringify(payload),
JSON.stringify(result.error),
);
return false;
}
return result.data;
} catch (e) {
console.log(e);
return false;
}
}
export async function postRefresh(): Promise<boolean> {
try {
const token = getToken();
if (!token) return false;
// Refresh the JWT
const response = await fetch(getApiBase() + "/refresh", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
},
});
if (response.status !== 200) return false;
const body = await response.json();
const result = RefreshResponseSchema.safeParse(body);
if (!result.success) {
console.error(
"Invalid response",
JSON.stringify(body),
JSON.stringify(result.error),
);
return false;
}
localStorage.setItem("token", result.data.token);
return true;
} catch (e) {
return false;
}
}
export async function getUserMe(): Promise<UserMeResponse | false> {
try {
const token = getToken();
if (!token) return false;
// Get the user object
const response = await fetch(getApiBase() + "/users/@me", {
headers: {
authorization: `Bearer ${token}`,
},
});
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
console.error(
"Invalid response",
JSON.stringify(body),
JSON.stringify(result.error),
);
return false;
}
return result.data;
} catch (e) {
return false;
}
}
+5
View File
@@ -194,6 +194,11 @@ label.option-card:hover {
margin: 8px 0px 0px 0px;
}
#lobbyIdInput {
font-family: monospace;
font-weight: 600;
}
.lobby-id-button {
display: flex;
align-items: center;
+11
View File
@@ -78,3 +78,14 @@
font-size: 14px;
color: #ccc;
}
.setting-input.keybind:hover .key,
.setting-input.keybind:focus .key {
background-color: #333;
box-shadow: 0 2px 0 #222;
}
.setting-input.keybind.listening .key {
background-color: #1d4ed8; /* blue-700 */
box-shadow: 0 2px 0 #0f172a; /* darker blue */
}
+78 -9
View File
@@ -22,6 +22,10 @@
gap: 12px;
}
.setting-item.column {
flex-direction: column;
}
@keyframes rainbow-background {
0% {
background-position: 0% 50%;
@@ -64,6 +68,20 @@
z-index: 9999;
}
.setting-popup {
position: fixed;
top: 40px;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
padding: 16px 24px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 20px;
border-radius: 12px;
animation: fadePop_2 10s ease-out forwards;
z-index: 9999;
}
@keyframes fadePop {
0% {
opacity: 0;
@@ -82,6 +100,25 @@
}
}
@keyframes fadePop_2 {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
5% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.05);
}
95% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.05);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
}
.setting-item:hover {
background: #2a2a2a;
}
@@ -158,17 +195,14 @@
cursor: pointer;
}
.setting-input.slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 2px solid #2196f3;
cursor: pointer;
.setting-input.slider::-moz-range-track {
background-color: #444;
height: 10px;
border-radius: 5px;
}
.setting-input.slider::-moz-range-track {
background: linear-gradient(to right, #2196f3 50%, #444 50%);
.setting-input.slider::-moz-range-progress {
background-color: #2196f3;
height: 10px;
border-radius: 5px;
}
@@ -255,3 +289,38 @@
white-space: normal;
word-break: break-word;
}
.setting-keybind-box {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.setting-keybind-description {
flex: 1;
font-size: 0.75rem;
color: #e5e5e5;
word-break: break-word;
overflow-wrap: break-word;
min-width: 0;
}
.setting-key {
background-color: black;
color: white;
font-weight: 600;
padding: 4px 12px;
border-radius: 6px;
font-family: monospace;
font-size: 0.875rem;
box-shadow: 0 2px 0 #444;
white-space: nowrap;
user-select: none;
outline: none;
}
.setting-key:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
+3
View File
@@ -4,6 +4,7 @@ import australia from "../../../resources/maps/AustraliaThumb.webp";
import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp";
import blackSea from "../../../resources/maps/BlackSeaThumb.webp";
import britannia from "../../../resources/maps/BritanniaThumb.webp";
import deglaciatedAntarctica from "../../../resources/maps/DeglaciatedAntarcticaThumb.webp";
import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp";
import europe from "../../../resources/maps/EuropeThumb.webp";
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
@@ -63,6 +64,8 @@ export function getMapsImage(map: GameMapType): string {
return knownworld;
case GameMapType.FaroeIslands:
return faroeislands;
case GameMapType.DeglaciatedAntarctica:
return deglaciatedAntarctica;
default:
return "";
}
+41 -16
View File
@@ -4,9 +4,11 @@ import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Cell,
Game,
GameUpdates,
NameViewData,
Nation,
Player,
PlayerActions,
PlayerBorderTiles,
@@ -23,8 +25,9 @@ import {
GameUpdateViewData,
} from "./game/GameUpdates";
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { PseudoRandom } from "./PseudoRandom";
import { ClientID, GameStartInfo, Turn } from "./Schemas";
import { sanitize } from "./Util";
import { sanitize, simpleHash } from "./Util";
import { fixProfaneUsername } from "./validations/username";
export async function createGameRunner(
@@ -34,26 +37,48 @@ export async function createGameRunner(
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(gameStart.config.gameMap);
const game = createGame(
gameStart.players.map(
(p) =>
new PlayerInfo(
p.flag,
p.clientID == clientID
? sanitize(p.username)
: fixProfaneUsername(sanitize(p.username)),
PlayerType.Human,
p.clientID,
p.playerID,
),
),
const random = new PseudoRandom(simpleHash(gameStart.gameID));
const humans = gameStart.players.map(
(p) =>
new PlayerInfo(
p.flag,
p.clientID == clientID
? sanitize(p.username)
: fixProfaneUsername(sanitize(p.username)),
PlayerType.Human,
p.clientID,
p.playerID,
),
);
const nations = gameStart.config.disableNPCs
? []
: gameMap.nationMap.nations.map(
(n) =>
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
new PlayerInfo(
n.flag || "",
n.name,
PlayerType.FakeHuman,
null,
random.nextID(),
),
),
);
const game: Game = createGame(
humans,
nations,
gameMap.gameMap,
gameMap.miniGameMap,
gameMap.nationMap,
config,
);
const gr = new GameRunner(
game as Game,
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
);
+1 -1
View File
@@ -48,7 +48,7 @@ export class PseudoRandom {
return this.nextInt(0, odds) == 0;
}
shuffleArray(array: any[]) {
shuffleArray(array: any[]): any[] {
for (let i = array.length - 1; i >= 0; i--) {
const j = Math.floor(this.nextInt(0, i + 1));
[array[i], array[j]] = [array[j], array[i]];
+11 -15
View File
@@ -2,13 +2,14 @@ import { z } from "zod";
import {
AllPlayers,
Difficulty,
Duos,
GameMapType,
GameMode,
GameType,
PlayerType,
Team,
UnitType,
} from "./game/Game";
import { flattenedEmojiTable } from "./Util";
export type GameID = string;
export type ClientID = string;
@@ -121,9 +122,11 @@ const GameConfigSchema = z.object({
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
maxPlayers: z.number().optional(),
numPlayerTeams: z.number().optional(),
playerTeams: z.union([z.number().optional(), z.literal(Duos)]),
});
export const TeamSchema = z.string();
const SafeString = z
.string()
.regex(
@@ -131,14 +134,10 @@ const SafeString = z
)
.max(1000);
const EmojiSchema = z.string().refine(
(val) => {
return /\p{Emoji}/u.test(val);
},
{
message: "Must contain at least one emoji character",
},
);
const EmojiSchema = z
.number()
.nonnegative()
.max(flattenedEmojiTable.length - 1);
const ID = z
.string()
.regex(/^[a-zA-Z0-9]+$/)
@@ -364,7 +363,7 @@ const ClientBaseMessageSchema = z.object({
export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
type: z.literal("winner"),
winner: ID.or(z.nativeEnum(Team)).nullable(),
winner: z.union([ID, TeamSchema]).nullable(),
allPlayersStats: AllPlayersStatsSchema,
winnerType: z.enum(["player", "team"]),
});
@@ -425,10 +424,7 @@ export const GameRecordSchema = z.object({
date: SafeString,
num_turns: z.number(),
turns: z.array(TurnSchema),
winner: z
.union([ID, z.nativeEnum(Team)])
.nullable()
.optional(),
winner: z.union([ID, SafeString]).nullable().optional(),
winnerType: z.enum(["player", "team"]).nullable().optional(),
allPlayersStats: z.record(ID, PlayerStatsSchema),
version: z.enum(["v0.0.1"]),
+16
View File
@@ -307,3 +307,19 @@ export function createRandomName(
}
return randomName;
}
export const emojiTable: string[][] = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
["👋", "👏", "🤌", "💪", "🫡"],
["👍", "👎", "❓", "🐔", "🐀"],
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
["🔥", "💥", "💀", "☢️", "⚠️"],
["↖️", "⬆️", "↗️", "👑", "🥇"],
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
// 2d to 1d array
export const flattenedEmojiTable: string[] = [].concat(...emojiTable);
+8 -2
View File
@@ -2,8 +2,10 @@ import { Colord } from "colord";
import { GameConfig, GameID } from "../Schemas";
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
Gold,
Player,
PlayerInfo,
@@ -26,7 +28,7 @@ export enum GameEnv {
export interface ServerConfig {
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyMaxPlayers(map: GameMapType): number;
lobbyMaxPlayers(map: GameMapType, mode: GameMode): number;
discordRedirectURI(): string;
numWorkers(): number;
workerIndex(gameID: GameID): number;
@@ -43,6 +45,10 @@ export interface ServerConfig {
r2Endpoint(): string;
r2AccessKey(): string;
r2SecretKey(): string;
otelEndpoint(): string;
otelUsername(): string;
otelPassword(): string;
otelEnabled(): boolean;
}
export interface NukeMagnitude {
@@ -67,7 +73,7 @@ export interface Config {
instantBuild(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
numPlayerTeams(): number;
playerTeams(): number | typeof Duos;
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
+86 -59
View File
@@ -1,5 +1,6 @@
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
@@ -24,6 +25,20 @@ import { pastelTheme } from "./PastelTheme";
import { pastelThemeDark } from "./PastelThemeDark";
export abstract class DefaultServerConfig implements ServerConfig {
otelEnabled(): boolean {
return Boolean(
this.otelEndpoint() && this.otelUsername() && this.otelPassword(),
);
}
otelEndpoint(): string {
return process.env.OTEL_ENDPOINT;
}
otelUsername(): string {
return process.env.OTEL_USERNAME;
}
otelPassword(): string {
return process.env.OTEL_PASSWORD;
}
region(): string {
if (this.env() == GameEnv.Dev) {
return "dev";
@@ -42,7 +57,11 @@ export abstract class DefaultServerConfig implements ServerConfig {
r2SecretKey(): string {
return process.env.R2_SECRET_KEY;
}
abstract r2Bucket(): string;
r2Bucket(): string {
return process.env.R2_BUCKET;
}
adminHeader(): string {
return "x-admin-key";
}
@@ -58,60 +77,66 @@ export abstract class DefaultServerConfig implements ServerConfig {
gameCreationRate(): number {
return 60 * 1000;
}
lobbyMaxPlayers(map: GameMapType): number {
// Maps with ~4 mil pixels
if (
[
GameMapType.GatewayToTheAtlantic,
GameMapType.SouthAmerica,
GameMapType.NorthAmerica,
GameMapType.Africa,
GameMapType.Europe,
].includes(map)
) {
return Math.random() < 0.2 ? 100 : 50;
}
// Maps with ~2.5 - ~3.5 mil pixels
if (
[
GameMapType.Australia,
GameMapType.Iceland,
GameMapType.Britannia,
GameMapType.Asia,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
}
// Maps with ~2 mil pixels
if (
[
GameMapType.Mena,
GameMapType.Mars,
GameMapType.Oceania,
GameMapType.Japan, // Japan at this level because its 2/3 water
GameMapType.FaroeIslands,
GameMapType.EuropeClassic,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
}
// Maps smaller than ~2 mil pixels
if (
[
GameMapType.BetweenTwoSeas,
GameMapType.BlackSea,
GameMapType.Pangaea,
].includes(map)
) {
return Math.random() < 0.5 ? 30 : 15;
}
// world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended.
if (map == GameMapType.World) {
return Math.random() < 0.2 ? 150 : 50;
}
// default return for non specified map
return Math.random() < 0.2 ? 50 : 20;
lobbyMaxPlayers(map: GameMapType, mode: GameMode): number {
const numPlayers = () => {
// Maps with ~4 mil pixels
if (
[
GameMapType.GatewayToTheAtlantic,
GameMapType.SouthAmerica,
GameMapType.NorthAmerica,
GameMapType.Africa,
GameMapType.Europe,
].includes(map)
) {
return Math.random() < 0.2 ? 100 : 50;
}
// Maps with ~2.5 - ~3.5 mil pixels
if (
[
GameMapType.Australia,
GameMapType.Iceland,
GameMapType.Britannia,
GameMapType.Asia,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
}
// Maps with ~2 mil pixels
if (
[
GameMapType.Mena,
GameMapType.Mars,
GameMapType.Oceania,
GameMapType.Japan, // Japan at this level because its 2/3 water
GameMapType.FaroeIslands,
GameMapType.DeglaciatedAntarctica,
GameMapType.EuropeClassic,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
}
// Maps smaller than ~2 mil pixels
if (
[
GameMapType.BetweenTwoSeas,
GameMapType.BlackSea,
GameMapType.Pangaea,
].includes(map)
) {
return Math.random() < 0.5 ? 30 : 15;
}
// world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended.
if (map == GameMapType.World) {
return Math.random() < 0.2 ? 150 : 50;
}
// default return for non specified map
return Math.random() < 0.2 ? 50 : 20;
};
return Math.min(150, numPlayers() * (mode == GameMode.Team ? 2 : 1));
}
workerIndex(gameID: GameID): number {
return simpleHash(gameID) % this.numWorkers();
}
@@ -133,10 +158,6 @@ export class DefaultConfig implements Config {
private _userSettings: UserSettings,
) {}
numPlayerTeams(): number {
return this.gameConfig().numPlayerTeams;
}
samHittingChance(): number {
return 0.8;
}
@@ -202,9 +223,14 @@ export class DefaultConfig implements Config {
defensePostDefenseBonus(): number {
return 5;
}
playerTeams(): number | typeof Duos {
return this._gameConfig.playerTeams ?? 0;
}
spawnNPCs(): boolean {
return !this._gameConfig.disableNPCs;
}
disableNukes(): boolean {
return this._gameConfig.disableNukes;
}
@@ -688,7 +714,8 @@ export class DefaultConfig implements Config {
}
structureMinDist(): number {
return 12;
// TODO: Increase this to ~15 once upgradable structures are implemented.
return 1;
}
shellLifetime(): number {
-3
View File
@@ -5,9 +5,6 @@ import { GameEnv, ServerConfig } from "./Config";
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
export class DevServerConfig extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-staging";
}
adminToken(): string {
return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION";
}
+11 -10
View File
@@ -1,7 +1,7 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
@@ -43,24 +43,25 @@ export const pastelTheme = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
+11 -10
View File
@@ -1,7 +1,7 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
@@ -43,24 +43,25 @@ export const pastelThemeDark = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
-3
View File
@@ -2,9 +2,6 @@ import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const preprodConfig = new (class extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-staging";
}
env(): GameEnv {
return GameEnv.Preprod;
}
-3
View File
@@ -2,9 +2,6 @@ import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const prodConfig = new (class extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-prod";
}
numWorkers(): number {
return 6;
}
+6 -3
View File
@@ -7,6 +7,7 @@ import {
PlayerID,
PlayerType,
} from "../game/Game";
import { flattenedEmojiTable } from "../Util";
export class EmojiExecution implements Execution {
private requestor: Player;
@@ -17,7 +18,7 @@ export class EmojiExecution implements Execution {
constructor(
private senderID: PlayerID,
private recipientID: PlayerID | typeof AllPlayers,
private emoji: string,
private emoji: number,
) {}
init(mg: Game, ticks: number): void {
@@ -38,10 +39,12 @@ export class EmojiExecution implements Execution {
}
tick(ticks: number): void {
const emojiString = flattenedEmojiTable.at(this.emoji);
if (this.requestor.canSendEmoji(this.recipient)) {
this.requestor.sendEmoji(this.recipient, this.emoji);
this.requestor.sendEmoji(this.recipient, emojiString);
if (
this.emoji == "🖕" &&
emojiString == "🖕" &&
this.recipient != AllPlayers &&
this.recipient.type() == PlayerType.FakeHuman
) {
+2 -14
View File
@@ -1,4 +1,4 @@
import { Execution, Game, PlayerInfo, PlayerType } from "../game/Game";
import { Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
@@ -120,19 +120,7 @@ export class Executor {
fakeHumanExecutions(): Execution[] {
const execs = [];
for (const nation of this.mg.nations()) {
execs.push(
new FakeHumanExecution(
this.gameID,
new PlayerInfo(
nation.flag || "",
nation.name,
PlayerType.FakeHuman,
null,
this.random.nextID(),
nation,
),
),
);
execs.push(new FakeHumanExecution(this.gameID, nation));
}
return execs;
}
+14 -10
View File
@@ -4,9 +4,9 @@ import {
Difficulty,
Execution,
Game,
Nation,
Player,
PlayerID,
PlayerInfo,
PlayerType,
Relation,
TerrainType,
@@ -43,6 +43,8 @@ export class FakeHumanExecution implements Execution {
private lastEmojiSent = new Map<Player, Tick>();
private lastNukeSent: [Tick, TileRef][] = [];
private embargoMalusApplied = new Set<PlayerID>();
private heckleEmoji: number[];
private dogpileEmoji: number;
private portTargetRatio: number = 0.00005; // desired ports per tile
private cityTargetRatio: number = 0.0001; // desired cities per tile
@@ -57,10 +59,10 @@ export class FakeHumanExecution implements Execution {
constructor(
gameID: GameID,
private playerInfo: PlayerInfo,
private nation: Nation,
) {
this.random = new PseudoRandom(
simpleHash(playerInfo.id) + simpleHash(gameID),
simpleHash(nation.playerInfo.id) + simpleHash(gameID),
);
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
@@ -124,15 +126,17 @@ export class FakeHumanExecution implements Execution {
if (this.mg.inSpawnPhase()) {
const rl = this.randomLand();
if (rl == null) {
consolex.warn(`cannot spawn ${this.playerInfo.name}`);
consolex.warn(`cannot spawn ${this.nation.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.playerInfo, rl));
this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl));
return;
}
if (this.player == null) {
this.player = this.mg.players().find((p) => p.id() == this.playerInfo.id);
this.player = this.mg
.players()
.find((p) => p.id() == this.nation.playerInfo.id);
if (this.player == null) {
return;
}
@@ -303,7 +307,7 @@ export class FakeHumanExecution implements Execution {
if (!this.lastEmojiSent.has(enemy)) {
this.lastEmojiSent.set(enemy, this.mg.ticks());
this.mg.addExecution(
new EmojiExecution(this.player.id(), enemy.id(), "🖕"),
new EmojiExecution(this.player.id(), enemy.id(), this.dogpileEmoji),
);
}
return;
@@ -317,7 +321,7 @@ export class FakeHumanExecution implements Execution {
new EmojiExecution(
this.player.id(),
enemy.id(),
this.random.randElement(["🤡", "😡"]),
this.random.randElement(this.heckleEmoji),
),
);
}
@@ -632,7 +636,7 @@ export class FakeHumanExecution implements Execution {
if (!builtDefensePost) {
consolex.log(
`[${this.playerInfo.name}] no valid tile found for Defense Post`,
`[${this.nation.playerInfo.name}] no valid tile found for Defense Post`,
);
}
}
@@ -825,7 +829,7 @@ export class FakeHumanExecution implements Execution {
let tries = 0;
while (tries < 50) {
tries++;
const cell = this.playerInfo.nation.cell;
const cell = this.nation.spawnCell;
const x = this.random.nextInt(cell.x - delta, cell.x + delta);
const y = this.random.nextInt(cell.y - delta, cell.y + delta);
if (!this.mg.isValidCoord(x, y)) {
+9 -2
View File
@@ -1,5 +1,12 @@
import { GameEvent } from "../EventBus";
import { Execution, Game, GameMode, Player, Team } from "../game/Game";
import {
ColoredTeams,
Execution,
Game,
GameMode,
Player,
Team,
} from "../game/Game";
export class WinEvent implements GameEvent {
constructor(public readonly winner: Player) {}
@@ -66,7 +73,7 @@ export class WinCheckExecution implements Execution {
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
const percentage = (max[1] / numTilesWithoutFallout) * 100;
if (percentage > this.mg.config().percentageTilesOwnedToWin()) {
if (max[0] == Team.Bot) return;
if (max[0] == ColoredTeams.Bot) return;
this.mg.setWinner(max[0], this.mg.stats().stats());
console.log(`${max[0]} has won the game`);
this.active = false;
+13 -10
View File
@@ -8,7 +8,7 @@ import {
Tick,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import { within } from "../../Util";
import { flattenedEmojiTable, within } from "../../Util";
import { AttackExecution } from "../AttackExecution";
import { EmojiExecution } from "../EmojiExecution";
@@ -16,13 +16,17 @@ export class BotBehavior {
private enemy: Player | null = null;
private enemyUpdated: Tick;
private assistAcceptEmoji: number;
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
private triggerRatio: number,
private reserveRatio: number,
) {}
) {
this.assistAcceptEmoji = flattenedEmojiTable.indexOf("👍");
}
handleAllianceRequests() {
for (const req of this.player.incomingAllianceRequests()) {
@@ -34,7 +38,7 @@ export class BotBehavior {
}
}
private emoji(player: Player, emoji: string) {
private emoji(player: Player, emoji: number) {
if (player.type() !== PlayerType.Human) return;
this.game.addExecution(
new EmojiExecution(this.player.id(), player.id(), emoji),
@@ -79,7 +83,7 @@ export class BotBehavior {
this.player.updateRelation(ally, -20);
this.enemy = target;
this.enemyUpdated = this.game.ticks();
this.emoji(ally, "👍");
this.emoji(ally, this.assistAcceptEmoji);
break outer;
}
}
@@ -129,12 +133,11 @@ export class BotBehavior {
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (neighbor.isPlayer()) {
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() == PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
}
if (!neighbor.isPlayer()) continue;
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() == PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
}
}
this.enemy = neighbor;
+23 -14
View File
@@ -37,16 +37,20 @@ export enum Difficulty {
Impossible = "Impossible",
}
export enum Team {
Red = "Red",
Blue = "Blue",
Teal = "Teal",
Purple = "Purple",
Yellow = "Yellow",
Orange = "Orange",
Green = "Green",
Bot = "Bot",
}
export type Team = string;
export const Duos = "Duos" as const;
export const ColoredTeams: Record<string, Team> = {
Red: "Red",
Blue: "Blue",
Teal: "Teal",
Purple: "Purple",
Yellow: "Yellow",
Orange: "Orange",
Green: "Green",
Bot: "Bot",
} as const;
export enum GameMapType {
World = "World",
@@ -69,6 +73,7 @@ export enum GameMapType {
BetweenTwoSeas = "Between Two Seas",
KnownWorld = "Known World",
FaroeIslands = "FaroeIslands",
DeglaciatedAntarctica = "Deglaciated Antarctica",
}
export const mapCategories: Record<string, GameMapType[]> = {
@@ -93,7 +98,12 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Australia,
GameMapType.FaroeIslands,
],
fantasy: [GameMapType.Pangaea, GameMapType.Mars, GameMapType.KnownWorld],
fantasy: [
GameMapType.Pangaea,
GameMapType.Mars,
GameMapType.KnownWorld,
GameMapType.DeglaciatedAntarctica,
],
};
export enum GameType {
@@ -152,10 +162,9 @@ export enum Relation {
export class Nation {
constructor(
public readonly flag: string,
public readonly name: string,
public readonly cell: Cell,
public readonly spawnCell: Cell,
public readonly strength: number,
public readonly playerInfo: PlayerInfo,
) {}
}
+37 -27
View File
@@ -8,6 +8,8 @@ import {
Alliance,
AllianceRequest,
Cell,
ColoredTeams,
Duos,
EmojiMessage,
Execution,
Game,
@@ -32,19 +34,18 @@ import { PlayerImpl } from "./PlayerImpl";
import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
import { NationMap } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid } from "./UnitGrid";
import { UnitImpl } from "./UnitImpl";
export function createGame(
humans: PlayerInfo[],
nations: Nation[],
gameMap: GameMap,
miniGameMap: GameMap,
nationMap: NationMap,
config: Config,
): Game {
return new GameImpl(humans, gameMap, miniGameMap, nationMap, config);
return new GameImpl(humans, nations, gameMap, miniGameMap, config);
}
export type CellString = string;
@@ -54,8 +55,6 @@ export class GameImpl implements Game {
private unInitExecs: Execution[] = [];
private nations_: Nation[] = [];
_players: Map<PlayerID, PlayerImpl> = new Map<PlayerID, PlayerImpl>();
_playersBySmallID = [];
@@ -75,51 +74,62 @@ export class GameImpl implements Game {
private _stats: StatsImpl = new StatsImpl();
private playerTeams: Team[] = [Team.Red, Team.Blue];
private botTeam: Team = Team.Bot;
private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue];
private botTeam: Team = ColoredTeams.Bot;
constructor(
private _humans: PlayerInfo[],
private _nations: Nation[],
private _map: GameMap,
private miniGameMap: GameMap,
nationMap: NationMap,
private _config: Config,
) {
this.addHumans();
this._terraNullius = new TerraNulliusImpl();
this._width = _map.width();
this._height = _map.height();
this.nations_ = nationMap.nations.map(
(n) =>
new Nation(
n.flag || "",
n.name,
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
),
);
this.unitGrid = new UnitGrid(this._map);
if (_config.gameConfig().gameMode === GameMode.Team) {
const numPlayerTeams = _config.numPlayerTeams();
this.populateTeams();
}
this.addPlayers();
}
private populateTeams() {
if (this._config.playerTeams() === Duos) {
this.playerTeams = [];
const numTeams = Math.ceil(
(this._humans.length + this._nations.length) / 2,
);
for (let i = 0; i < numTeams; i++) {
this.playerTeams.push("Team " + (i + 1));
}
} else {
const numPlayerTeams = this._config.playerTeams() as number;
if (numPlayerTeams < 2)
throw new Error(`Too few teams: ${numPlayerTeams}`);
if (numPlayerTeams >= 3) this.playerTeams.push(Team.Teal);
if (numPlayerTeams >= 4) this.playerTeams.push(Team.Purple);
if (numPlayerTeams >= 5) this.playerTeams.push(Team.Yellow);
if (numPlayerTeams >= 6) this.playerTeams.push(Team.Orange);
if (numPlayerTeams >= 7) this.playerTeams.push(Team.Green);
if (numPlayerTeams >= 3) this.playerTeams.push(ColoredTeams.Yellow);
if (numPlayerTeams >= 4) this.playerTeams.push(ColoredTeams.Green);
if (numPlayerTeams >= 5) this.playerTeams.push(ColoredTeams.Purple);
if (numPlayerTeams >= 6) this.playerTeams.push(ColoredTeams.Orange);
if (numPlayerTeams >= 7) this.playerTeams.push(ColoredTeams.Teal);
if (numPlayerTeams >= 8)
throw new Error(`Too many teams: ${numPlayerTeams}`);
}
}
private addHumans() {
private addPlayers() {
if (this.config().gameConfig().gameMode != GameMode.Team) {
this._humans.forEach((p) => this.addPlayer(p));
this._nations.forEach((n) => this.addPlayer(n.playerInfo));
return;
}
const playerToTeam = assignTeams(this._humans, this.playerTeams);
const isDuos = this.config().gameConfig().playerTeams === Duos;
const allPlayers = [
...this._humans,
...this._nations.map((n) => n.playerInfo),
];
const playerToTeam = assignTeams(allPlayers, this.playerTeams);
for (const [playerInfo, team] of playerToTeam.entries()) {
if (team == "kicked") {
console.warn(`Player ${playerInfo.name} was kicked from team`);
@@ -180,7 +190,7 @@ export class GameImpl implements Game {
return this.config().unitInfo(type);
}
nations(): Nation[] {
return this.nations_;
return this._nations;
}
createAllianceRequest(requestor: Player, recipient: Player): AllianceRequest {
+26 -13
View File
@@ -58,6 +58,11 @@ export class GameMapImpl implements GameMap {
private readonly width_: number;
private readonly height_: number;
// Lookup tables (LUTs) contain pre-computed values to avoid performing division at runtime
private readonly refToX: number[];
private readonly refToY: number[];
private readonly yToRef: number[];
// Terrain bits (Uint8Array)
private static readonly IS_LAND_BIT = 7;
private static readonly SHORELINE_BIT = 6;
@@ -87,6 +92,19 @@ export class GameMapImpl implements GameMap {
this.height_ = height;
this.terrain = terrainData;
this.state = new Uint16Array(width * height);
// Precompute the LUTs
let ref = 0;
this.refToX = new Array(width * height);
this.refToY = new Array(width * height);
this.yToRef = new Array(height);
for (let y = 0; y < height; y++) {
this.yToRef[y] = ref;
for (let x = 0; x < width; x++) {
this.refToX[ref] = x;
this.refToY[ref] = y;
ref++;
}
}
}
numTilesWithFallout(): number {
return this._numTilesWithFallout;
@@ -96,15 +114,15 @@ export class GameMapImpl implements GameMap {
if (!this.isValidCoord(x, y)) {
throw new Error(`Invalid coordinates: ${x},${y}`);
}
return y * this.width_ + x;
return this.yToRef[y] + x;
}
x(ref: TileRef): number {
return ref % this.width_;
return this.refToX[ref];
}
y(ref: TileRef): number {
return Math.floor(ref / this.width_);
return this.refToY[ref];
}
cell(ref: TileRef): Cell {
@@ -240,24 +258,19 @@ export class GameMapImpl implements GameMap {
neighbors(ref: TileRef): TileRef[] {
const neighbors: TileRef[] = [];
const w = this.width_;
const x = this.refToX[ref];
if (ref >= w) neighbors.push(ref - w);
if (ref < (this.height_ - 1) * w) neighbors.push(ref + w);
if (ref % w !== 0) neighbors.push(ref - 1);
if (ref % w !== w - 1) neighbors.push(ref + 1);
for (const n of neighbors) {
this.ref(this.x(n), this.y(n));
}
if (x !== 0) neighbors.push(ref - 1);
if (x !== w - 1) neighbors.push(ref + 1);
return neighbors;
}
forEachTile(fn: (tile: TileRef) => void): void {
for (let x = 0; x < this.width_; x++) {
for (let y = 0; y < this.height_; y++) {
fn(this.ref(x, y));
}
for (let ref: TileRef = 0; ref < this.width_ * this.height_; ref++) {
fn(ref);
}
}
+3 -9
View File
@@ -21,7 +21,6 @@ import {
BuildableUnit,
Cell,
EmojiMessage,
GameMode,
Gold,
MessageType,
MutableAlliance,
@@ -527,13 +526,6 @@ export class PlayerImpl implements Player {
}
canDonate(recipient: Player): boolean {
if (
recipient.type() == PlayerType.Human &&
this.mg.config().gameConfig().gameMode == GameMode.FFA
) {
return false;
}
if (!this.isFriendly(recipient)) {
return false;
}
@@ -754,7 +746,9 @@ export class PlayerImpl implements Player {
return Object.values(UnitType).map((u) => {
return {
type: u,
canBuild: this.canBuild(u, tile, validTiles),
canBuild: this.mg.inSpawnPhase()
? false
: this.canBuild(u, tile, validTiles),
cost: this.mg.config().unitInfo(u).cost(this),
} as BuildableUnit;
});
+1
View File
@@ -42,6 +42,7 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
[GameMapType.KnownWorld]: "KnownWorld",
[GameMapType.FaroeIslands]: "FaroeIslands",
[GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica",
[GameMapType.EuropeClassic]: "EuropeClassic",
};
+1
View File
@@ -24,6 +24,7 @@ const maps = [
"Japan",
"KnownWorld",
"FaroeIslands",
"DeglaciatedAntarctica",
];
const removeSmall = true;
-61
View File
@@ -1,61 +0,0 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { Client, Events, GatewayIntentBits } from "discord.js";
export class DiscordBot {
private client: Client;
private secretManager: SecretManagerServiceClient;
constructor() {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
this.secretManager = new SecretManagerServiceClient();
this.setupEventHandlers();
}
private setupEventHandlers(): void {
this.client.once(Events.ClientReady, (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`);
});
this.client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.content === "!ping") {
await message.reply("Pong! 🏓");
}
if (message.content === "!hello") {
await message.reply(`Hello ${message.author.username}! 👋`);
}
});
}
private async getToken(): Promise<string | undefined> {
const name =
"projects/openfrontio/secrets/discord-bot-token/versions/latest";
const [version] = await this.secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString().trim();
}
public async start(): Promise<void> {
try {
const token = await this.getToken();
if (!token) {
throw new Error("Failed to retrieve Discord token");
}
await this.client.login(token);
} catch (error) {
console.error("Failed to start bot:", error);
throw error;
}
}
public stop(): void {
this.client.destroy();
}
}
+2 -2
View File
@@ -95,8 +95,8 @@ export class GameServer {
if (gameConfig.gameMode != null) {
this.gameConfig.gameMode = gameConfig.gameMode;
}
if (gameConfig.numPlayerTeams != null) {
this.gameConfig.numPlayerTeams = gameConfig.numPlayerTeams;
if (gameConfig.playerTeams != null) {
this.gameConfig.playerTeams = gameConfig.playerTeams;
}
}
+56 -1
View File
@@ -1,4 +1,56 @@
import * as logsAPI from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import {
LoggerProvider,
SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
import * as dotenv from "dotenv";
import winston from "winston";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { getOtelResource } from "./OtelResource";
dotenv.config();
const config = getServerConfigFromServer();
const resource = getOtelResource();
// Initialize the OpenTelemetry Logger Provider
const loggerProvider = new LoggerProvider({
resource,
});
if (config.env() == GameEnv.Prod && config.otelEnabled()) {
console.log("OTEL enabled");
// Configure OpenTelemetry endpoint with basic auth (if provided)
const headers = {};
if (config.otelUsername() && config.otelPassword()) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${config.otelUsername()}:${config.otelPassword()}`).toString(
"base64",
);
}
// Add OTLP exporter for logs
const logExporter = new OTLPLogExporter({
url: `${config.otelEndpoint()}/v1/logs`,
headers,
});
// Add a log processor with the exporter
loggerProvider.addLogRecordProcessor(
new SimpleLogRecordProcessor(logExporter),
);
// Set as the global logger provider
logsAPI.logs.setGlobalLoggerProvider(loggerProvider);
} else {
console.log(
"No OTLP endpoint and credentials provided, remote logging disabled",
);
}
// Custom format to add severity tag based on log level
const addSeverityFormat = winston.format((info) => {
@@ -20,7 +72,10 @@ const logger = winston.createLogger({
service: "openfront",
environment: process.env.NODE_ENV,
},
transports: [new winston.transports.Console()],
transports: [
new winston.transports.Console(),
new OpenTelemetryTransportV3(),
],
});
// Export both the main logger and the child logger factory
+108 -94
View File
@@ -1,119 +1,133 @@
import { GameMapType, GameMode } from "../core/game/Game";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameConfig } from "../core/Schemas";
import { logger } from "./Logger";
enum PlaylistType {
BigMaps,
SmallMaps,
const log = logger.child({});
const config = getServerConfigFromServer();
const frequency = {
World: 3,
Europe: 2,
Africa: 2,
Australia: 1,
NorthAmerica: 1,
Britannia: 1,
GatewayToTheAtlantic: 1,
Iceland: 1,
SouthAmerica: 1,
KnownWorld: 1,
DeglaciatedAntarctica: 1,
EuropeClassic: 1,
Mena: 1,
Pangaea: 1,
Asia: 1,
Mars: 1,
BetweenTwoSeas: 1,
Japan: 1,
BlackSea: 1,
FaroeIslands: 1,
};
interface MapWithMode {
map: GameMapType;
mode: GameMode;
}
const random = new PseudoRandom(123);
export class MapPlaylist {
private gameModeRotation = [GameMode.FFA, GameMode.FFA, GameMode.Team];
private currentGameModeIndex = 0;
private mapsPlaylist: MapWithMode[] = [];
private mapsPlaylistBig: GameMapType[] = [];
private mapsPlaylistSmall: GameMapType[] = [];
private currentPlaylistCounter = 0;
public gameConfig(): GameConfig {
const { map, mode } = this.getNextMap();
// Get the next map in rotation
public getNextMap(): GameMapType {
const playlistType: PlaylistType = this.getNextPlaylistType();
const mapsPlaylist: GameMapType[] = this.getNextMapsPlayList(playlistType);
return mapsPlaylist.shift()!;
const numPlayerTeams =
mode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined;
// Create the default public game config (from your GameManager)
return {
gameMap: map,
maxPlayers: config.lobbyMaxPlayers(map, mode),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: mode == GameMode.Team,
disableNukes: false,
gameMode: mode,
playerTeams: numPlayerTeams,
bots: 400,
} as GameConfig;
}
public getNextGameMode(): GameMode {
const nextGameMode = this.gameModeRotation[this.currentGameModeIndex];
this.currentGameModeIndex =
(this.currentGameModeIndex + 1) % this.gameModeRotation.length;
return nextGameMode;
}
private getNextMapsPlayList(playlistType: PlaylistType): GameMapType[] {
switch (playlistType) {
case PlaylistType.BigMaps:
if (!(this.mapsPlaylistBig.length > 0)) {
this.fillMapsPlaylist(playlistType, this.mapsPlaylistBig);
private getNextMap(): MapWithMode {
if (this.mapsPlaylist.length === 0) {
const numAttempts = 10000;
for (let i = 0; i < numAttempts; i++) {
if (this.shuffleMapsPlaylist()) {
log.info(`Generated map playlist in ${i} attempts`);
return this.mapsPlaylist.shift()!;
}
return this.mapsPlaylistBig;
case PlaylistType.SmallMaps:
if (!(this.mapsPlaylistSmall.length > 0)) {
this.fillMapsPlaylist(playlistType, this.mapsPlaylistSmall);
}
return this.mapsPlaylistSmall;
}
log.error("Failed to generate a valid map playlist");
}
// Even if it failed, playlist will be partially populated.
return this.mapsPlaylist.shift()!;
}
private fillMapsPlaylist(
playlistType: PlaylistType,
mapsPlaylist: GameMapType[],
): void {
const frequency = this.getFrequency(playlistType);
private shuffleMapsPlaylist(): boolean {
const maps: GameMapType[] = [];
Object.keys(GameMapType).forEach((key) => {
let count = parseInt(frequency[key]);
while (count > 0) {
mapsPlaylist.push(GameMapType[key]);
count--;
for (let i = 0; i < parseInt(frequency[key]); i++) {
maps.push(GameMapType[key]);
}
});
while (!this.allNonConsecutive(mapsPlaylist)) {
random.shuffleArray(mapsPlaylist);
}
}
// Specifically controls how the playlists rotate.
private getNextPlaylistType(): PlaylistType {
switch (this.currentPlaylistCounter) {
case 0:
case 1:
this.currentPlaylistCounter++;
return PlaylistType.BigMaps;
case 2:
this.currentPlaylistCounter = 0;
return PlaylistType.SmallMaps;
}
}
const rand = new PseudoRandom(Date.now());
private getFrequency(playlistType: PlaylistType) {
switch (playlistType) {
// Big Maps are those larger than ~2.5 mil pixels
case PlaylistType.BigMaps:
return {
Europe: 2,
NorthAmerica: 1,
Africa: 2,
Britannia: 1,
GatewayToTheAtlantic: 2,
Australia: 2,
Iceland: 2,
SouthAmerica: 1,
KnownWorld: 2,
};
case PlaylistType.SmallMaps:
return {
World: 4,
EuropeClassic: 3,
Mena: 2,
Pangaea: 1,
Asia: 1,
Mars: 1,
BetweenTwoSeas: 2,
Japan: 2,
BlackSea: 1,
FaroeIslands: 2,
};
}
}
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
const ffa3: GameMapType[] = rand.shuffleArray([...maps]);
const team: GameMapType[] = rand.shuffleArray([...maps]);
// Check for consecutive duplicates in the maps array
private allNonConsecutive(maps: GameMapType[]): boolean {
for (let i = 0; i < maps.length - 1; i++) {
if (maps[i] === maps[i + 1]) {
this.mapsPlaylist = [];
for (let i = 0; i < maps.length; i++) {
if (!this.addNextMap(this.mapsPlaylist, ffa1, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
return false;
}
}
return true;
}
private addNextMap(
playlist: MapWithMode[],
nextEls: GameMapType[],
mode: GameMode,
): boolean {
const nonConsecutiveNum = 5;
const lastEls = playlist
.slice(playlist.length - nonConsecutiveNum)
.map((m) => m.map);
for (let i = 0; i < nextEls.length; i++) {
const next = nextEls[i];
if (lastEls.includes(next)) {
continue;
}
nextEls.splice(i, 1);
playlist.push({ map: next, mode: mode });
return true;
}
return false;
}
}
+2 -40
View File
@@ -5,13 +5,11 @@ import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { Difficulty, GameMode, GameType } from "../core/game/Game";
import { GameConfig, GameInfo } from "../core/Schemas";
import { GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { setupMetricsServer } from "./MasterMetrics";
const config = getServerConfigFromServer();
const playlist = new MapPlaylist();
@@ -20,10 +18,6 @@ const readyWorkers = new Set();
const app = express();
const server = http.createServer(app);
// Create a separate metrics server on port 9090
const metricsApp = express();
const metricsServer = http.createServer(metricsApp);
const log = logger.child({ comp: "m" });
const __filename = fileURLToPath(import.meta.url);
@@ -146,9 +140,6 @@ export async function startMaster() {
server.listen(PORT, () => {
log.info(`Master HTTP server listening on port ${PORT}`);
});
// Setup the metrics server
setupMetricsServer();
}
app.get(
@@ -222,40 +213,11 @@ async function fetchLobbies(): Promise<number> {
return publicLobbyIDs.size;
}
let lastGameMode: GameMode = GameMode.FFA;
// Function to schedule a new public game
async function schedulePublicGame(playlist: MapPlaylist) {
const gameID = generateID();
const map = playlist.getNextMap();
publicLobbyIDs.add(gameID);
if (lastGameMode == GameMode.FFA) {
lastGameMode = GameMode.Team;
} else {
lastGameMode = GameMode.FFA;
}
const gameMode = playlist.getNextGameMode();
const numPlayerTeams =
gameMode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined;
// Create the default public game config (from your GameManager)
const defaultGameConfig: GameConfig = {
gameMap: map,
maxPlayers: config.lobbyMaxPlayers(map),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: gameMode == GameMode.Team,
disableNukes: false,
gameMode,
numPlayerTeams,
bots: 400,
};
const workerPath = config.workerPath(gameID);
// Send request to the worker to start the game
@@ -269,7 +231,7 @@ async function schedulePublicGame(playlist: MapPlaylist) {
[config.adminHeader()]: config.adminToken(),
},
body: JSON.stringify({
gameConfig: defaultGameConfig,
gameConfig: playlist.gameConfig(),
}),
},
);
-189
View File
@@ -1,189 +0,0 @@
import express from "express";
import http from "http";
import promClient from "prom-client";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
const config = getServerConfigFromServer();
// Create a separate metrics server on port 9090
const metricsApp = express();
const metricsServer = http.createServer(metricsApp);
// Initialize the Prometheus registry for the master's own metrics
const register = new promClient.Registry();
// Default Prometheus metrics
promClient.collectDefaultMetrics({ register });
// Prometheus metrics endpoint that gathers metrics from workers
export function setupMetricsServer() {
metricsApp.get("/metrics", async (req, res) => {
// Set a timeout for the request to avoid hanging
const timeout = setTimeout(() => {
res.status(500).end("# Error: Request timed out after 30 seconds");
}, 30000);
console.log("Metrics requested");
try {
// Get the master's metrics
const masterMetrics = await register.metrics();
// Track seen metric names to avoid duplicate metadata
const seenMetrics = new Set();
const processedLines = [];
const allMetricValues = [];
// Process all metadata information in the master metrics first
const masterLines = masterMetrics.split("\n");
for (let j = 0; j < masterLines.length; j++) {
const line = masterLines[j];
if (line.startsWith("# HELP ")) {
const metricName = line.split(" ")[2];
seenMetrics.add(metricName);
processedLines.push(line);
} else if (line.startsWith("# TYPE ")) {
const metricName = line.split(" ")[2];
if (seenMetrics.has(metricName)) {
processedLines.push(line);
}
} else if (line.trim() && !line.startsWith("#")) {
// Add worker label to each metric line and collect for later
const processedLine = line.replace(
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
(match, metricName, existingLabels, valueAndRest) => {
if (existingLabels) {
return `${metricName}{${existingLabels},worker="master"}${valueAndRest}`;
} else {
return `${metricName}{worker="master"}${valueAndRest}`;
}
},
);
allMetricValues.push(processedLine);
}
}
// Collect metrics from all workers
for (let i = 0; i < config.numWorkers(); i++) {
const workerPort = config.workerPortByIndex(i);
const workerUrl = `http://localhost:${workerPort}/metrics`;
console.log(`Fetching metrics from worker ${i} at ${workerUrl}`);
try {
const response = await fetch(workerUrl, {
headers: {
[config.adminHeader()]: config.adminToken(),
},
});
if (!response.ok) {
console.error(`Worker ${i} returned status ${response.status}`);
continue;
}
const metricsText = await response.text();
const lines = metricsText.split("\n");
for (let j = 0; j < lines.length; j++) {
const line = lines[j];
// Collect HELP and TYPE info if we haven't seen this metric before
if (line.startsWith("# HELP ")) {
const metricName = line.split(" ")[2];
if (!seenMetrics.has(metricName)) {
seenMetrics.add(metricName);
processedLines.push(line);
}
} else if (line.startsWith("# TYPE ")) {
const metricName = line.split(" ")[2];
if (
seenMetrics.has(metricName) &&
!processedLines.some((l) =>
l.startsWith(`# TYPE ${metricName}`),
)
) {
processedLines.push(line);
}
} else if (line.trim() && !line.startsWith("#")) {
// Process and collect actual metric values
try {
const processedLine = line.replace(
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
(match, metricName, existingLabels, valueAndRest) => {
if (existingLabels) {
return `${metricName}{${existingLabels},worker="worker-${i}"}${valueAndRest}`;
} else {
return `${metricName}{worker="worker-${i}"}${valueAndRest}`;
}
},
);
// Make sure the line was actually processed (regex matched)
if (processedLine !== line) {
allMetricValues.push(processedLine);
} else if (
line.match(/^[a-z][a-z0-9_]*(?:{[^}]*})?\s+[0-9.e+-]+.*/)
) {
// This looks like a metric line but didn't match our regex, try a more general approach
const parts = line.split(/({|\s+)/);
if (parts.length >= 3) {
const metricName = parts[0];
if (line.includes("{")) {
// Has labels
const labelEndIndex = line.indexOf("}");
const valueStartIndex = labelEndIndex + 1;
if (labelEndIndex > 0 && valueStartIndex < line.length) {
const labels = line.substring(
line.indexOf("{") + 1,
labelEndIndex,
);
const valueAndRest = line.substring(valueStartIndex);
allMetricValues.push(
`${metricName}{${labels},worker="worker-${i}"}${valueAndRest}`,
);
}
} else {
// No labels
const valueAndRest = line.substring(metricName.length);
allMetricValues.push(
`${metricName}{worker="worker-${i}"}${valueAndRest}`,
);
}
}
}
} catch (error) {
console.error(`Error processing metric line: ${line}`, error);
// Skip this line if there's an error
}
}
}
} catch (error) {
console.error(`Error fetching metrics from worker ${i}:`, error);
allMetricValues.push(
`# Error fetching metrics from worker ${i}: ${error.message}`,
);
}
}
// Combine metadata with all metric values and ensure it ends with a newline
const combinedMetrics = [...processedLines, ...allMetricValues].join(
"\n",
);
// Send the combined response with a final newline to prevent unexpected end of input
clearTimeout(timeout);
res.set("Content-Type", register.contentType);
res.end(combinedMetrics + "\n");
} catch (error) {
console.error("Error collecting metrics:", error);
clearTimeout(timeout);
res.status(500).end(`# Error collecting metrics: ${error.message}`);
}
});
// Start the metrics server on port 9090
const METRICS_PORT = 9090;
metricsServer.listen(METRICS_PORT, () => {
console.log(`Metrics server listening on port ${METRICS_PORT}`);
});
}
+27
View File
@@ -0,0 +1,27 @@
import { resourceFromAttributes } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
const config = getServerConfigFromServer();
export function getOtelResource() {
return resourceFromAttributes({
[ATTR_SERVICE_NAME]: "openfront",
[ATTR_SERVICE_VERSION]: "1.0.0",
"service.instance.id": process.env.HOSTNAME,
"openfront.environment": config.env(),
"openfront.host": process.env.HOST,
"openfront.domain": process.env.DOMAIN,
"openfront.subdomain": process.env.SUBDOMAIN,
"openfront.component": process.env.WORKER_ID
? "Worker " + process.env.WORKER_ID
: "Master",
// The comma-separated list tells OpenTelemetry which resource attributes
// should be converted to Loki labels
"loki.resource.labels":
"service.name,service.instance.id,openfront.environment,openfront.host,openfront.domain,openfront.subdomain,openfront.component",
});
}
+5 -24
View File
@@ -13,7 +13,7 @@ import { Client } from "./Client";
import { GameManager } from "./GameManager";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { logger } from "./Logger";
import { metrics } from "./WorkerMetrics";
import { initWorkerMetrics } from "./WorkerMetrics";
const config = getServerConfigFromServer();
@@ -33,10 +33,9 @@ export function startWorker() {
const gm = new GameManager(config, log);
// Set up periodic metrics updates
setInterval(() => {
metrics.updateGameMetrics(gm);
}, 15000); // Update every 15 seconds
if (config.env() == GameEnv.Prod && config.otelEnabled()) {
initWorkerMetrics(gm);
}
// Middleware to handle /wX path prefix
app.use((req, res, next) => {
@@ -165,7 +164,7 @@ export function startWorker() {
disableNPCs: req.body.disableNPCs,
disableNukes: req.body.disableNukes,
gameMode: req.body.gameMode,
numPlayerTeams: req.body.numPlayerTeams,
playerTeams: req.body.playerTeams,
});
res.status(200).json({ success: true });
}),
@@ -251,24 +250,6 @@ export function startWorker() {
}),
);
app.get(
"/metrics",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
if (req.headers[config.adminHeader()] !== config.adminToken()) {
return res.status(403).end("Access denied");
}
log.info(`metrics requested on worker ${workerId}`);
try {
const metricsData = await metrics.register.metrics();
res.set("Content-Type", metrics.register.contentType);
res.end(metricsData);
} catch (error) {
res.status(500).end(error.message);
}
}),
);
// WebSocket handling
wss.on("connection", (ws: WebSocket, req) => {
ws.on(
+82 -35
View File
@@ -1,45 +1,92 @@
import promClient from "prom-client";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import {
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import * as dotenv from "dotenv";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameManager } from "./GameManager";
import { getOtelResource } from "./OtelResource";
// Initialize the Prometheus registry
const register = new promClient.Registry();
dotenv.config();
// Enable default Node.js metrics collection
promClient.collectDefaultMetrics({ register });
export function initWorkerMetrics(gameManager: GameManager): void {
// Get server configuration
const config = getServerConfigFromServer();
// Add worker-specific metrics
const activeGamesGauge = new promClient.Gauge({
name: "openfront_active_games_count",
help: "Number of active games on this worker",
registers: [register],
});
// Create resource with worker information
const resource = getOtelResource();
const connectedClientsGauge = new promClient.Gauge({
name: "openfront_connected_clients_count",
help: "Number of connected clients on this worker",
registers: [register],
});
// Configure auth headers
const headers = {};
if (config.otelEnabled()) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${config.otelUsername()}:${config.otelPassword()}`).toString(
"base64",
);
}
const memoryUsageGauge = new promClient.Gauge({
name: "openfront_memory_usage_bytes",
help: "Current memory usage of the worker process in bytes",
registers: [register],
});
// Create metrics exporter
const metricExporter = new OTLPMetricExporter({
url: `${config.otelEndpoint()}/v1/metrics`,
headers,
});
// Export the metrics for use in the worker
export const metrics = {
register,
activeGamesGauge,
connectedClientsGauge,
memoryUsageGauge,
// Configure the metric reader
const metricReader = new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: 15000, // Export metrics every 15 seconds
});
// Function to update game-related metrics
updateGameMetrics: (gameManager: GameManager) => {
activeGamesGauge.set(gameManager.activeGames());
connectedClientsGauge.set(gameManager.activeClients());
// Create a meter provider
const meterProvider = new MeterProvider({
resource,
readers: [metricReader],
});
// Update memory usage metrics
// Get meter for creating metrics
const meter = meterProvider.getMeter("worker-metrics");
// Create observable gauges
const activeGamesGauge = meter.createObservableGauge(
"openfront.active_games.gauge",
{
description: "Number of active games on this worker",
},
);
const connectedClientsGauge = meter.createObservableGauge(
"openfront.connected_clients.gauge",
{
description: "Number of connected clients on this worker",
},
);
const memoryUsageGauge = meter.createObservableGauge(
"openfront.memory_usage.bytes",
{
description: "Current memory usage of the worker process in bytes",
},
);
// Register callback for active games metric
activeGamesGauge.addCallback((result) => {
const count = gameManager.activeGames();
result.observe(count);
});
// Register callback for connected clients metric
connectedClientsGauge.addCallback((result) => {
const count = gameManager.activeClients();
result.observe(count);
});
// Register callback for memory usage metric
memoryUsageGauge.addCallback((result) => {
const memoryUsage = process.memoryUsage();
memoryUsageGauge.set(memoryUsage.heapUsed);
},
};
result.observe(memoryUsage.heapUsed);
});
console.log("Metrics initialized with GameManager");
}
+19 -2
View File
@@ -78,13 +78,30 @@ else
--data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
fi
# Log the tunnel information
echo "Tunnel is set up! Site will be available at: https://${SUBDOMAIN}.${DOMAIN}"
# Export the tunnel token for supervisord
export CLOUDFLARE_TUNNEL_TOKEN=${TUNNEL_TOKEN}
# Check if Basic Auth credentials are set
if [ -z "$BASIC_AUTH_USER" ] || [ -z "$BASIC_AUTH_PASS" ]; then
echo "HTTP Basic Authentication will be disabled"
else
# Create the htpasswd file
echo "Creating basic auth credentials for user: ${BASIC_AUTH_USER}"
# Ensure apache2-utils is installed for htpasswd
command -v htpasswd >/dev/null 2>&1 || { echo "htpasswd not found, installing apache2-utils..."; apt-get update && apt-get install -y apache2-utils; }
# Create the password file
htpasswd -bc /etc/nginx/.htpasswd ${BASIC_AUTH_USER} ${BASIC_AUTH_PASS}
# Update Nginx configuration to enable Basic Auth
sed -i '1i auth_basic "Restricted Access";' /etc/nginx/conf.d/default.conf
sed -i '2i auth_basic_user_file /etc/nginx/.htpasswd;' /etc/nginx/conf.d/default.conf
echo "HTTP Basic Authentication enabled for user: ${BASIC_AUTH_USER}"
fi
# Start supervisord
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
+1 -16
View File
@@ -29,19 +29,4 @@ command=cloudflared tunnel run --token %(ENV_CLOUDFLARE_TUNNEL_TOKEN)s
autostart=true
autorestart=true
stdout_logfile=/var/log/cloudflared.log
stderr_logfile=/var/log/cloudflared-err.log
[program:node_exporter]
command=/usr/local/bin/node_exporter
autostart=true
autorestart=true
stdout_logfile=/var/log/node_exporter.log
stderr_logfile=/var/log/node_exporter-err.log
[program:metrics_exporter]
command=/usr/src/app/metric-exporter.sh
autostart=true
autorestart=true
stdout_logfile=/var/log/metrics-exporter.log
stderr_logfile=/var/log/metrics-exporter-err.log
stderr_logfile=/var/log/cloudflared-err.log
+46 -46
View File
@@ -1,7 +1,7 @@
import { PlayerInfo, PlayerType, Team } from "../src/core/game/Game";
import { ColoredTeams, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { assignTeams } from "../src/core/game/TeamAssignment";
const teams = [Team.Red, Team.Blue];
const teams = [ColoredTeams.Red, ColoredTeams.Blue];
describe("assignTeams", () => {
const createPlayer = (id: string, clan?: string): PlayerInfo => {
@@ -27,10 +27,10 @@ describe("assignTeams", () => {
const result = assignTeams(players, teams);
// Check that players are assigned alternately
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Blue);
expect(result.get(players[2])).toEqual(Team.Red);
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Blue);
expect(result.get(players[2])).toEqual(ColoredTeams.Red);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
});
it("should keep clan members together on the same team", () => {
@@ -44,10 +44,10 @@ describe("assignTeams", () => {
const result = assignTeams(players, teams);
// Check that clan members are on the same team
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[2])).toEqual(Team.Blue);
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Blue);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
});
it("should handle mixed clan and non-clan players", () => {
@@ -61,10 +61,10 @@ describe("assignTeams", () => {
const result = assignTeams(players, teams);
// Check that clan members are together and non-clan players balance teams
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[2])).toEqual(Team.Blue);
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Blue);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
});
it("should kick players when teams are full", () => {
@@ -80,14 +80,14 @@ describe("assignTeams", () => {
const result = assignTeams(players, teams);
// Check that players are kicked when teams are full
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[2])).toEqual(Team.Red);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Red);
expect(result.get(players[3])).toEqual("kicked");
expect(result.get(players[4])).toEqual(Team.Blue);
expect(result.get(players[5])).toEqual(Team.Blue);
expect(result.get(players[4])).toEqual(ColoredTeams.Blue);
expect(result.get(players[5])).toEqual(ColoredTeams.Blue);
});
it("should handle empty player list", () => {
@@ -98,7 +98,7 @@ describe("assignTeams", () => {
it("should handle single player", () => {
const players = [createPlayer("1")];
const result = assignTeams(players, teams);
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
});
it("should handle multiple clans with different sizes", () => {
@@ -114,12 +114,12 @@ describe("assignTeams", () => {
const result = assignTeams(players, teams);
// Check that larger clans are assigned first
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[2])).toEqual(Team.Red);
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[4])).toEqual(Team.Blue);
expect(result.get(players[5])).toEqual(Team.Blue);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual(ColoredTeams.Red);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
expect(result.get(players[4])).toEqual(ColoredTeams.Blue);
expect(result.get(players[5])).toEqual(ColoredTeams.Blue);
});
it("should distribute players among a larger number of teams", () => {
@@ -141,28 +141,28 @@ describe("assignTeams", () => {
];
const result = assignTeams(players, [
Team.Red,
Team.Blue,
Team.Teal,
Team.Purple,
Team.Yellow,
Team.Orange,
Team.Green,
ColoredTeams.Red,
ColoredTeams.Blue,
ColoredTeams.Yellow,
ColoredTeams.Green,
ColoredTeams.Purple,
ColoredTeams.Orange,
ColoredTeams.Teal,
]);
expect(result.get(players[0])).toEqual(Team.Red);
expect(result.get(players[1])).toEqual(Team.Red);
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
expect(result.get(players[2])).toEqual("kicked");
expect(result.get(players[3])).toEqual(Team.Blue);
expect(result.get(players[4])).toEqual(Team.Blue);
expect(result.get(players[5])).toEqual(Team.Teal);
expect(result.get(players[6])).toEqual(Team.Purple);
expect(result.get(players[7])).toEqual(Team.Yellow);
expect(result.get(players[8])).toEqual(Team.Orange);
expect(result.get(players[9])).toEqual(Team.Green);
expect(result.get(players[10])).toEqual(Team.Teal);
expect(result.get(players[11])).toEqual(Team.Purple);
expect(result.get(players[12])).toEqual(Team.Yellow);
expect(result.get(players[13])).toEqual(Team.Orange);
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
expect(result.get(players[4])).toEqual(ColoredTeams.Blue);
expect(result.get(players[5])).toEqual(ColoredTeams.Yellow);
expect(result.get(players[6])).toEqual(ColoredTeams.Green);
expect(result.get(players[7])).toEqual(ColoredTeams.Purple);
expect(result.get(players[8])).toEqual(ColoredTeams.Orange);
expect(result.get(players[9])).toEqual(ColoredTeams.Teal);
expect(result.get(players[10])).toEqual(ColoredTeams.Yellow);
expect(result.get(players[11])).toEqual(ColoredTeams.Green);
expect(result.get(players[12])).toEqual(ColoredTeams.Purple);
expect(result.get(players[13])).toEqual(ColoredTeams.Orange);
});
});
+1 -2
View File
@@ -18,7 +18,6 @@ export async function setup(mapName: string, _gameConfig: GameConfig = {}) {
const miniGameMap = await genTerrainFromBin(
String.fromCharCode.apply(null, miniMap),
);
const nationMap = { nations: [] };
// Configure the game
const serverConfig = new TestServerConfig();
@@ -36,5 +35,5 @@ export async function setup(mapName: string, _gameConfig: GameConfig = {}) {
const config = new TestConfig(serverConfig, gameConfig, new UserSettings());
// Create and return the game
return createGame([], gameMap, miniGameMap, nationMap, config); // TODO: !!!
return createGame([], [], gameMap, miniGameMap, config);
}

Some files were not shown because too many files have changed in this diff Show More