Merge main into nations-ai

This commit is contained in:
Scott Anderson
2025-04-23 03:04:49 -04:00
55 changed files with 1436 additions and 587 deletions
+48 -9
View File
@@ -1,4 +1,4 @@
name: Deploy to Remote
name: 🚀 Deploy
on:
workflow_dispatch:
@@ -9,14 +9,32 @@ on:
default: "staging"
type: choice
options:
- production
- prod
- staging
target_host:
description: "Deployment Host"
required: true
default: "staging"
type: choice
options:
- eu
- us
- staging
target_subdomain:
description: "Deployment Subdomain"
required: false
default: ""
type: string
push:
branches:
- main
jobs:
deploy:
name: Deploy to ${{ inputs.target_environment }}
# Use different logic based on event type
name: Deploy to ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}
runs-on: ubuntu-latest
environment: ${{ inputs.target_environment }}
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || 'staging' }}
steps:
- uses: actions/checkout@v4
@@ -29,10 +47,31 @@ jobs:
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
cat >.env <<EOF
SERVER_HOST_STAGING=${{ secrets.SERVER_USERNAME }}@${{ secrets.SERVER_HOST }}
DOCKER_REPO_STAGING=${{ vars.DOCKERHUB_REPO }}
ssh-keyscan -H ${{ secrets.SERVER_HOST_STAGING }} >> ~/.ssh/known_hosts
# 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"
EOF
./deploy.sh ${{ inputs.target_environment }}
./deploy.sh $TARGET_ENV $TARGET_HOST $TARGET_SUBDOMAIN
echo "Deployed to $TARGET_ENV environment on $TARGET_HOST host with subdomain $TARGET_SUBDOMAIN"
+1 -1
View File
@@ -5,5 +5,5 @@ static/
TODO.txt
resources/images/.DS_Store
resources/.DS_Store
.env
.env*
.DS_Store
Regular → Executable
+8 -1
View File
@@ -1 +1,8 @@
npx lint-staged
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Add PATH setup to ensure npx is found
export PATH="/usr/local/bin:$HOME/.npm-global/bin:$HOME/.nvm/versions/node/$(node -v)/bin:$PATH"
# Then run lint-staged if tests pass
npx lint-staged
+25 -8
View File
@@ -1,12 +1,28 @@
# Use an official Node runtime as the base image
FROM node:18
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
# Install Nginx, Supervisor and Git (for Husky)
RUN apt-get update && apt-get install -y nginx supervisor git && \
rm -rf /var/lib/apt/lists/*
# Install Nginx, Supervisor, Git, jq, curl, and Node Exporter dependencies
RUN apt-get update && apt-get install -y \
nginx \
supervisor \
git \
curl \
jq \
wget \
&& 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
# Set the working directory in the container
WORKDIR /usr/src/app
@@ -33,8 +49,9 @@ RUN rm -f /etc/nginx/sites-enabled/default
RUN mkdir -p /var/log/supervisor
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Expose only the Nginx port
EXPOSE 80 443
# Copy and make executable the startup script
COPY startup.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/startup.sh
# Start Supervisor to manage both Node.js and Nginx
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
# Use the startup script as the entrypoint
ENTRYPOINT ["/usr/local/bin/startup.sh"]
+76 -46
View File
@@ -7,6 +7,27 @@
set -e # Exit immediately if a command exits with a non-zero status
# 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]"
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]"
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]"
exit 1
fi
# Function to print section headers
print_header() {
echo "======================================================"
@@ -14,59 +35,59 @@ print_header() {
echo "======================================================"
}
# Load environment variables
ENV=$1
HOST=$2
SUBDOMAIN=$3 # Optional third argument for custom subdomain
# Set subdomain - use the custom subdomain if provided, otherwise use REGION
if [ -n "$SUBDOMAIN" ]; then
echo "Using custom subdomain: $SUBDOMAIN"
else
SUBDOMAIN=$HOST
echo "Using host as subdomain: $SUBDOMAIN"
fi
# Load common environment variables first
if [ -f .env ]; then
echo "Loading configuration from .env file..."
echo "Loading common configuration from .env file..."
export $(grep -v '^#' .env | xargs)
fi
# Check command line argument
if [ $# -ne 1 ] || ([ "$1" != "staging" ] && [ "$1" != "eu" ] && [ "$1" != "us" ]); then
echo "Error: Please specify environment (staging, eu, or us)"
echo "Usage: $0 [staging|eu|us]"
# Load environment-specific variables
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
REGION=$1
VERSION_TAG="latest"
DOCKER_REPO=""
ENV=""
SSH_KEY=""
# Set environment-specific variables
if [ "$REGION" == "staging" ]; then
print_header "DEPLOYING TO STAGING ENVIRONMENT"
if [ "$HOST" == "staging" ]; then
print_header "DEPLOYING TO STAGING HOST"
SERVER_HOST=$SERVER_HOST_STAGING
DOCKER_REPO=$DOCKER_REPO_STAGING
ENV="staging"
SSH_KEY=$SSH_KEY_STAGING
elif [ "$REGION" == "us" ]; then
print_header "DEPLOYING TO US ENVIRONMENT"
elif [ "$HOST" == "us" ]; then
print_header "DEPLOYING TO US HOST"
SERVER_HOST=$SERVER_HOST_US
DOCKER_REPO=$DOCKER_REPO_PROD # Uses prod Docker repo for alt environment
SSH_KEY=$SSH_KEY_PROD
ENV="prod"
else
print_header "DEPLOYING TO EU ENVIRONMENT"
print_header "DEPLOYING TO EU HOST"
SERVER_HOST=$SERVER_HOST_EU
DOCKER_REPO=$DOCKER_REPO_PROD
SSH_KEY=$SSH_KEY_PROD
ENV="prod"
fi
# Check required environment variables
if [ -z "$SERVER_HOST" ]; then
echo "Error: SERVER_HOST_${REGION^^} not defined in .env file or environment"
echo "Error: ${HOST} not defined in .env file or environment"
exit 1
fi
# Configuration
DOCKER_USERNAME=${DOCKER_USERNAME} # Docker Hub username
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}"
# Check if update script exists
if [ ! -f "$UPDATE_SCRIPT" ]; then
echo "Error: Update script $UPDATE_SCRIPT not found!"
@@ -75,7 +96,9 @@ fi
# Step 1: Build and upload Docker image to Docker Hub
print_header "STEP 1: Building and uploading Docker image to Docker Hub"
echo "Region: ${REGION}"
echo "Environment: ${ENV}"
echo "Host: ${HOST}"
echo "Subdomain: ${SUBDOMAIN}"
echo "Using version tag: $VERSION_TAG"
echo "Docker repository: $DOCKER_REPO"
@@ -107,25 +130,32 @@ chmod +x $UPDATE_SCRIPT
# Copy the update script to the server
scp -i $SSH_KEY $UPDATE_SCRIPT $REMOTE_USER@$SERVER_HOST:$REMOTE_UPDATE_SCRIPT
# Copy environment variables if needed
if [ -f .env ]; then
scp -i $SSH_KEY .env $REMOTE_USER@$SERVER_HOST:$REMOTE_UPDATE_PATH/.env
# Secure the .env file
ssh -i $SSH_KEY $REMOTE_USER@$SERVER_HOST "chmod 600 $REMOTE_UPDATE_PATH/.env"
fi
if [ $? -ne 0 ]; then
echo "❌ Failed to copy update script to server. Stopping deployment."
exit 1
fi
echo "✅ Update script successfully copied to server."
# Step 3: Execute the update script on the server
print_header "STEP 3: Executing update script on server"
# Make the script executable on the remote server and execute it with the environment parameter
ssh -i $SSH_KEY $REMOTE_USER@$SERVER_HOST "chmod +x $REMOTE_UPDATE_SCRIPT && $REMOTE_UPDATE_SCRIPT $REGION $DOCKER_USERNAME $DOCKER_REPO"
ssh -i $SSH_KEY $REMOTE_USER@$SERVER_HOST "chmod +x $REMOTE_UPDATE_SCRIPT && \
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
CF_ACCOUNT_ID=$CF_ACCOUNT_ID
R2_ACCESS_KEY=$R2_ACCESS_KEY
R2_SECRET_KEY=$R2_SECRET_KEY
R2_BUCKET=$R2_BUCKET
CF_API_TOKEN=$CF_API_TOKEN
DOMAIN=$DOMAIN
SUBDOMAIN=$SUBDOMAIN
MON_USERNAME=$MON_USERNAME
MON_PASSWORD=$MON_PASSWORD
EOL
chmod 600 $REMOTE_UPDATE_PATH/.env && \
$REMOTE_UPDATE_SCRIPT"
if [ $? -ne 0 ]; then
echo "❌ Failed to execute update script on server."
@@ -133,6 +163,6 @@ if [ $? -ne 0 ]; then
fi
print_header "DEPLOYMENT COMPLETED SUCCESSFULLY"
echo "✅ New version deployed to ${REGION} environment!"
echo "🌐 Check your ${REGION} server to verify the deployment."
echo "✅ New version deployed to ${ENV} environment in ${HOST} with subdomain ${SUBDOMAIN}!"
echo "🌐 Check your server to verify the deployment."
echo "======================================================="
+1
View File
@@ -13,6 +13,7 @@ const gitignorePath = path.resolve(__dirname, ".gitignore");
/** @type {import('eslint').Linter.Config[]} */
export default [
includeIgnoreFile(gitignorePath),
{ ignores: ["src/server/gatekeeper/**"] },
{ files: ["**/*.{js,mjs,cjs,ts}"] },
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
+25 -13
View File
@@ -1,20 +1,32 @@
# Server Configuration
SERVER_HOST_STAGING=xxx.xxx.xx.xxx
SERVER_HOST_EU=xxx.xxx.xxx.xxx
SERVER_HOST_US=x.xxx.xxx.xxx
SSH_KEY_STAGING=~/.ssh/your-staging-key
SSH_KEY_PROD=~/.ssh/your-prod-key
# SSH Configuration
SSH_KEY=~/.ssh/your-ssh-key
# Docker Configuration
DOCKER_USERNAME=username
DOCKER_REPO_PROD=your-prod-repo
DOCKER_REPO_STAGING=your-staging-repo
DOCKER_TOKEN=your_docker_token
DOCKER_REPO=your-repo-name
DOCKER_TOKEN=your_docker_token_here
# Admin credentials
ADMIN_TOKEN=your_admin_token
ADMIN_TOKEN=your_admin_token_here
# Cloudflare Configuration
CF_ACCOUNT_ID=your_cloudflare_account_id
CF_API_TOKEN=your_cloudflare_api_token
DOMAIN=your-domain.com
# R2 Configuration
R2_ACCESS_KEY=your_r2_access_key
R2_SECRET_KEY=your_r2_secret_key
R2_ACCOUNT_ID=your_r2_account_id
R2_PROD_BUCKET=your-prod-bucket
R2_STAGING_BUCKET=your-staging-bucket
R2_BUCKET=your-bucket-name
# Server Hosts
SERVER_HOST_STAGING=123.456.78.90
SERVER_HOST_EU=123.456.78.91
SERVER_HOST_US=123.456.78.92
# Monitoring Credentials
MON_USERNAME=monitor_username
MON_PASSWORD=monitor_password
# Version
VERSION_TAG="latest"
+103
View File
@@ -0,0 +1,103 @@
#!/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
+5
View File
@@ -123,6 +123,11 @@
"knownworld": "Known World",
"faroeislands": "Faroe Islands"
},
"map_categories": {
"continental": "Continental",
"regional": "Regional",
"fantasy": "Other"
},
"private_lobby": {
"title": "Join Private Lobby",
"enter_id": "Enter Lobby ID",
+9 -3
View File
@@ -353,7 +353,13 @@ export class ClientGameRunner {
}
}
this.myPlayer.actions(tile).then((actions) => {
console.log(`got actions: ${JSON.stringify(actions)}`);
const bu = actions.buildableUnits.find(
(bu) => bu.type == UnitType.TransportShip,
);
if (bu == null) {
console.warn(`no transport ship buildable units`);
return;
}
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
@@ -362,8 +368,8 @@ export class ClientGameRunner {
),
);
} else if (
actions.canBoat !== false &&
this.shouldBoat(tile, actions.canBoat) &&
bu.canBuild !== false &&
this.shouldBoat(tile, bu.canBuild) &&
this.gameView.isLand(tile)
) {
this.eventBus.emit(
+42 -18
View File
@@ -4,7 +4,12 @@ import randomMap from "../../resources/images/RandomMap.webp";
import { translateText } from "../client/Utils";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { consolex } from "../core/Consolex";
import { Difficulty, GameMapType, GameMode } from "../core/game/Game";
import {
Difficulty,
GameMapType,
GameMode,
mapCategories,
} from "../core/game/Game";
import { GameConfig, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import "./components/baseComponents/Modal";
@@ -74,23 +79,40 @@ export class HostLobbyModal extends LitElement {
<!-- Map Selection -->
<div class="options-section">
<div class="option-title">${translateText("map.map")}</div>
<div class="option-cards">
${Object.entries(GameMapType)
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<div @click=${() => this.handleMapSelection(value)}>
<map-display
.mapKey=${key}
.selected=${!this.useRandomMap &&
this.selectedMap === value}
.translation=${translateText(
`map.${key.toLowerCase()}`,
)}
></map-display>
<div class="option-cards flex-col">
<!-- Use the imported mapCategories -->
${Object.entries(mapCategories).map(
([categoryKey, maps]) => html`
<div class="w-full mb-4">
<h3
class="text-lg font-semibold mb-2 text-center text-gray-300"
>
${translateText(`map_categories.${categoryKey}`)}
</h3>
<div class="flex flex-row flex-wrap justify-center gap-4">
${maps.map((mapValue) => {
const mapKey = Object.keys(GameMapType).find(
(key) => GameMapType[key] === mapValue,
);
return html`
<div
@click=${() => this.handleMapSelection(mapValue)}
>
<map-display
.mapKey=${mapKey}
.selected=${!this.useRandomMap &&
this.selectedMap === mapValue}
.translation=${translateText(
`map.${mapKey.toLowerCase()}`,
)}
></map-display>
</div>
`;
})}
</div>
`,
)}
</div>
`,
)}
<div
class="option-card random-map ${
this.useRandomMap ? "selected" : ""
@@ -104,7 +126,9 @@ export class HostLobbyModal extends LitElement {
style="width:100%; aspect-ratio: 4/2; object-fit:cover; border-radius:8px;"
/>
</div>
<div class="option-card-title">${translateText("map.random")}</div>
<div class="option-card-title">
${translateText("map.random")}
</div>
</div>
</div>
</div>
+10
View File
@@ -21,6 +21,8 @@ import { LangSelector } from "./LangSelector";
import { LanguageModal } from "./LanguageModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import "./RandomNameButton";
import { RandomNameButton } from "./RandomNameButton";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
@@ -46,6 +48,7 @@ class Client {
private usernameInput: UsernameInput | null = null;
private flagInput: FlagInput | null = null;
private darkModeButton: DarkModeButton | null = null;
private randomNameButton: RandomNameButton | null = null;
private joinModal: JoinPrivateLobbyModal;
private publicLobby: PublicLobby;
@@ -80,6 +83,13 @@ class Client {
consolex.warn("Dark mode button element not found");
}
this.randomNameButton = document.querySelector(
"random-name-button",
) as RandomNameButton;
if (!this.randomNameButton) {
consolex.warn("Random name button element not found");
}
this.usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
+30
View File
@@ -0,0 +1,30 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserSettings } from "../core/game/UserSettings";
@customElement("random-name-button")
export class RandomNameButton extends LitElement {
private userSettings: UserSettings = new UserSettings();
@state() private randomName: boolean = this.userSettings.anonymousNames();
createRenderRoot() {
return this;
}
toggleRandomName() {
this.userSettings.toggleRandomName();
this.randomName = this.userSettings.anonymousNames();
}
render() {
return html`
<button
title="Random Name"
class="absolute top-0 left-0 md:top-[10px] md:left-[10px] border-none bg-none cursor-pointer text-2xl"
@click=${() => this.toggleRandomName()}
>
${this.randomName ? "🥷" : "🕵️"}
</button>
`;
}
}
+39 -20
View File
@@ -3,7 +3,13 @@ import { customElement, query, state } from "lit/decorators.js";
import randomMap from "../../resources/images/RandomMap.webp";
import { translateText } from "../client/Utils";
import { consolex } from "../core/Consolex";
import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
import {
Difficulty,
GameMapType,
GameMode,
GameType,
mapCategories,
} from "../core/game/Game";
import { generateID } from "../core/Util";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
@@ -39,27 +45,40 @@ export class SinglePlayerModal extends LitElement {
<!-- Map Selection -->
<div class="options-section">
<div class="option-title">${translateText("map.map")}</div>
<div class="option-cards">
${Object.entries(GameMapType)
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<div
@click=${function () {
this.handleMapSelection(value);
}}
<div class="option-cards flex-col">
<!-- Use the imported mapCategories -->
${Object.entries(mapCategories).map(
([categoryKey, maps]) => html`
<div class="w-full mb-4">
<h3
class="text-lg font-semibold mb-2 text-center text-gray-300"
>
<map-display
.mapKey=${key}
.selected=${!this.useRandomMap &&
this.selectedMap === value}
.translation=${translateText(
`map.${key.toLowerCase()}`,
)}
></map-display>
${translateText(`map_categories.${categoryKey}`)}
</h3>
<div class="flex flex-row flex-wrap justify-center gap-4">
${maps.map((mapValue) => {
const mapKey = Object.keys(GameMapType).find(
(key) => GameMapType[key] === mapValue,
);
return html`
<div
@click=${() => this.handleMapSelection(mapValue)}
>
<map-display
.mapKey=${mapKey}
.selected=${!this.useRandomMap &&
this.selectedMap === mapValue}
.translation=${translateText(
`map.${mapKey.toLowerCase()}`,
)}
></map-display>
</div>
`;
})}
</div>
`,
)}
</div>
`,
)}
<div
class="option-card random-map ${this.useRandomMap
? "selected"
+6 -3
View File
@@ -68,8 +68,9 @@ export class SendAttackIntentEvent implements GameEvent {
export class SendBoatAttackIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
public readonly cell: Cell,
public readonly dst: Cell,
public readonly troops: number,
public readonly src: Cell | null = null,
) {}
}
@@ -414,8 +415,10 @@ export class Transport {
clientID: this.lobbyConfig.clientID,
targetID: event.targetID,
troops: event.troops,
x: event.cell.x,
y: event.cell.y,
dstX: event.dst.x,
dstY: event.dst.y,
srcX: event.src?.x,
srcY: event.src?.y,
});
}
+2 -4
View File
@@ -82,10 +82,8 @@ export const getColoredSprite = (
const territoryColor = customTerritoryColor ?? theme.territoryColor(owner);
const borderColor = customBorderColor ?? theme.borderColor(owner);
const spawnHighlightColor = theme.spawnHighlightColor();
const colorKey = customTerritoryColor
? customTerritoryColor.toRgbString()
: "";
const key = owner.id() + unit.type() + colorKey;
const colorKey = territoryColor.toRgbString() + borderColor.toRgbString();
const key = unit.type() + colorKey;
if (coloredSpriteCache.has(key)) {
return coloredSpriteCache.get(key)!;
+1 -1
View File
@@ -301,7 +301,7 @@ export class BuildMenu extends LitElement implements Layer {
if (!unit) {
return false;
}
return unit[0].canBuild;
return unit[0].canBuild !== false;
}
private cost(item: BuildItemDisplay): number {
+5
View File
@@ -195,6 +195,7 @@ export class NameLayer implements Layer {
nameDiv.style.alignItems = "center";
const nameSpan = document.createElement("span");
nameSpan.className = "player-name-span";
nameSpan.innerHTML = player.name();
nameDiv.appendChild(nameSpan);
element.appendChild(nameDiv);
@@ -262,6 +263,10 @@ export class NameLayer implements Layer {
nameDiv.style.fontSize = `${render.fontSize}px`;
nameDiv.style.lineHeight = `${render.fontSize}px`;
nameDiv.style.color = render.fontColor;
const span = nameDiv.querySelector(".player-name-span");
if (span) {
span.innerHTML = render.player.name();
}
if (flagDiv) {
flagDiv.style.height = `${render.fontSize}px`;
}
+10
View File
@@ -106,6 +106,10 @@ export class OptionsMenu extends LitElement implements Layer {
this.eventBus.emit(new RefreshGraphicsEvent());
}
private onToggleRandomNameModeButtonClick() {
this.userSettings.toggleRandomName();
}
private onToggleFocusLockedButtonClick() {
this.userSettings.toggleFocusLocked();
this.requestUpdate();
@@ -196,6 +200,12 @@ export class OptionsMenu extends LitElement implements Layer {
title: "Dark Mode",
children: "🌙: " + (this.userSettings.darkMode() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleRandomNameModeButtonClick,
title: "Random name mode",
children:
"🥷: " + (this.userSettings.anonymousNames() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleLeftClickOpensMenu,
title: "Left click",
+26 -9
View File
@@ -8,7 +8,12 @@ import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { consolex } from "../../../core/Consolex";
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game";
import {
Cell,
PlayerActions,
TerraNullius,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
@@ -374,15 +379,27 @@ export class RadialMenu implements Layer {
);
});
}
if (actions.canBoat) {
if (
actions.buildableUnits.find((bu) => bu.type == UnitType.TransportShip)
?.canBuild
) {
this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => {
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.g.owner(tile).id(),
this.clickedCell,
this.uiState.attackRatio * myPlayer.troops(),
),
);
// BestTransportShipSpawn is an expensive operation, so
// we calculate it here and send the spawn tile to other clients.
myPlayer.bestTransportShipSpawn(tile).then((spawn) => {
if (spawn == false) {
return;
}
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.g.owner(tile).id(),
this.clickedCell,
this.uiState.attackRatio * myPlayer.troops(),
new Cell(this.g.x(spawn), this.g.y(spawn)),
),
);
});
});
}
if (actions.canAttack) {
+39 -95
View File
@@ -3,11 +3,7 @@ import { EventBus } from "../../../core/EventBus";
import { ClientID } from "../../../core/Schemas";
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import {
euclDistFN,
manhattanDistFN,
TileRef,
} from "../../../core/game/GameMap";
import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import {
@@ -264,22 +260,10 @@ export class UnitLayer implements Layer {
}
private handleWarShipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 6, false),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
if (unit.warshipTargetId()) {
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
} else {
this.drawSprite(unit);
}
if (unit.warshipTargetId()) {
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
} else {
this.drawSprite(unit);
}
}
@@ -317,46 +301,11 @@ export class UnitLayer implements Layer {
// interception missle from SAM
private handleMissileEvent(unit: UnitView) {
const rel = this.relationship(unit);
const range = 2;
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), range, false),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
this.drawSprite(unit);
}
this.drawSprite(unit);
}
private handleNuke(unit: UnitView) {
let range = 0;
switch (unit.type()) {
case UnitType.AtomBomb:
range = 4;
break;
case UnitType.HydrogenBomb:
range = 6;
break;
case UnitType.MIRV:
range = 9;
break;
}
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), range, false),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
this.drawSprite(unit);
}
this.drawSprite(unit);
}
private handleMIRVWarhead(unit: UnitView) {
@@ -377,17 +326,7 @@ export class UnitLayer implements Layer {
}
private handleTradeShipEvent(unit: UnitView) {
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 3, false),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
this.drawSprite(unit);
}
this.drawSprite(unit);
}
private handleBoatEvent(unit: UnitView) {
@@ -399,29 +338,21 @@ export class UnitLayer implements Layer {
const trail = this.boatToTrail.get(unit);
trail.push(unit.lastTile());
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
manhattanDistFN(unit.lastTile(), 4),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
// Paint trail
for (const t of trail.slice(-1)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner()),
150,
this.transportShipTrailContext,
);
}
if (unit.isActive()) {
// Paint trail
for (const t of trail.slice(-1)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner()),
150,
this.transportShipTrailContext,
);
}
this.drawSprite(unit);
this.drawSprite(unit);
} else {
if (!unit.isActive()) {
for (const t of trail) {
this.clearCell(
this.game.x(t),
@@ -488,6 +419,8 @@ export class UnitLayer implements Layer {
drawSprite(unit: UnitView, customTerritoryColor?: Colord) {
const x = this.game.x(unit.tile());
const y = this.game.y(unit.tile());
const lastX = this.game.x(unit.lastTile());
const lastY = this.game.y(unit.lastTile());
let alternateViewColor = null;
@@ -513,12 +446,23 @@ export class UnitLayer implements Layer {
alternateViewColor,
);
this.context.drawImage(
sprite,
Math.round(x - sprite.width / 2),
Math.round(y - sprite.height / 2),
sprite.width,
sprite.width,
const clearsize = sprite.width + 1;
this.context.clearRect(
lastX - clearsize / 2,
lastY - clearsize / 2,
clearsize,
clearsize,
);
if (unit.isActive()) {
this.context.drawImage(
sprite,
Math.round(x - sprite.width / 2),
Math.round(y - sprite.height / 2),
sprite.width,
sprite.width,
);
}
}
}
+10 -17
View File
@@ -203,32 +203,22 @@
/>
</g>
</svg>
<div class="l-header__highlightText">v21.2</div>
<div class="l-header__highlightText">v22.0</div>
</div>
</header>
<div class="bg-image"></div>
<random-name-button></random-name-button>
<dark-mode-button></dark-mode-button>
<!-- Main container with responsive padding -->
<main class="flex justify-center items-center flex-grow">
<div class="container">
<main class="flex justify-center flex-grow">
<div class="container pt-12">
<div class="container__row">
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
<username-input class="w-full"></username-input>
</div>
<div>
<a
target="_blank"
href="https://discord.gg/openfront"
class="w-full bg-[#5865F2] hover:bg-[#4752C4] text-white p-3 sm:p-4 lg:p-5 font-medium text-lg sm:text-xl lg:text-2xl rounded-lg border-none cursor-pointer transition-colors duration-300 flex justify-center items-center gap-5"
>
<img
style="height: 50px; width: 50px"
alt="Discord"
src="../../resources/icons/discord.svg"
/>
<span data-i18n="main.join_discord"> Join the Discord! </span>
</a>
</div>
<div></div>
<div>
<public-lobby class="w-full"></public-lobby>
</div>
@@ -331,6 +321,9 @@
>
Wiki
</a>
<a target="_blank" href="https://discord.gg/openfront" class="t-link">
<span data-i18n="main.join_discord"> Join the Discord! </span>
</a>
</div>
<div class="l-footer__col t-text-white">
© 2025
+1 -1
View File
@@ -20,5 +20,5 @@
.l-footer__col {
display: flex;
gap: 10px;
gap: 20px;
}
+12 -10
View File
@@ -4,7 +4,6 @@ import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
BuildableUnit,
Game,
GameUpdates,
NameViewData,
@@ -15,9 +14,9 @@ import {
PlayerInfo,
PlayerProfile,
PlayerType,
UnitType,
} from "./game/Game";
import { createGame } from "./game/GameImpl";
import { TileRef } from "./game/GameMap";
import {
ErrorUpdate,
GameUpdateType,
@@ -159,15 +158,8 @@ export class GameRunner {
const player = this.game.player(playerID);
const tile = this.game.ref(x, y);
const actions = {
canBoat: player.canBoat(tile),
canAttack: player.canAttack(tile),
buildableUnits: Object.values(UnitType).map((u) => {
return {
type: u,
canBuild: player.canBuild(u, tile) != false,
cost: this.game.config().unitInfo(u).cost(player),
} as BuildableUnit;
}),
buildableUnits: player.buildableUnits(tile),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
} as PlayerActions;
@@ -202,4 +194,14 @@ export class GameRunner {
borderTiles: player.borderTiles(),
} as PlayerBorderTiles;
}
public bestTransportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
): TileRef | false {
const player = this.game.player(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return player.bestTransportShipSpawn(targetTile);
}
}
+4 -2
View File
@@ -196,8 +196,10 @@ export const BoatAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("boat"),
targetID: ID.nullable(),
troops: z.number().nullable(),
x: z.number(),
y: z.number(),
dstX: z.number(),
dstY: z.number(),
srcX: z.number(),
srcY: z.number(),
});
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
+23 -68
View File
@@ -1,8 +1,8 @@
import DOMPurify from "dompurify";
import { customAlphabet } from "nanoid";
import twemoji from "twemoji";
import { Cell, Game, Player, Team, Unit } from "./game/Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./game/GameMap";
import { Cell, Team, Unit } from "./game/Game";
import { GameMap, TileRef } from "./game/GameMap";
import {
AllPlayersStats,
ClientID,
@@ -13,6 +13,11 @@ import {
Turn,
} from "./Schemas";
import {
BOT_NAME_PREFIXES,
BOT_NAME_SUFFIXES,
} from "./execution/utils/BotNames";
export function manhattanDistWrapped(
c1: Cell,
c2: Cell,
@@ -57,72 +62,6 @@ export function distSortUnit(
};
}
// TODO: refactor to new file
export function sourceDstOceanShore(
gm: Game,
src: Player,
tile: TileRef,
): [TileRef | null, TileRef | null] {
const dst = gm.owner(tile);
const srcTile = closestShoreFromPlayer(gm, src, tile);
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return [srcTile, dstTile];
}
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
const dst = gm.playerBySmallID(gm.ownerID(tile));
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return dstTile;
}
export function closestShoreFromPlayer(
gm: GameMap,
player: Player,
target: TileRef,
): TileRef | null {
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isShore(t),
);
if (shoreTiles.length == 0) {
return null;
}
return shoreTiles.reduce((closest, current) => {
const closestDistance = gm.manhattanDist(target, closest);
const currentDistance = gm.manhattanDist(target, current);
return currentDistance < closestDistance ? current : closest;
});
}
function closestShoreTN(
gm: GameMap,
tile: TileRef,
searchDist: number,
): TileRef {
const tn = Array.from(
gm.bfs(
tile,
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
),
)
.filter((t) => gm.isShore(t))
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
if (tn.length == 0) {
return null;
}
return tn[0];
}
export function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
@@ -352,3 +291,19 @@ export function withinInt(num: bigint, min: bigint, max: bigint): bigint {
const atLeastMin = maxInt(num, min);
return minInt(atLeastMin, max);
}
export function createRandomName(
name: string,
playerType: string,
): string | null {
let randomName = null;
if (playerType === "HUMAN") {
const hash = simpleHash(name);
const prefixIndex = hash % BOT_NAME_PREFIXES.length;
const suffixIndex =
Math.floor(hash / BOT_NAME_PREFIXES.length) % BOT_NAME_SUFFIXES.length;
randomName = `👤 ${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
}
return randomName;
}
+8 -1
View File
@@ -98,7 +98,7 @@ export interface Config {
maxPopulation(player: Player | PlayerView): number;
cityPopulationIncrease(): number;
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number;
warshipShellLifetime(): number;
shellLifetime(): number;
boatMaxNumber(): number;
allianceDuration(): Tick;
allianceRequestCooldown(): Tick;
@@ -111,18 +111,25 @@ export interface Config {
unitInfo(type: UnitType): UnitInfo;
tradeShipGold(dist: number): Gold;
tradeShipSpawnRate(numberOfPorts: number): number;
safeFromPiratesCooldownMax(): number;
defensePostRange(): number;
SAMCooldown(): number;
SiloCooldown(): number;
defensePostDefenseBonus(): number;
falloutDefenseModifier(percentOfFallout: number): number;
difficultyModifier(difficulty: Difficulty): number;
warshipPatrolRange(): number;
warshipShellAttackRate(): number;
warshipTargettingRange(): number;
defensePostShellAttackRate(): number;
defensePostTargettingRange(): number;
// 0-1
traitorDefenseDebuff(): number;
traitorDuration(): number;
nukeMagnitudes(unitType: UnitType): NukeMagnitude;
defaultNukeSpeed(): number;
nukeDeathFactor(humans: number, tilesOwned: number): number;
structureMinDist(): number;
}
export interface Theme {
+41 -9
View File
@@ -34,7 +34,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
return process.env.GIT_COMMIT;
}
r2Endpoint(): string {
return `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
return `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
}
r2AccessKey(): string {
return process.env.R2_ACCESS_KEY;
@@ -69,7 +69,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
GameMapType.Europe,
].includes(map)
) {
return Math.random() < 0.2 ? 150 : 70;
return Math.random() < 0.2 ? 100 : 50;
}
// Maps with ~2.5 - ~3.5 mil pixels
if (
@@ -80,7 +80,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
GameMapType.Asia,
].includes(map)
) {
return Math.random() < 0.2 ? 100 : 50;
return Math.random() < 0.3 ? 50 : 25;
}
// Maps with ~2 mil pixels
if (
@@ -92,7 +92,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
GameMapType.FaroeIslands,
].includes(map)
) {
return Math.random() < 0.2 ? 70 : 40;
return Math.random() < 0.3 ? 50 : 25;
}
// Maps smaller than ~2 mil pixels
if (
@@ -102,14 +102,14 @@ export abstract class DefaultServerConfig implements ServerConfig {
GameMapType.Pangaea,
].includes(map)
) {
return Math.random() < 0.2 ? 60 : 35;
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 : 60;
return Math.random() < 0.2 ? 150 : 50;
}
// default return for non specified map
return Math.random() < 0.2 ? 85 : 45;
return Math.random() < 0.2 ? 50 : 20;
}
workerIndex(gameID: GameID): number {
return simpleHash(gameID) % this.numWorkers();
@@ -144,7 +144,7 @@ export class DefaultConfig implements Config {
return 0.5;
}
traitorDuration(): number {
return 30 * 10; // 30 seconds
return 15 * 10; // 15 seconds
}
spawnImmunityDuration(): Tick {
return 5 * 10;
@@ -396,7 +396,7 @@ export class DefaultConfig implements Config {
return 80;
}
boatMaxNumber(): number {
return 3;
return 9;
}
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType == GameType.Singleplayer ? 100 : 300;
@@ -676,4 +676,36 @@ export class DefaultConfig implements Config {
nukeDeathFactor(humans: number, tilesOwned: number): number {
return (5 * humans) / Math.max(1, tilesOwned);
}
structureMinDist(): number {
return 18;
}
shellLifetime(): number {
return 50;
}
warshipPatrolRange(): number {
return 100;
}
warshipTargettingRange(): number {
return 130;
}
warshipShellAttackRate(): number {
return 20;
}
defensePostShellAttackRate(): number {
return 100;
}
safeFromPiratesCooldownMax(): number {
return 20;
}
defensePostTargettingRange(): number {
return 75;
}
}
@@ -8,6 +8,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ShellExecution } from "./ShellExecution";
export class DefensePostExecution implements Execution {
private player: Player;
@@ -15,6 +16,11 @@ export class DefensePostExecution implements Execution {
private post: Unit;
private active: boolean = true;
private target: Unit = null;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
constructor(
private ownerId: PlayerID,
private tile: TileRef,
@@ -30,6 +36,27 @@ export class DefensePostExecution implements Execution {
this.player = mg.player(this.ownerId);
}
private shoot() {
const shellAttackRate = this.mg.config().defensePostShellAttackRate();
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
this.lastShellAttack = this.mg.ticks();
this.mg.addExecution(
new ShellExecution(
this.post.tile(),
this.post.owner(),
this.post,
this.target,
),
);
if (!this.target.hasHealth()) {
// Don't send multiple shells to target that can be oneshotted
this.alreadySentShell.add(this.target);
this.target = null;
return;
}
}
}
tick(ticks: number): void {
if (this.post == null) {
const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile);
@@ -48,6 +75,52 @@ export class DefensePostExecution implements Execution {
if (this.player != this.post.owner()) {
this.player = this.post.owner();
}
if (this.target != null && !this.target.isActive()) {
this.target = null;
}
const ships = this.mg
.nearbyUnits(
this.post.tile(),
this.mg.config().defensePostTargettingRange(),
[UnitType.TransportShip, UnitType.Warship],
)
.filter(
({ unit }) =>
unit.owner() !== this.post.owner() &&
!unit.owner().isFriendly(this.post.owner()) &&
!this.alreadySentShell.has(unit),
);
this.target =
ships.sort((a, b) => {
const { unit: unitA, distSquared: distA } = a;
const { unit: unitB, distSquared: distB } = b;
// Prioritize TransportShip
if (
unitA.type() === UnitType.TransportShip &&
unitB.type() !== UnitType.TransportShip
)
return -1;
if (
unitA.type() !== UnitType.TransportShip &&
unitB.type() === UnitType.TransportShip
)
return 1;
// If both are the same type, sort by distance (lower `distSquared` means closer)
return distA - distB;
})[0]?.unit ?? null;
if (this.target == null || !this.target.isActive()) {
this.target = null;
return;
} else {
this.shoot();
return;
}
}
isActive(): boolean {
+2 -1
View File
@@ -68,8 +68,9 @@ export class Executor {
return new TransportShipExecution(
playerID,
intent.targetID,
this.mg.ref(intent.x, intent.y),
this.mg.ref(intent.dstX, intent.dstY),
intent.troops,
this.mg.ref(intent.srcX, intent.srcY),
);
case "allianceRequest":
return new AllianceRequestExecution(playerID, intent.recipient);
+2
View File
@@ -394,6 +394,7 @@ export class FakeHumanExecution implements Execution {
other.id(),
closest.y,
this.player.troops() / 5,
null,
),
);
}
@@ -541,6 +542,7 @@ export class FakeHumanExecution implements Execution {
this.mg.owner(dst).id(),
dst,
this.player.troops() / 5,
null,
),
);
return;
+3 -2
View File
@@ -33,14 +33,15 @@ export class MissileSiloExecution implements Execution {
tick(ticks: number): void {
if (this.silo == null) {
if (!this.player.canBuild(UnitType.MissileSilo, this.tile)) {
const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile);
if (spawn === false) {
consolex.warn(
`player ${this.player} cannot build missile silo at ${this.tile}`,
);
this.active = false;
return;
}
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, this.tile, {
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, spawn, {
cooldownDuration: this.mg.config().SiloCooldown(),
});
+6 -2
View File
@@ -15,7 +15,6 @@ import { SAMMissileExecution } from "./SAMMissileExecution";
export class SAMLauncherExecution implements Execution {
private player: Player;
private mg: Game;
private sam: Unit;
private active: boolean = true;
private target: Unit = null;
@@ -32,7 +31,12 @@ export class SAMLauncherExecution implements Execution {
constructor(
private ownerId: PlayerID,
private tile: TileRef,
) {}
private sam: Unit | null = null,
) {
if (sam != null) {
this.tile = sam.tile();
}
}
init(mg: Game, ticks: number): void {
this.mg = mg;
+7 -3
View File
@@ -42,8 +42,7 @@ export class ShellExecution implements Execution {
}
if (this.destroyAtTick == -1 && !this.ownerUnit.isActive()) {
this.destroyAtTick =
this.mg.ticks() + this.mg.config().warshipShellLifetime();
this.destroyAtTick = this.mg.ticks() + this.mg.config().shellLifetime();
}
for (let i = 0; i < 3; i++) {
@@ -55,7 +54,7 @@ export class ShellExecution implements Execution {
switch (result.type) {
case PathFindResultType.Completed:
this.active = false;
this.target.modifyHealth(-this.shell.info().damage);
this.target.modifyHealth(-this.effectOnTarget());
this.shell.delete(false);
return;
case PathFindResultType.NextTile:
@@ -72,6 +71,11 @@ export class ShellExecution implements Execution {
}
}
private effectOnTarget(): number {
const baseDamage: number = this.mg.config().unitInfo(UnitType.Shell).damage;
return baseDamage;
}
isActive(): boolean {
return this.active;
}
+7 -2
View File
@@ -47,6 +47,7 @@ export class TradeShipExecution implements Execution {
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn, {
dstPort: this._dstPort,
lastSetSafeFromPirates: ticks,
});
}
@@ -56,11 +57,11 @@ export class TradeShipExecution implements Execution {
}
if (this.origOwner != this.tradeShip.owner()) {
// Store as vairable in case ship is recaptured by previous owner
// Store as variable in case ship is recaptured by previous owner
this.wasCaptured = true;
}
// If a player captures an other player's port while trading we should delete
// If a player captures another player's port while trading we should delete
// the ship.
if (this._dstPort.owner().id() == this.srcPort.owner().id()) {
this.tradeShip.delete(false);
@@ -107,6 +108,10 @@ export class TradeShipExecution implements Execution {
this.tradeShip.move(this.tradeShip.tile());
break;
case PathFindResultType.NextTile:
// Update safeFromPirates status
if (this.mg.isWater(result.tile) && this.mg.isShoreline(result.tile)) {
this.tradeShip.setSafeFromPirates();
}
this.tradeShip.move(result.tile);
break;
case PathFindResultType.PathNotFound:
+18 -5
View File
@@ -10,9 +10,9 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { targetTransportTile } from "../game/TransportShipUtils";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { targetTransportTile } from "../Util";
import { AttackExecution } from "./AttackExecution";
export class TransportShipExecution implements Execution {
@@ -26,10 +26,10 @@ export class TransportShipExecution implements Execution {
private mg: Game;
private attacker: Player;
private target: Player | TerraNullius;
private embarkDelay = 10;
// TODO make private
public path: TileRef[];
private src: TileRef | null;
private dst: TileRef | null;
private boat: Unit;
@@ -41,6 +41,7 @@ export class TransportShipExecution implements Execution {
private targetID: PlayerID | null,
private ref: TileRef,
private troops: number | null,
private src: TileRef | null,
) {}
activeDuringSpawnPhase(): boolean {
@@ -112,14 +113,22 @@ export class TransportShipExecution implements Execution {
this.active = false;
return;
}
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
if (src == false) {
const closestTileSrc = this.attacker.canBuild(
UnitType.TransportShip,
this.dst,
);
if (closestTileSrc == false) {
consolex.warn(`can't build transport ship`);
this.active = false;
return;
}
this.src = src;
if (this.src == null) {
// Only update the src if it's not already set
// because we assume that the src is set to the best spawn tile
this.src = closestTileSrc;
}
this.boat = this.attacker.buildUnit(
UnitType.TransportShip,
@@ -136,6 +145,10 @@ export class TransportShipExecution implements Execution {
this.active = false;
return;
}
if (this.embarkDelay > 0) {
this.embarkDelay--;
return;
}
if (ticks - this.lastMove < this.ticksPerMove) {
return;
}
+25 -15
View File
@@ -26,12 +26,7 @@ export class WarshipExecution implements Execution {
private patrolTile: TileRef;
// TODO: put in config
private searchRange = 100;
private shellAttackRate = 5;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
constructor(
@@ -72,7 +67,8 @@ export class WarshipExecution implements Execution {
}
private shoot() {
if (this.mg.ticks() - this.lastShellAttack > this.shellAttackRate) {
const shellAttackRate = this.mg.config().warshipShellAttackRate();
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
this.lastShellAttack = this.mg.ticks();
this.mg.addExecution(
new ShellExecution(
@@ -137,7 +133,7 @@ export class WarshipExecution implements Execution {
const ships = this.mg
.nearbyUnits(
this.warship.tile(),
130, // Search range
this.mg.config().warshipTargettingRange(),
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
)
.filter(
@@ -146,9 +142,11 @@ export class WarshipExecution implements Execution {
unit !== this.warship &&
!unit.owner().isFriendly(this.warship.owner()) &&
!this.alreadySentShell.has(unit) &&
(unit.type() !== UnitType.TradeShip || hasPort) &&
(unit.type() !== UnitType.TradeShip ||
unit.dstPort()?.owner() !== this._owner),
(hasPort &&
unit.dstPort()?.owner() !== this.warship.owner() &&
!unit.dstPort()?.owner().isFriendly(this.warship.owner()) &&
unit.isSafeFromPirates() !== true)),
);
this.target =
@@ -198,9 +196,10 @@ export class WarshipExecution implements Execution {
if (
this.target == null ||
!this.target.isActive() ||
this.target.owner() == this._owner
this.target.owner() == this._owner ||
this.target.isSafeFromPirates() == true
) {
// In case another destroyer captured or destroyed target
// In case another warship captured or destroyed target, or the target escaped into safe waters
this.target = null;
return;
}
@@ -250,18 +249,29 @@ export class WarshipExecution implements Execution {
}
randomTile(): TileRef {
while (true) {
let warshipPatrolRange = this.mg.config().warshipPatrolRange();
const maxAttemptBeforeExpand: number = warshipPatrolRange * 2;
let attemptCount: number = 0;
let expandCount: number = 0;
while (expandCount < 3) {
const x =
this.mg.x(this.patrolCenterTile) +
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
const y =
this.mg.y(this.patrolCenterTile) +
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
if (!this.mg.isValidCoord(x, y)) {
continue;
}
const tile = this.mg.ref(x, y);
if (!this.mg.isOcean(tile)) {
if (!this.mg.isOcean(tile) || this.mg.isShoreline(tile)) {
attemptCount++;
if (attemptCount === maxAttemptBeforeExpand) {
expandCount++;
attemptCount = 0;
warshipPatrolRange =
warshipPatrolRange + Math.floor(warshipPatrolRange / 2);
}
continue;
}
return tile;
+31 -3
View File
@@ -70,6 +70,30 @@ export enum GameMapType {
FaroeIslands = "FaroeIslands",
}
export const mapCategories: Record<string, GameMapType[]> = {
continental: [
GameMapType.World,
GameMapType.NorthAmerica,
GameMapType.SouthAmerica,
GameMapType.Europe,
GameMapType.Asia,
GameMapType.Africa,
GameMapType.Oceania,
],
regional: [
GameMapType.BlackSea,
GameMapType.Britannia,
GameMapType.GatewayToTheAtlantic,
GameMapType.BetweenTwoSeas,
GameMapType.Iceland,
GameMapType.Japan,
GameMapType.Mena,
GameMapType.Australia,
GameMapType.FaroeIslands,
],
fantasy: [GameMapType.Pangaea, GameMapType.Mars, GameMapType.KnownWorld],
};
export enum GameType {
Singleplayer = "Singleplayer",
Public = "Public",
@@ -240,6 +264,7 @@ export class PlayerInfo {
// Some units have info specific to them
export interface UnitSpecificInfos {
dstPort?: Unit; // Only for trade ships
lastSetSafeFromPirates?: number; // Only for trade ships
detonationDst?: TileRef; // Only for nukes
warshipTarget?: Unit;
cooldownDuration?: number;
@@ -273,6 +298,8 @@ export interface Unit {
isCooldown(): boolean;
setDstPort(dstPort: Unit): void;
dstPort(): Unit; // Only for trade ships
setSafeFromPirates(): void; // Only for trade ships
isSafeFromPirates(): boolean; // Only for trade ships
detonationDst(): TileRef; // Only for nukes
setMoveTarget(cell: TileRef): void;
@@ -347,6 +374,7 @@ export interface Player {
// Units
units(...types: UnitType[]): Unit[];
unitsIncludingConstruction(type: UnitType): Unit[];
buildableUnits(tile: TileRef): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit(
type: UnitType,
@@ -416,8 +444,9 @@ export interface Player {
// Misc
toUpdate(): PlayerUpdate;
playerProfile(): PlayerProfile;
canBoat(tile: TileRef): TileRef | false;
tradingPorts(port: Unit): Unit[];
// WARNING: this operation is expensive.
bestTransportShipSpawn(tile: TileRef): TileRef | false;
}
export interface Game extends GameMap {
@@ -474,7 +503,6 @@ export interface Game extends GameMap {
}
export interface PlayerActions {
canBoat: TileRef | false;
canAttack: boolean;
buildableUnits: BuildableUnit[];
canSendEmojiAllPlayers: boolean;
@@ -482,7 +510,7 @@ export interface PlayerActions {
}
export interface BuildableUnit {
canBuild: boolean;
canBuild: TileRef | false;
type: UnitType;
cost: number;
}
+24 -3
View File
@@ -1,5 +1,6 @@
import { Config } from "../configuration/Config";
import { ClientID, GameID, PlayerStats } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
Cell,
@@ -123,11 +124,22 @@ export class UnitView {
}
export class PlayerView {
public anonymousName: string;
constructor(
private game: GameView,
public data: PlayerUpdate,
public nameData: NameViewData,
) {}
) {
if (data.clientID == game.myClientID()) {
this.anonymousName = this.data.name;
} else {
this.anonymousName = createRandomName(
this.data.name,
this.data.playerType,
);
}
}
async actions(tile: TileRef): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
@@ -166,11 +178,16 @@ export class PlayerView {
return this.data.flag;
}
name(): string {
return this.data.name;
return userSettings.anonymousNames() && this.anonymousName !== null
? this.anonymousName
: this.data.name;
}
displayName(): string {
return this.data.displayName;
return userSettings.anonymousNames() && this.anonymousName !== null
? this.anonymousName
: this.data.name;
}
clientID(): ClientID {
return this.data.clientID;
}
@@ -242,6 +259,10 @@ export class PlayerView {
return this.game.worker.playerProfile(this.smallID());
}
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
return this.game.worker.transportShipSpawn(this.id(), targetTile);
}
transitiveTargets(): PlayerView[] {
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
}
+79 -92
View File
@@ -4,12 +4,10 @@ import { PseudoRandom } from "../PseudoRandom";
import { ClientID } from "../Schemas";
import {
assertNever,
closestShoreFromPlayer,
distSortUnit,
maxInt,
minInt,
simpleHash,
targetTransportTile,
toInt,
within,
} from "../Util";
@@ -20,6 +18,7 @@ import {
AllianceRequest,
AllPlayers,
Attack,
BuildableUnit,
Cell,
EmojiMessage,
GameMode,
@@ -43,6 +42,10 @@ import { GameImpl } from "./GameImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import {
bestShoreDeploymentSource,
canBuildTransportShip,
} from "./TransportShipUtils";
import { UnitImpl } from "./UnitImpl";
interface Target {
@@ -729,7 +732,22 @@ export class PlayerImpl implements Player {
return b;
}
canBuild(unitType: UnitType, targetTile: TileRef): TileRef | false {
public buildableUnits(tile: TileRef): BuildableUnit[] {
const validTiles = this.validStructureSpawnTiles(tile);
return Object.values(UnitType).map((u) => {
return {
type: u,
canBuild: this.canBuild(u, tile, validTiles),
cost: this.mg.config().unitInfo(u).cost(this),
} as BuildableUnit;
});
}
canBuild(
unitType: UnitType,
targetTile: TileRef,
validTiles: TileRef[] | null = null,
): TileRef | false {
// prevent the building of nukes and nuke related buildings
if (this.mg.config().disableNukes()) {
if (
@@ -761,14 +779,14 @@ export class PlayerImpl implements Player {
case UnitType.MIRVWarhead:
return targetTile;
case UnitType.Port:
return this.portSpawn(targetTile);
return this.portSpawn(targetTile, validTiles);
case UnitType.Warship:
return this.warshipSpawn(targetTile);
case UnitType.Shell:
case UnitType.SAMMissile:
return targetTile;
case UnitType.TransportShip:
return this.transportShipSpawn(targetTile);
return canBuildTransportShip(this.mg, this, targetTile);
case UnitType.TradeShip:
return this.tradeShipSpawn(targetTile);
case UnitType.MissileSilo:
@@ -776,7 +794,7 @@ export class PlayerImpl implements Player {
case UnitType.SAMLauncher:
case UnitType.City:
case UnitType.Construction:
return this.landBasedStructureSpawn(targetTile);
return this.landBasedStructureSpawn(targetTile, validTiles);
default:
assertNever(unitType);
}
@@ -801,7 +819,7 @@ export class PlayerImpl implements Player {
return spawns[0].tile();
}
portSpawn(tile: TileRef): TileRef | false {
portSpawn(tile: TileRef, validTiles: TileRef[]): TileRef | false {
const spawns = Array.from(
this.mg.bfs(
tile,
@@ -813,10 +831,15 @@ export class PlayerImpl implements Player {
(a, b) =>
this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile),
);
if (spawns.length == 0) {
return false;
const validTileSet = new Set(
validTiles ?? this.validStructureSpawnTiles(tile),
);
for (const t of spawns) {
if (validTileSet.has(t)) {
return t;
}
}
return spawns[0];
return false;
}
warshipSpawn(tile: TileRef): TileRef | false {
@@ -834,22 +857,54 @@ export class PlayerImpl implements Player {
return spawns[0].tile();
}
landBasedStructureSpawn(tile: TileRef): TileRef | false {
if (this.mg.owner(tile) != this) {
landBasedStructureSpawn(
tile: TileRef,
validTiles: TileRef[] | null = null,
): TileRef | false {
const tiles = validTiles ?? this.validStructureSpawnTiles(tile);
if (tiles.length == 0) {
return false;
}
return tile;
return tiles[0];
}
transportShipSpawn(targetTile: TileRef): TileRef | false {
if (!this.mg.isShore(targetTile)) {
return false;
private validStructureSpawnTiles(tile: TileRef): TileRef[] {
if (this.mg.owner(tile) != this) {
return [];
}
const spawn = closestShoreFromPlayer(this.mg, this, targetTile);
if (spawn == null) {
return false;
const searchRadius = 15;
const searchRadiusSquared = searchRadius ** 2;
const types = Object.values(UnitType).filter((unitTypeValue) => {
return this.mg.config().unitInfo(unitTypeValue).territoryBound;
});
const nearbyUnits = this.mg
.nearbyUnits(tile, searchRadius * 2, types)
.map((u) => u.unit);
const nearbyTiles = this.mg.bfs(tile, (gm, t) => {
return (
this.mg.euclideanDistSquared(tile, t) < searchRadiusSquared &&
gm.ownerID(t) == this.smallID()
);
});
const validSet: Set<TileRef> = new Set(nearbyTiles);
const minDistSquared = this.mg.config().structureMinDist() ** 2;
for (const t of nearbyTiles) {
for (const unit of nearbyUnits) {
if (this.mg.euclideanDistSquared(unit.tile(), t) < minDistSquared) {
validSet.delete(t);
break;
}
}
}
return spawn;
const valid = Array.from(validSet);
valid.sort(
(a, b) =>
this.mg.euclideanDistSquared(a, tile) -
this.mg.euclideanDistSquared(b, tile),
);
return valid;
}
tradeShipSpawn(targetTile: TileRef): TileRef | false {
@@ -892,78 +947,6 @@ export class PlayerImpl implements Player {
return rel;
}
public canBoat(tile: TileRef): TileRef | false {
if (
this.units(UnitType.TransportShip).length >=
this.mg.config().boatMaxNumber()
) {
return false;
}
const dst = targetTransportTile(this.mg, tile);
if (dst == null) {
return false;
}
const other = this.mg.owner(tile);
if (other == this) {
return false;
}
if (other.isPlayer() && this.isFriendly(other)) {
return false;
}
if (this.mg.isOceanShore(dst)) {
let myPlayerBordersOcean = false;
for (const bt of this.borderTiles()) {
if (this.mg.isOceanShore(bt)) {
myPlayerBordersOcean = true;
break;
}
}
let otherPlayerBordersOcean = false;
if (!this.mg.hasOwner(tile)) {
otherPlayerBordersOcean = true;
} else {
for (const bt of (other as Player).borderTiles()) {
if (this.mg.isOceanShore(bt)) {
otherPlayerBordersOcean = true;
break;
}
}
}
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
return this.canBuild(UnitType.TransportShip, dst);
} else {
return false;
}
}
// Now we are boating in a lake, so do a bfs from target until we find
// a border tile owned by the player
const tiles = this.mg.bfs(
dst,
andFN(
manhattanDistFN(dst, 300),
(_, t: TileRef) => this.mg.isLake(t) || this.mg.isShore(t),
),
);
const sorted = Array.from(tiles).sort(
(a, b) => this.mg.manhattanDist(dst, a) - this.mg.manhattanDist(dst, b),
);
for (const t of sorted) {
if (this.mg.owner(t) == this) {
return this.canBuild(UnitType.TransportShip, dst);
}
}
return false;
}
createAttack(
target: Player | TerraNullius,
troops: number,
@@ -1032,6 +1015,10 @@ export class PlayerImpl implements Player {
}
}
bestTransportShipSpawn(targetTile: TileRef): TileRef | false {
return bestShoreDeploymentSource(this.mg, this, targetTile);
}
// It's a probability list, so if an element appears twice it's because it's
// twice more likely to be picked later.
tradingPorts(port: Unit): Unit[] {
+276
View File
@@ -0,0 +1,276 @@
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { Game, Player, UnitType } from "./Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
export function canBuildTransportShip(
game: Game,
player: Player,
tile: TileRef,
): TileRef | false {
if (
player.units(UnitType.TransportShip).length >= game.config().boatMaxNumber()
) {
return false;
}
const dst = targetTransportTile(game, tile);
if (dst == null) {
return false;
}
const other = game.owner(tile);
if (other == player) {
return false;
}
if (other.isPlayer() && player.isFriendly(other)) {
return false;
}
if (game.isOceanShore(dst)) {
let myPlayerBordersOcean = false;
for (const bt of player.borderTiles()) {
if (game.isOceanShore(bt)) {
myPlayerBordersOcean = true;
break;
}
}
let otherPlayerBordersOcean = false;
if (!game.hasOwner(tile)) {
otherPlayerBordersOcean = true;
} else {
for (const bt of (other as Player).borderTiles()) {
if (game.isOceanShore(bt)) {
otherPlayerBordersOcean = true;
break;
}
}
}
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
return transportShipSpawn(game, player, dst);
} else {
return false;
}
}
// Now we are boating in a lake, so do a bfs from target until we find
// a border tile owned by the player
const tiles = game.bfs(
dst,
andFN(
manhattanDistFN(dst, 300),
(_, t: TileRef) => game.isLake(t) || game.isShore(t),
),
);
const sorted = Array.from(tiles).sort(
(a, b) => game.manhattanDist(dst, a) - game.manhattanDist(dst, b),
);
for (const t of sorted) {
if (game.owner(t) == player) {
return transportShipSpawn(game, player, t);
}
}
return false;
}
function transportShipSpawn(
game: Game,
player: Player,
targetTile: TileRef,
): TileRef | false {
if (!game.isShore(targetTile)) {
return false;
}
const spawn = closestShoreFromPlayer(game, player, targetTile);
if (spawn == null) {
return false;
}
return spawn;
}
export function sourceDstOceanShore(
gm: Game,
src: Player,
tile: TileRef,
): [TileRef | null, TileRef | null] {
const dst = gm.owner(tile);
const srcTile = closestShoreFromPlayer(gm, src, tile);
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return [srcTile, dstTile];
}
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
const dst = gm.playerBySmallID(gm.ownerID(tile));
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return dstTile;
}
export function closestShoreFromPlayer(
gm: GameMap,
player: Player,
target: TileRef,
): TileRef | null {
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isShore(t),
);
if (shoreTiles.length == 0) {
return null;
}
return shoreTiles.reduce((closest, current) => {
const closestDistance = gm.manhattanDist(target, closest);
const currentDistance = gm.manhattanDist(target, current);
return currentDistance < closestDistance ? current : closest;
});
}
/**
* Finds the best shore tile for deployment among the player's shore tiles for the shortest route.
* Calculates paths from 4 extremum tiles and the Manhattan-closest tile.
*/
export function bestShoreDeploymentSource(
gm: Game,
player: Player,
target: TileRef,
): TileRef | null {
target = targetTransportTile(gm, target);
if (target == null) {
return null;
}
let closestManhattanDistance = Infinity;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
let bestByManhattan: TileRef = null;
const extremumTiles: Record<string, TileRef> = {
minX: null,
minY: null,
maxX: null,
maxY: null,
};
for (const tile of player.borderTiles()) {
if (!gm.isShore(tile)) continue;
const distance = gm.manhattanDist(tile, target);
const cell = gm.cell(tile);
// Manhattan-closest tile
if (distance < closestManhattanDistance) {
closestManhattanDistance = distance;
bestByManhattan = tile;
}
// Extremum tiles
if (cell.x < minX) {
minX = cell.x;
extremumTiles.minX = tile;
} else if (cell.y < minY) {
minY = cell.y;
extremumTiles.minY = tile;
} else if (cell.x > maxX) {
maxX = cell.x;
extremumTiles.maxX = tile;
} else if (cell.y > maxY) {
maxY = cell.y;
extremumTiles.maxY = tile;
}
}
const candidates = [
bestByManhattan,
extremumTiles.minX,
extremumTiles.minY,
extremumTiles.maxX,
extremumTiles.maxY,
].filter(Boolean);
if (!candidates.length) {
return null;
}
// Find the shortest actual path distance
let closestShoreTile: TileRef | null = null;
let closestDistance = Infinity;
for (const shoreTile of candidates) {
const pathDistance = calculatePathDistance(gm, shoreTile, target);
if (pathDistance !== null && pathDistance < closestDistance) {
closestDistance = pathDistance;
closestShoreTile = shoreTile;
}
}
// Fall back to the Manhattan-closest tile if no path was found
return closestShoreTile || bestByManhattan;
}
/**
* Calculates the distance between two tiles using A*
* Returns null if no path is found
*/
function calculatePathDistance(
gm: Game,
start: TileRef,
target: TileRef,
): number | null {
let currentTile = start;
let tileDistance = 0;
const pathFinder = PathFinder.Mini(gm, 20_000, false);
while (true) {
const result = pathFinder.nextTile(currentTile, target);
if (result.type === PathFindResultType.Completed) {
return tileDistance;
} else if (result.type === PathFindResultType.NextTile) {
currentTile = result.tile;
tileDistance++;
} else if (
result.type === PathFindResultType.PathNotFound ||
result.type === PathFindResultType.Pending
) {
return null;
} else {
// @ts-expect-error type is never
throw new Error(`Unexpected pathfinding result type: ${result.type}`);
}
}
}
function closestShoreTN(
gm: GameMap,
tile: TileRef,
searchDist: number,
): TileRef {
const tn = Array.from(
gm.bfs(
tile,
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
),
)
.filter((t) => gm.isShore(t))
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
if (tn.length == 0) {
return null;
}
return tn[0];
}
+17 -2
View File
@@ -17,11 +17,11 @@ export class UnitImpl implements Unit {
private _active = true;
private _health: bigint;
private _lastTile: TileRef = null;
// Currently only warship use it
private _target: Unit = null;
private _moveTarget: TileRef = null;
private _targetedBySAM = false;
private _safeFromPiratesCooldown: number; // Only for trade ships
private _lastSetSafeFromPirates: number; // Only for trade ships
private _constructionType: UnitType = undefined;
private _cooldownTick: Tick | null = null;
@@ -45,6 +45,10 @@ export class UnitImpl implements Unit {
this._detonationDst = unitsSpecificInfos.detonationDst;
this._warshipTarget = unitsSpecificInfos.warshipTarget;
this._cooldownDuration = unitsSpecificInfos.cooldownDuration;
this._lastSetSafeFromPirates = unitsSpecificInfos.lastSetSafeFromPirates;
this._safeFromPiratesCooldown = this.mg
.config()
.safeFromPiratesCooldownMax();
}
id() {
@@ -233,4 +237,15 @@ export class UnitImpl implements Unit {
targetedBySAM(): boolean {
return this._targetedBySAM;
}
setSafeFromPirates(): void {
this._lastSetSafeFromPirates = this.mg.ticks();
}
isSafeFromPirates(): boolean {
return (
this.mg.ticks() - this._lastSetSafeFromPirates <
this._safeFromPiratesCooldown
);
}
}
+7
View File
@@ -15,6 +15,9 @@ export class UserSettings {
emojis() {
return this.get("settings.emojis", true);
}
anonymousNames() {
return this.get("settings.anonymousNames", false);
}
darkMode() {
return this.get("settings.darkMode", false);
@@ -42,6 +45,10 @@ export class UserSettings {
this.set("settings.emojis", !this.emojis());
}
toggleRandomName() {
this.set("settings.anonymousNames", !this.anonymousNames());
}
toggleDarkMode() {
this.set("settings.darkMode", !this.darkMode());
if (this.darkMode()) {
+20
View File
@@ -6,6 +6,7 @@ import {
PlayerActionsResultMessage,
PlayerBorderTilesResultMessage,
PlayerProfileResultMessage,
TransportShipSpawnResultMessage,
WorkerMessage,
} from "./WorkerMessages";
@@ -120,6 +121,25 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "transport_ship_spawn":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const spawnTile = (await gameRunner).bestTransportShipSpawn(
message.playerID,
message.targetTile,
);
sendMessage({
type: "transport_ship_spawn_result",
id: message.id,
result: spawnTile,
} as TransportShipSpawnResultMessage);
} catch (error) {
console.error("Failed to spawn transport ship:", error);
}
break;
default:
console.warn("Unknown message :", message);
}
+31
View File
@@ -4,6 +4,7 @@ import {
PlayerID,
PlayerProfile,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
@@ -188,6 +189,36 @@ export class WorkerClient {
});
}
transportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
): Promise<TileRef | false> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
return;
}
const messageId = generateID();
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "transport_ship_spawn_result" &&
message.result !== undefined
) {
resolve(message.result);
}
});
this.worker.postMessage({
type: "transport_ship_spawn",
id: messageId,
playerID: playerID,
targetTile: targetTile,
});
});
}
cleanup() {
this.worker.terminate();
this.messageHandlers.clear();
+19 -3
View File
@@ -4,6 +4,7 @@ import {
PlayerID,
PlayerProfile,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
@@ -18,7 +19,9 @@ export type WorkerMessageType =
| "player_profile"
| "player_profile_result"
| "player_border_tiles"
| "player_border_tiles_result";
| "player_border_tiles_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
// Base interface for all messages
interface BaseWorkerMessage {
@@ -84,6 +87,17 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
result: PlayerBorderTiles;
}
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
type: "transport_ship_spawn";
playerID: PlayerID;
targetTile: TileRef;
}
export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
type: "transport_ship_spawn_result";
result: TileRef | false;
}
// Union types for type safety
export type MainThreadMessage =
| HeartbeatMessage
@@ -91,7 +105,8 @@ export type MainThreadMessage =
| TurnMessage
| PlayerActionsMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage;
| PlayerBorderTilesMessage
| TransportShipSpawnMessage;
// Message send from worker
export type WorkerMessage =
@@ -99,4 +114,5 @@ export type WorkerMessage =
| GameUpdateMessage
| PlayerActionsResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage;
| PlayerBorderTilesResultMessage
| TransportShipSpawnResultMessage;
+6 -6
View File
@@ -81,25 +81,25 @@ export class MapPlaylist {
// Big Maps are those larger than ~2.5 mil pixels
case PlaylistType.BigMaps:
return {
Europe: 3,
NorthAmerica: 2,
Europe: 2,
NorthAmerica: 1,
Africa: 2,
Britannia: 1,
GatewayToTheAtlantic: 2,
Australia: 2,
Iceland: 2,
SouthAmerica: 3,
SouthAmerica: 1,
KnownWorld: 2,
};
case PlaylistType.SmallMaps:
return {
World: 1,
World: 4,
Mena: 2,
Pangaea: 1,
Asia: 1,
Mars: 1,
BetweenTwoSeas: 3,
Japan: 3,
BetweenTwoSeas: 2,
Japan: 2,
BlackSea: 1,
FaroeIslands: 2,
};
+3 -10
View File
@@ -1,10 +1,6 @@
import promClient from "prom-client";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameManager } from "./GameManager";
const config = getServerConfigFromServer();
const region = config.region();
// Initialize the Prometheus registry
const register = new promClient.Registry();
@@ -15,21 +11,18 @@ promClient.collectDefaultMetrics({ register });
const activeGamesGauge = new promClient.Gauge({
name: "openfront_active_games_count",
help: "Number of active games on this worker",
labelNames: ["region"],
registers: [register],
});
const connectedClientsGauge = new promClient.Gauge({
name: "openfront_connected_clients_count",
help: "Number of connected clients on this worker",
labelNames: ["region"],
registers: [register],
});
const memoryUsageGauge = new promClient.Gauge({
name: "openfront_memory_usage_bytes",
help: "Current memory usage of the worker process in bytes",
labelNames: ["region"],
registers: [register],
});
@@ -42,11 +35,11 @@ export const metrics = {
// Function to update game-related metrics
updateGameMetrics: (gameManager: GameManager) => {
activeGamesGauge.set({ region: region }, gameManager.activeGames());
connectedClientsGauge.set({ region: region }, gameManager.activeClients());
activeGamesGauge.set(gameManager.activeGames());
connectedClientsGauge.set(gameManager.activeClients());
// Update memory usage metrics
const memoryUsage = process.memoryUsage();
memoryUsageGauge.set({ region: region }, memoryUsage.heapUsed);
memoryUsageGauge.set(memoryUsage.heapUsed);
},
};
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
set -e
# Check if required environment variables are set
if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$SUBDOMAIN" ] || [ -z "$DOMAIN" ]; then
echo "Error: Required environment variables not set"
echo "Please set CF_API_TOKEN, CF_ACCOUNT_ID, SUBDOMAIN, and DOMAIN"
exit 1
fi
# Generate a unique tunnel name using timestamp
TIMESTAMP=$(date +%Y%m%d%H%M%S)
TUNNEL_NAME="${SUBDOMAIN}-tunnel-${TIMESTAMP}"
echo "Using unique tunnel name: ${TUNNEL_NAME}"
# Create a new tunnel
echo "Creating Cloudflare tunnel for subdomain ${SUBDOMAIN}..."
TUNNEL_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "{\"name\":\"${TUNNEL_NAME}\"}")
# Extract tunnel ID and token
TUNNEL_ID=$(echo $TUNNEL_RESPONSE | jq -r '.result.id')
TUNNEL_TOKEN=$(echo $TUNNEL_RESPONSE | jq -r '.result.token')
if [ -z "$TUNNEL_ID" ] || [ "$TUNNEL_ID" == "null" ]; then
echo "Failed to create tunnel"
echo $TUNNEL_RESPONSE
exit 1
fi
echo "Tunnel created with ID: ${TUNNEL_ID}"
# Configure the tunnel with hostname
echo "Configuring tunnel to point to ${SUBDOMAIN}.${DOMAIN}..."
curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "{\"config\":{\"ingress\":[{\"hostname\":\"${SUBDOMAIN}.${DOMAIN}\",\"service\":\"http://localhost:80\"},{\"service\":\"http_status:404\"}]}}"
# Update DNS record to point to the new tunnel
echo "Updating DNS record to point to the new tunnel..."
# First check if DNS record exists
DNS_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${DOMAIN}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json")
ZONE_ID=$(echo $DNS_RECORDS | jq -r '.result[0].id')
if [ -z "$ZONE_ID" ] || [ "$ZONE_ID" == "null" ]; then
echo "Could not find zone ID for domain ${DOMAIN}"
exit 1
fi
# Check for existing record
EXISTING_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${SUBDOMAIN}.${DOMAIN}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json")
RECORD_ID=$(echo $EXISTING_RECORDS | jq -r '.result[0].id')
# Create or update the DNS record
if [ -z "$RECORD_ID" ] || [ "$RECORD_ID" == "null" ]; then
# Create new record
echo "Creating new DNS record..."
DNS_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
else
# Update existing record
echo "Updating existing DNS record..."
DNS_RESPONSE=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--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}
# Start supervisord
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
+23 -1
View File
@@ -22,4 +22,26 @@ user=node
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stderr_logfile_maxbytes=0
[program:cloudflared]
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
+3 -3
View File
@@ -19,9 +19,9 @@ let defender: Player;
let defenderSpawn: TileRef;
let attackerSpawn: TileRef;
function sendBoat(target: TileRef, troops: number) {
function sendBoat(target: TileRef, source: TileRef, troops: number) {
game.addExecution(
new TransportShipExecution(defender.id(), null, target, troops),
new TransportShipExecution(defender.id(), null, target, troops, source),
);
}
@@ -97,7 +97,7 @@ describe("Attack", () => {
constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo);
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
sendBoat(game.ref(15, 8), 100);
sendBoat(game.ref(15, 8), game.ref(10, 5), 100);
constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3);
const nuke = defender.units(UnitType.AtomBomb)[0];
+31 -56
View File
@@ -1,4 +1,4 @@
import { NukeExecution } from "../src/core/execution/NukeExecution";
import { SAMLauncherExecution } from "../src/core/execution/SAMLauncherExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import {
Game,
@@ -7,32 +7,13 @@ import {
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { setup } from "./util/Setup";
import { constructionExecution } from "./util/utils";
import { constructionExecution, executeTicks } from "./util/utils";
let game: Game;
let attacker: Player;
let defender: Player;
function attackerBuildsNuke(
source: TileRef,
target: TileRef,
initialize = true,
) {
game.addExecution(
new NukeExecution(UnitType.AtomBomb, attacker.id(), target, source),
);
if (initialize) {
game.executeNextTick();
game.executeNextTick();
}
}
function defenderBuildsSam(x: number, y: number) {
constructionExecution(game, defender.id(), x, y, UnitType.SAMLauncher);
}
describe("SAM", () => {
beforeEach(async () => {
game = await setup("Plains", { infiniteGold: true, instantBuild: true });
@@ -69,62 +50,56 @@ describe("SAM", () => {
});
test("one sam should take down one nuke", async () => {
defenderBuildsSam(1, 1);
attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1));
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
const sam = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(1, 1));
executeTicks(game, 3);
game.executeNextTick();
game.executeNextTick();
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0);
});
test("sam should only get one nuke at a time", async () => {
defenderBuildsSam(1, 1);
attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1), false);
attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1));
const sam = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(2, 1));
attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(1, 2));
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(2);
game.executeNextTick();
game.executeNextTick();
executeTicks(game, 3);
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
});
test("sam should cooldown as long as configured", async () => {
defenderBuildsSam(1, 1);
expect(defender.units(UnitType.SAMLauncher)[0].isCooldown()).toBeFalsy();
attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1));
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
const sam = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
expect(sam.isCooldown()).toBeFalsy();
const nuke = attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(1, 2));
game.executeNextTick();
game.executeNextTick();
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0);
executeTicks(game, 3);
expect(nuke.isActive()).toBeFalsy();
for (let i = 0; i < game.config().SAMCooldown() - 2; i++) {
game.executeNextTick();
expect(defender.units(UnitType.SAMLauncher)[0].isCooldown()).toBeTruthy();
expect(sam.isCooldown()).toBeTruthy();
}
game.executeNextTick();
expect(defender.units(UnitType.SAMLauncher)[0].isCooldown()).toBeFalsy();
executeTicks(game, 2);
expect(sam.isCooldown()).toBeFalsy();
});
test("two sams should not target twice same nuke", async () => {
defenderBuildsSam(1, 1);
defenderBuildsSam(1, 2);
attackerBuildsNuke(game.ref(7, 7), game.ref(1, 1));
const sam1 = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam1));
const sam2 = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 2));
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam2));
const nuke = attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(2, 2));
expect(defender.units(UnitType.SAMLauncher)).toHaveLength(2);
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
executeTicks(game, 3);
game.executeNextTick();
game.executeNextTick();
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0);
const sams = defender.units(UnitType.SAMLauncher);
// Only one sam must have shot
expect(
(sams[0].isCooldown() && !sams[1].isCooldown()) ||
(sams[1].isCooldown() && !sams[0].isCooldown()),
).toBe(true);
expect(nuke.isActive()).toBeFalsy();
expect([sam1, sam2].filter((s) => s.isCooldown())).toHaveLength(1);
});
});
+6
View File
@@ -28,3 +28,9 @@ export function constructionExecution(
game.executeNextTick();
}
}
export function executeTicks(game: Game, numTicks: number): void {
for (let i = 0; i < numTicks; i++) {
game.executeNextTick();
}
}
+16 -38
View File
@@ -2,35 +2,20 @@
# update.sh - Script to update Docker container on Hetzner server
# Called by deploy.sh after uploading Docker image to Docker Hub
# Check if environment parameter is provided
if [ $# -lt 3 ]; then
echo "Error: Required parameters missing"
echo "Usage: $0 <REGION> <docker_username> <docker_repo>"
exit 1
fi
# Set parameters
REGION=$1
DOCKER_USERNAME=$2
DOCKER_REPO=$3
# Container and image configuration
CONTAINER_NAME="openfront-${REGION}"
IMAGE_NAME="${DOCKER_USERNAME}/${DOCKER_REPO}"
FULL_IMAGE_NAME="${IMAGE_NAME}:latest"
echo "======================================================"
echo "🔄 UPDATING SERVER: ${REGION} ENVIRONMENT"
echo "======================================================"
echo "Container name: ${CONTAINER_NAME}"
echo "Docker image: ${FULL_IMAGE_NAME}"
# Load environment variables if .env exists
if [ -f /home/openfront/.env ]; then
echo "Loading environment variables from .env file..."
export $(grep -v '^#' /home/openfront/.env | xargs)
fi
echo "======================================================"
echo "🔄 UPDATING SERVER: ${HOST} ENVIRONMENT"
echo "======================================================"
# Container and image configuration
CONTAINER_NAME="openfront-${ENV}-${SUBDOMAIN}"
docker login -u $DOCKER_USERNAME -p $DOCKER_TOKEN
# Install Loki Docker plugin if not already installed
@@ -46,8 +31,8 @@ else
echo "Loki Docker plugin already installed."
fi
echo "Pulling latest image from Docker Hub..."
docker pull $FULL_IMAGE_NAME
echo "Pulling ${DOCKER_IMAGE} from Docker Hub..."
docker pull $DOCKER_IMAGE
echo "Checking for existing container..."
# Check for running container
@@ -86,27 +71,20 @@ if [ -n "$PORT_CHECK" ]; then
echo "Attempting to proceed anyway..."
fi
ENV="prod"
if [ "$REGION" == "staging" ]; then
ENV="staging"
fi
echo "Starting new container for ${REGION} environment..."
docker run -d -p 80:80 -p 127.0.0.1:9090:9090 \
echo "Starting new container for ${HOST} environment..."
docker run -d \
--restart=always \
$VOLUME_MOUNTS \
--log-driver=loki \
--log-opt loki-url="http://localhost:3100/loki/api/v1/push" \
--log-opt loki-url="https://${MON_USERNAME}:${MON_PASSWORD}@mon.openfront.io/loki/loki/api/v1/push" \
--log-opt loki-batch-size="400" \
--log-opt loki-external-labels="job=docker,environment=${ENV},host=${REGION},region=${REGION}" \
--env GAME_ENV=${ENV} \
--env REGION=${REGION} \
--log-opt loki-external-labels="job=docker,environment=${ENV},host=${HOST},subdomain=${SUBDOMAIN}" \
--env-file /home/openfront/.env \
--name ${CONTAINER_NAME} \
$FULL_IMAGE_NAME
$DOCKER_IMAGE
if [ $? -eq 0 ]; then
echo "Update complete! New ${REGION} container is running."
echo "Update complete! New ${CONTAINER_NAME} container is running."
# Final cleanup after successful deployment
echo "Performing final cleanup of unused Docker resources..."