Merge branch 'main' into meta3
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 "======================================================="
|
||||
@@ -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
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 34 KiB |
@@ -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 |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 88 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 98 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 53 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -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 site’s 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 (1–100%)",
|
||||
"troop_ratio_label": "🪖🛠️ Troops and Workers Ratio",
|
||||
"troop_ratio_desc": "Adjust the balance between troops (for combat) and workers (for gold production) (1–100%)",
|
||||
"easter_writing_speed_label": "Writing Speed Multiplier",
|
||||
"easter_writing_speed_desc": "Adjust how fast you pretend to code (x1–x100)",
|
||||
"easter_bug_count_label": "Bug Count",
|
||||
"easter_bug_count_desc": "How many bugs you're okay with (0–1000, 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (1–100%)",
|
||||
"troop_ratio_label": "🪖🛠️ Trupoj kaj Laboristoj kvociento",
|
||||
"troop_ratio_desc": "Alĝustigu la ekvilibron inter soldatoj (por batalo) kaj laboristoj (por orproduktado) (1–100%)",
|
||||
"easter_writing_speed_label": "Rapidskriba multiganto",
|
||||
"easter_writing_speed_desc": "Alĝustigu kiom rapide vi ŝajnigas kodi (x1–x100)",
|
||||
"easter_bug_count_label": "Nombro da cimoj",
|
||||
"easter_bug_count_desc": "Kiom da cimoj vi bonfartas (0–1000, 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,8 @@
|
||||
},
|
||||
"public_lobby": {
|
||||
"join": "次のゲームに参加",
|
||||
"waiting": "人が参加しています..."
|
||||
"waiting": "人が参加しています...",
|
||||
"teams": "{num}チーム"
|
||||
},
|
||||
"username": {
|
||||
"enter_username": "ユーザー名を入力",
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [208, 832],
|
||||
"name": "Sierra Leon",
|
||||
"name": "Sierra Leone",
|
||||
"strength": 2,
|
||||
"flag": "sl"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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
|
||||
"control" 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 "the
|
||||
Company", "We", "Us" or "Our" 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 "Persistent" or "Session" 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 "Last
|
||||
updated" 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>
|
||||
@@ -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>
|
||||
@@ -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 "====================================================="
|
||||
@@ -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>;
|
||||
@@ -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,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),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ export class LangSelector extends LitElement {
|
||||
"help-modal",
|
||||
"username-input",
|
||||
"public-lobby",
|
||||
"user-setting",
|
||||
"o-modal",
|
||||
"o-button",
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 site’s 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 (1–100%)"
|
||||
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) (1–100%)"
|
||||
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 (x1–x100)"
|
||||
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 (0–1000, 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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": "Polish–Lithuanian Commonwealth",
|
||||
"continent": "Europe",
|
||||
"name": "Polish–Lithuanian 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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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]];
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -307,3 +307,19 @@ export function createRandomName(
|
||||
}
|
||||
return randomName;
|
||||
}
|
||||
|
||||
export const emojiTable: string[][] = [
|
||||
["😀", "😊", "🥰", "😇", "😎"],
|
||||
["😞", "🥺", "😭", "😱", "😡"],
|
||||
["😈", "🤡", "🖕", "🥱", "🤦♂️"],
|
||||
["👋", "👏", "🤌", "💪", "🫡"],
|
||||
["👍", "👎", "❓", "🐔", "🐀"],
|
||||
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
|
||||
["🔥", "💥", "💀", "☢️", "⚠️"],
|
||||
["↖️", "⬆️", "↗️", "👑", "🥇"],
|
||||
["⬅️", "🎯", "➡️", "🥈", "🥉"],
|
||||
["↙️", "⬇️", "↘️", "❤️", "💔"],
|
||||
["💰", "⚓", "⛵", "🏡", "🛡️"],
|
||||
];
|
||||
// 2d to 1d array
|
||||
export const flattenedEmojiTable: string[] = [].concat(...emojiTable);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const maps = [
|
||||
"Japan",
|
||||
"KnownWorld",
|
||||
"FaroeIslands",
|
||||
"DeglaciatedAntarctica",
|
||||
];
|
||||
|
||||
const removeSmall = true;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||