Merge branch 'main' into randmap

This commit is contained in:
Todd Groff
2025-03-02 16:29:57 -05:00
committed by GitHub
83 changed files with 2788 additions and 24367 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
@@ -9,7 +7,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
submodules: false
- name: Setup node
uses: actions/setup-node@v4
with:
+3
View File
@@ -0,0 +1,3 @@
[submodule "src/server/gatekeeper"]
path = src/server/gatekeeper
url = https://github.com/openfrontio/gatekeeper.git
+6 -1
View File
@@ -1,9 +1,14 @@
*.bin
*.svg
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.webp
*.txt
.prettierignore
.gitignore
.gitignore
Dockerfile
*.conf
.gitmodules
+28 -6
View File
@@ -5,17 +5,39 @@ FROM node:18
ARG GAME_ENV=preprod
ENV GAME_ENV=$GAME_ENV
# Install Nginx, Supervisor and Git (for Husky)
RUN apt-get update && apt-get install -y nginx supervisor git && \
rm -rf /var/lib/apt/lists/*
# Set the working directory in the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
RUN npm install
# Install dependencies while bypassing Husky hooks
ENV HUSKY=0
ENV NPM_CONFIG_IGNORE_SCRIPTS=1
RUN mkdir -p .git && npm install --include=dev
# Copy the rest of the application code
COPY . .
# Build the client-side application
RUN npm run build-prod
# Expose the port the app runs on
EXPOSE 3000
# Define the command to run the app
CMD ["npm", "run", "start:server"]
ENV NODE_ENV=production
# 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
# Setup supervisor configuration
RUN mkdir -p /var/log/supervisor
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Expose only the Nginx port
EXPOSE 80 443
# Start Supervisor to manage both Node.js and Nginx
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
+86 -11
View File
@@ -1,29 +1,104 @@
# OpenFront.io
# OpenFrontIO
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="resources/images/OpenFrontLogoDark.svg">
<source media="(prefers-color-scheme: light)" srcset="resources/images/OpenFrontLogo.svg">
<img src="resources/images/OpenFrontLogo.svg" alt="OpenFrontIO Logo" width="300">
</picture>
</p>
![Prettier Check](https://github.com/openfrontio/OpenFrontIO/actions/workflows/prettier.yml/badge.svg)
OpenFront is an online rts.
OpenFront is an online real-time strategy game focused on territorial control and alliance building. Players compete to expand their territory, build structures, and form strategic alliances in various maps based on real-world geography.
This is a fork/rewrite of WarFront.io. Credit to https://github.com/WarFrontIO.
## Building
## 🌟 Features
To build the project, you will need to have Node.js and npm installed.
- **Real-time Strategy Gameplay**: Expand your territory and engage in strategic battles
- **Alliance System**: Form alliances with other players for mutual defense
- **Multiple Maps**: Play across various geographical regions including Europe, Asia, Africa, and more
- **Resource Management**: Balance your expansion with defensive capabilities
- **Cross-platform**: Play in any modern web browser
Before building the project, install the dependencies:
## 📋 Prerequisites
```bash
npm install
```
- [Node.js](https://nodejs.org/) (v16.x or higher)
- [npm](https://www.npmjs.com/) (v8.x or higher)
- A modern web browser (Chrome, Firefox, Edge, etc.)
To run dev build:
## 🚀 Installation
1. **Clone the repository**
```bash
git clone https://github.com/openfrontio/OpenFrontIO.git
cd OpenFrontIO
```
2. **Install dependencies**
```bash
npm install
```
## 🎮 Running the Game
### Development Mode
Run both the client and server in development mode with live reloading:
```bash
npm run dev
```
Make sure to format code using prettier extension or by running:
This will:
- Start the webpack dev server for the client
- Launch the game server with development settings
- Open the game in your default browser
### Client Only
To run just the client with hot reloading:
```bash
npm run format
npm run start:client
```
### Server Only
To run just the server with development settings:
```bash
npm run start:server-dev
```
## 🛠️ Development Tools
- **Format code**:
```bash
npm run format
```
## 🏗️ Project Structure
- `/src/client` - Frontend game client
- `/src/core` - Shared game logic
- `/src/server` - Backend game server
- `/resources` - Static assets (images, maps, etc.)
## 📝 License
This project is licensed under the terms found in the [LICENSE](LICENSE) file.
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
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
-18
View File
@@ -1,18 +0,0 @@
version: "3"
services:
game-server:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
nginx:
image: nginx:latest
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- /etc/letsencrypt:/etc/letsencrypt
depends_on:
- game-server
+82
View File
@@ -0,0 +1,82 @@
# Map URI to ports
map $uri $port {
~^/w0/ 3001;
~^/w1/ 3002;
~^/w2/ 3003;
~^/w3/ 3004;
~^/w4/ 3005;
~^/w5/ 3006;
~^/w6/ 3007;
~^/w7/ 3008;
~^/w8/ 3009;
~^/w9/ 3010;
~^/w10/ 3011;
~^/w11/ 3012;
~^/w12/ 3013;
~^/w13/ 3014;
~^/w14/ 3015;
default 3000;
}
# WebSocket settings
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# WebSocket path handling
map $uri $uri_path {
~^/w\d+(/.*)?$ $1;
default $uri;
}
server {
listen 80 default_server;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Main location
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Worker locations
location ~* ^/w(\d+)(/.*)?$ {
set $worker $1;
set $worker_port 3001;
if ($worker = "0") { set $worker_port 3001; }
if ($worker = "1") { set $worker_port 3002; }
if ($worker = "2") { set $worker_port 3003; }
if ($worker = "3") { set $worker_port 3004; }
if ($worker = "4") { set $worker_port 3005; }
if ($worker = "5") { set $worker_port 3006; }
if ($worker = "6") { set $worker_port 3007; }
if ($worker = "7") { set $worker_port 3008; }
if ($worker = "8") { set $worker_port 3009; }
if ($worker = "9") { set $worker_port 3010; }
if ($worker = "10") { set $worker_port 3011; }
if ($worker = "11") { set $worker_port 3012; }
if ($worker = "12") { set $worker_port 3013; }
if ($worker = "13") { set $worker_port 3014; }
if ($worker = "14") { set $worker_port 3015; }
proxy_pass http://127.0.0.1:$worker_port$2;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+10 -271
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -15,7 +15,7 @@
},
"lint-staged": {
"**/*": [
"prettier --write"
"prettier --ignore-unknown --write"
]
},
"devDependencies": {
@@ -38,13 +38,11 @@
"binary-base64-loader": "^1.0.0",
"chai": "^5.1.1",
"concurrently": "^8.2.2",
"copy-webpack-plugin": "^12.0.2",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"file-loader": "^6.2.0",
"html-inline-script-webpack-plugin": "^3.2.1",
"html-loader": "^5.1.0",
"html-webpack-plugin": "^5.6.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"lint-staged": "^15.4.3",
@@ -59,15 +57,12 @@
"style-loader": "^4.0.0",
"tailwindcss": "^3.4.17",
"ts-jest": "^29.2.4",
"ts-loader": "^9.5.1",
"ts-loader": "^9.5.2",
"ts-mocha": "^10.0.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"typescript": "^5.7.2",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4",
"worker-loader": "^3.0.8"
},
"dependencies": {
@@ -85,6 +80,7 @@
"@types/twemoji": "^13.1.1",
"binary-loader": "^0.0.1",
"colord": "^2.9.3",
"copy-webpack-plugin": "^12.0.2",
"crypto": "^1.0.1",
"d3": "^7.9.0",
"discord.js": "^14.16.3",
@@ -95,6 +91,7 @@
"google-auth-library": "^9.14.0",
"googleapis": "^143.0.0",
"hammerjs": "^2.0.8",
"html-webpack-plugin": "^5.6.3",
"ip-anonymize": "^0.1.0",
"jimp": "^0.22.12",
"lit": "^3.2.1",
@@ -113,6 +110,9 @@
"systeminformation": "^5.25.11",
"twemoji": "^14.0.2",
"uuid": "^10.0.0",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4",
"wheelnav": "^1.7.1",
"ws": "^8.18.0",
"zod": "^3.23.8"
+2 -12
View File
@@ -1,12 +1,2 @@
<svg id="emoji" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="color">
<rect x="5" y="17" width="62" height="38"/>
<rect x="5" y="17" width="62" height="13" fill="#d22f27"/>
<rect x="5" y="30" width="62" height="12" fill="#fff"/>
<polygon fill="#5c9e31" stroke="#5c9e31" stroke-linecap="round" stroke-linejoin="round" points="28.5 33.59 30.045 38.59 26 35.5 31 35.5 26.955 38.59 28.5 33.59"/>
<polygon fill="#5c9e31" stroke="#5c9e31" stroke-linecap="round" stroke-linejoin="round" points="43.5 33.59 45.045 38.59 41 35.5 46 35.5 41.955 38.59 43.5 33.59"/>
</g>
<g id="line">
<rect x="5" y="17" width="62" height="38" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="900" height="600" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h900v600H0z"/><path d="M0 0h900v400H0z" fill="#fff"/><path d="M0 0h900v200H0z" fill="#007a3d"/><path d="m176.26 375 48.738-150 48.738 150-127.6-92.705h157.72m322.4 92.705 48.738-150 48.738 150-127.6-92.705h157.72m-352.6 92.705 48.738-150 48.738 150-127.6-92.705h157.72" fill="#ce1126"/></svg>

Before

Width:  |  Height:  |  Size: 769 B

After

Width:  |  Height:  |  Size: 426 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 365.467"><path fill="#fff" d="M378.186 365.028s-15.794-18.865-28.956-35.099c57.473-16.232 79.41-51.77 79.41-51.77-17.989 11.846-35.099 20.182-50.454 25.885-21.938 9.213-42.997 14.917-63.617 18.866-42.118 7.898-80.726 5.703-113.631-.438-25.008-4.827-46.506-11.407-64.494-18.867-10.091-3.947-21.059-8.774-32.027-14.917-1.316-.877-2.633-1.316-3.948-2.193-.877-.438-1.316-.878-1.755-.878-7.898-4.388-12.285-7.458-12.285-7.458s21.06 34.659 76.779 51.331c-13.163 16.673-29.395 35.977-29.395 35.977C36.854 362.395 0 299.218 0 299.218 0 159.263 63.177 45.633 63.177 45.633 126.354-1.311 186.022.005 186.022.005l4.388 5.264C111.439 27.645 75.461 62.305 75.461 62.305s9.653-5.265 25.886-12.285c46.945-20.621 84.236-25.885 99.592-27.64 2.633-.439 4.827-.878 7.458-.878 26.763-3.51 57.036-4.387 88.624-.878 41.68 4.826 86.43 17.111 132.058 41.68 0 0-34.66-32.906-109.244-55.281l6.143-7.019s60.105-1.317 122.844 45.628c0 0 63.178 113.631 63.178 253.585 0-.438-36.854 62.739-133.813 65.81l-.001.001zm-43.874-203.133c-25.006 0-44.75 21.498-44.75 48.262 0 26.763 20.182 48.26 44.75 48.26 25.008 0 44.752-21.497 44.752-48.26 0-26.764-20.182-48.262-44.752-48.262zm-160.135 0c-25.008 0-44.751 21.498-44.751 48.262 0 26.763 20.182 48.26 44.751 48.26 25.007 0 44.75-21.497 44.75-48.26.439-26.763-19.742-48.262-44.75-48.262z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+8 -8
View File
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="834px" height="834px" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" xmlns:xlink="http://www.w3.org/1999/xlink">
<g><path style="opacity:0.655" fill="#278f06" d="M -0.5,397.5 C -0.5,395.833 -0.5,394.167 -0.5,392.5C 44.6795,312.648 90.0129,232.815 135.5,153C 137.167,152.333 138.833,152.333 140.5,153C 161.252,165.458 181.752,178.291 202,191.5C 202.638,192.609 203.138,193.775 203.5,195C 159.14,275.555 114.14,355.722 68.5,435.5C 65.4383,437.997 62.4383,437.83 59.5,435C 39.3038,422.739 19.3038,410.239 -0.5,397.5 Z"/></g>
<g><path style="opacity:0.655" fill="#278f06" d="M 833.5,392.5 C 833.5,394.167 833.5,395.833 833.5,397.5C 812.262,411.707 790.429,425.041 768,437.5C 766.715,437.05 765.548,436.383 764.5,435.5C 718.86,355.722 673.86,275.555 629.5,195C 630.083,192.501 631.416,190.501 633.5,189C 653.333,177.251 673,165.251 692.5,153C 694.167,152.333 695.833,152.333 697.5,153C 742.987,232.815 788.32,312.648 833.5,392.5 Z"/></g>
<g><path style="opacity:0.655" fill="#278f04" d="M 432.5,217.5 C 454.565,216.918 476.565,217.751 498.5,220C 526.867,225.508 555.2,231.175 583.5,237C 589.167,237.667 594.833,237.667 600.5,237C 610.347,235.451 620.014,233.451 629.5,231C 665.047,292.944 699.88,355.11 734,417.5C 723.137,428.697 711.971,439.53 700.5,450C 693.085,455.46 685.252,460.293 677,464.5C 597.446,397.776 511.279,340.609 418.5,293C 414.302,290.486 409.802,288.653 405,287.5C 390.991,291.461 377.158,295.961 363.5,301C 349.788,321.712 332.455,338.712 311.5,352C 297.958,359.51 283.458,363.677 268,364.5C 264.417,363.942 260.917,363.109 257.5,362C 255.418,359.591 254.418,356.757 254.5,353.5C 255.061,347.256 256.561,341.256 259,335.5C 280.319,301.023 303.486,267.856 328.5,236C 339.522,230.327 351.188,226.66 363.5,225C 386.537,221.431 409.537,218.931 432.5,217.5 Z"/></g>
<g><path style="opacity:0.655" fill="#278f04" d="M 201.5,232.5 C 211.945,238.224 222.612,243.724 233.5,249C 252.001,253.528 270.668,257.195 289.5,260C 272.05,283.055 256.217,307.222 242,332.5C 230.84,367.414 243.34,383.247 279.5,380C 296.864,377.547 312.864,371.547 327.5,362C 345.877,349.29 361.21,333.624 373.5,315C 384.249,310.745 395.249,307.745 406.5,306C 473.343,338.897 536.343,377.564 595.5,422C 620.377,440.276 644.71,459.276 668.5,479C 685.816,495.374 685.816,511.708 668.5,528C 658.279,533.794 647.612,534.794 636.5,531C 591.897,504.053 547.064,477.553 502,451.5C 492.799,454.525 491.299,459.691 497.5,467C 539.083,492.041 580.583,517.208 622,542.5C 625.242,566.905 614.742,580.405 590.5,583C 586.167,583.667 581.833,583.667 577.5,583C 541.33,561.747 504.997,540.747 468.5,520C 459.972,519.844 456.805,524.011 459,532.5C 494.337,554.004 530.004,575.004 566,595.5C 567.107,607.85 563.274,618.35 554.5,627C 551.052,628.927 547.385,630.261 543.5,631C 534.78,631.839 526.113,631.505 517.5,630C 489.289,616.061 460.955,602.394 432.5,589C 422.952,588.253 419.785,592.42 423,601.5C 423.833,602.333 424.667,603.167 425.5,604C 453.042,616.955 480.375,630.289 507.5,644C 490.599,668.862 467.432,677.362 438,669.5C 419.079,665.812 400.579,660.645 382.5,654C 399.437,634.309 400.937,613.476 387,591.5C 376.531,578.176 363.031,571.843 346.5,572.5C 348.341,546.672 337.675,528.172 314.5,517C 308.736,514.786 302.903,513.953 297,514.5C 299.293,484.475 286.127,464.975 257.5,456C 241.526,453.323 228.359,458.157 218,470.5C 215.027,474.974 211.86,479.307 208.5,483.5C 194.849,452.501 172.182,442.668 140.5,454C 136.682,457.185 133.015,460.185 129.5,463C 118.644,448.964 107.31,435.298 95.5,422C 131.491,359.182 166.824,296.016 201.5,232.5 Z"/></g>
<g><path style="opacity:0.655" fill="#28900b" d="M 158.5,464.5 C 175.508,465.009 186.675,473.342 192,489.5C 193.31,496.871 192.31,503.871 189,510.5C 181.527,520.607 173.694,530.44 165.5,540C 147.399,546.072 133.899,540.572 125,523.5C 121.399,514.654 121.733,505.988 126,497.5C 133.473,487.393 141.306,477.56 149.5,468C 152.571,466.704 155.571,465.538 158.5,464.5 Z"/></g>
<g><path style="opacity:0.655" fill="#289009" d="M 246.5,471.5 C 271.231,474.34 282.064,488.007 279,512.5C 260.055,539.779 240.721,566.779 221,593.5C 210.288,603.218 198.788,604.052 186.5,596C 174.201,586.578 170.035,574.412 174,559.5C 193.728,531.104 214.228,503.271 235.5,476C 239.053,473.879 242.72,472.379 246.5,471.5 Z"/></g>
<g><path style="opacity:0.655" fill="#28900b" d="M 293.5,530.5 C 312.401,530.236 324.234,539.236 329,557.5C 330.405,564.717 329.071,571.384 325,577.5C 309.833,598 294.667,618.5 279.5,639C 259.89,647.459 245.39,641.959 236,622.5C 232.774,615.487 232.774,608.487 236,601.5C 251.422,579.657 267.088,557.99 283,536.5C 286.101,533.648 289.601,531.648 293.5,530.5 Z"/></g>
<g><path style="opacity:0.655" fill="#28900c" d="M 344.5,588.5 C 368.069,589.569 379.902,601.902 380,625.5C 379.144,629.544 377.478,633.211 375,636.5C 366.09,647.988 357.423,659.655 349,671.5C 339.128,681.673 327.961,683.173 315.5,676C 301.764,666.368 297.264,653.535 302,637.5C 313.116,622.049 324.616,606.883 336.5,592C 339.309,590.907 341.976,589.74 344.5,588.5 Z"/></g>
<g><path fill="#278f06" d="M -0.5,397.5 C -0.5,395.833 -0.5,394.167 -0.5,392.5C 44.6795,312.648 90.0129,232.815 135.5,153C 137.167,152.333 138.833,152.333 140.5,153C 161.252,165.458 181.752,178.291 202,191.5C 202.638,192.609 203.138,193.775 203.5,195C 159.14,275.555 114.14,355.722 68.5,435.5C 65.4383,437.997 62.4383,437.83 59.5,435C 39.3038,422.739 19.3038,410.239 -0.5,397.5 Z"/></g>
<g><path fill="#278f06" d="M 833.5,392.5 C 833.5,394.167 833.5,395.833 833.5,397.5C 812.262,411.707 790.429,425.041 768,437.5C 766.715,437.05 765.548,436.383 764.5,435.5C 718.86,355.722 673.86,275.555 629.5,195C 630.083,192.501 631.416,190.501 633.5,189C 653.333,177.251 673,165.251 692.5,153C 694.167,152.333 695.833,152.333 697.5,153C 742.987,232.815 788.32,312.648 833.5,392.5 Z"/></g>
<g><path fill="#278f04" d="M 432.5,217.5 C 454.565,216.918 476.565,217.751 498.5,220C 526.867,225.508 555.2,231.175 583.5,237C 589.167,237.667 594.833,237.667 600.5,237C 610.347,235.451 620.014,233.451 629.5,231C 665.047,292.944 699.88,355.11 734,417.5C 723.137,428.697 711.971,439.53 700.5,450C 693.085,455.46 685.252,460.293 677,464.5C 597.446,397.776 511.279,340.609 418.5,293C 414.302,290.486 409.802,288.653 405,287.5C 390.991,291.461 377.158,295.961 363.5,301C 349.788,321.712 332.455,338.712 311.5,352C 297.958,359.51 283.458,363.677 268,364.5C 264.417,363.942 260.917,363.109 257.5,362C 255.418,359.591 254.418,356.757 254.5,353.5C 255.061,347.256 256.561,341.256 259,335.5C 280.319,301.023 303.486,267.856 328.5,236C 339.522,230.327 351.188,226.66 363.5,225C 386.537,221.431 409.537,218.931 432.5,217.5 Z"/></g>
<g><path fill="#278f04" d="M 201.5,232.5 C 211.945,238.224 222.612,243.724 233.5,249C 252.001,253.528 270.668,257.195 289.5,260C 272.05,283.055 256.217,307.222 242,332.5C 230.84,367.414 243.34,383.247 279.5,380C 296.864,377.547 312.864,371.547 327.5,362C 345.877,349.29 361.21,333.624 373.5,315C 384.249,310.745 395.249,307.745 406.5,306C 473.343,338.897 536.343,377.564 595.5,422C 620.377,440.276 644.71,459.276 668.5,479C 685.816,495.374 685.816,511.708 668.5,528C 658.279,533.794 647.612,534.794 636.5,531C 591.897,504.053 547.064,477.553 502,451.5C 492.799,454.525 491.299,459.691 497.5,467C 539.083,492.041 580.583,517.208 622,542.5C 625.242,566.905 614.742,580.405 590.5,583C 586.167,583.667 581.833,583.667 577.5,583C 541.33,561.747 504.997,540.747 468.5,520C 459.972,519.844 456.805,524.011 459,532.5C 494.337,554.004 530.004,575.004 566,595.5C 567.107,607.85 563.274,618.35 554.5,627C 551.052,628.927 547.385,630.261 543.5,631C 534.78,631.839 526.113,631.505 517.5,630C 489.289,616.061 460.955,602.394 432.5,589C 422.952,588.253 419.785,592.42 423,601.5C 423.833,602.333 424.667,603.167 425.5,604C 453.042,616.955 480.375,630.289 507.5,644C 490.599,668.862 467.432,677.362 438,669.5C 419.079,665.812 400.579,660.645 382.5,654C 399.437,634.309 400.937,613.476 387,591.5C 376.531,578.176 363.031,571.843 346.5,572.5C 348.341,546.672 337.675,528.172 314.5,517C 308.736,514.786 302.903,513.953 297,514.5C 299.293,484.475 286.127,464.975 257.5,456C 241.526,453.323 228.359,458.157 218,470.5C 215.027,474.974 211.86,479.307 208.5,483.5C 194.849,452.501 172.182,442.668 140.5,454C 136.682,457.185 133.015,460.185 129.5,463C 118.644,448.964 107.31,435.298 95.5,422C 131.491,359.182 166.824,296.016 201.5,232.5 Z"/></g>
<g><path fill="#28900b" d="M 158.5,464.5 C 175.508,465.009 186.675,473.342 192,489.5C 193.31,496.871 192.31,503.871 189,510.5C 181.527,520.607 173.694,530.44 165.5,540C 147.399,546.072 133.899,540.572 125,523.5C 121.399,514.654 121.733,505.988 126,497.5C 133.473,487.393 141.306,477.56 149.5,468C 152.571,466.704 155.571,465.538 158.5,464.5 Z"/></g>
<g><path fill="#289009" d="M 246.5,471.5 C 271.231,474.34 282.064,488.007 279,512.5C 260.055,539.779 240.721,566.779 221,593.5C 210.288,603.218 198.788,604.052 186.5,596C 174.201,586.578 170.035,574.412 174,559.5C 193.728,531.104 214.228,503.271 235.5,476C 239.053,473.879 242.72,472.379 246.5,471.5 Z"/></g>
<g><path fill="#28900b" d="M 293.5,530.5 C 312.401,530.236 324.234,539.236 329,557.5C 330.405,564.717 329.071,571.384 325,577.5C 309.833,598 294.667,618.5 279.5,639C 259.89,647.459 245.39,641.959 236,622.5C 232.774,615.487 232.774,608.487 236,601.5C 251.422,579.657 267.088,557.99 283,536.5C 286.101,533.648 289.601,531.648 293.5,530.5 Z"/></g>
<g><path fill="#28900c" d="M 344.5,588.5 C 368.069,589.569 379.902,601.902 380,625.5C 379.144,629.544 377.478,633.211 375,636.5C 366.09,647.988 357.423,659.655 349,671.5C 339.128,681.673 327.961,683.173 315.5,676C 301.764,666.368 297.264,653.535 302,637.5C 313.116,622.049 324.616,606.883 336.5,592C 339.309,590.907 341.976,589.74 344.5,588.5 Z"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 75.294 75.294" xml:space="preserve">
<g>
<path d="M66.097,12.089h-56.9C4.126,12.089,0,16.215,0,21.286v32.722c0,5.071,4.126,9.197,9.197,9.197h56.9
c5.071,0,9.197-4.126,9.197-9.197V21.287C75.295,16.215,71.169,12.089,66.097,12.089z M61.603,18.089L37.647,33.523L13.691,18.089
H61.603z M66.097,57.206h-56.9C7.434,57.206,6,55.771,6,54.009V21.457l29.796,19.16c0.04,0.025,0.083,0.042,0.124,0.065
c0.043,0.024,0.087,0.047,0.131,0.069c0.231,0.119,0.469,0.215,0.712,0.278c0.025,0.007,0.05,0.01,0.075,0.016
c0.267,0.063,0.537,0.102,0.807,0.102c0.001,0,0.002,0,0.002,0c0.002,0,0.003,0,0.004,0c0.27,0,0.54-0.038,0.807-0.102
c0.025-0.006,0.05-0.009,0.075-0.016c0.243-0.063,0.48-0.159,0.712-0.278c0.044-0.022,0.088-0.045,0.131-0.069
c0.041-0.023,0.084-0.04,0.124-0.065l29.796-19.16v32.551C69.295,55.771,67.86,57.206,66.097,57.206z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="512px" height="450px" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" xmlns:xlink="http://www.w3.org/1999/xlink">
<g><path style="opacity:0.553" fill="#feea4f" d="M 248.5,-0.5 C 252.167,-0.5 255.833,-0.5 259.5,-0.5C 278.819,5.4526 286.319,18.4526 282,38.5C 279.284,45.8812 274.451,51.3812 267.5,55C 266.941,55.7247 266.608,56.5581 266.5,57.5C 273.333,88.8333 280.167,120.167 287,151.5C 289.807,165.255 296.307,176.755 306.5,186C 316.671,191.131 326.004,189.798 334.5,182C 345.222,172.37 353.888,161.204 360.5,148.5C 347.194,133.298 347.194,118.131 360.5,103C 376.565,92.8493 391.065,94.6826 404,108.5C 413.587,127.733 409.087,142.566 390.5,153C 385.077,153.627 379.911,154.794 375,156.5C 373.24,167.82 371.573,179.154 370,190.5C 368.693,201.412 369.693,212.079 373,222.5C 376.407,231.455 382.907,235.788 392.5,235.5C 404.426,234.039 415.092,229.539 424.5,222C 435.833,212.667 447.167,203.333 458.5,194C 451.529,181.867 452.029,170.034 460,158.5C 471.782,147.376 484.948,145.543 499.5,153C 505.328,157.305 509.328,162.805 511.5,169.5C 511.5,174.833 511.5,180.167 511.5,185.5C 504.523,202.241 492.023,209.241 474,206.5C 456.963,254.069 439.796,301.569 422.5,349C 310.5,349.5 198.5,349.667 86.5,349.5C 70.6411,301.757 54.4744,254.09 38,206.5C 19.5189,211.509 6.68559,205.176 -0.5,187.5C -0.5,182.167 -0.5,176.833 -0.5,171.5C 7.69791,152.574 21.6979,146.407 41.5,153C 57.7565,164.191 61.4231,178.691 52.5,196.5C 68.4555,208.943 85.1222,220.443 102.5,231C 111.912,235.038 121.246,235.038 130.5,231C 136.151,226.533 139.317,220.699 140,213.5C 140.668,193.676 139.168,174.009 135.5,154.5C 114.743,156.249 103.077,146.749 100.5,126C 104.608,102.566 118.275,93.5665 141.5,99C 158.634,108.45 163.467,122.284 156,140.5C 153.486,143.678 150.986,146.844 148.5,150C 155.392,160.231 163.058,169.898 171.5,179C 177.581,185.207 184.915,189.041 193.5,190.5C 198.804,190.264 203.304,188.264 207,184.5C 214.423,174.592 219.757,163.592 223,151.5C 230.341,120.468 237.841,89.4681 245.5,58.5C 239.652,55.167 234.485,50.8337 230,45.5C 219.901,23.3887 226.068,8.05541 248.5,-0.5 Z"/></g>
<g><path style="opacity:0.553" fill="#feea4f" d="M 423.5,449.5 C 311.167,449.5 198.833,449.5 86.5,449.5C 86.5,429.167 86.5,408.833 86.5,388.5C 198.833,388.5 311.167,388.5 423.5,388.5C 423.5,408.833 423.5,429.167 423.5,449.5 Z"/></g>
<g><path fill="#feea4f" d="M 248.5,-0.5 C 252.167,-0.5 255.833,-0.5 259.5,-0.5C 278.819,5.4526 286.319,18.4526 282,38.5C 279.284,45.8812 274.451,51.3812 267.5,55C 266.941,55.7247 266.608,56.5581 266.5,57.5C 273.333,88.8333 280.167,120.167 287,151.5C 289.807,165.255 296.307,176.755 306.5,186C 316.671,191.131 326.004,189.798 334.5,182C 345.222,172.37 353.888,161.204 360.5,148.5C 347.194,133.298 347.194,118.131 360.5,103C 376.565,92.8493 391.065,94.6826 404,108.5C 413.587,127.733 409.087,142.566 390.5,153C 385.077,153.627 379.911,154.794 375,156.5C 373.24,167.82 371.573,179.154 370,190.5C 368.693,201.412 369.693,212.079 373,222.5C 376.407,231.455 382.907,235.788 392.5,235.5C 404.426,234.039 415.092,229.539 424.5,222C 435.833,212.667 447.167,203.333 458.5,194C 451.529,181.867 452.029,170.034 460,158.5C 471.782,147.376 484.948,145.543 499.5,153C 505.328,157.305 509.328,162.805 511.5,169.5C 511.5,174.833 511.5,180.167 511.5,185.5C 504.523,202.241 492.023,209.241 474,206.5C 456.963,254.069 439.796,301.569 422.5,349C 310.5,349.5 198.5,349.667 86.5,349.5C 70.6411,301.757 54.4744,254.09 38,206.5C 19.5189,211.509 6.68559,205.176 -0.5,187.5C -0.5,182.167 -0.5,176.833 -0.5,171.5C 7.69791,152.574 21.6979,146.407 41.5,153C 57.7565,164.191 61.4231,178.691 52.5,196.5C 68.4555,208.943 85.1222,220.443 102.5,231C 111.912,235.038 121.246,235.038 130.5,231C 136.151,226.533 139.317,220.699 140,213.5C 140.668,193.676 139.168,174.009 135.5,154.5C 114.743,156.249 103.077,146.749 100.5,126C 104.608,102.566 118.275,93.5665 141.5,99C 158.634,108.45 163.467,122.284 156,140.5C 153.486,143.678 150.986,146.844 148.5,150C 155.392,160.231 163.058,169.898 171.5,179C 177.581,185.207 184.915,189.041 193.5,190.5C 198.804,190.264 203.304,188.264 207,184.5C 214.423,174.592 219.757,163.592 223,151.5C 230.341,120.468 237.841,89.4681 245.5,58.5C 239.652,55.167 234.485,50.8337 230,45.5C 219.901,23.3887 226.068,8.05541 248.5,-0.5 Z"/></g>
<g><path fill="#feea4f" d="M 423.5,449.5 C 311.167,449.5 198.833,449.5 86.5,449.5C 86.5,429.167 86.5,408.833 86.5,388.5C 198.833,388.5 311.167,388.5 423.5,388.5C 423.5,408.833 423.5,429.167 423.5,449.5 Z"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2285 4675 c-640 -84 -1211 -457 -1551 -1015 -278 -456 -372 -1020
-258 -1550 86 -400 284 -760 579 -1055 351 -352 787 -560 1284 -615 171 -19
442 -8 615 24 423 80 802 282 1111 591 407 407 625 931 625 1505 0 574 -218
1098 -625 1505 -348 349 -786 561 -1270 615 -122 13 -387 11 -510 -5z m574
-436 c375 -64 738 -269 989 -560 504 -583 557 -1420 129 -2067 -33 -50 -63
-92 -67 -92 -3 0 -68 63 -145 140 -77 77 -144 140 -150 140 -13 0 -295 -282
-295 -295 0 -6 63 -73 140 -150 77 -77 140 -142 140 -145 0 -12 -195 -134
-289 -181 -399 -198 -882 -229 -1302 -83 -595 206 -1020 703 -1130 1320 -30
166 -30 422 -1 585 48 265 145 487 324 744 4 6 68 -50 152 -134 l145 -145 22
20 c57 49 279 272 279 280 0 5 -64 73 -142 152 -83 83 -139 146 -133 150 297
207 556 308 878 342 113 12 320 2 456 -21z"/>
<path d="M2350 3715 l0 -126 -45 -19 c-128 -55 -251 -173 -315 -303 -143 -291
-60 -569 230 -764 25 -17 132 -76 238 -131 195 -102 284 -159 302 -192 28 -52
1 -148 -57 -202 -58 -55 -77 -58 -388 -58 l-285 0 0 -215 0 -215 160 0 160 0
0 -105 0 -105 210 0 210 0 0 125 0 126 45 19 c232 100 398 365 382 610 -12
179 -112 332 -299 458 -25 18 -132 76 -237 131 -193 100 -283 158 -301 191
-28 52 -1 148 57 202 58 55 77 58 388 58 l285 0 0 215 0 215 -160 0 -160 0 0
105 0 105 -210 0 -210 0 0 -125z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+2 -17
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 18 22.5"
viewBox="0 0 18 18"
x="0px"
y="0px"
version="1.1"
@@ -35,22 +35,7 @@
fill-rule="evenodd"
d="M9,0 C4.029,0 0,4.029 0,9 C0,13.971 4.029,18 9,18 C13.971,18 18,13.971 18,9 C18,4.029 13.971,0 9,0 M9.5,6 L8.5,6 C8.224,6 8,5.776 8,5.5 L8,4.5 C8,4.224 8.224,4 8.5,4 L9.5,4 C9.776,4 10,4.224 10,4.5 L10,5.5 C10,5.776 9.776,6 9.5,6 M9.5,14 L8.5,14 C8.224,14 8,13.776 8,13.5 L8,8.5 C8,8.224 8.224,8 8.5,8 L9.5,8 C9.776,8 10,8.224 10,8.5 L10,13.5 C10,13.776 9.776,14 9.5,14"
id="path1" />
<text
x="0"
y="33"
fill="#000000"
font-size="5px"
font-weight="bold"
font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif"
id="text1">Created by Kevin White</text>
<text
x="0"
y="38"
fill="#000000"
font-size="5px"
font-weight="bold"
font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif"
id="text2">from the Noun Project</text>
<path
style="fill:#000000;stroke-width:0.0252525;fill-opacity:1"
d="m 8.3080808,13.943016 c -0.122592,-0.05761 -0.1993586,-0.137633 -0.25132,-0.261994 -0.034429,-0.0824 -0.039018,-0.402646 -0.038529,-2.689049 5.3e-4,-2.4803968 0.00265,-2.6001182 0.047756,-2.69945 0.05802,-0.127763 0.1797421,-0.2238457 0.3320856,-0.2621343 0.074823,-0.018805 0.3095405,-0.026114 0.6691919,-0.020838 0.5517602,0.00809 0.5544116,0.0084 0.6716986,0.077347 0.064763,0.038072 0.144309,0.1119352 0.1767677,0.1641414 l 0.059016,0.09492 0.00677,2.6277479 0.00677,2.627746 -0.05921,0.112153 c -0.032566,0.06168 -0.1075808,0.146242 -0.1667004,0.187909 l -0.1074902,0.07576 -0.6228943,0.0066 c -0.5652599,0.006 -0.6322404,0.0022 -0.7239044,-0.04086 z"

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1364 259" width="100%" height="100%" fill="currentColor">
<g>
<path d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z" fill="#ffffff"/>
<path d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z" fill="#ffffff"/>
<path d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z" fill="#ffffff"/>
<path d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z" fill="#ffffff"/>
<path d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z" fill="#ffffff"/>
<path d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z" fill="#ffffff"/>
<path d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z" fill="#ffffff"/>
<path d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z" fill="#ffffff"/>
<path d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z" fill="#ffffff"/>
<path d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z" fill="#ffffff"/>
<path d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z" fill="#ffffff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="512px" height="512px" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" xmlns:xlink="http://www.w3.org/1999/xlink">
<g><path style="opacity:0.384" fill="#9e0000" d="M 57.5,16.5 C 79.6232,16.1252 88.4565,26.7919 84,48.5C 83.2196,51.5117 82.0529,54.3451 80.5,57C 111.436,74.9338 136.936,98.7672 157,128.5C 182.331,109.668 210.831,100.334 242.5,100.5C 242.5,106.833 242.5,113.167 242.5,119.5C 215.494,119.225 191.16,127.058 169.5,143C 181.768,144.418 194.101,145.418 206.5,146C 223.046,163.709 239.379,181.543 255.5,199.5C 271.621,181.543 287.954,163.709 304.5,146C 316.899,145.418 329.232,144.418 341.5,143C 319.84,127.058 295.506,119.225 268.5,119.5C 268.5,113.167 268.5,106.833 268.5,100.5C 300.169,100.334 328.669,109.668 354,128.5C 374.064,98.7672 399.564,74.9338 430.5,57C 422.929,44.1613 423.929,32.1613 433.5,21C 441.053,16.4447 449.053,15.4447 457.5,18C 478.079,25.4111 490.079,39.9111 493.5,61.5C 490.867,80.2657 480.201,87.4324 461.5,83C 458.488,82.2196 455.655,81.0529 453,79.5C 435.066,110.436 411.233,135.936 381.5,156C 400.886,181.491 410.72,210.324 411,242.5C 404.924,243.476 398.758,243.81 392.5,243.5C 391.875,215.457 383.375,190.124 367,167.5C 365.477,180.07 364.477,192.737 364,205.5C 344.658,222.677 325.492,240.01 306.5,257.5C 367.877,327.636 426.377,399.969 482,474.5C 486.572,480.683 490.572,487.016 494,493.5C 465.55,472.718 437.384,451.551 409.5,430C 357.697,387.864 306.197,345.364 255,302.5C 178.278,369.57 98.9443,433.237 17,493.5C 20.4282,487.016 24.4282,480.683 29,474.5C 84.5044,400.043 143.004,327.876 204.5,258C 185.762,240.273 166.596,222.773 147,205.5C 146.523,192.737 145.523,180.07 144,167.5C 127.625,190.124 119.125,215.457 118.5,243.5C 112.242,243.81 106.076,243.476 100,242.5C 100.28,210.324 110.114,181.491 129.5,156C 99.7672,135.936 75.9338,110.436 58,79.5C 46.6724,86.2353 35.5058,86.0686 24.5,79C 18.004,71.1766 16.1706,62.3433 19,52.5C 25.9173,34.08 38.7506,22.08 57.5,16.5 Z"/></g>
<g><path fill="#9e0000" d="M 57.5,16.5 C 79.6232,16.1252 88.4565,26.7919 84,48.5C 83.2196,51.5117 82.0529,54.3451 80.5,57C 111.436,74.9338 136.936,98.7672 157,128.5C 182.331,109.668 210.831,100.334 242.5,100.5C 242.5,106.833 242.5,113.167 242.5,119.5C 215.494,119.225 191.16,127.058 169.5,143C 181.768,144.418 194.101,145.418 206.5,146C 223.046,163.709 239.379,181.543 255.5,199.5C 271.621,181.543 287.954,163.709 304.5,146C 316.899,145.418 329.232,144.418 341.5,143C 319.84,127.058 295.506,119.225 268.5,119.5C 268.5,113.167 268.5,106.833 268.5,100.5C 300.169,100.334 328.669,109.668 354,128.5C 374.064,98.7672 399.564,74.9338 430.5,57C 422.929,44.1613 423.929,32.1613 433.5,21C 441.053,16.4447 449.053,15.4447 457.5,18C 478.079,25.4111 490.079,39.9111 493.5,61.5C 490.867,80.2657 480.201,87.4324 461.5,83C 458.488,82.2196 455.655,81.0529 453,79.5C 435.066,110.436 411.233,135.936 381.5,156C 400.886,181.491 410.72,210.324 411,242.5C 404.924,243.476 398.758,243.81 392.5,243.5C 391.875,215.457 383.375,190.124 367,167.5C 365.477,180.07 364.477,192.737 364,205.5C 344.658,222.677 325.492,240.01 306.5,257.5C 367.877,327.636 426.377,399.969 482,474.5C 486.572,480.683 490.572,487.016 494,493.5C 465.55,472.718 437.384,451.551 409.5,430C 357.697,387.864 306.197,345.364 255,302.5C 178.278,369.57 98.9443,433.237 17,493.5C 20.4282,487.016 24.4282,480.683 29,474.5C 84.5044,400.043 143.004,327.876 204.5,258C 185.762,240.273 166.596,222.773 147,205.5C 146.523,192.737 145.523,180.07 144,167.5C 127.625,190.124 119.125,215.457 118.5,243.5C 112.242,243.81 106.076,243.476 100,242.5C 100.28,210.324 110.114,181.491 129.5,156C 99.7672,135.936 75.9338,110.436 58,79.5C 46.6724,86.2353 35.5058,86.0686 24.5,79C 18.004,71.1766 16.1706,62.3433 19,52.5C 25.9173,34.08 38.7506,22.08 57.5,16.5 Z"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

+209 -18376
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -137,7 +137,7 @@
},
{
"coordinates": [243, 1067],
"name": "Somolia",
"name": "Somalia",
"strength": 1,
"flag": "so"
},
+1 -1
View File
@@ -23,7 +23,7 @@
},
{
"coordinates": [300, 188],
"name": "Maldova",
"name": "Moldova",
"strength": 1,
"flag": "md"
},
+16 -1
View File
@@ -6,11 +6,16 @@ import { ClientID, GameConfig, GameID, ServerMessage } from "../core/Schemas";
import { loadTerrainMap } from "../core/game/TerrainMapLoader";
import {
SendAttackIntentEvent,
SendHashEvent,
SendSpawnIntentEvent,
Transport,
} from "./Transport";
import { createCanvas } from "./Utils";
import { ErrorUpdate } from "../core/game/GameUpdates";
import {
ErrorUpdate,
GameUpdateType,
HashUpdate,
} from "../core/game/GameUpdates";
import { WorkerClient } from "../core/worker/WorkerClient";
import { consolex, initRemoteSender } from "../core/Consolex";
import { getConfig, getServerConfig } from "../core/configuration/Config";
@@ -171,6 +176,9 @@ export class ClientGameRunner {
showErrorModal(gu.errMsg, gu.stack, this.clientID);
return;
}
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
this.gameView.update(gu);
this.renderer.tick();
});
@@ -205,6 +213,13 @@ export class ClientGameRunner {
this.turnsSeen++;
}
}
if (message.type == "desync") {
showErrorModal(
`desync from server: ${JSON.stringify(message)}`,
"",
this.clientID,
);
}
if (message.type == "turn") {
if (!this.hasJoined) {
this.transport.joinGame(0);
+10 -7
View File
@@ -1,8 +1,7 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import Countries from "./data/countries.json";
import { UserSettings } from "../core/game/UserSettings";
import { ModalOverlay } from "./components/ModalOverlay";
const flagKey: string = "flag";
@customElement("flag-input")
@@ -10,7 +9,6 @@ export class FlagInput extends LitElement {
@state() private flag: string = "";
@state() private search: string = "";
@state() private showModal: boolean = false;
private userSettings: UserSettings = new UserSettings();
static styles = css`
@media (max-width: 768px) {
@@ -29,11 +27,10 @@ export class FlagInput extends LitElement {
}
private setFlag(flag: string) {
if (flag == "") {
this.flag = "";
} else {
this.flag = flag;
if (flag == "xx") {
flag = "";
}
this.flag = flag;
this.showModal = false;
this.storeFlag(flag);
}
@@ -80,6 +77,12 @@ export class FlagInput extends LitElement {
render() {
return html`
<div
class="absolute left-0 top-0 w-full h-full ${this.showModal
? ""
: "hidden"}"
@click=${() => (this.showModal = false)}
></div>
<div class="flex relative">
<button
@click=${() => (this.showModal = !this.showModal)}
+42
View File
@@ -0,0 +1,42 @@
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("google-ad")
export class GoogleAdElement extends LitElement {
createRenderRoot() {
return this;
}
static styles = css`
.google-ad-container {
margin-top: 1rem;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 0.5rem;
width: 100%;
overflow: hidden;
}
.dark .google-ad-container {
background-color: rgba(0, 0, 0, 0.2);
}
`;
render() {
return html`
<div class="google-ad-container">
<ins
class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-7035513310742290"
data-ad-slot="rightsidebar"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div>
`;
}
}
+24 -4
View File
@@ -1,5 +1,5 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./components/Difficulties";
import "./components/Maps";
@@ -207,11 +207,17 @@ export class HelpModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
class="absolute left-0 top-0 w-full h-full ${
this.isModalOpen ? "" : "hidden"
}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<div class="flex flex-col items-center">
<div class="text-center text-2xl font-bold mb-4">Keybinds</div>
<div class="text-center text-2xl font-bold mb-4">Hotkeys</div>
<table>
<thead>
<tr>
@@ -220,10 +226,18 @@ export class HelpModal extends LitElement {
</tr>
</thead>
<tbody class="text-left">
<tr>
<td>CTRL + Left Click</td>
<td>Open build menu</td>
</tr>
<tr>
<td>Space</td>
<td>Alternate view</td>
</tr>
<tr>
<td>C</td>
<td>Center camera on player</td>
</tr>
<tr>
<td>Q / E</td>
<td>Zoom out/in</td>
@@ -236,6 +250,10 @@ export class HelpModal extends LitElement {
<td>1 / 2</td>
<td>Decrease/Increase attack ratio</td>
</tr>
<tr>
<td>Shift + scroll down / scroll up</td>
<td>Decrease/Increase attack ratio</td>
</tr>
<tr>
<td>ALT + R</td>
<td>Reset graphics</td>
@@ -303,7 +321,8 @@ export class HelpModal extends LitElement {
<p class="mb-4">Right clicking (or touch on mobile) opens the radial menu. From there you can:</p>
<ul>
<li class="mb-4"><div class="inline-block icon build-icon"></div> - Open the build menu.</li>
<li class="mb-4"><div class="inline-block icon info-icon"></div> - Open the Info menu.</li>
<li class="mb-4">
<img src="/images/InfoIcon.svg" class="inline-block icon" style="fill: white; background: transparent;"/> - Open the Info menu.</li>
<li class="mb-4"><div class="inline-block icon boat-icon"></div> - Send a boat to attack at the selected location (only available if you have access to water).</li>
<li class="mb-4"><div class="inline-block icon cancel-icon"></div> - Close the menu.</li>
</ul>
@@ -455,5 +474,6 @@ export class HelpModal extends LitElement {
public close() {
this.isModalOpen = false;
console.log("closing modal");
}
}
+61 -45
View File
@@ -1,13 +1,16 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { Lobby } from "../core/Schemas";
import { GameConfig, GameInfo } from "../core/Schemas";
import { consolex } from "../core/Consolex";
import "./components/Difficulties";
import { DifficultyDescription } from "./components/Difficulties";
import "./components/Maps";
import { generateID } from "../core/Util";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import randomMap from "../../resources/images/RandomMap.png";
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@state() private isModalOpen = false;
@@ -323,6 +326,11 @@ export class HostLobbyModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
class="${this.isModalOpen ? "" : "hidden"}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
@@ -430,7 +438,7 @@ export class HostLobbyModal extends LitElement {
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${this.bots}"
.value="${String(this.bots)}"
/>
<div class="option-card-title">
Bots: ${this.bots == 0 ? "Disabled" : this.bots}
@@ -526,7 +534,7 @@ export class HostLobbyModal extends LitElement {
public open() {
createLobby()
.then((lobby) => {
this.lobbyId = lobby.id;
this.lobbyId = lobby.gameID;
// join lobby
})
.then(() => {
@@ -535,7 +543,7 @@ export class HostLobbyModal extends LitElement {
detail: {
gameType: GameType.Private,
lobby: {
id: this.lobbyId,
gameID: this.lobbyId,
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
@@ -604,21 +612,24 @@ export class HostLobbyModal extends LitElement {
}
private async putGameConfig() {
const response = await fetch(`/private_lobby/${this.lobbyId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${window.location.origin}/${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
} as GameConfig),
},
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
}),
});
);
}
private getRandomMap(): GameMapType {
@@ -637,12 +648,15 @@ export class HostLobbyModal extends LitElement {
`Starting private game with map: ${GameMapType[this.selectedMap]} ${this.useRandomMap ? " (Randomly selected)" : ""}`,
);
this.close();
const response = await fetch(`/start_private_lobby/${this.lobbyId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${window.location.origin}/${getServerConfig().workerPath(this.lobbyId)}/start_game/${this.lobbyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
});
);
}
private async copyToClipboard() {
@@ -656,34 +670,42 @@ export class HostLobbyModal extends LitElement {
this.copySuccess = false;
}, 2000);
} catch (err) {
consolex.error("Failed to copy text: ", err);
consolex.error(`Failed to copy text: ${err}`);
}
}
private async pollPlayers() {
fetch(`/lobby/${this.lobbyId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
fetch(
`/${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
})
)
.then((response) => response.json())
.then((data) => {
.then((data: GameInfo) => {
console.log(`got response: ${data}`);
this.players = data.players.map((p) => p.username);
this.players = data.clients.map((p) => p.username);
});
}
}
async function createLobby(): Promise<Lobby> {
async function createLobby(): Promise<GameInfo> {
const serverConfig = getServerConfig();
try {
const response = await fetch("/private_lobby", {
method: "POST",
headers: {
"Content-Type": "application/json",
const id = generateID();
const response = await fetch(
`/${serverConfig.workerPath(id)}/create_game/${id}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
// body: JSON.stringify(data), // Include this if you need to send data
},
// body: JSON.stringify(data), // Include this if you need to send data
});
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@@ -692,13 +714,7 @@ async function createLobby(): Promise<Lobby> {
const data = await response.json();
consolex.log("Success:", data);
// Assuming the server returns an object with an 'id' property
const lobby: Lobby = {
id: data.id,
// Add other properties as needed
};
return lobby;
return data as GameInfo;
} catch (error) {
consolex.error("Error creating lobby:", error);
throw error; // Re-throw the error so the caller can handle it
+24 -2
View File
@@ -1,5 +1,4 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { Game } from "../core/game/Game";
export class MouseUpEvent implements GameEvent {
constructor(
@@ -63,6 +62,10 @@ export class AttackRatioEvent implements GameEvent {
constructor(public readonly attackRatio: number) {}
}
export class CenterCameraEvent implements GameEvent {
constructor() {}
}
export class InputHandler {
private lastPointerX: number = 0;
private lastPointerY: number = 0;
@@ -95,6 +98,9 @@ export class InputHandler {
this.canvas.addEventListener("wheel", (e) => this.onScroll(e), {
passive: false,
});
this.canvas.addEventListener("wheel", (e) => this.onShiftScroll(e), {
passive: false,
});
window.addEventListener("pointermove", this.onPointerMove.bind(this));
this.canvas.addEventListener("contextmenu", (e: MouseEvent) => {
this.onContextMenu(e);
@@ -173,6 +179,7 @@ export class InputHandler {
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
].includes(e.code)
) {
this.activeKeys.add(e.code);
@@ -200,6 +207,11 @@ export class InputHandler {
this.eventBus.emit(new AttackRatioEvent(10));
}
if (e.code === "KeyC") {
e.preventDefault();
this.eventBus.emit(new CenterCameraEvent());
}
// Remove all movement keys from activeKeys
if (
[
@@ -217,6 +229,7 @@ export class InputHandler {
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
].includes(e.code)
) {
this.activeKeys.delete(e.code);
@@ -272,7 +285,16 @@ export class InputHandler {
}
private onScroll(event: WheelEvent) {
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY));
if (!event.shiftKey) {
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY));
}
}
private onShiftScroll(event: WheelEvent) {
if (event.shiftKey) {
const ratio = event.deltaY > 0 ? -10 : 10;
this.eventBus.emit(new AttackRatioEvent(ratio));
}
}
private onPointerMove(event: PointerEvent) {
+26 -13
View File
@@ -1,7 +1,9 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state, query } from "lit/decorators.js";
import { GameMapType, GameType } from "../core/game/Game";
import { LitElement, css, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { getServerConfig } from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameMapType, GameType } from "../core/game/Game";
import { GameInfo } from "../core/Schemas";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@@ -231,6 +233,11 @@ export class JoinPrivateLobbyModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
class="${this.isModalOpen ? "" : "hidden"}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.closeAndLeave}>&times;</span>
<div class="title">Join Private Lobby</div>
@@ -358,13 +365,16 @@ export class JoinPrivateLobbyModal extends LitElement {
consolex.log(`Joining lobby with ID: ${lobbyId}`);
this.message = "Checking lobby..."; // Set initial message
fetch(`/lobby/${lobbyId}/exists`, {
const url = `/${getServerConfig().workerPath(lobbyId)}/game/${lobbyId}/exists`;
fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((response) => {
return response.json();
})
.then((data) => {
if (data.exists) {
this.message = "Joined successfully! Waiting for game to start...";
@@ -372,7 +382,7 @@ export class JoinPrivateLobbyModal extends LitElement {
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
lobby: { id: lobbyId },
lobby: { gameID: lobbyId },
gameType: GameType.Private,
map: GameMapType.World,
},
@@ -394,15 +404,18 @@ export class JoinPrivateLobbyModal extends LitElement {
private async pollPlayers() {
if (!this.lobbyIdInput?.value) return;
fetch(`/lobby/${this.lobbyIdInput.value}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
fetch(
`/${getServerConfig().workerPath(this.lobbyIdInput.value)}/game/${this.lobbyIdInput.value}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
})
)
.then((response) => response.json())
.then((data) => {
this.players = data.players.map((p) => p.username);
.then((data: GameInfo) => {
this.players = data.clients.map((p) => p.username);
})
.catch((error) => {
consolex.error("Error polling players:", error);
+8 -2
View File
@@ -1,4 +1,9 @@
import { Config, GameEnv, ServerConfig } from "../core/configuration/Config";
import {
Config,
GameEnv,
getServerConfig,
ServerConfig,
} from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameEvent } from "../core/EventBus";
import {
@@ -125,6 +130,7 @@ export class LocalServer {
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: "application/json",
});
navigator.sendBeacon("/archive_singleplayer_game", blob);
const workerPath = getServerConfig().workerPath(this.lobbyConfig.gameID);
navigator.sendBeacon(`/${workerPath}/archive_singleplayer_game`, blob);
}
}
+9 -13
View File
@@ -17,6 +17,7 @@ import { PublicLobby } from "./PublicLobby";
import { UserSettings } from "../core/game/UserSettings";
import "./DarkModeButton";
import { DarkModeButton } from "./DarkModeButton";
import "./GoogleAdElement";
import { HelpModal } from "./HelpModal";
import { GameType } from "../core/game/Game";
@@ -65,10 +66,6 @@ class Client {
setFavicon();
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener(
"single-player",
this.handleSinglePlayer.bind(this),
);
const spModal = document.querySelector(
"single-player-modal",
@@ -112,9 +109,9 @@ class Client {
});
if (this.userSettings.darkMode()) {
document.body.classList.add("dark");
document.documentElement.classList.add("dark");
} else {
document.body.classList.remove("dark");
document.documentElement.classList.remove("dark");
}
page("/join/:lobbyId", (ctx) => {
if (ctx.init && sessionStorage.getItem("inLobby")) {
@@ -143,9 +140,12 @@ class Client {
this.gameStop = joinLobby(
{
gameType: gameType,
flag: (): string => this.flagInput.getCurrentFlag(),
flag: (): string =>
this.flagInput.getCurrentFlag() == "xx"
? ""
: this.flagInput.getCurrentFlag(),
playerName: (): string => this.usernameInput.getCurrentUsername(),
gameID: lobby.id,
gameID: lobby.gameID,
persistentID: getPersistentIDFromCookie(),
playerID: generateID(),
clientID: generateID(),
@@ -161,7 +161,7 @@ class Client {
this.joinModal.close();
this.publicLobby.stop();
if (gameType != GameType.Singleplayer) {
window.history.pushState({}, "", `/join/${lobby.id}`);
window.history.pushState({}, "", `/join/${lobby.gameID}`);
sessionStorage.setItem("inLobby", "true");
}
},
@@ -177,10 +177,6 @@ class Client {
this.gameStop = null;
this.publicLobby.leaveLobby();
}
private async handleSinglePlayer(event: CustomEvent) {
alert("coming soon");
}
}
// Initialize the client when the DOM is loaded
+10 -7
View File
@@ -1,16 +1,16 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Lobby } from "../core/Schemas";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { consolex } from "../core/Consolex";
import { getMapsImage } from "./utilities/Maps";
import { GameInfo } from "../core/Schemas";
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private lobbies: Lobby[] = [];
@state() private lobbies: GameInfo[] = [];
@state() public isLobbyHighlighted: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: Lobby = null;
private currLobby: GameInfo = null;
createRenderRoot() {
return this;
@@ -42,9 +42,9 @@ export class PublicLobby extends LitElement {
}
}
async fetchLobbies(): Promise<Lobby[]> {
async fetchLobbies(): Promise<GameInfo[]> {
try {
const response = await fetch("/lobbies");
const response = await fetch(`/public_lobbies`);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
@@ -67,6 +67,9 @@ export class PublicLobby extends LitElement {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
if (!lobby?.gameConfig) {
return;
}
const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000));
// Format time to show minutes and seconds
@@ -81,7 +84,7 @@ export class PublicLobby extends LitElement {
? "bg-gradient-to-r from-green-600 to-green-500"
: "bg-gradient-to-r from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90"
>
<div class="text-lg md:text-2xl font-semibold mb-2">Next Game</div>
<div class="text-lg md:text-2xl font-semibold mb-2">Join next Game</div>
<div class="flex">
<img
src="${getMapsImage(lobby.gameConfig.gameMap)}"
@@ -121,7 +124,7 @@ export class PublicLobby extends LitElement {
this.currLobby = null;
}
private lobbyClicked(lobby: Lobby) {
private lobbyClicked(lobby: GameInfo) {
if (this.currLobby == null) {
this.isLobbyHighlighted = true;
this.currLobby = lobby;
+6 -1
View File
@@ -256,6 +256,11 @@ export class SinglePlayerModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
class="${this.isModalOpen ? "" : "hidden"}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
@@ -476,7 +481,7 @@ export class SinglePlayerModal extends LitElement {
detail: {
gameType: GameType.Singleplayer,
lobby: {
id: generateID(),
gameID: generateID(),
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
+56 -3
View File
@@ -9,6 +9,7 @@ import {
Player,
PlayerID,
PlayerType,
Tick,
UnitType,
} from "../core/game/Game";
import {
@@ -23,6 +24,7 @@ import {
GameConfig,
ClientLogMessageSchema,
ClientSendWinnerSchema,
ClientMessageSchema,
} from "../core/Schemas";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
@@ -100,6 +102,14 @@ export class SendDonateIntentEvent implements GameEvent {
) {}
}
export class SendEmbargoIntentEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
public readonly target: PlayerView,
public readonly action: "start" | "stop",
) {}
}
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(public readonly ratio: number) {}
}
@@ -107,6 +117,12 @@ export class SendSetTargetTroopRatioEvent implements GameEvent {
export class SendWinnerEvent implements GameEvent {
constructor(public readonly winner: ClientID) {}
}
export class SendHashEvent implements GameEvent {
constructor(
public readonly tick: Tick,
public readonly hash: number,
) {}
}
export class Transport {
private socket: WebSocket;
@@ -151,6 +167,9 @@ export class Transport {
);
this.eventBus.on(SendEmojiIntentEvent, (e) => this.onSendEmojiIntent(e));
this.eventBus.on(SendDonateIntentEvent, (e) => this.onSendDonateIntent(e));
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
this.onSendEmbargoIntent(e),
);
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) =>
this.onSendSetTargetTroopRatioEvent(e),
);
@@ -159,6 +178,7 @@ export class Transport {
this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e));
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e));
this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e));
}
private startPing() {
@@ -219,9 +239,10 @@ export class Transport {
) {
this.startPing();
this.maybeKillSocket();
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
const wsHost = window.location.host;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
this.socket = new WebSocket(`${wsProtocol}//${wsHost}`);
const workerPath = this.serverConfig.workerPath(this.lobbyConfig.gameID);
this.socket = new WebSocket(`${wsProtocol}//${wsHost}/${workerPath}`);
this.onconnect = onconnect;
this.onmessage = onmessage;
this.socket.onopen = () => {
@@ -237,7 +258,9 @@ export class Transport {
const serverMsg = ServerMessageSchema.parse(JSON.parse(event.data));
this.onmessage(serverMsg);
} catch (error) {
console.error("Failed to process server message:", error);
console.error(
`Failed to process server message ${event.data}: ${error}`,
);
}
};
this.socket.onerror = (err) => {
@@ -397,6 +420,16 @@ export class Transport {
});
}
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
this.sendIntent({
type: "embargo",
clientID: this.lobbyConfig.clientID,
playerID: this.lobbyConfig.playerID,
targetID: event.target.id(),
action: event.action,
});
}
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
@@ -448,6 +481,26 @@ export class Transport {
}
}
private onSendHashEvent(event: SendHashEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientMessageSchema.parse({
type: "hash",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
tick: event.tick,
hash: event.hash,
});
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
);
console.log("attempting reconnect");
}
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
+26
View File
@@ -0,0 +1,26 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("modal-overlay")
export class ModalOverlay extends LitElement {
@property({ reflect: true }) public visible: boolean = false;
static styles = css`
.overlay {
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
}
`;
render() {
return html`
<div
class="overlay ${this.visible ? "" : "hidden"}"
@click=${() => (this.visible = false)}
></div>
`;
}
}
+10 -1
View File
@@ -5,7 +5,7 @@ import {
calculateBoundingBox,
calculateBoundingBoxCenter,
} from "../../core/Util";
import { ZoomEvent, DragEvent } from "../InputHandler";
import { ZoomEvent, DragEvent, CenterCameraEvent } from "../InputHandler";
import { GoToPlayerEvent } from "./layers/Leaderboard";
import { placeName } from "./NameBoxCalculator";
import { GameView } from "../../core/game/GameView";
@@ -27,6 +27,7 @@ export class TransformHandler {
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e));
this.eventBus.on(DragEvent, (e) => this.onMove(e));
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e));
this.eventBus.on(CenterCameraEvent, () => this.centerCamera());
}
boundingRect(): DOMRect {
@@ -148,6 +149,14 @@ export class TransformHandler {
this.intervalID = setInterval(() => this.goTo(), 1);
}
centerCamera() {
this.clearTarget();
const player = this.game.myPlayer();
if (!player || !player.nameLocation()) return;
this.target = new Cell(player.nameLocation().x, player.nameLocation().y);
this.intervalID = setInterval(() => this.goTo(), 1);
}
private goTo() {
const { screenX, screenY } = this.screenCenter();
const screenMapCenter = new Cell(screenX, screenY);
+1 -1
View File
@@ -190,7 +190,7 @@ export class ControlPanel extends LitElement implements Layer {
</style>
<div
class="${this._isVisible
? "w-full text-sm lg:text-m lg:w-72 bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg rounded-lg backdrop-blur"
? "w-full text-sm lg:text-m lg:w-72 bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur"
: "hidden"}"
@contextmenu=${(e) => e.preventDefault()}
>
+36 -6
View File
@@ -25,6 +25,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { onlyImages, sanitize } from "../../../core/Util";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { renderTroops } from "../../Utils";
import { GoToPlayerEvent } from "./Leaderboard";
interface Event {
description: string;
@@ -33,6 +34,7 @@ interface Event {
text: string;
className: string;
action: () => void;
preventClose?: boolean;
}[];
type: MessageType;
highlight?: boolean;
@@ -53,6 +55,15 @@ export class EventsDisplay extends LitElement implements Layer {
@state() private incomingAttacks: AttackUpdate[] = [];
@state() private outgoingAttacks: AttackUpdate[] = [];
@state() private _hidden: boolean = false;
@state() private newEvents: number = 0;
private toggleHidden() {
this._hidden = !this._hidden;
if (this._hidden) {
this.newEvents = 0;
}
this.requestUpdate();
}
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
@@ -63,7 +74,7 @@ export class EventsDisplay extends LitElement implements Layer {
],
[GameUpdateType.BrokeAlliance, (u) => this.onBrokeAllianceEvent(u)],
[GameUpdateType.TargetPlayer, (u) => this.onTargetPlayerEvent(u)],
[GameUpdateType.EmojiUpdate, (u) => this.onEmojiMessageEvent(u)],
[GameUpdateType.Emoji, (u) => this.onEmojiMessageEvent(u)],
]);
constructor() {
@@ -119,6 +130,9 @@ export class EventsDisplay extends LitElement implements Layer {
private addEvent(event: Event) {
this.events = [...this.events, event];
if (this._hidden == true) {
this.newEvents++;
}
this.requestUpdate();
}
@@ -169,6 +183,12 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: `${requestor.name()} requests an alliance!`,
buttons: [
{
text: "Focus",
className: "btn-gray",
action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)),
preventClose: true,
},
{
text: "Accept",
className: "btn",
@@ -397,7 +417,7 @@ export class EventsDisplay extends LitElement implements Layer {
<div
class="${this._hidden
? "w-fit px-[10px] py-[5px]"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-3xl lg:w-full lg:w-auto"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-[30vw] lg:w-full lg:w-auto"
>
<div>
<div class="w-full bg-black/80 sticky top-0 px-[10px]">
@@ -406,7 +426,7 @@ export class EventsDisplay extends LitElement implements Layer {
._hidden
? "hidden"
: ""}"
@click=${() => (this._hidden = true)}
@click=${this.toggleHidden}
>
Hide
</button>
@@ -415,9 +435,15 @@ export class EventsDisplay extends LitElement implements Layer {
class="text-white cursor-pointer pointer-events-auto ${this._hidden
? ""
: "hidden"}"
@click=${() => (this._hidden = false)}
@click=${this.toggleHidden}
>
Events
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-sm"
>${this.newEvents}</span
>
</button>
<table
class="w-full border-collapse text-white shadow-lg lg:text-xl text-xs ${this
@@ -447,10 +473,14 @@ export class EventsDisplay extends LitElement implements Layer {
class="inline-block px-3 py-1 text-white rounded text-sm cursor-pointer transition-colors duration-300
${btn.className.includes("btn-info")
? "bg-blue-500 hover:bg-blue-600"
: "bg-green-600 hover:bg-green-700"}"
: btn.className.includes("btn-gray")
? "bg-gray-500 hover:bg-gray-600"
: "bg-green-600 hover:bg-green-700"}"
@click=${() => {
btn.action();
this.removeEvent(index);
if (!btn.preventClose) {
this.removeEvent(index);
}
this.requestUpdate();
}}
>
+60 -24
View File
@@ -1,17 +1,18 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Layer } from "./Layer";
import { ClientID } from "../../../core/Schemas";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { renderNumber } from "../../Utils";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { renderNumber } from "../../Utils";
import { Layer } from "./Layer";
interface Entry {
name: string;
position: number;
score: string;
gold: string;
troops: string;
isMyPlayer: boolean;
player: PlayerView;
}
@@ -26,6 +27,13 @@ export class Leaderboard extends LitElement implements Layer {
public clientID: ClientID;
public eventBus: EventBus;
players: Entry[] = [];
@state()
private _leaderboardHidden = true;
private _shownOnInit = false;
private showTopFive = true;
init() {}
tick() {
@@ -58,14 +66,25 @@ export class Leaderboard extends LitElement implements Layer {
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
this.players = sorted.slice(0, 5).map((player, index) => ({
name: player.displayName(),
position: index + 1,
score: formatPercentage(player.numTilesOwned() / numTilesWithoutFallout),
gold: renderNumber(player.gold()),
isMyPlayer: player == myPlayer,
player: player,
}));
const playersToShow = this.showTopFive ? sorted.slice(0, 5) : sorted;
this.players = playersToShow.map((player, index) => {
let troops = player.troops() / 10;
if (!player.isAlive()) {
troops = 0;
}
return {
name: player.displayName(),
position: index + 1,
score: formatPercentage(
player.numTilesOwned() / numTilesWithoutFallout,
),
gold: renderNumber(player.gold()),
troops: renderNumber(troops),
isMyPlayer: player == myPlayer,
player: player,
};
});
if (myPlayer != null && this.players.find((p) => p.isMyPlayer) == null) {
let place = 0;
@@ -76,6 +95,10 @@ export class Leaderboard extends LitElement implements Layer {
}
}
let myPlayerTroops = myPlayer.troops() / 10;
if (!myPlayer.isAlive()) {
myPlayerTroops = 0;
}
this.players.pop();
this.players.push({
name: myPlayer.displayName(),
@@ -84,6 +107,7 @@ export class Leaderboard extends LitElement implements Layer {
myPlayer.numTilesOwned() / this.game.numLandTiles(),
),
gold: renderNumber(myPlayer.gold()),
troops: renderNumber(myPlayerTroops),
isMyPlayer: true,
player: myPlayer,
});
@@ -119,10 +143,10 @@ export class Leaderboard extends LitElement implements Layer {
padding-top: 0px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
max-width: 300px;
max-height: 80vh;
max-width: 500px;
max-height: 30vh;
overflow-y: auto;
width: 300px;
width: 400px;
backdrop-filter: blur(5px);
}
table {
@@ -180,6 +204,13 @@ export class Leaderboard extends LitElement implements Layer {
cursor: pointer;
}
.leaderboard-top-five-button {
background: none;
border: none;
color: white;
cursor: pointer;
}
.player-name {
max-width: 10ch;
overflow: hidden;
@@ -188,7 +219,8 @@ export class Leaderboard extends LitElement implements Layer {
@media (max-width: 1000px) {
.leaderboard {
top: 60px;
top: 70px;
left: 0px;
}
.leaderboard-button {
@@ -198,13 +230,6 @@ export class Leaderboard extends LitElement implements Layer {
}
`;
players: Entry[] = [];
@state()
private _leaderboardHidden = true;
private _shownOnInit = false;
render() {
return html`
<button
@@ -225,6 +250,15 @@ export class Leaderboard extends LitElement implements Layer {
>
Hide
</button>
<button
class="leaderboard-top-five-button"
@click=${() => {
this.showTopFive = !this.showTopFive;
this.updateLeaderboard();
}}
>
${this.showTopFive ? "Show All" : "Show Top 5"}
</button>
<table>
<thead>
<tr>
@@ -232,6 +266,7 @@ export class Leaderboard extends LitElement implements Layer {
<th>Player</th>
<th>Owned</th>
<th>Gold</th>
<th>Troops</th>
</tr>
</thead>
<tbody>
@@ -245,6 +280,7 @@ export class Leaderboard extends LitElement implements Layer {
<td class="player-name">${unsafeHTML(player.name)}</td>
<td>${player.score}</td>
<td>${player.gold}</td>
<td>${player.troops}</td>
</tr>
`,
)}
+44
View File
@@ -11,8 +11,10 @@ import { Layer } from "./Layer";
import { TransformHandler } from "../TransformHandler";
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
import allianceRequestIcon from "../../../../resources/images/AllianceRequestIcon.svg";
import crownIcon from "../../../../resources/images/CrownIcon.svg";
import targetIcon from "../../../../resources/images/TargetIcon.svg";
import embargoIcon from "../../../../resources/images/EmbargoIcon.svg";
import { ClientID } from "../../../core/Schemas";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { createCanvas, renderTroops } from "../../Utils";
@@ -40,9 +42,11 @@ export class NameLayer implements Layer {
private renders: RenderInfo[] = [];
private seenPlayers: Set<PlayerView> = new Set();
private traitorIconImage: HTMLImageElement;
private allianceRequestIconImage: HTMLImageElement;
private allianceIconImage: HTMLImageElement;
private targetIconImage: HTMLImageElement;
private crownIconImage: HTMLImageElement;
private embargoIconImage: HTMLImageElement;
private container: HTMLDivElement;
private myPlayer: PlayerView | null = null;
private firstPlace: PlayerView | null = null;
@@ -57,10 +61,14 @@ export class NameLayer implements Layer {
this.traitorIconImage.src = traitorIcon;
this.allianceIconImage = new Image();
this.allianceIconImage.src = allianceIcon;
this.allianceRequestIconImage = new Image();
this.allianceRequestIconImage.src = allianceRequestIcon;
this.crownIconImage = new Image();
this.crownIconImage.src = crownIcon;
this.targetIconImage = new Image();
this.targetIconImage.src = targetIcon;
this.embargoIconImage = new Image();
this.embargoIconImage.src = embargoIcon;
}
resizeCanvas() {
@@ -162,6 +170,7 @@ export class NameLayer implements Layer {
iconsDiv.style.justifyContent = "center";
iconsDiv.style.alignItems = "center";
iconsDiv.style.zIndex = "2";
iconsDiv.style.opacity = "0.8";
element.appendChild(iconsDiv);
const nameDiv = document.createElement("div");
@@ -314,6 +323,23 @@ export class NameLayer implements Layer {
existingAlliance.remove();
}
// Alliance request icon
const data = '[data-icon="alliance-request"]';
const existingRequestAlliance = iconsDiv.querySelector(data);
if (myPlayer != null && render.player.isRequestingAllianceWith(myPlayer)) {
if (!existingRequestAlliance) {
iconsDiv.appendChild(
this.createIconElement(
this.allianceRequestIconImage.src,
iconSize,
"alliance-request",
),
);
}
} else if (existingRequestAlliance) {
existingRequestAlliance.remove();
}
// Target icon
const existingTarget = iconsDiv.querySelector('[data-icon="target"]');
if (
@@ -359,6 +385,24 @@ export class NameLayer implements Layer {
existingEmoji.remove();
}
const existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]');
const hasEmbargo =
render.player.hasEmbargoAgainst(myPlayer) ||
myPlayer.hasEmbargoAgainst(render.player);
if (myPlayer && hasEmbargo) {
if (!existingEmbargo) {
iconsDiv.appendChild(
this.createIconElement(
this.embargoIconImage.src,
iconSize,
"embargo",
),
);
}
} else if (existingEmbargo) {
existingEmbargo.remove();
}
// Update all icon sizes
const icons = iconsDiv.getElementsByTagName("img");
for (const icon of icons) {
+2 -2
View File
@@ -107,7 +107,7 @@ export class OptionsMenu extends LitElement implements Layer {
tick() {
this.hasWinner =
this.hasWinner ||
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate].length > 0;
this.game.updatesSinceLastTick()[GameUpdateType.Win].length > 0;
if (this.game.inSpawnPhase()) {
this.timer = 0;
} else if (!this.hasWinner && this.game.ticks() % 10 == 0) {
@@ -127,7 +127,7 @@ export class OptionsMenu extends LitElement implements Layer {
@contextmenu=${(e) => e.preventDefault()}
>
<div
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-lg backdrop-blur-md"
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-es-sm lg:rounded-lg backdrop-blur-md"
>
<div class="flex items-stretch gap-1 lg:gap-2">
${button({
+98 -12
View File
@@ -4,7 +4,12 @@ import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { MouseUpEvent } from "../../InputHandler";
import { AllPlayers, Player, PlayerActions } from "../../../core/game/Game";
import {
AllPlayers,
Player,
PlayerActions,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { renderNumber, renderTroops } from "../../Utils";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
@@ -18,6 +23,7 @@ import {
SendDonateIntentEvent,
SendEmojiIntentEvent,
SendTargetPlayerIntentEvent,
SendEmbargoIntentEvent,
} from "../../Transport";
import { EmojiTable } from "./EmojiTable";
@@ -76,6 +82,26 @@ export class PlayerPanel extends LitElement implements Layer {
this.hide();
}
private handleEmbargoClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "start"));
this.hide();
}
private handleStopEmbargoClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "stop"));
this.hide();
}
private handleEmojiClick(e: Event, myPlayer: PlayerView, other: PlayerView) {
e.stopPropagation();
this.emojiTable.showTable((emoji: string) => {
@@ -107,6 +133,24 @@ export class PlayerPanel extends LitElement implements Layer {
this.requestUpdate();
}
getTotalNukesSent(): number {
const stats = this.actions.interaction?.stats;
if (!stats) {
return 0;
}
let sum = 0;
const nukes = stats.sentNukes[this.g.myPlayer().id()];
if (!nukes) {
return 0;
}
for (const nukeType in nukes) {
if (nukeType != UnitType.MIRVWarhead) {
sum += nukes[nukeType];
}
}
return sum;
}
render() {
if (!this.isVisible) {
return html``;
@@ -131,6 +175,7 @@ export class PlayerPanel extends LitElement implements Layer {
: this.actions.interaction?.canSendEmoji;
const canBreakAlliance = this.actions.interaction?.canBreakAlliance;
const canTarget = this.actions.interaction?.canTarget;
const canEmbargo = this.actions.interaction?.canEmbargo;
return html`
<div
@@ -143,7 +188,7 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Close button -->
<button
@click=${this.handleClose}
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
bg-red-500 hover:bg-red-600 text-white rounded-full
text-sm font-bold transition-colors"
>
@@ -155,7 +200,7 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="flex items-center gap-1 lg:gap-2">
<div
class="px-4 h-8 lg:h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
rounded text-sm lg:text-xl w-full"
>
${other?.name()}
@@ -190,12 +235,32 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
</div>
<!-- Embargo -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Embargo against you
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.hasEmbargoAgainst(myPlayer) ? "Yes" : "No"}
</div>
</div>
<!-- Stats -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Nukes sent by them to you
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${this.getTotalNukesSent()}
</div>
</div>
<!-- Action buttons -->
<div class="flex justify-center gap-2">
${canTarget
? html`<button
@click=${(e) => this.handleTargetClick(e, other)}
class="w-10 h-10 flex items-center justify-center
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
@@ -206,8 +271,8 @@ export class PlayerPanel extends LitElement implements Layer {
? html`<button
@click=${(e) =>
this.handleBreakAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img
@@ -221,8 +286,8 @@ export class PlayerPanel extends LitElement implements Layer {
? html`<button
@click=${(e) =>
this.handleAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${allianceIcon} alt="Alliance" class="w-6 h-6" />
@@ -231,8 +296,8 @@ export class PlayerPanel extends LitElement implements Layer {
${canDonate
? html`<button
@click=${(e) => this.handleDonateClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${donateIcon} alt="Donate" class="w-6 h-6" />
@@ -241,14 +306,35 @@ export class PlayerPanel extends LitElement implements Layer {
${canSendEmoji
? html`<button
@click=${(e) => this.handleEmojiClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${emojiIcon} alt="Emoji" class="w-6 h-6" />
</button>`
: ""}
</div>
${canEmbargo && other != myPlayer
? html`<button
@click=${(e) => this.handleEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Start embargo
</button>`
: ""}
${!canEmbargo && other != myPlayer
? html`<button
@click=${(e) =>
this.handleStopEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Stop embargo
</button>`
: ""}
</div>
</div>
</div>
+19 -2
View File
@@ -8,6 +8,9 @@ import { renderNumber, renderTroops } from "../../Utils";
export class TopBar extends LitElement implements Layer {
public game: GameView;
private isVisible = false;
private _population = 0;
private _lastPopulationIncreaseRate = 0;
private _popRateIsIncreasing = false;
createRenderRoot() {
return this;
@@ -19,6 +22,15 @@ export class TopBar extends LitElement implements Layer {
}
tick() {
if (this.game?.myPlayer() !== null) {
const popIncreaseRate =
this.game.myPlayer().population() - this._population;
if (this.game.ticks() % 5 == 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
}
this.requestUpdate();
}
@@ -38,7 +50,7 @@ export class TopBar extends LitElement implements Layer {
return html`
<div
class="fixed top-0 z-50 bg-gray-800/70 text-white text-sm p-1 rounded grid grid-cols-1 sm:grid-cols-2 w-1/2 sm:w-2/3 md:w-1/2 lg:hidden backdrop-blur"
class="fixed top-0 z-50 bg-gray-800/70 text-white text-sm p-1 rounded-ee-sm lg:rounded grid grid-cols-1 sm:grid-cols-2 w-1/2 sm:w-2/3 md:w-1/2 lg:hidden backdrop-blur"
>
<!-- Pop section (takes 2 columns on desktop) -->
<div
@@ -49,7 +61,12 @@ export class TopBar extends LitElement implements Layer {
>${renderTroops(myPlayer.population())} /
${renderTroops(maxPop)}</span
>
<span>(+${renderTroops(popRate)})</span>
<span
class="${this._popRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
>(+${renderTroops(popRate)})</span
>
</div>
<!-- Gold section (takes 1 column on desktop) -->
<div
+48 -27
View File
@@ -34,12 +34,13 @@ export class WinModal extends LitElement implements Layer {
private _title: string;
private won: boolean;
static styles = css`
:host {
display: block;
}
// Override to prevent shadow DOM creation
createRenderRoot() {
return this;
}
.modal {
static styles = css`
.win-modal {
display: none;
position: fixed;
top: 50%;
@@ -58,7 +59,7 @@ export class WinModal extends LitElement implements Layer {
visibility 0.3s ease-in-out;
}
.modal.visible {
.win-modal.visible {
display: block;
animation: fadeIn 0.3s ease-out;
}
@@ -74,14 +75,14 @@ export class WinModal extends LitElement implements Layer {
}
}
h2 {
.win-modal h2 {
margin: 0 0 15px 0;
font-size: 24px;
text-align: center;
color: white;
}
p {
.win-modal p {
margin: 0 0 20px 0;
text-align: center;
background-color: rgba(0, 0, 0, 0.3);
@@ -95,7 +96,7 @@ export class WinModal extends LitElement implements Layer {
gap: 10px;
}
button {
.win-modal button {
flex: 1;
padding: 12px;
font-size: 16px;
@@ -109,38 +110,46 @@ export class WinModal extends LitElement implements Layer {
transform 0.1s ease;
}
button:hover {
.win-modal button:hover {
background: rgba(0, 150, 255, 0.8);
transform: translateY(-1px);
}
button:active {
.win-modal button:active {
transform: translateY(1px);
}
@media (max-width: 768px) {
.modal {
.win-modal {
width: 90%;
max-width: 300px;
padding: 20px;
}
h2 {
.win-modal h2 {
font-size: 20px;
}
button {
.win-modal button {
padding: 10px;
font-size: 14px;
}
}
`;
constructor() {
super();
// Add styles to document
const styleEl = document.createElement("style");
styleEl.textContent = WinModal.styles.toString();
document.head.appendChild(styleEl);
}
render() {
return html`
<div class="modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title}</h2>
<div>${this.won ? this.supportHTML() : this.adsHTML()}</div>
<div class="win-modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title || ""}</h2>
${this.won ? this.supportHTML() : this.adsHTML()}
<div class="button-container">
<button @click=${this._handleExit}>Exit Game</button>
<button @click=${this.hide}>Keep Playing</button>
@@ -149,24 +158,40 @@ export class WinModal extends LitElement implements Layer {
`;
}
adsHTML(): ReturnType<typeof html> {
updated(changedProperties) {
super.updated(changedProperties);
// Initialize ads if modal is visible and showing ads
if (changedProperties.has("isVisible") && this.isVisible && !this.won) {
try {
setTimeout(() => {
(adsbygoogle = window.adsbygoogle || []).push({});
}, 0);
} catch (error) {
console.error("Error initializing ad:", error);
}
}
}
adsHTML() {
return html`<ins
class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-7035513310742290"
data-ad-slot="3772893937"
data-ad-slot="winmodalad"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>`;
}
supportHTML(): ReturnType<typeof html> {
supportHTML() {
return html`
<div style="text-align: center; margin: 15px 0;">
<p>
Like the game? Help make this my full-time project!
<a
href="https://discord.com/channels/1284581928254701718/shop/1330243291366559744"
href="https://discord.gg/k22YrnAzGp"
target="_blank"
rel="noopener noreferrer"
style="color: #0096ff; text-decoration: underline; display: block; margin-top: 5px;"
>
Support the game!
@@ -201,14 +226,10 @@ export class WinModal extends LitElement implements Layer {
this.hasShownDeathModal = true;
this._title = "You died";
this.won = false;
try {
(adsbygoogle = window.adsbygoogle || []).push({});
} catch (error) {
console.error("Error initializing ad:", error);
}
this.show();
}
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate].forEach((wu) => {
this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => {
const winner = this.game.playerBySmallID(wu.winnerID) as PlayerView;
this.eventBus.emit(new SendWinnerEvent(winner.clientID()));
if (winner == this.game.myPlayer()) {
+9 -3
View File
@@ -153,19 +153,20 @@
</g>
</svg>
<div class="flex justify-center text-sm font-bold mt-[-5px] logo-version">
v0.16.0
v0.17.1
</div>
</div>
<div class="bg-image"></div>
<dark-mode-button></dark-mode-button>
<google-ad></google-ad>
<!-- Main container with responsive padding -->
<div class="flex justify-center items-center flex-grow">
<div class="container px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
<div
class="relative flex gap-1 items-center max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl mx-auto p-2 pb-4"
class="flex gap-1 items-center max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl mx-auto p-2 pb-4"
>
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
<username-input class="w-full"></username-input>
@@ -174,8 +175,13 @@
<div class="max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl mx-auto mt-4">
<a
href="https://discord.gg/k22YrnAzGp"
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"
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"
/>
Join the Discord!
</a>
</div>
+2
View File
@@ -167,6 +167,8 @@ export class GameRunner {
canSendAllianceRequest: player.canSendAllianceRequest(other),
canBreakAlliance: player.isAlliedWith(other),
canDonate: player.canDonate(other),
canEmbargo: !player.hasEmbargoAgainst(other),
stats: this.game.stats().getPlayerStats(other.id()),
};
}
+47 -14
View File
@@ -22,7 +22,8 @@ export type Intent =
| EmojiIntent
| DonateIntent
| TargetTroopRatioIntent
| BuildUnitIntent;
| BuildUnitIntent
| EmbargoIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
@@ -35,6 +36,7 @@ export type BreakAllianceIntent = z.infer<typeof BreakAllianceIntentSchema>;
export type TargetPlayerIntent = z.infer<typeof TargetPlayerIntentSchema>;
export type EmojiIntent = z.infer<typeof EmojiIntentSchema>;
export type DonateIntent = z.infer<typeof DonateIntentSchema>;
export type EmbargoIntent = z.infer<typeof EmbargoIntentSchema>;
export type TargetTroopRatioIntent = z.infer<
typeof TargetTroopRatioIntentSchema
>;
@@ -48,29 +50,44 @@ export type ClientMessage =
| ClientPingMessage
| ClientIntentMessage
| ClientJoinMessage
| ClientLogMessage;
| ClientLogMessage
| ClientHashMessage;
export type ServerMessage =
| ServerSyncMessage
| ServerStartGameMessage
| ServerPingMessage;
| ServerPingMessage
| ServerDesyncMessage;
export type ServerSyncMessage = z.infer<typeof ServerTurnMessageSchema>;
export type ServerStartGameMessage = z.infer<
typeof ServerStartGameMessageSchema
>;
export type ServerPingMessage = z.infer<typeof ServerPingMessageSchema>;
export type ServerDesyncMessage = z.infer<typeof ServerDesyncSchema>;
export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>;
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>;
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>;
export type ClientLogMessage = z.infer<typeof ClientLogMessageSchema>;
export type ClientHashMessage = z.infer<typeof ClientHashSchema>;
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
export type GameRecord = z.infer<typeof GameRecordSchema>;
const PlayerTypeSchema = z.nativeEnum(PlayerType);
export interface GameInfo {
gameID: GameID;
clients?: ClientInfo[];
numClients?: number;
msUntilStart?: number;
gameConfig?: GameConfig;
}
export interface ClientInfo {
clientID: ClientID;
username: string;
}
export enum LogSeverity {
Debug = "DEBUG",
Info = "INFO",
@@ -79,15 +96,6 @@ export enum LogSeverity {
Fatal = "FATAL",
}
// TODO: create Cell schema
export interface Lobby {
id: string;
msUntilStart?: number;
numClients?: number;
gameConfig?: GameConfig;
}
const GameConfigSchema = z.object({
gameMap: z.nativeEnum(GameMapType),
difficulty: z.nativeEnum(Difficulty),
@@ -133,6 +141,7 @@ const BaseIntentSchema = z.object({
"emoji",
"troop_ratio",
"build_unit",
"embargo",
]),
clientID: ID,
playerID: ID,
@@ -196,6 +205,13 @@ export const EmojiIntentSchema = BaseIntentSchema.extend({
emoji: EmojiSchema,
});
export const EmbargoIntentSchema = BaseIntentSchema.extend({
type: z.literal("embargo"),
playerID: ID,
targetID: ID,
action: z.union([z.literal("start"), z.literal("stop")]),
});
export const DonateIntentSchema = BaseIntentSchema.extend({
type: z.literal("donate"),
playerID: ID,
@@ -229,6 +245,7 @@ const IntentSchema = z.union([
DonateIntentSchema,
TargetTroopRatioIntentSchema,
BuildUnitIntentSchema,
EmbargoIntentSchema,
]);
export const TurnSchema = z.object({
@@ -240,7 +257,7 @@ export const TurnSchema = z.object({
// Server
const ServerBaseMessageSchema = z.object({
type: SafeString,
type: z.enum(["turn", "ping", "start", "desync"]),
});
export const ServerTurnMessageSchema = ServerBaseMessageSchema.extend({
@@ -259,16 +276,25 @@ export const ServerStartGameMessageSchema = ServerBaseMessageSchema.extend({
config: GameConfigSchema,
});
export const ServerDesyncSchema = ServerBaseMessageSchema.extend({
type: z.literal("desync"),
turn: z.number(),
correctHash: z.number().nullable(),
clientsWithCorrectHash: z.number(),
totalActiveClients: z.number(),
});
export const ServerMessageSchema = z.union([
ServerTurnMessageSchema,
ServerStartGameMessageSchema,
ServerPingMessageSchema,
ServerDesyncSchema,
]);
// Client
const ClientBaseMessageSchema = z.object({
type: z.enum(["winner", "join", "intent", "ping", "log"]),
type: z.enum(["winner", "join", "intent", "ping", "log", "hash"]),
clientID: ID,
persistentID: SafeString.nullable(), // WARNING: persistent id is private.
gameID: ID,
@@ -279,6 +305,12 @@ export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
winner: ID.nullable(),
});
export const ClientHashSchema = ClientBaseMessageSchema.extend({
type: z.literal("hash"),
hash: z.number(),
tick: z.number(),
});
export const ClientLogMessageSchema = ClientBaseMessageSchema.extend({
type: z.literal("log"),
severity: z.nativeEnum(LogSeverity),
@@ -308,6 +340,7 @@ export const ClientMessageSchema = z.union([
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientLogMessageSchema,
ClientHashSchema,
]);
export const PlayerRecordSchema = z.object({
+17 -2
View File
@@ -208,13 +208,12 @@ export function getMode(list: Set<number>): number {
export function sanitize(name: string): string {
return Array.from(name)
.join("")
.replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}\[\]]/gu, "");
.replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}\[\]_]/gu, "");
}
export function processName(name: string): string {
// First sanitize the raw input - strip everything except text and emojis
const sanitizedName = sanitize(name);
// Process emojis with twemoji
const withEmojis = twemoji.parse(sanitizedName, {
base: "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/",
@@ -307,3 +306,19 @@ export function generateID(): GameID {
);
return nanoid();
}
export function toInt(num: number): bigint {
return BigInt(Math.floor(num));
}
export function maxInt(a: bigint, b: bigint): bigint {
return a > b ? a : b;
}
export function minInt(a: bigint, b: bigint): bigint {
return a < b ? a : b;
}
export function withinInt(num: bigint, min: bigint, max: bigint): bigint {
const atLeastMin = maxInt(num, min);
return minInt(atLeastMin, max);
}
+8 -3
View File
@@ -15,7 +15,7 @@ import { Colord, colord } from "colord";
import { preprodConfig } from "./PreprodConfig";
import { prodConfig } from "./ProdConfig";
import { consolex } from "../Consolex";
import { GameConfig } from "../Schemas";
import { GameConfig, GameID } from "../Schemas";
import { DefaultConfig } from "./DefaultConfig";
import { DevConfig, DevServerConfig } from "./DevConfig";
import { GameMap, TileRef } from "../game/GameMap";
@@ -63,9 +63,14 @@ export function getServerConfig(): ServerConfig {
export interface ServerConfig {
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyLifetime(): number;
gameCreationRate(highTraffic: boolean): number;
lobbyLifetime(highTraffic): number;
discordRedirectURI(): string;
numWorkers(): number;
workerIndex(gameID: GameID): number;
workerPath(gameID: GameID): string;
workerPort(gameID: GameID): number;
workerPortByIndex(workerID: number): number;
env(): GameEnv;
}
+26 -9
View File
@@ -1,10 +1,8 @@
import { renderNumber } from "../../client/Utils";
import {
Difficulty,
Game,
GameType,
Gold,
MessageType,
Player,
PlayerInfo,
PlayerType,
@@ -14,26 +12,45 @@ import {
UnitInfo,
UnitType,
} from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { assertNever, within } from "../Util";
import { GameConfig, GameID } from "../Schemas";
import { assertNever, simpleHash, within } from "../Util";
import { Config, GameEnv, ServerConfig, Theme } from "./Config";
import { pastelTheme } from "./PastelTheme";
import { pastelThemeDark } from "./PastelThemeDark";
export abstract class DefaultServerConfig implements ServerConfig {
numWorkers(): number {
return 2;
}
abstract env(): GameEnv;
abstract discordRedirectURI(): string;
turnIntervalMs(): number {
return 100;
}
gameCreationRate(): number {
return 1 * 60 * 1000;
gameCreationRate(highTraffic: boolean): number {
if (highTraffic) {
return 30 * 1000;
} else {
return 60 * 1000;
}
}
lobbyLifetime(): number {
return 2 * 60 * 1000;
lobbyLifetime(highTraffic: boolean): number {
return this.gameCreationRate(highTraffic) * 2;
}
workerIndex(gameID: GameID): number {
return simpleHash(gameID) % this.numWorkers();
}
workerPath(gameID: GameID): string {
return `w${this.workerIndex(gameID)}`;
}
workerPort(gameID: GameID): number {
return this.workerPortByIndex(this.workerIndex(gameID));
}
workerPortByIndex(index: number): number {
return 3001 + index;
}
}
+2 -6
View File
@@ -8,12 +8,8 @@ export class DevServerConfig extends DefaultServerConfig {
env(): GameEnv {
return GameEnv.Dev;
}
gameCreationRate(): number {
return 10 * 1000;
}
lobbyLifetime(): number {
return 10 * 1000;
gameCreationRate(highTraffic: boolean): number {
return 5 * 1000;
}
discordRedirectURI(): string {
+1 -1
View File
@@ -253,7 +253,7 @@ export const pastelThemeDark = new (class implements Theme {
}
textColor(playerInfo: PlayerInfo): string {
return playerInfo.playerType == PlayerType.Human ? "#ffffff" : "#dbdbdb";
return playerInfo.playerType == PlayerType.Human ? "#ffffff" : "#e6e6e6";
}
borderColor(playerInfo: PlayerInfo): Colord {
+44
View File
@@ -0,0 +1,44 @@
import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class EmbargoExecution implements Execution {
private active = true;
constructor(
private player: Player,
private targetID: PlayerID,
private readonly action: "start" | "stop",
) {}
init(mg: Game, _: number): void {
if (!mg.hasPlayer(this.player.id())) {
console.warn(`EmbargoExecution: sender ${this.player.id()} not found`);
this.active = false;
return;
}
if (!mg.hasPlayer(this.targetID)) {
console.warn(`EmbargoExecution recipient ${this.targetID} not found`);
this.active = false;
return;
}
}
tick(_: number): void {
if (this.action == "start") this.player.addEmbargo(this.targetID);
else this.player.stopEmbargo(this.targetID);
this.active = false;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+6 -1
View File
@@ -34,6 +34,7 @@ import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { ConstructionExecution } from "./ConstructionExecution";
import { fixProfaneUsername, isProfaneUsername } from "../validations/username";
import { NoOpExecution } from "./NoOpExecution";
import { EmbargoExecution } from "./EmbargoExecution";
export class Executor {
// private random = new PseudoRandom(999)
@@ -53,6 +54,7 @@ export class Executor {
}
createExec(intent: Intent): Execution {
let player: Player;
if (intent.type != "spawn") {
if (!this.mg.hasPlayer(intent.playerID)) {
console.warn(
@@ -60,7 +62,7 @@ export class Executor {
);
return new NoOpExecution();
}
const player = this.mg.player(intent.playerID);
player = this.mg.player(intent.playerID);
if (player.clientID() != intent.clientID) {
console.warn(
`intent ${intent.type} has incorrect clientID ${intent.clientID} for player ${player.name()} with clientID ${player.clientID()}`,
@@ -68,6 +70,7 @@ export class Executor {
return new NoOpExecution();
}
}
switch (intent.type) {
case "attack": {
return new AttackExecution(
@@ -124,6 +127,8 @@ export class Executor {
);
case "troop_ratio":
return new SetTargetTroopRatioExecution(intent.playerID, intent.ratio);
case "embargo":
return new EmbargoExecution(player, intent.targetID, intent.action);
case "build_unit":
return new ConstructionExecution(
intent.playerID,
+8
View File
@@ -55,6 +55,14 @@ export class MirvExecution implements Execution {
this.pathFinder = PathFinder.Mini(mg, 10_000, true);
this.player = mg.player(this.senderID);
this.targetPlayer = this.mg.owner(this.dst);
this.mg
.stats()
.increaseNukeCount(
this.player.id(),
this.targetPlayer.id(),
UnitType.MIRV,
);
}
tick(ticks: number): void {
+11 -4
View File
@@ -8,6 +8,7 @@ import {
UnitType,
TerraNullius,
MessageType,
NukeType,
} from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { consolex } from "../Consolex";
@@ -22,10 +23,7 @@ export class NukeExecution implements Execution {
private random: PseudoRandom;
constructor(
private type:
| UnitType.AtomBomb
| UnitType.HydrogenBomb
| UnitType.MIRVWarhead,
private type: NukeType,
private senderID: PlayerID,
private dst: TileRef,
private src?: TileRef,
@@ -74,6 +72,14 @@ export class NukeExecution implements Execution {
target.id(),
);
}
this.mg
.stats()
.increaseNukeCount(
this.senderID,
target.id(),
this.nuke.type() as NukeType,
);
}
}
if (this.waitTicks > 0) {
@@ -157,6 +163,7 @@ export class NukeExecution implements Execution {
const prev = attacked.get(mp);
attacked.set(mp, prev + 1);
}
if (this.mg.isLand(tile)) {
this.mg.setFallout(tile, true);
}
+1 -1
View File
@@ -76,7 +76,7 @@ export class PlayerExecution implements Execution {
}
const popInc = this.config.populationIncreaseRate(this.player);
this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio())); // (1 - this.player.targetTroopRatio()))
this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio()));
this.player.addTroops(popInc * this.player.targetTroopRatio());
this.player.addGold(this.config.goldAdditionRate(this.player));
const adjustRate = this.config.troopAdjustmentRate(this.player);
+8 -10
View File
@@ -69,22 +69,20 @@ export class PortExecution implements Execution {
return;
}
const alliedPorts = this.player()
.alliances()
.map((a) => a.other(this.player()))
const tradingPartnersPorts = this.player()
.tradingPartners()
.flatMap((p) => p.units(UnitType.Port));
const alliedPortsSet = new Set(alliedPorts);
const tradingPartnersPortsSet = new Set(tradingPartnersPorts);
const allyConnections = new Set(
const tradingPartnersConnections = new Set(
Array.from(this.portPaths.keys()).map((p) => p.owner()),
);
allyConnections;
for (const port of alliedPorts) {
if (allyConnections.has(port.owner())) {
for (const port of tradingPartnersPorts) {
if (tradingPartnersConnections.has(port.owner())) {
continue;
}
allyConnections.add(port.owner());
tradingPartnersConnections.add(port.owner());
if (this.computingPaths.has(port)) {
const aStar = this.computingPaths.get(port);
switch (aStar.compute()) {
@@ -114,7 +112,7 @@ export class PortExecution implements Execution {
}
for (const port of this.portPaths.keys()) {
if (!port.isActive() || !alliedPortsSet.has(port)) {
if (!port.isActive() || !tradingPartnersPortsSet.has(port)) {
this.portPaths.delete(port);
this.computingPaths.delete(port);
}
+17 -8
View File
@@ -27,7 +27,7 @@ export class TradeShipExecution implements Execution {
constructor(
private _owner: PlayerID,
private srcPort: Unit,
private dstPort: Unit,
private _dstPort: Unit,
private pathFinder: PathFinder,
// don't modify
private path: TileRef[],
@@ -49,7 +49,12 @@ export class TradeShipExecution implements Execution {
this.active = false;
return;
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn);
this.tradeShip = this.origOwner.buildUnit(
UnitType.TradeShip,
0,
spawn,
this._dstPort,
);
}
if (!this.tradeShip.isActive()) {
@@ -64,8 +69,8 @@ export class TradeShipExecution implements Execution {
if (
!this.wasCaptured &&
(!this.dstPort.isActive() ||
!this.tradeShip.owner().isAlliedWith(this.dstPort.owner()))
(!this._dstPort.isActive() ||
!this.tradeShip.owner().canTrade(this._dstPort.owner()))
) {
this.tradeShip.delete(false);
this.active = false;
@@ -122,17 +127,17 @@ export class TradeShipExecution implements Execution {
const gold = this.mg
.config()
.tradeShipGold(
this.mg.manhattanDist(this.srcPort.tile(), this.dstPort.tile()),
this.mg.manhattanDist(this.srcPort.tile(), this._dstPort.tile()),
);
this.srcPort.owner().addGold(gold);
this.dstPort.owner().addGold(gold);
this._dstPort.owner().addGold(gold);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`,
MessageType.SUCCESS,
this.dstPort.owner().id(),
this._dstPort.owner().id(),
);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.dstPort.owner().displayName()}`,
`Received ${renderNumber(gold)} gold from trade with ${this._dstPort.owner().displayName()}`,
MessageType.SUCCESS,
this.srcPort.owner().id(),
);
@@ -154,4 +159,8 @@ export class TradeShipExecution implements Execution {
activeDuringSpawnPhase(): boolean {
return false;
}
dstPort(): TileRef {
return this._dstPort.tile();
}
}
+5 -1
View File
@@ -78,7 +78,11 @@ export class WarshipExecution implements Execution {
.filter((u) => u.owner() != this.warship.owner())
.filter((u) => u != this.warship)
.filter((u) => !u.owner().isAlliedWith(this.warship.owner()))
.filter((u) => !this.alreadySentShell.has(u));
.filter((u) => !this.alreadySentShell.has(u))
.filter(
(u) =>
u.type() != UnitType.TradeShip || u.dstPort().owner() != this.owner(),
);
this.target =
ships.sort((a, b) => {
+27 -2
View File
@@ -9,6 +9,7 @@ import {
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { PlayerStats, Stats } from "./Stats";
export type PlayerID = string;
export type Tick = number;
@@ -79,6 +80,11 @@ export enum UnitType {
MIRVWarhead = "MIRV Warhead",
Construction = "Construction",
}
export type NukeType =
| UnitType.AtomBomb
| UnitType.HydrogenBomb
| UnitType.MIRVWarhead
| UnitType.MIRV;
export enum Relation {
Hostile = 0,
@@ -214,6 +220,9 @@ export interface Unit {
// Updates
toUpdate(): UnitUpdate;
// Only for some types, otherwise return null
dstPort(): Unit;
}
export interface TerraNullius {
@@ -267,7 +276,12 @@ export interface Player {
units(...types: UnitType[]): Unit[];
unitsIncludingConstruction(type: UnitType): Unit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit(type: UnitType, troops: number, tile: TileRef): Unit;
buildUnit(
type: UnitType,
troops: number,
tile: TileRef,
dstPort?: Unit,
): Unit;
captureUnit(unit: Unit): void;
// Relations & Diplomacy
@@ -300,10 +314,17 @@ export interface Player {
outgoingEmojis(): EmojiMessage[];
sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void;
// Trading
// Donation
canDonate(recipient: Player): boolean;
donate(recipient: Player, troops: number): void;
// Embargo
hasEmbargoAgainst(other: Player): boolean;
tradingPartners(): Player[];
addEmbargo(other: PlayerID): void;
stopEmbargo(other: PlayerID): void;
canTrade(other: Player): boolean;
// Attacking.
canAttack(tile: TileRef): boolean;
createAttack(
@@ -364,6 +385,8 @@ export interface Game extends GameMap {
nations(): Nation[];
numTilesWithFallout(): number;
// Optional as it's not initialized before the end of spawn phase
stats(): Stats;
}
export interface PlayerActions {
@@ -392,6 +415,8 @@ export interface PlayerInteraction {
canBreakAlliance: boolean;
canTarget: boolean;
canDonate: boolean;
canEmbargo: boolean;
stats: PlayerStats;
}
export interface EmojiMessage {
+26 -10
View File
@@ -30,6 +30,8 @@ import { UnitImpl } from "./UnitImpl";
import { consolex } from "../Consolex";
import { GameMap, GameMapImpl, TileRef, TileUpdate } from "./GameMap";
import { DefenseGrid } from "./DefensePostGrid";
import { StatsImpl } from "./StatsImpl";
import { Stats } from "./Stats";
export function createGame(
gameMap: GameMap,
@@ -66,6 +68,9 @@ export class GameImpl implements Game {
private updates: GameUpdates = createGameUpdatesMap();
private defenseGrid: DefenseGrid;
// Not initialized until the game has finished spawning
private _stats: StatsImpl = new StatsImpl();
constructor(
private _map: GameMap,
private miniGameMap: GameMap,
@@ -241,21 +246,29 @@ export class GameImpl implements Game {
this.execs.push(...inited);
this.unInitExecs = unInited;
this._ticks++;
if (this._ticks % 100 == 0) {
let hash = 1;
this._players.forEach((p) => {
hash += p.hash();
});
consolex.log(`tick ${this._ticks}: hash ${hash}`);
}
for (const player of this._players.values()) {
// Players change each to so always add them
this.addUpdate(player.toUpdate());
}
if (this.ticks() % 10 == 0) {
this.addUpdate({
type: GameUpdateType.Hash,
tick: this.ticks(),
hash: this.hash(),
});
}
this._ticks++;
return this.updates;
}
private hash(): number {
let hash = 1;
this._players.forEach((p) => {
hash += p.hash();
});
return hash;
}
terraNullius(): TerraNullius {
return this._terraNullius;
}
@@ -494,14 +507,14 @@ export class GameImpl implements Game {
sendEmojiUpdate(msg: EmojiMessage): void {
this.addUpdate({
type: GameUpdateType.EmojiUpdate,
type: GameUpdateType.Emoji,
emoji: msg,
});
}
setWinner(winner: Player): void {
this.addUpdate({
type: GameUpdateType.WinUpdate,
type: GameUpdateType.Win,
winnerID: winner.smallID(),
});
}
@@ -630,6 +643,9 @@ export class GameImpl implements Game {
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
}
stats(): Stats {
return this._stats;
}
}
// Or a more dynamic approach that will catch new enum values:
+16 -5
View File
@@ -1,5 +1,6 @@
import { ClientID } from "../Schemas";
import {
AllianceRequest,
EmojiMessage,
GameUpdates,
MapPos,
@@ -34,8 +35,9 @@ export enum GameUpdateType {
BrokeAlliance,
AllianceExpired,
TargetPlayer,
EmojiUpdate,
WinUpdate,
Emoji,
Win,
Hash,
}
export type GameUpdate =
@@ -49,7 +51,8 @@ export type GameUpdate =
| DisplayMessageUpdate
| TargetPlayerUpdate
| EmojiUpdate
| WinUpdate;
| WinUpdate
| HashUpdate;
export interface TileUpdateWrapper {
type: GameUpdateType.Tile;
@@ -94,11 +97,13 @@ export interface PlayerUpdate {
troops: number;
targetTroopRatio: number;
allies: number[];
embargoes: Set<PlayerID>;
isTraitor: boolean;
targets: number[];
outgoingEmojis: EmojiMessage[];
outgoingAttacks: AttackUpdate[];
incomingAttacks: AttackUpdate[];
outgoingAllianceRequests: PlayerID[];
}
export interface AllianceRequestUpdate {
@@ -133,7 +138,7 @@ export interface TargetPlayerUpdate {
}
export interface EmojiUpdate {
type: GameUpdateType.EmojiUpdate;
type: GameUpdateType.Emoji;
emoji: EmojiMessage;
}
@@ -145,6 +150,12 @@ export interface DisplayMessageUpdate {
}
export interface WinUpdate {
type: GameUpdateType.WinUpdate;
type: GameUpdateType.Win;
winnerID: number;
}
export interface HashUpdate {
type: GameUpdateType.Hash;
tick: Tick;
hash: number;
}
+8
View File
@@ -187,6 +187,14 @@ export class PlayerView {
return this.data.allies.some((n) => other.smallID() == n);
}
isRequestingAllianceWith(other: PlayerView) {
return this.data.outgoingAllianceRequests.some((id) => other.id() == id);
}
hasEmbargoAgainst(other: PlayerView): boolean {
return this.data.embargoes.has(other.id());
}
profile(): Promise<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID());
}
+66 -26
View File
@@ -26,9 +26,12 @@ import {
assertNever,
closestOceanShoreFromPlayer,
distSortUnit,
maxInt,
minInt,
simpleHash,
sourceDstOceanShore,
targetTransportTile,
toInt,
within,
} from "../Util";
import { CellString, GameImpl } from "./GameImpl";
@@ -37,7 +40,6 @@ import { MessageType } from "./Game";
import { renderTroops } from "../../client/Utils";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { Emoji } from "discord.js";
import { AttackImpl } from "./AttackImpl";
interface Target {
@@ -55,13 +57,17 @@ class Donation {
export class PlayerImpl implements Player {
public _lastTileChange: number = 0;
private _gold: Gold;
private _troops: number;
private _workers: number;
private _targetTroopRatio: number = 1;
private _gold: bigint;
private _troops: bigint;
private _workers: bigint;
// 0 to 100
private _targetTroopRatio: bigint = 100n;
isTraitor_ = false;
private embargoes: Set<PlayerID> = new Set();
public _borderTiles: Set<TileRef> = new Set();
public _units: UnitImpl[] = [];
@@ -88,20 +94,24 @@ export class PlayerImpl implements Player {
private mg: GameImpl,
private _smallID: number,
private readonly playerInfo: PlayerInfo,
startPopulation: number,
startTroops: number,
) {
this._flag = playerInfo.flag;
this._name = playerInfo.name;
this._targetTroopRatio = 1;
this._troops = startPopulation * this._targetTroopRatio;
this._workers = startPopulation * (1 - this._targetTroopRatio);
this._gold = 0;
this._targetTroopRatio = 100n;
this._troops = toInt(startTroops);
this._workers = 0n;
this._gold = 0n;
this._displayName = this._name; // processName(this._name)
}
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
toUpdate(): PlayerUpdate {
const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) =>
ar.recipient().id(),
);
return {
type: GameUpdateType.Player,
clientID: this.clientID(),
@@ -113,12 +123,13 @@ export class PlayerImpl implements Player {
playerType: this.type(),
isAlive: this.isAlive(),
tilesOwned: this.numTilesOwned(),
gold: this._gold,
gold: Number(this._gold),
population: this.population(),
workers: this.workers(),
troops: this.troops(),
targetTroopRatio: this.targetTroopRatio(),
allies: this.alliances().map((a) => a.other(this).smallID()),
embargoes: this.embargoes,
isTraitor: this.isTraitor(),
targets: this.targets().map((p) => p.smallID()),
outgoingEmojis: this.outgoingEmojis(),
@@ -138,6 +149,7 @@ export class PlayerImpl implements Player {
troops: a.troops(),
}) as AttackUpdate,
),
outgoingAllianceRequests: outgoingAllianceRequests,
};
}
@@ -229,7 +241,7 @@ export class PlayerImpl implements Player {
return true as const;
}
setTroops(troops: number) {
this._troops = Math.floor(troops);
this._troops = toInt(troops);
}
conquer(tile: TileRef) {
this.mg.conquer(this, tile);
@@ -497,12 +509,34 @@ export class PlayerImpl implements Player {
);
}
hasEmbargoAgainst(other: Player): boolean {
return this.embargoes.has(other.id());
}
canTrade(other: Player): boolean {
return !other.hasEmbargoAgainst(this) && !this.hasEmbargoAgainst(other);
}
addEmbargo(other: PlayerID): void {
this.embargoes.add(other);
}
stopEmbargo(other: PlayerID): void {
this.embargoes.delete(other);
}
tradingPartners(): Player[] {
return this.mg
.players()
.filter((other) => other != this && this.canTrade(other));
}
gold(): Gold {
return this._gold;
return Number(this._gold);
}
addGold(toAdd: Gold): void {
this._gold += toAdd;
this._gold += toInt(toAdd);
}
removeGold(toRemove: Gold): void {
@@ -511,24 +545,24 @@ export class PlayerImpl implements Player {
`Player ${this} does not enough gold (${toRemove} vs ${this._gold}))`,
);
}
this._gold -= toRemove;
this._gold -= toInt(toRemove);
}
population(): number {
return this._troops + this._workers;
return Number(this._troops + this._workers);
}
workers(): number {
return Math.max(1, this._workers);
return Math.max(1, Number(this._workers));
}
addWorkers(toAdd: number): void {
this._workers += toAdd;
this._workers += toInt(toAdd);
}
removeWorkers(toRemove: number): void {
this._workers = Math.max(1, this._workers - toRemove);
this._workers = maxInt(1n, this._workers - toInt(toRemove));
}
targetTroopRatio(): number {
return this._targetTroopRatio;
return Number(this._targetTroopRatio) / 100;
}
setTargetTroopRatio(target: number): void {
@@ -537,11 +571,11 @@ export class PlayerImpl implements Player {
`invalid targetTroopRatio ${target} set on player ${PlayerImpl}`,
);
}
this._targetTroopRatio = target;
this._targetTroopRatio = toInt(target * 100);
}
troops(): number {
return this._troops;
return Number(this._troops);
}
addTroops(troops: number): void {
@@ -549,15 +583,15 @@ export class PlayerImpl implements Player {
this.removeTroops(-1 * troops);
return;
}
this._troops += Math.floor(troops);
this._troops += toInt(troops);
}
removeTroops(troops: number): number {
if (troops <= 1) {
return 0;
}
const toRemove = Math.floor(Math.min(this._troops - 1, troops));
const toRemove = minInt(this._troops, toInt(troops));
this._troops -= toRemove;
return toRemove;
return Number(toRemove);
}
captureUnit(unit: Unit): void {
@@ -583,7 +617,12 @@ export class PlayerImpl implements Player {
);
}
buildUnit(type: UnitType, troops: number, spawnTile: TileRef): UnitImpl {
buildUnit(
type: UnitType,
troops: number,
spawnTile: TileRef,
dstPort?: Unit,
): UnitImpl {
const cost = this.mg.unitInfo(type).cost(this);
const b = new UnitImpl(
type,
@@ -592,6 +631,7 @@ export class PlayerImpl implements Player {
troops,
this.mg.nextUnitID(),
this,
dstPort,
);
this._units.push(b);
this.removeGold(cost);
+15
View File
@@ -0,0 +1,15 @@
import { NukeType, PlayerID } from "./Game";
export interface PlayerStats {
sentNukes: {
// target
[key: PlayerID]: {
[key in NukeType]: number;
};
};
}
export interface Stats {
increaseNukeCount(sender: PlayerID, target: PlayerID, type: NukeType): void;
getPlayerStats(player: PlayerID): PlayerStats;
}
+34
View File
@@ -0,0 +1,34 @@
import { NukeType, Player, PlayerID, UnitType } from "./Game";
import { PlayerStats, Stats } from "./Stats";
interface StatsInternalData {
// player
[key: PlayerID]: PlayerStats;
}
export class StatsImpl implements Stats {
data: StatsInternalData = {};
_createUserData(sender: PlayerID, target: PlayerID): void {
if (!this.data[sender]) {
this.data[sender] = { sentNukes: {} };
}
if (!this.data[sender].sentNukes[target]) {
this.data[sender].sentNukes[target] = {
[UnitType.MIRV]: 0,
[UnitType.MIRVWarhead]: 0,
[UnitType.AtomBomb]: 0,
[UnitType.HydrogenBomb]: 0,
};
}
}
increaseNukeCount(sender: PlayerID, target: PlayerID, type: NukeType): void {
this._createUserData(sender, target);
this.data[sender].sentNukes[target][type]++;
}
getPlayerStats(player: PlayerID): PlayerStats {
return this.data[player];
}
}
+17 -8
View File
@@ -1,7 +1,7 @@
import { MessageType } from "./Game";
import { UnitUpdate } from "./GameUpdates";
import { GameUpdateType } from "./GameUpdates";
import { simpleHash, within } from "../Util";
import { simpleHash, toInt, within, withinInt } from "../Util";
import { Unit, TerraNullius, UnitType, Player, UnitInfo } from "./Game";
import { GameImpl } from "./GameImpl";
import { PlayerImpl } from "./PlayerImpl";
@@ -9,7 +9,7 @@ import { TileRef } from "./GameMap";
export class UnitImpl implements Unit {
private _active = true;
private _health: number;
private _health: bigint;
private _lastTile: TileRef = null;
private _constructionType: UnitType = undefined;
@@ -21,9 +21,10 @@ export class UnitImpl implements Unit {
private _troops: number,
private _id: number,
public _owner: PlayerImpl,
private _dstPort?: Unit,
) {
// default to half health (or 1 is no health specified)
this._health = (this.mg.unitInfo(_type).maxHealth ?? 2) / 2;
// default to 60% health (or 1.2 is no health specified)
this._health = toInt((this.mg.unitInfo(_type).maxHealth ?? 2) * 0.6);
this._lastTile = _tile;
}
@@ -37,7 +38,7 @@ export class UnitImpl implements Unit {
isActive: this._active,
pos: this._tile,
lastPos: this._lastTile,
health: this.hasHealth() ? this._health : undefined,
health: this.hasHealth() ? Number(this._health) : undefined,
constructionType: this._constructionType,
};
}
@@ -65,7 +66,7 @@ export class UnitImpl implements Unit {
return this._troops;
}
health(): number {
return this._health;
return Number(this._health);
}
hasHealth(): boolean {
return this.info().maxHealth != undefined;
@@ -94,7 +95,11 @@ export class UnitImpl implements Unit {
}
modifyHealth(delta: number): void {
this._health = within(this._health + delta, 0, this.info().maxHealth ?? 1);
this._health = withinInt(
this._health + toInt(delta),
0n,
toInt(this.info().maxHealth ?? 1),
);
}
delete(displayMessage: boolean = true): void {
@@ -135,10 +140,14 @@ export class UnitImpl implements Unit {
}
hash(): number {
return this.tile() + simpleHash(this.type());
return this.tile() + simpleHash(this.type()) * this._id;
}
toString(): string {
return `Unit:${this._type},owner:${this.owner().name()}`;
}
dstPort(): Unit {
return this._dstPort;
}
}
+5 -1
View File
@@ -10,7 +10,6 @@ export class UserSettings {
set(key: string, value: boolean) {
localStorage.setItem(key, value ? "true" : "false");
document.body.classList.toggle("dark");
}
emojis() {
@@ -27,5 +26,10 @@ export class UserSettings {
toggleDarkMode() {
this.set("settings.darkMode", !this.darkMode());
if (this.darkMode()) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const mapName = "Asia";
const mapName = "Africa";
interface Coord {
x: number;
+8 -8
View File
@@ -121,9 +121,13 @@ async function archiveToGCS(gameRecord: GameRecord) {
});
const file = bucket.file(recordCopy.id);
await file.save(JSON.stringify(GameRecordSchema.parse(recordCopy)), {
contentType: "application/json",
});
try {
await file.save(JSON.stringify(recordCopy), {
contentType: "application/json",
});
} catch (error) {
console.log(`error saving game ${gameRecord.id}`);
}
console.log(`${gameRecord.id}: game record successfully written to GCS`);
}
@@ -142,11 +146,7 @@ export async function readGameRecord(gameId: GameID): Promise<GameRecord> {
const [content] = await file.download();
const gameRecord = JSON.parse(content.toString());
// Validate the parsed content against the schema
const validatedRecord = GameRecordSchema.parse(gameRecord);
console.log(`${gameId}: Successfully read game record from GCS`);
return validatedRecord;
return gameRecord as GameRecord;
} catch (error) {
console.error(`${gameId}: Error reading game record from GCS: ${error}`, {
message: error?.message || error,
+3
View File
@@ -1,9 +1,12 @@
import WebSocket from "ws";
import { ClientID } from "../core/Schemas";
import { Tick } from "../core/game/Game";
export class Client {
public lastPing: number;
public hashes: Map<Tick, number> = new Map();
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
+43 -134
View File
@@ -1,32 +1,23 @@
import { Config, ServerConfig } from "../core/configuration/Config";
import { ClientID, GameConfig, GameID } from "../core/Schemas";
import { v4 as uuidv4 } from "uuid";
import { ServerConfig } from "../core/configuration/Config";
import { GameConfig, GameID } from "../core/Schemas";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { generateID } from "../core/Util";
import { PseudoRandom } from "../core/PseudoRandom";
import { isHighTrafficTime } from "./Util";
export class GameManager {
private lastNewLobby: number = 0;
private mapsPlaylist: GameMapType[] = [];
private games: Map<GameID, GameServer> = new Map();
private games: GameServer[] = [];
private random = new PseudoRandom(123);
constructor(private config: ServerConfig) {}
public game(id: GameID): GameServer | null {
return this.games.find((g) => g.id == id);
constructor(private config: ServerConfig) {
setInterval(() => this.tick(), 1000);
}
gamesByPhase(phase: GamePhase): GameServer[] {
return this.games.filter((g) => g.phase() == phase);
public game(id: GameID): GameServer | null {
return this.games.get(id);
}
addClient(client: Client, gameID: GameID, lastTurn: number): boolean {
const game = this.games.find((g) => g.id == gameID);
const game = this.games.get(gameID);
if (game) {
game.addClient(client, lastTurn);
return true;
@@ -34,23 +25,13 @@ export class GameManager {
return false;
}
updateGameConfig(gameID: GameID, gameConfig: GameConfig) {
const game = this.games.find((g) => g.id == gameID);
if (game == null) {
console.warn(`game ${gameID} not found`);
return;
}
if (game.isPublic) {
console.warn(`cannot update public game ${gameID}`);
return;
}
game.updateGameConfig(gameConfig);
}
createPrivateGame(): string {
const id = generateID();
this.games.push(
new GameServer(id, Date.now(), false, this.config, {
createGame(id: GameID, gameConfig: GameConfig | undefined) {
const game = new GameServer(
id,
Date.now(),
isHighTrafficTime(),
this.config,
{
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
@@ -59,109 +40,37 @@ export class GameManager {
infiniteTroops: false,
instantBuild: false,
bots: 400,
}),
...gameConfig,
},
);
return id;
}
hasActiveGame(gameID: GameID): boolean {
const game = this.games
.filter((g) => g.id == gameID)
.filter(
(g) => g.phase() == GamePhase.Lobby || g.phase() == GamePhase.Active,
);
return game.length > 0;
}
// TODO: stop private games to prevent memory leak.
startPrivateGame(gameID: GameID) {
const game = this.games.find((g) => g.id == gameID);
console.log(`found game ${game}`);
if (game) {
game.start();
} else {
throw new Error(`cannot start private game, game ${gameID} not found`);
}
}
private getNextMap(): GameMapType {
if (this.mapsPlaylist.length > 0) {
return this.mapsPlaylist.shift();
}
const frequency = {
World: 4,
Europe: 4,
Mena: 2,
NorthAmerica: 2,
Oceania: 1,
BlackSea: 2,
Africa: 2,
Asia: 2,
Mars: 0,
};
Object.keys(GameMapType).map((key) => {
let count = parseInt(frequency[key]);
while (count > 0) {
this.mapsPlaylist.push(GameMapType[key]);
count--;
}
});
while (true) {
this.random.shuffleArray(this.mapsPlaylist);
if (this.allNonConsecutive(this.mapsPlaylist)) {
return this.mapsPlaylist.shift();
}
}
}
private allNonConsecutive(maps: GameMapType[]): boolean {
// Check for consecutive duplicates in the maps array
for (let i = 0; i < maps.length - 1; i++) {
if (maps[i] === maps[i + 1]) {
return false;
}
}
return true;
this.games.set(id, game);
return game;
}
tick() {
const lobbies = this.gamesByPhase(GamePhase.Lobby);
const active = this.gamesByPhase(GamePhase.Active);
const finished = this.gamesByPhase(GamePhase.Finished);
const now = Date.now();
if (now > this.lastNewLobby + this.config.gameCreationRate()) {
this.lastNewLobby = now;
lobbies.push(
new GameServer(generateID(), now, true, this.config, {
gameMap: this.getNextMap(),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: false,
bots: 400,
}),
);
}
active
.filter((g) => !g.hasStarted() && g.isPublic)
.forEach((g) => {
g.start();
});
finished.forEach((g) => {
try {
g.endGame();
} catch (error) {
console.log(`error ending game ${g.id}: `, error);
const active = new Map<GameID, GameServer>();
for (const [id, game] of this.games) {
const phase = game.phase();
if (phase == GamePhase.Active) {
if (game.isPublic && !game.hasStarted()) {
try {
game.start();
} catch (error) {
console.log(`error starting game ${id}: ${error}`);
}
}
}
});
this.games = [...lobbies, ...active];
if (phase == GamePhase.Finished) {
try {
game.end();
} catch (error) {
console.log(`error ending game ${id}: ${error}`);
}
} else {
active.set(id, game);
}
}
this.games = active;
}
}
+203 -76
View File
@@ -1,24 +1,25 @@
import { RateLimiterMemory } from "rate-limiter-flexible";
import WebSocket from "ws";
import {
ClientID,
ClientMessage,
ClientMessageSchema,
GameConfig,
GameRecordSchema,
GameInfo,
Intent,
PlayerRecord,
ServerPingMessageSchema,
ServerStartGameMessage,
ServerDesyncSchema,
ServerStartGameMessageSchema,
ServerTurnMessageSchema,
Turn,
} from "../core/Schemas";
import { Config, ServerConfig } from "../core/configuration/Config";
import { Client } from "./Client";
import WebSocket from "ws";
import { slog } from "./StructuredLog";
import { CreateGameRecord } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import { archive } from "./Archive";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { Client } from "./Client";
import { slog } from "./StructuredLog";
import { gatekeeper } from "./Gatekeeper";
export enum GamePhase {
Lobby = "LOBBY",
@@ -27,10 +28,7 @@ export enum GamePhase {
}
export class GameServer {
private rateLimiter = new RateLimiterMemory({
points: 50,
duration: 1, // per 1 second
});
private outOfSyncClients = new Set<ClientID>();
private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours
@@ -51,7 +49,7 @@ export class GameServer {
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
public readonly highTraffic: boolean,
private config: ServerConfig,
public gameConfig: GameConfig,
) {}
@@ -97,6 +95,7 @@ export class GameServer {
});
if (
this.gameConfig.gameType == GameType.Public &&
this.activeClients.filter(
(c) => c.ip == client.ip && c.clientID != client.clientID,
).length >= 3
@@ -122,52 +121,55 @@ export class GameServer {
this.allClients.set(client.clientID, client);
client.ws.on("message", async (message: string) => {
try {
await this.rateLimiter.consume(client.ip);
} catch (error) {
console.warn(`Rate limit exceeded for ${client.ip}`);
return;
}
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message),
);
if (this.allClients.has(clientMsg.clientID)) {
const client = this.allClients.get(clientMsg.clientID);
if (client.persistentID != clientMsg.persistentID) {
console.warn(
`Client ID ${clientMsg.clientID} sent incorrect id ${clientMsg.persistentID}, does not match persistent id ${client.persistentID}`,
);
return;
client.ws.on(
"message",
gatekeeper.wsHandler(client.ip, async (message: string) => {
try {
let clientMsg: ClientMessage = null;
try {
clientMsg = ClientMessageSchema.parse(JSON.parse(message));
} catch (error) {
throw Error(`error parsing schema for ${client.ip}`);
}
}
// Clear out persistent id to make sure it doesn't get sent to other clients.
clientMsg.persistentID = null;
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent);
} else {
console.warn(
`${this.id}: client ${clientMsg.clientID} sent to wrong game`,
);
if (this.allClients.has(clientMsg.clientID)) {
const client = this.allClients.get(clientMsg.clientID);
if (client.persistentID != clientMsg.persistentID) {
console.warn(
`Client ID ${clientMsg.clientID} sent incorrect id ${clientMsg.persistentID}, does not match persistent id ${client.persistentID}`,
);
return;
}
}
// Clear out persistent id to make sure it doesn't get sent to other clients.
clientMsg.persistentID = null;
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent);
} else {
console.warn(
`${this.id}: client ${clientMsg.clientID} sent to wrong game`,
);
}
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
}
if (clientMsg.type == "hash") {
client.hashes.set(clientMsg.tick, clientMsg.hash);
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner;
}
} catch (error) {
console.log(
`error handline websocket request in game server: ${error}`,
);
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner;
}
} catch (error) {
console.log(
`error handline websocket request in game server: ${error}`,
);
}
});
}),
);
client.ws.on("close", () => {
console.log(`${this.id}: client ${client.clientID} disconnected`);
this.activeClients = this.activeClients.filter(
@@ -195,7 +197,7 @@ export class GameServer {
return this._startTime;
} else {
//game hasn't started yet, only works for public games
return this.createdAt + this.config.lobbyLifetime();
return this.createdAt + this.config.lobbyLifetime(this.highTraffic);
}
}
@@ -221,15 +223,19 @@ export class GameServer {
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
ws.send(
JSON.stringify(
ServerStartGameMessageSchema.parse({
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig,
}),
),
);
try {
ws.send(
JSON.stringify(
ServerStartGameMessageSchema.parse({
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig,
}),
),
);
} catch (error) {
throw new Error(`error sending start message for game ${this.id}`);
}
}
private endTurn() {
@@ -241,18 +247,27 @@ export class GameServer {
this.turns.push(pastTurn);
this.intents = [];
const msg = JSON.stringify(
ServerTurnMessageSchema.parse({
type: "turn",
turn: pastTurn,
}),
);
this.maybeSendDesync();
let msg = "";
try {
msg = JSON.stringify(
ServerTurnMessageSchema.parse({
type: "turn",
turn: pastTurn,
}),
);
} catch (error) {
console.log(`error sending message for game ${this.id}`);
return;
}
this.activeClients.forEach((c) => {
c.ws.send(msg);
});
}
async endGame() {
async end() {
// Close all WebSocket connections
clearInterval(this.endTurnIntervalID);
this.allClients.forEach((client) => {
@@ -337,7 +352,7 @@ export class GameServer {
const noRecentPings = now > this.lastPingUpdate + 20 * 1000;
const noActive = this.activeClients.length == 0;
if (!this.isPublic) {
if (this.gameConfig.gameType != GameType.Public) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
console.log(`${this.id}: private game: ${this.id} complete`);
@@ -350,11 +365,12 @@ export class GameServer {
}
}
if (now - this.createdAt < this.config.lobbyLifetime()) {
if (now - this.createdAt < this.config.lobbyLifetime(this.highTraffic)) {
return GamePhase.Lobby;
}
const warmupOver =
now > this.createdAt + this.config.lobbyLifetime() + 30 * 1000;
now >
this.createdAt + this.config.lobbyLifetime(this.highTraffic) + 30 * 1000;
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished;
}
@@ -365,4 +381,115 @@ export class GameServer {
hasStarted(): boolean {
return this._hasStarted;
}
public gameInfo(): GameInfo {
return {
gameID: this.id,
clients: this.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
})),
gameConfig: this.gameConfig,
msUntilStart: this.isPublic()
? this.createdAt + this.config.lobbyLifetime(this.highTraffic)
: undefined,
};
}
public isPublic(): boolean {
return this.gameConfig.gameType == GameType.Public;
}
private maybeSendDesync() {
if (this.activeClients.length <= 1) {
return;
}
if (this.turns.length % 10 == 0 && this.turns.length != 0) {
const lastHashTurn = this.turns.length - 10;
let { mostCommonHash, outOfSyncClients } =
this.findOutOfSyncClients(lastHashTurn);
if (
outOfSyncClients.length >= Math.floor(this.activeClients.length / 2)
) {
// If half clients out of sync assume all are out of sync.
outOfSyncClients = this.activeClients;
}
for (const oos of outOfSyncClients) {
if (!this.outOfSyncClients.has(oos.clientID)) {
console.warn(
`Game ${this.id}: has out of sync client ${oos.clientID} on turn ${lastHashTurn}`,
);
this.outOfSyncClients.add(oos.clientID);
}
}
return;
// TODO: renable this once desync issue fixed
const serverDesync = ServerDesyncSchema.safeParse({
type: "desync",
turn: lastHashTurn,
correctHash: mostCommonHash,
clientsWithCorrectHash:
this.activeClients.length - outOfSyncClients.length,
totalActiveClients: this.activeClients.length,
});
if (serverDesync.success) {
const desyncMsg = JSON.stringify(serverDesync.data);
for (const c of outOfSyncClients) {
console.log(
`game: ${this.id}: sending desync to client ${c.clientID}`,
);
c.ws.send(desyncMsg);
}
} else {
console.warn(`failed to create desync message ${serverDesync.error}`);
}
}
}
findOutOfSyncClients(turnNumber: number): {
mostCommonHash: number | null;
outOfSyncClients: Client[];
} {
const counts = new Map<number, number>();
// Count occurrences of each hash
for (const client of this.activeClients) {
if (client.hashes.has(turnNumber)) {
const clientHash = client.hashes.get(turnNumber)!;
counts.set(clientHash, (counts.get(clientHash) || 0) + 1);
}
}
// Find the most common hash
let mostCommonHash: number | null = null;
let maxCount = 0;
for (const [hash, count] of counts.entries()) {
if (count > maxCount) {
mostCommonHash = hash;
maxCount = count;
}
}
// Create a list of clients whose hash doesn't match the most common one
const outOfSyncClients: Client[] = [];
for (const client of this.activeClients) {
if (client.hashes.has(turnNumber)) {
const clientHash = client.hashes.get(turnNumber)!;
if (clientHash !== mostCommonHash) {
outOfSyncClients.push(client);
}
}
}
return {
mostCommonHash,
outOfSyncClients,
};
}
}
+124
View File
@@ -0,0 +1,124 @@
// src/server/Security.ts
import { Request, Response, NextFunction } from "express";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
export enum LimiterType {
Get = "get",
Post = "post",
Put = "put",
WebSocket = "websocket",
}
export interface Gatekeeper {
// The wrapper for request handlers with optional rate limiting
httpHandler: (
limiterType: LimiterType,
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>,
) => (req: Request, res: Response, next: NextFunction) => Promise<void>;
// The wrapper for WebSocket message handlers with rate limiting
wsHandler: (
req: http.IncomingMessage | string,
fn: (message: string) => Promise<void>,
) => (message: string) => Promise<void>;
}
// Function to get the appropriate security middleware implementation
async function getGatekeeper(): Promise<Gatekeeper> {
try {
// Get the current file's directory
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
try {
// Check if the file exists before attempting to import it
const realMiddlewarePath = path.resolve(
__dirname,
"./gatekeeper/RealGatekeeper.js",
);
const tsMiddlewarePath = path.resolve(
__dirname,
"./gatekeeper/RealGatekeeper.ts",
);
if (
!fs.existsSync(realMiddlewarePath) &&
!fs.existsSync(tsMiddlewarePath)
) {
console.log("RealGatekeeper file not found, using NoOpGatekeeper");
return new NoOpGatekeeper();
}
// Use dynamic import for ES modules
// Using a type assertion to avoid TypeScript errors for optional modules
const module = await import(
"./gatekeeper/RealGatekeeper.js" as any
).catch(() => import("./gatekeeper/RealGatekeeper.js" as any));
if (!module || !module.RealGatekeeper) {
console.log(
"RealGatekeeper class not found in module, using NoOpGatekeeper",
);
return new NoOpGatekeeper();
}
console.log("Successfully loaded real gatekeeper");
return new module.RealGatekeeper();
} catch (error) {
console.log("Failed to load real gatekeeper:", error);
return new NoOpGatekeeper();
}
} catch (e) {
// Fall back to no-op if real implementation isn't available
console.log("using no-op gatekeeper", e);
return new NoOpGatekeeper();
}
}
export class NoOpGatekeeper implements Gatekeeper {
// Simple pass-through with no rate limiting
httpHandler(
limiterType: LimiterType,
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>,
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await fn(req, res, next);
} catch (error) {
next(error);
}
};
}
// Corrected implementation for WebSocket handler wrapper
wsHandler(
req: http.IncomingMessage | string,
fn: (message: string) => Promise<void>,
) {
return async (message: string) => {
try {
await fn(message);
} catch (error) {
console.error("WebSocket handler error:", error);
}
};
}
}
// Initialize the security middleware with a default implementation
// We'll use the NoOpSecurityMiddleware initially and then replace it
// with the real implementation once it's loaded
export const gatekeeper: Gatekeeper = new NoOpGatekeeper();
// Immediately try to load the real middleware
getGatekeeper()
.then((middleware) => {
// Replace the methods of securityMiddleware with those from the loaded middleware
Object.assign(gatekeeper, middleware);
})
.catch((error) => {
console.error("Failed to initialize gatekeeper:", error);
});
+285
View File
@@ -0,0 +1,285 @@
import cluster from "cluster";
import http from "http";
import express from "express";
import { GameMapType, GameType, Difficulty } from "../core/game/Game";
import { generateID } from "../core/Util";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameEnv, getServerConfig } from "../core/configuration/Config";
import { GameInfo } from "../core/Schemas";
import path from "path";
import rateLimit from "express-rate-limit";
import { fileURLToPath } from "url";
import { isHighTrafficTime } from "./Util";
import { gatekeeper, LimiterType } from "./Gatekeeper";
const config = getServerConfig();
const readyWorkers = new Set();
const app = express();
const server = http.createServer(app);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.json());
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, "../../out")));
app.use(express.json());
app.set("trust proxy", 3);
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
let publicLobbiesJsonStr = "";
let publicLobbyIDs: Set<string> = new Set();
// Start the master process
export async function startMaster() {
if (!cluster.isPrimary) {
throw new Error(
"startMaster() should only be called in the primary process",
);
}
console.log(`Primary ${process.pid} is running`);
console.log(`Setting up ${config.numWorkers()} workers...`);
// Fork workers
for (let i = 0; i < config.numWorkers(); i++) {
const worker = cluster.fork({
WORKER_ID: i,
});
console.log(`Started worker ${i} (PID: ${worker.process.pid})`);
}
cluster.on("message", (worker, message) => {
if (message.type === "WORKER_READY") {
const workerId = message.workerId;
readyWorkers.add(workerId);
console.log(
`Worker ${workerId} is ready. (${readyWorkers.size}/${config.numWorkers()} ready)`,
);
// Start scheduling when all workers are ready
if (readyWorkers.size === config.numWorkers()) {
console.log("All workers ready, starting game scheduling");
// Safe implementation of dynamic interval
let timeoutId = null;
const scheduleLobbies = () => {
schedulePublicGame()
.catch((error) => {
console.error("Error scheduling public game:", error);
})
.finally(() => {
// Schedule next run with the current config value
const currentLifetime =
config.gameCreationRate(isHighTrafficTime());
timeoutId = setTimeout(scheduleLobbies, currentLifetime);
});
};
// Run first execution immediately
scheduleLobbies();
// Regular interval for fetching lobbies
setInterval(() => fetchLobbies(), 250);
}
}
});
// Handle worker crashes
cluster.on("exit", (worker, code, signal) => {
const workerId = (worker as any).process?.env?.WORKER_ID;
if (!workerId) {
console.error(`worker crashed could not find id`);
return;
}
console.warn(
`Worker ${workerId} (PID: ${worker.process.pid}) died with code: ${code} and signal: ${signal}`,
);
console.log(`Restarting worker ${workerId}...`);
// Restart the worker with the same ID
const newWorker = cluster.fork({
WORKER_ID: workerId,
});
console.log(
`Restarted worker ${workerId} (New PID: ${newWorker.process.pid})`,
);
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Master HTTP server listening on port ${PORT}`);
});
}
// Add lobbies endpoint to list public games for this worker
app.get(
"/public_lobbies",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
res.send(publicLobbiesJsonStr);
}),
);
async function fetchLobbies(): Promise<void> {
const fetchPromises = [];
for (const gameID of publicLobbyIDs) {
const port = config.workerPort(gameID);
const promise = fetch(`http://localhost:${port}/game/${gameID}`)
.then((resp) => resp.json())
.then((json) => {
return json as GameInfo;
})
.catch((error) => {
console.error(`Error fetching game ${gameID}:`, error);
// Return null or a placeholder if fetch fails
return null;
});
fetchPromises.push(promise);
}
// Wait for all promises to resolve
const results = await Promise.all(fetchPromises);
// Filter out any null results from failed fetches
const lobbyInfos: GameInfo[] = results
.filter((result) => result !== null)
.map((gi: GameInfo) => {
return {
gameID: gi.gameID,
numClients: gi?.clients?.length ?? 0,
gameConfig: gi.gameConfig,
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
} as GameInfo;
});
lobbyInfos.forEach((l) => {
if (l.msUntilStart <= 250) {
publicLobbyIDs.delete(l.gameID);
}
});
// Update the JSON string
publicLobbiesJsonStr = JSON.stringify({
lobbies: lobbyInfos,
});
}
// Function to schedule a new public game
async function schedulePublicGame() {
const gameID = generateID();
publicLobbyIDs.add(gameID);
// Create the default public game config (from your GameManager)
const defaultGameConfig = {
gameMap: getNextMap(),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: false,
bots: 400,
};
const workerPath = config.workerPath(gameID);
// Send request to the worker to start the game
try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/create_game/${gameID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Request": "true", // Special header for internal requests
},
body: JSON.stringify({
gameID: gameID,
gameConfig: defaultGameConfig,
}),
},
);
if (!response.ok) {
throw new Error(`Failed to schedule public game: ${response.statusText}`);
}
const data = await response.json();
} catch (error) {
console.error(
`Failed to schedule public game on worker ${workerPath}:`,
error,
);
throw error;
}
}
// Map rotation management (moved from GameManager)
let mapsPlaylist: GameMapType[] = [];
const random = new PseudoRandom(123);
// Get the next map in rotation
function getNextMap(): GameMapType {
if (mapsPlaylist.length > 0) {
return mapsPlaylist.shift()!;
}
const frequency = {
World: 4,
Europe: 4,
Mena: 2,
NorthAmerica: 2,
Oceania: 1,
BlackSea: 2,
Africa: 2,
Asia: 2,
Mars: 0,
};
Object.keys(GameMapType).forEach((key) => {
let count = parseInt(frequency[key]);
while (count > 0) {
mapsPlaylist.push(GameMapType[key]);
count--;
}
});
while (true) {
random.shuffleArray(mapsPlaylist);
if (allNonConsecutive(mapsPlaylist)) {
return mapsPlaylist.shift()!;
}
}
}
// Check for consecutive duplicates in the maps array
function allNonConsecutive(maps: GameMapType[]): boolean {
for (let i = 0; i < maps.length - 1; i++) {
if (maps[i] === maps[i + 1]) {
return false;
}
}
return true;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// SPA fallback route
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "../../out/index.html"));
});
+18 -486
View File
@@ -1,490 +1,22 @@
import express, { json, Request, Response, NextFunction } from "express";
import http from "http";
import { WebSocketServer } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { GameManager } from "./GameManager";
import {
ClientMessage,
ClientMessageSchema,
GameRecord,
GameRecordSchema,
LogSeverity,
ServerStartGameMessageSchema,
} from "../core/Schemas";
import {
GameEnv,
getConfig,
getServerConfig,
} from "../core/configuration/Config";
import { slog } from "./StructuredLog";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { archive, gameRecordExists, readGameRecord } from "./Archive";
import { DiscordBot } from "./DiscordBot";
import {
sanitizeUsername,
validateUsername,
} from "../core/validations/username";
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import dotenv from "dotenv";
import crypto from "crypto";
dotenv.config();
import rateLimit from "express-rate-limit";
import { RateLimiterMemory } from "rate-limiter-flexible";
import * as si from "systeminformation";
import cluster from "cluster";
import { startMaster } from "./Master";
import { startWorker } from "./Worker";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const serverConfig = getServerConfig();
// Initialize Secret Manager
const secretManager = new SecretManagerServiceClient();
// Discord OAuth Configuration (will be populated from secrets)
let DISCORD_CLIENT_ID: string;
let DISCORD_CLIENT_SECRET: string;
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, "../../out")));
app.use(express.json());
app.set("trust proxy", 2);
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
const rateLimiter = new RateLimiterMemory({
points: 50, // 50 messages
duration: 1, // per 1 second
});
const updateRateLimiter = new RateLimiterMemory({
points: 10,
duration: 240, // 4 minutes
});
const gm = new GameManager(getServerConfig());
const bot = new DiscordBot();
try {
await bot.start();
} catch (error) {
console.error("Failed to start bot:", error);
}
let lobbiesString = "";
// Async error wrapper with rate limiting support
const asyncHandler =
(fn: Function, limiter = null) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
// Apply rate limiting if a limiter is provided
if (limiter) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
try {
await limiter.consume(clientIP);
} catch (error) {
console.warn(`Rate limited for IP ${clientIP}`);
return res.status(429).json({ error: "Too many requests" });
}
}
// Execute the route handler
await fn(req, res, next);
} catch (error) {
// Pass any errors to Express error handler
next(error);
}
};
// Discord OAuth callback endpoint
app.get(
"/auth/callback",
asyncHandler(async (req: Request, res: Response) => {
const { code } = req.query;
if (!code) {
return res.status(400).send("No code provided");
}
// Exchange code for access token
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
body: new URLSearchParams({
client_id: DISCORD_CLIENT_ID!,
client_secret: DISCORD_CLIENT_SECRET!,
code: code as string,
grant_type: "authorization_code",
redirect_uri: serverConfig.discordRedirectURI(),
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!tokenResponse.ok) {
throw new Error("Failed to get access token");
}
const tokenData = await tokenResponse.json();
// Get user information
const userResponse = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
});
if (!userResponse.ok) {
throw new Error("Failed to get user information");
}
const userData = await userResponse.json();
const sessionToken = crypto.randomBytes(32).toString("hex");
// TODO: store userData and sessionToken in database.
res.cookie("session", sessionToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds
});
res.redirect(`/`);
}),
);
app.get("/auth/discord", (req: Request, res: Response) => {
console.log("Redirecting to Discord OAuth...");
const redirectUri = serverConfig.discordRedirectURI();
const authorizeUrl = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify`;
console.log("Auth URL:", authorizeUrl);
res.redirect(authorizeUrl);
});
// New GET endpoint to list lobbies
app.get("/lobbies", (req: Request, res: Response) => {
res.send(lobbiesString);
});
app.post(
"/private_lobby",
asyncHandler(async (req, res) => {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
const id = gm.createPrivateGame();
console.log(`ip ${clientIP} creating private lobby with id ${id}`);
res.json({
id: id,
});
}, updateRateLimiter),
);
app.post(
"/archive_singleplayer_game",
asyncHandler(async (req, res) => {
const gameRecord: GameRecord = req.body;
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
if (!gameRecord) {
console.log("game record not found in request");
res.status(404).json({ error: "Game record not found" });
return;
}
gameRecord.players.forEach((p) => (p.ip = clientIP));
GameRecordSchema.parse(gameRecord);
archive(gameRecord);
res.json({
success: true,
});
}, updateRateLimiter),
);
app.post(
"/start_private_lobby/:id",
asyncHandler(async (req, res) => {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
console.log(`starting private lobby with id ${req.params.id}`);
gm.startPrivateGame(req.params.id);
res.status(200).json({ success: true });
}, updateRateLimiter),
);
app.put(
"/private_lobby/:id",
asyncHandler(async (req, res) => {
const lobbyID = req.params.id;
gm.updateGameConfig(lobbyID, {
gameMap: req.body.gameMap,
difficulty: req.body.difficulty,
infiniteGold: req.body.infiniteGold,
infiniteTroops: req.body.infiniteTroops,
instantBuild: req.body.instantBuild,
bots: req.body.bots,
disableNPCs: req.body.disableNPCs,
});
res.status(200).json({ success: true });
}),
);
app.get(
"/lobby/:id/exists",
asyncHandler(async (req, res) => {
const lobbyId = req.params.id;
let gameExists = gm.hasActiveGame(lobbyId);
if (!gameExists) {
gameExists = await gameRecordExists(lobbyId);
}
res.json({
exists: gameExists,
});
}),
);
app.get(
"/lobby/:id",
asyncHandler(async (req, res) => {
const game = gm.game(req.params.id);
if (game == null) {
console.log(`lobby ${req.params.id} not found`);
return res.status(404).json({ error: "Game not found" });
}
res.json({
players: game.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
})),
});
}),
);
app.get(
"/private_lobby/:id",
asyncHandler(async (req, res) => {
res.json({
hi: "5",
});
}),
);
app.get(
"/debug-ip",
asyncHandler(async (req, res) => {
res.send({
"x-forwarded-for": req.headers["x-forwarded-for"],
"real-ip": req.ip,
"raw-headers": req.rawHeaders,
});
}),
);
app.get("*", function (req, res) {
// SPA routing
res.sendFile(path.join(__dirname, "../../out/index.html"));
});
wss.on("connection", (ws, req) => {
ws.on("message", async (message: string) => {
let ip = "";
try {
const forwarded = req.headers["x-forwarded-for"];
ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded || req.socket.remoteAddress;
await rateLimiter.consume(ip);
} catch (error) {
console.warn(`rate limit exceede for ${ip}`);
return;
}
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message.toString()),
);
if (clientMsg.type == "join") {
const forwarded = req.headers["x-forwarded-for"];
let ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded || req.socket.remoteAddress;
if (Array.isArray(ip)) {
ip = ip[0];
}
const { isValid, error } = validateUsername(clientMsg.username);
if (!isValid) {
console.log(
`game ${clientMsg.gameID}, client ${clientMsg.clientID} received invalid username, ${error}`,
);
return;
}
clientMsg.username = sanitizeUsername(clientMsg.username);
const wasFound = gm.addClient(
new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws,
),
clientMsg.gameID,
clientMsg.lastTurn,
);
if (!wasFound) {
console.log(`game ${clientMsg.gameID} not found, loading from gcs`);
const record = await readGameRecord(clientMsg.gameID);
ws.send(
JSON.stringify(
ServerStartGameMessageSchema.parse({
type: "start",
turns: record.turns,
config: record.gameConfig,
}),
),
);
}
}
if (clientMsg.type == "log") {
slog({
logKey: "client_console_log",
msg: clientMsg.log,
severity: clientMsg.severity,
clientID: clientMsg.clientID,
gameID: clientMsg.gameID,
persistentID: clientMsg.persistentID,
});
}
} catch (error) {
console.warn(`errror handling websocket message for ${ip}: ${error}`);
}
});
ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
ws.close(1002);
}
});
});
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`Error in ${req.method} ${req.path}:`, err);
slog({
logKey: "server_error",
msg: `Unhandled exception in ${req.method} ${req.path}: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
res.status(500).json({ error: "An unexpected error occurred" });
});
function startServer() {
setInterval(() => tick(), 1000);
setInterval(() => updateLobbies(), 100);
setInterval(async () => {
await getCurrentCpuUsage();
console.log("---");
}, 5 * 1000);
initializeSecrets();
const PORT = process.env.PORT || 3000;
console.log(`Server will try to run on http://localhost:${PORT}`);
server.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
}
function tick() {
gm.tick();
}
function updateLobbies() {
lobbiesString = JSON.stringify({
lobbies: gm
.gamesByPhase(GamePhase.Lobby)
.filter((g) => g.isPublic)
.map((g) => ({
id: g.id,
msUntilStart: g.startTime() - Date.now(),
numClients: g.numClients(),
gameConfig: g.gameConfig,
}))
.sort((a, b) => a.msUntilStart - b.msUntilStart),
});
}
// Process-level unhandled exception handlers
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
slog({
logKey: "uncaught_exception",
msg: `Uncaught exception: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
// Note: We're not exiting the process to maintain uptime
// but be aware the app might be in an inconsistent state
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection at:", promise, "reason:", reason);
slog({
logKey: "unhandled_rejection",
msg: `Unhandled promise rejection: ${reason}`,
severity: LogSeverity.Error,
});
});
// Initialize secrets and start server
async function initializeSecrets() {
try {
DISCORD_CLIENT_ID = await getSecret(
"DISCORD_CLIENT_ID",
serverConfig.env(),
);
DISCORD_CLIENT_SECRET = await getSecret(
"DISCORD_CLIENT_SECRET",
serverConfig.env(),
);
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) {
throw new Error("Failed to load Discord secrets");
}
} catch (error) {
console.error("Failed to initialize secrets:", error);
// Main entry point of the application
async function main() {
// Check if this is the primary (master) process
if (cluster.isPrimary) {
console.log("Starting master process...");
await startMaster();
} else {
// This is a worker process
console.log("Starting worker process...");
await startWorker();
}
}
async function getSecret(secretName: string, ge: GameEnv) {
if (ge == GameEnv.Dev) {
console.log(`loading secret ${secretName} from environment variable`);
const value = process.env[secretName];
if (!value) {
throw Error(`error loading secret ${secretName}`);
}
}
console.log(`loading secret ${secretName} from Google secrets manager`);
const name = `projects/openfrontio/secrets/${secretName}/versions/latest`;
const [version] = await secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString();
}
async function getCurrentCpuUsage(): Promise<void> {
const cpuData = await si.currentLoad();
console.log(`Current CPU Load: ${cpuData.currentLoad.toFixed(2)}%`);
console.log(
`Current CPU Load (User): ${cpuData.currentLoadUser.toFixed(2)}%`,
);
console.log(
`Current CPU Load (System): ${cpuData.currentLoadSystem.toFixed(2)}%`,
);
}
startServer();
// Start the application
main().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
+17
View File
@@ -0,0 +1,17 @@
export function isHighTrafficTime(): boolean {
// More traffic from 4am to 4pm
const now = new Date();
// Convert current time to PST (America/Los_Angeles timezone)
// Using a more compatible approach
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Los_Angeles",
hour: "numeric",
hour12: false,
});
const formattedTime = formatter.format(now);
const hourPST = parseInt(formattedTime.split(":")[0], 10);
return hourPST >= 4 && hourPST < 16;
}
+346
View File
@@ -0,0 +1,346 @@
import express, { Request, Response, NextFunction } from "express";
import http from "http";
import { WebSocketServer } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { GameManager } from "./GameManager";
import { getServerConfig } from "../core/configuration/Config";
import { WebSocket } from "ws";
import { Client } from "./Client";
import rateLimit from "express-rate-limit";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { GameConfig, GameRecord, LogSeverity } from "../core/Schemas";
import { slog } from "./StructuredLog";
import { GameType } from "../core/game/Game";
import { archive } from "./Archive";
import { LimiterType, gatekeeper } from "./Gatekeeper";
const config = getServerConfig();
// Worker setup
export function startWorker() {
// Get worker ID from environment variable
const workerId = parseInt(process.env.WORKER_ID || "0");
console.log(`Worker ${workerId} starting...`);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const gm = new GameManager(config);
// Middleware to handle /wX path prefix
app.use((req, res, next) => {
// Extract the original path without the worker prefix
const originalPath = req.url;
const match = originalPath.match(/^\/w(\d+)(.*)$/);
if (match) {
const pathWorkerId = parseInt(match[1]);
const actualPath = match[2] || "/";
// Verify this request is for the correct worker
if (pathWorkerId !== workerId) {
return res.status(404).json({
error: "Worker mismatch",
message: `This is worker ${workerId}, but you requested worker ${pathWorkerId}`,
});
}
// Update the URL to remove the worker prefix
req.url = actualPath;
}
next();
});
app.set("trust proxy", 3);
app.use(express.json());
app.use(express.static(path.join(__dirname, "../../out")));
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
// Endpoint to create a private lobby
app.post(
"/create_game/:id",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
const id = req.params.id;
if (!id) {
console.warn(`cannot create game, id not found`);
return;
}
// TODO: if game is public make sure request came from localhohst!!!
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
const gc = req.body?.gameConfig as GameConfig;
if (gc?.gameType == GameType.Public && !isLocalhost(req)) {
console.warn(
`cannot create public game ${id}, ip ${clientIP} not localhost`,
);
return res.status(400);
}
// Double-check this worker should host this game
const expectedWorkerId = config.workerIndex(id);
if (expectedWorkerId !== workerId) {
console.warn(
`This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
);
return res.status(400);
}
const game = gm.createGame(id, gc);
console.log(
`Worker ${workerId}: IP ${clientIP} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,
);
res.json(game.gameInfo());
}),
);
// Add other endpoints from your original server
app.post(
"/start_game/:id",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
console.log(`starting private lobby with id ${req.params.id}`);
const game = gm.game(req.params.id);
if (!game) {
return;
}
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
console.log(
`cannot start public game ${game.id}, game is public, ip: ${clientIP}`,
);
return;
}
game.start();
res.status(200).json({ success: true });
}),
);
app.put(
"/game/:id",
gatekeeper.httpHandler(LimiterType.Put, async (req, res) => {
// TODO: only update public game if from local host
const lobbyID = req.params.id;
if (req.body.gameType == GameType.Public) {
console.log(`cannot update game ${lobbyID} to public`);
return res.status(400);
}
const game = gm.game(lobbyID);
if (!game) {
return res.status(400);
}
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
console.warn(`cannot update public game ${game.id}, ip: ${clientIP}`);
return res.status(400);
}
game.updateGameConfig({
gameMap: req.body.gameMap,
difficulty: req.body.difficulty,
infiniteGold: req.body.infiniteGold,
infiniteTroops: req.body.infiniteTroops,
instantBuild: req.body.instantBuild,
bots: req.body.bots,
disableNPCs: req.body.disableNPCs,
});
res.status(200).json({ success: true });
}),
);
app.get(
"/game/:id/exists",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const lobbyId = req.params.id;
res.json({
exists: gm.game(lobbyId) != null,
});
}),
);
app.get(
"/game/:id",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const game = gm.game(req.params.id);
if (game == null) {
console.log(`lobby ${req.params.id} not found`);
return res.status(404).json({ error: "Game not found" });
}
res.json(game.gameInfo());
}),
);
app.post(
"/archive_singleplayer_game",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
const gameRecord: GameRecord = req.body;
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
if (!gameRecord) {
console.log("game record not found in request");
res.status(404).json({ error: "Game record not found" });
return;
}
gameRecord.players.forEach((p) => (p.ip = clientIP));
archive(gameRecord);
res.json({
success: true,
});
}),
);
// WebSocket handling
wss.on("connection", (ws: WebSocket, req) => {
ws.on(
"message",
gatekeeper.wsHandler(req, async (message: string) => {
const forwarded = req.headers["x-forwarded-for"];
const ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded || req.socket.remoteAddress;
try {
// Process WebSocket messages as in your original code
// Parse and handle client messages
const clientMsg = JSON.parse(message.toString());
if (clientMsg.type == "join") {
// Verify this worker should handle this game
const expectedWorkerId = config.workerIndex(clientMsg.gameID);
if (expectedWorkerId !== workerId) {
console.warn(
`Worker mismatch: Game ${clientMsg.gameID} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
);
return;
}
// Create client and add to game
const client = new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws,
);
const wasFound = gm.addClient(
client,
clientMsg.gameID,
clientMsg.lastTurn,
);
if (!wasFound) {
console.log(
`game ${clientMsg.gameID} not found on worker ${workerId}`,
);
// Handle game not found case
}
}
// Handle other message types
} catch (error) {
console.warn(
`error handling websocket message for ${ip}: ${error}`.substring(
0,
250,
),
);
}
}),
);
ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
ws.close(1002);
}
});
});
// Set up ticker
setInterval(() => gm.tick(), 1000);
// The load balancer will handle routing to this server based on path
const PORT = config.workerPortByIndex(workerId);
server.listen(PORT, () => {
console.log(`Worker ${workerId} running on http://localhost:${PORT}`);
console.log(`Handling requests with path prefix /w${workerId}/`);
// Signal to the master process that this worker is ready
if (process.send) {
process.send({
type: "WORKER_READY",
workerId: workerId,
});
console.log(`Worker ${workerId} signaled ready state to master`);
}
});
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`Error in ${req.method} ${req.path}:`, err);
slog({
logKey: "server_error",
msg: `Unhandled exception in ${req.method} ${req.path}: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
res.status(500).json({ error: "An unexpected error occurred" });
});
// Process-level error handlers
process.on("uncaughtException", (err) => {
console.error(`Worker ${workerId} uncaught exception:`, err);
slog({
logKey: "uncaught_exception",
msg: `Worker ${workerId} uncaught exception: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
});
process.on("unhandledRejection", (reason, promise) => {
console.error(
`Worker ${workerId} unhandled rejection at:`,
promise,
"reason:",
reason,
);
slog({
logKey: "unhandled_rejection",
msg: `Worker ${workerId} unhandled promise rejection: ${reason}`,
severity: LogSeverity.Error,
});
});
}
const isLocalhost = (req: Request): boolean => {
// Get client IP address from various possible sources
const clientIP =
req.ip ||
req.socket.remoteAddress ||
(req.headers["x-forwarded-for"] as string)?.split(",").shift() ||
"unknown";
// Check if the request is from a loopback address
const isLoopbackIP =
// IPv4 localhost
clientIP === "127.0.0.1" ||
// IPv6 localhost
clientIP === "::1" ||
// Full loopback range
clientIP.startsWith("127.");
// Check hostname
const isLocalHostname =
req.hostname === "localhost" || req.headers.host?.startsWith("localhost:");
// Consider request local if either IP is loopback or hostname is localhost
return isLoopbackIP || isLocalHostname;
};
+24
View File
@@ -0,0 +1,24 @@
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:node]
command=npm run start:server
directory=/usr/src/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+57 -6
View File
@@ -123,21 +123,72 @@ export default (env, argv) => {
compress: true,
port: 9000,
proxy: [
// WebSocket proxies
{
context: ["/socket"],
target: "ws://localhost:3000",
ws: true,
changeOrigin: true,
logLevel: "debug",
},
// Worker WebSocket proxies - using direct paths without /socket suffix
{
context: ["/w0"],
target: "ws://localhost:3001",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w1"],
target: "ws://localhost:3002",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w2"],
target: "ws://localhost:3003",
ws: true,
secure: false,
changeOrigin: true,
logLevel: "debug",
},
// Worker proxies for HTTP requests
{
context: ["/w0"],
target: "http://localhost:3001",
pathRewrite: { "^/w0": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w1"],
target: "http://localhost:3002",
pathRewrite: { "^/w1": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
{
context: ["/w2"],
target: "http://localhost:3003",
pathRewrite: { "^/w2": "" },
secure: false,
changeOrigin: true,
logLevel: "debug",
},
// Original API endpoints
{
context: [
"/lobbies",
"/public_lobbies",
"/join_game",
"/join_lobby",
"/private_lobby",
"/start_private_lobby",
"/lobby",
"/start_game",
"/create_game",
"/archive_singleplayer_game",
"/validate-username",
"/debug-ip",
"/auth/callback",
"/auth/discord",