Merge pull request #103 from openfrontio/cherry-pick-branch
Merge ClosedFront branch
@@ -0,0 +1,3 @@
|
||||
[submodule "src/server/gatekeeper"]
|
||||
path = src/server/gatekeeper
|
||||
url = https://github.com/openfrontio/gatekeeper.git
|
||||
@@ -4,7 +4,11 @@
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.svg
|
||||
*.webp
|
||||
*.txt
|
||||
.prettierignore
|
||||
.gitignore
|
||||
Dockerfile
|
||||
*.conf
|
||||
.gitmodules
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.5 MiB |
@@ -137,7 +137,7 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [243, 1067],
|
||||
"name": "Somolia",
|
||||
"name": "Somalia",
|
||||
"strength": 1,
|
||||
"flag": "so"
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [300, 188],
|
||||
"name": "Maldova",
|
||||
"name": "Moldova",
|
||||
"strength": 1,
|
||||
"flag": "md"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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}>×</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>
|
||||
@@ -307,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>
|
||||
@@ -459,5 +474,6 @@ export class HelpModal extends LitElement {
|
||||
|
||||
public close() {
|
||||
this.isModalOpen = false;
|
||||
console.log("closing modal");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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";
|
||||
|
||||
@customElement("host-lobby-modal")
|
||||
export class HostLobbyModal extends LitElement {
|
||||
@@ -321,6 +323,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}>×</span>
|
||||
|
||||
@@ -412,7 +419,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}
|
||||
@@ -508,7 +515,7 @@ export class HostLobbyModal extends LitElement {
|
||||
public open() {
|
||||
createLobby()
|
||||
.then((lobby) => {
|
||||
this.lobbyId = lobby.id;
|
||||
this.lobbyId = lobby.gameID;
|
||||
// join lobby
|
||||
})
|
||||
.then(() => {
|
||||
@@ -517,7 +524,7 @@ export class HostLobbyModal extends LitElement {
|
||||
detail: {
|
||||
gameType: GameType.Private,
|
||||
lobby: {
|
||||
id: this.lobbyId,
|
||||
gameID: this.lobbyId,
|
||||
},
|
||||
map: this.selectedMap,
|
||||
difficulty: this.selectedDifficulty,
|
||||
@@ -582,21 +589,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 async startGame() {
|
||||
@@ -604,12 +614,15 @@ export class HostLobbyModal extends LitElement {
|
||||
`Starting private game with map: ${GameMapType[this.selectedMap]}`,
|
||||
);
|
||||
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() {
|
||||
@@ -623,34 +636,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}`);
|
||||
@@ -659,13 +680,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
|
||||
|
||||
@@ -62,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;
|
||||
@@ -175,6 +179,7 @@ export class InputHandler {
|
||||
"KeyQ",
|
||||
"Digit1",
|
||||
"Digit2",
|
||||
"KeyC",
|
||||
].includes(e.code)
|
||||
) {
|
||||
this.activeKeys.add(e.code);
|
||||
@@ -202,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 (
|
||||
[
|
||||
@@ -219,6 +229,7 @@ export class InputHandler {
|
||||
"KeyQ",
|
||||
"Digit1",
|
||||
"Digit2",
|
||||
"KeyC",
|
||||
].includes(e.code)
|
||||
) {
|
||||
this.activeKeys.delete(e.code);
|
||||
|
||||
@@ -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}>×</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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")) {
|
||||
@@ -148,7 +145,7 @@ class Client {
|
||||
? ""
|
||||
: this.flagInput.getCurrentFlag(),
|
||||
playerName: (): string => this.usernameInput.getCurrentUsername(),
|
||||
gameID: lobby.id,
|
||||
gameID: lobby.gameID,
|
||||
persistentID: getPersistentIDFromCookie(),
|
||||
playerID: generateID(),
|
||||
clientID: generateID(),
|
||||
@@ -164,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");
|
||||
}
|
||||
},
|
||||
@@ -180,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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -245,6 +245,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}>×</span>
|
||||
|
||||
@@ -424,7 +429,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
detail: {
|
||||
gameType: GameType.Singleplayer,
|
||||
lobby: {
|
||||
id: generateID(),
|
||||
gameID: generateID(),
|
||||
},
|
||||
map: this.selectedMap,
|
||||
difficulty: this.selectedDifficulty,
|
||||
|
||||
@@ -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";
|
||||
@@ -107,6 +109,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;
|
||||
@@ -159,6 +167,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 +228,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 +247,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) => {
|
||||
@@ -448,6 +460,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({
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -219,7 +219,8 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.leaderboard {
|
||||
top: 60px;
|
||||
top: 70px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.leaderboard-button {
|
||||
|
||||
@@ -166,6 +166,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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -48,29 +48,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 +94,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),
|
||||
@@ -240,7 +246,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 +265,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 +294,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 +329,7 @@ export const ClientMessageSchema = z.union([
|
||||
ClientIntentMessageSchema,
|
||||
ClientJoinMessageSchema,
|
||||
ClientLogMessageSchema,
|
||||
ClientHashSchema,
|
||||
]);
|
||||
|
||||
export const PlayerRecordSchema = z.object({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { UnitImpl } from "./UnitImpl";
|
||||
import { consolex } from "../Consolex";
|
||||
import { GameMap, GameMapImpl, TileRef, TileUpdate } from "./GameMap";
|
||||
import { DefenseGrid } from "./DefensePostGrid";
|
||||
import { simpleHash } from "../Util";
|
||||
|
||||
export function createGame(
|
||||
gameMap: GameMap,
|
||||
@@ -241,21 +242,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 +503,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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,8 +35,9 @@ export enum GameUpdateType {
|
||||
BrokeAlliance,
|
||||
AllianceExpired,
|
||||
TargetPlayer,
|
||||
EmojiUpdate,
|
||||
WinUpdate,
|
||||
Emoji,
|
||||
Win,
|
||||
Hash,
|
||||
}
|
||||
|
||||
export type GameUpdate =
|
||||
@@ -50,7 +51,8 @@ export type GameUpdate =
|
||||
| DisplayMessageUpdate
|
||||
| TargetPlayerUpdate
|
||||
| EmojiUpdate
|
||||
| WinUpdate;
|
||||
| WinUpdate
|
||||
| HashUpdate;
|
||||
|
||||
export interface TileUpdateWrapper {
|
||||
type: GameUpdateType.Tile;
|
||||
@@ -135,7 +137,7 @@ export interface TargetPlayerUpdate {
|
||||
}
|
||||
|
||||
export interface EmojiUpdate {
|
||||
type: GameUpdateType.EmojiUpdate;
|
||||
type: GameUpdateType.Emoji;
|
||||
emoji: EmojiMessage;
|
||||
}
|
||||
|
||||
@@ -147,6 +149,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;
|
||||
}
|
||||
|
||||
@@ -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,10 +57,12 @@ 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;
|
||||
|
||||
@@ -88,14 +92,14 @@ 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)
|
||||
}
|
||||
|
||||
@@ -117,7 +121,7 @@ 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(),
|
||||
@@ -234,7 +238,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);
|
||||
@@ -503,11 +507,11 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
gold(): Gold {
|
||||
return this._gold;
|
||||
return Number(this._gold);
|
||||
}
|
||||
|
||||
addGold(toAdd: Gold): void {
|
||||
this._gold += toAdd;
|
||||
this._gold += toInt(toAdd);
|
||||
}
|
||||
|
||||
removeGold(toRemove: Gold): void {
|
||||
@@ -516,24 +520,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 {
|
||||
@@ -542,11 +546,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 {
|
||||
@@ -554,15 +558,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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -23,7 +23,7 @@ export class UnitImpl implements Unit {
|
||||
public _owner: PlayerImpl,
|
||||
) {
|
||||
// default to 60% health (or 1.2 is no health specified)
|
||||
this._health = (this.mg.unitInfo(_type).maxHealth ?? 2) * 0.6;
|
||||
this._health = toInt((this.mg.unitInfo(_type).maxHealth ?? 2) * 0.6);
|
||||
this._lastTile = _tile;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,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 +65,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 +94,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,7 +139,7 @@ export class UnitImpl implements Unit {
|
||||
}
|
||||
|
||||
hash(): number {
|
||||
return this.tile() + simpleHash(this.type());
|
||||
return this.tile() + simpleHash(this.type()) * this._id;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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/RealSecurityMiddleware.js",
|
||||
);
|
||||
const tsMiddlewarePath = path.resolve(
|
||||
__dirname,
|
||||
"./gatekeeper/RealSecurityMiddleware.ts",
|
||||
);
|
||||
|
||||
if (
|
||||
!fs.existsSync(realMiddlewarePath) &&
|
||||
!fs.existsSync(tsMiddlewarePath)
|
||||
) {
|
||||
console.log(
|
||||
"RealSecurityMiddleware 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.RealSecurityMiddleware) {
|
||||
console.log(
|
||||
"RealSecurityMiddleware 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);
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||