Merge main into strict

This commit is contained in:
Scott Anderson
2025-05-13 03:41:42 -04:00
99 changed files with 3042 additions and 562 deletions
+16 -7
View File
@@ -1,4 +1,4 @@
name: CI
name: 🧪 CI
on: [push, pull_request]
jobs:
build:
@@ -12,13 +12,22 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup npm
run: npm install
- name: Run tests
run: npm test
- name: Build
run: npm run build-prod
- run: npm ci
- run: npm run build-prod
- uses: actions/upload-artifact@v4
with:
path: out/index.html
retention-days: 1
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: false
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
+74 -10
View File
@@ -39,7 +39,7 @@ jobs:
# Don't deploy on push if this is a fork
if: ${{ github.event_name == 'workflow_dispatch' || github.repository == 'openfrontio/OpenFrontIO' }}
# Use different logic based on event type
name: Deploy to ${{
name: ${{
github.event_name == 'push'
&& (github.ref_name == 'main' && 'openfront.dev'
|| format('{0}.openfront.dev', github.ref_name))
@@ -61,7 +61,7 @@ jobs:
SUBDOMAIN: ${{ github.event_name == 'push' && github.ref_name || inputs.target_subdomain || 'main' }}
steps:
- uses: actions/checkout@v4
- name: Update deployment status
- name: 📝 Update job summary
env:
FQDN: ${{ env.SUBDOMAIN && format('{0}.{1}', env.SUBDOMAIN, env.DOMAIN) || env.DOMAIN || 'openfront.dev' }}
run: |
@@ -71,26 +71,44 @@ jobs:
Deploying from $GITHUB_REF to $FQDN
EOF
- name: Log in to Docker Hub
- uses: actions/create-github-app-token@v2
id: generate-token
if: ${{ github.repository == 'openfrontio/OpenFrontIO' }}
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Export the token
if: ${{ github.repository == 'openfrontio/OpenFrontIO' }}
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
echo "GH_TOKEN=$GH_TOKEN" >> $GITHUB_ENV
gh api octocat
- name: 📝 Create deployment
uses: chrnorm/deployment-action@v2
id: deployment
with:
token: ${{ steps.generate-token.outputs.token }}
environment-url: https://${{ env.FQDN }}
environment: ${{ env.FQDN }}
- name: 🔗 Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create SSH private key
- name: 🔑 Create SSH private key
env:
SERVER_HOST_EU: ${{ secrets.SERVER_HOST_EU }}
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
SERVER_HOST_US: ${{ secrets.SERVER_HOST_US }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
set -euxo pipefail
mkdir -p ~/.ssh
echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa
test -n "$SERVER_HOST_STAGING" && ssh-keyscan -H "$SERVER_HOST_STAGING" >> ~/.ssh/known_hosts
test -n "$SERVER_HOST_US" && ssh-keyscan -H "$SERVER_HOST_US" >> ~/.ssh/known_hosts
test -n "$SERVER_HOST_EU" && ssh-keyscan -H "$SERVER_HOST_EU" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/id_rsa
- name: Deploy
- name: 🚢 Deploy
env:
ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
@@ -109,14 +127,52 @@ jobs:
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
SERVER_HOST_EU: ${{ secrets.SERVER_HOST_EU }}
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
SERVER_HOST_US: ${{ secrets.SERVER_HOST_US }}
SSH_KEY: ~/.ssh/id_rsa
VERSION_TAG: latest
run: |
echo "::group::deploy.sh"
./deploy.sh "$ENV" "$HOST" "$SUBDOMAIN"
echo "Deployment created in ${SECONDS} seconds" >> $GITHUB_STEP_SUMMARY
echo "::endgroup::"
- name: Update deployment status ✅
- name: ⏳ Wait for deployment to start
run: |
echo "::group::Wait for deployment to start"
set -euxo pipefail
while [ "$(curl -s https://${FQDN}/commit.txt)" != "${GITHUB_SHA}" ]; do
if [ "$SECONDS" -ge 300 ]; then
echo "Timeout: deployment did not start within 5 minutes"
exit 1
fi
sleep 10
done
echo "Deployment started in ${SECONDS} seconds" >> $GITHUB_STEP_SUMMARY
echo "::endgroup::"
- name: 🚀 Notify PR
if: ${{ success() && github.event_name == 'push' }}
env:
BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
run: |
set -euxo pipefail
if [ -z "${BRANCH}" ]; then
echo "Branch not found"
exit 1
fi
echo "Checking for open PR from $BRANCH..."
pr_url=$(gh pr list --head "$BRANCH" --state open --json url -q '.[0].url')
if [ -z "$pr_url" ]; then
echo "No open PR found for branch $BRANCH"
exit 0
fi
gh pr comment "$pr_url" --body "🚀 Deployed ${GITHUB_SHA} to [$FQDN](https://$FQDN)."
- name: ✅ Update deployment status
if: success()
uses: chrnorm/deployment-status@v2
with:
token: ${{ steps.generate-token.outputs.token }}
environment-url: https://${{ env.FQDN }}
state: success
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
- name: ✅ Update job summary
if: success()
run: |
cat <<EOF >> $GITHUB_STEP_SUMMARY
@@ -124,7 +180,15 @@ jobs:
Deployed from $GITHUB_REF to $FQDN
EOF
- name: Update deployment status
- name: Update deployment status
if: failure()
uses: chrnorm/deployment-status@v2
with:
token: ${{ steps.generate-token.outputs.token }}
environment-url: https://${{ env.FQDN }}
state: failure
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
- name: ❌ Update job summary
if: failure()
run: |
cat <<EOF >> $GITHUB_STEP_SUMMARY
+2 -2
View File
@@ -1,4 +1,4 @@
name: ESLint Check
name: 🔍 ESLint
on:
pull_request:
@@ -6,7 +6,7 @@ on:
branches: [main]
jobs:
eslint:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+2 -2
View File
@@ -1,4 +1,4 @@
name: Prettier Check
name: 🎨 Prettier
on:
pull_request:
@@ -6,7 +6,7 @@ on:
branches: [main]
jobs:
prettier:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+8 -8
View File
@@ -31,21 +31,21 @@ set -- "${POSITIONAL_ARGS[@]}"
# Check command line arguments
if [ $# -lt 2 ] || [ $# -gt 3 ]; then
echo "Error: Please specify environment and host, with optional subdomain"
echo "Usage: $0 [prod|staging] [eu|us|staging|masters] [subdomain] [--enable_basic_auth]"
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
exit 1
fi
# Validate first argument (environment)
if [ "$1" != "prod" ] && [ "$1" != "staging" ]; then
echo "Error: First argument must be either 'prod' or 'staging'"
echo "Usage: $0 [prod|staging] [eu|us|staging|masters] [subdomain] [--enable_basic_auth]"
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
exit 1
fi
# Validate second argument (host)
if [ "$2" != "eu" ] && [ "$2" != "us" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then
echo "Error: Second argument must be either 'eu', 'us', 'staging', or 'masters'"
echo "Usage: $0 [prod|staging] [eu|us|staging|masters] [subdomain] [--enable_basic_auth]"
if [ "$2" != "eu" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then
echo "Error: Second argument must be either 'eu', 'nbg1', 'staging', or 'masters'"
echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]"
exit 1
fi
@@ -83,9 +83,9 @@ fi
if [ "$HOST" == "staging" ]; then
print_header "DEPLOYING TO STAGING HOST"
SERVER_HOST=$SERVER_HOST_STAGING
elif [ "$HOST" == "us" ]; then
print_header "DEPLOYING TO US HOST"
SERVER_HOST=$SERVER_HOST_US
elif [ "$HOST" == "nbg1" ]; then
print_header "DEPLOYING TO NBG1 HOST"
SERVER_HOST=$SERVER_HOST_NBG1
elif [ "$HOST" == "masters" ]; then
print_header "DEPLOYING TO MASTERS HOST"
SERVER_HOST=$SERVER_HOST_MASTERS
+182
View File
@@ -0,0 +1,182 @@
{
"help": [
{
"key": "troops",
"requiresPlayer": false
},
{
"key": "gold",
"requiresPlayer": false
},
{
"key": "no_attack",
"requiresPlayer": false
},
{
"key": "sorry_attack",
"requiresPlayer": false
},
{
"key": "alliance",
"requiresPlayer": false
},
{
"key": "help_defend",
"requiresPlayer": true
},
{
"key": "team_up",
"requiresPlayer": true
}
],
"attack": [
{
"key": "attack",
"requiresPlayer": true
},
{
"key": "mirv",
"requiresPlayer": true
},
{
"key": "focus",
"requiresPlayer": true
},
{
"key": "finish",
"requiresPlayer": true
}
],
"defend": [
{
"key": "defend",
"requiresPlayer": true
},
{
"key": "dont_attack",
"requiresPlayer": true
},
{
"key": "ally",
"requiresPlayer": true
}
],
"greet": [
{
"key": "hello",
"requiresPlayer": false
},
{
"key": "good_luck",
"requiresPlayer": false
},
{
"key": "have_fun",
"requiresPlayer": false
},
{
"key": "gg",
"requiresPlayer": false
},
{
"key": "nice_to_meet",
"requiresPlayer": false
},
{
"key": "well_played",
"requiresPlayer": false
},
{
"key": "hi_again",
"requiresPlayer": false
},
{
"key": "bye",
"requiresPlayer": false
},
{
"key": "thanks",
"requiresPlayer": false
},
{
"key": "oops",
"requiresPlayer": false
},
{
"key": "trust_me",
"requiresPlayer": false
},
{
"key": "trust_broken",
"requiresPlayer": false
}
],
"misc": [
{
"key": "go",
"requiresPlayer": false
},
{
"key": "strategy",
"requiresPlayer": false
},
{
"key": "fun",
"requiresPlayer": false
},
{
"key": "pr",
"requiresPlayer": false
}
],
"warnings": [
{
"key": "strong",
"requiresPlayer": true
},
{
"key": "weak",
"requiresPlayer": true
},
{
"key": "mirv_soon",
"requiresPlayer": true
},
{
"key": "number1_warning",
"requiresPlayer": false
},
{
"key": "stalemate",
"requiresPlayer": false
},
{
"key": "has_allies",
"requiresPlayer": true
},
{
"key": "no_allies",
"requiresPlayer": true
},
{
"key": "betrayed",
"requiresPlayer": true
},
{
"key": "getting_big",
"requiresPlayer": true
},
{
"key": "danger_base",
"requiresPlayer": true
},
{
"key": "saving_for_mirv",
"requiresPlayer": true
},
{
"key": "mirv_ready",
"requiresPlayer": true
}
]
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="150px" height="150px" viewBox="0 0 150 75" version="1.1">
<g id="surface1">
<rect x="0" y="0" width="150" height="75" style="fill:rgb(100%,85.09804%,0%);fill-opacity:1;stroke:none;"/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 0 0 L 150 0 L 150 56.25 L 0 56.25 Z M 0 0 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,22.352941%,65.098041%);fill-opacity:1;" d="M 0 0 L 150 0 L 150 37.5 L 0 37.5 Z M 0 0 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,85.09804%,0%);fill-opacity:1;" d="M 27.675781 20.625 C 26.925781 24.578125 28.617188 28.59375 31.96875 30.820312 C 35.320312 33.042969 39.679688 33.042969 43.03125 30.820312 C 46.382812 28.59375 48.074219 24.578125 47.324219 20.625 C 46.429688 25.34375 42.304688 28.761719 37.5 28.761719 C 32.695312 28.761719 28.570312 25.34375 27.675781 20.625 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,85.09804%,0%);fill-opacity:1;" d="M 45 20 C 45 15.859375 41.640625 12.5 37.5 12.5 C 33.359375 12.5 30 15.859375 30 20 C 30 24.140625 33.359375 27.5 37.5 27.5 C 41.640625 27.5 45 24.140625 45 20 Z M 45 20 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,85.09804%,0%);fill-opacity:1;" d="M 37.425781 2.5 C 37.480469 2.925781 37.734375 3.269531 37.976562 3.605469 C 38.160156 3.949219 38.167969 4.355469 38.050781 4.726562 C 37.992188 5.105469 37.925781 5.539062 38.179688 5.867188 C 38.445312 6.226562 38.695312 6.652344 38.625 7.121094 C 38.617188 7.605469 38.292969 8.058594 38.351562 8.539062 C 38.480469 8.859375 38.847656 9.269531 39.050781 8.761719 C 39.3125 8.347656 38.925781 7.890625 39.042969 7.453125 C 39.121094 7.019531 39.476562 6.652344 39.386719 6.183594 C 39.378906 5.773438 38.804688 5.386719 39.25 5 C 39.40625 4.820312 39.664062 4.457031 39.640625 4.917969 C 39.90625 5.230469 40.214844 5.585938 40.175781 6.03125 C 40.132812 6.46875 39.964844 6.976562 40.375 7.300781 C 40.792969 7.742188 41.160156 8.296875 41.164062 8.925781 C 41.210938 9.476562 41.097656 10.0625 40.734375 10.488281 C 40.257812 10.882812 39.742188 11.292969 39.117188 11.394531 C 38.667969 11.457031 38.226562 11.578125 37.9375 11.953125 C 37.523438 12.296875 37.117188 11.964844 36.863281 11.632812 C 36.421875 11.457031 35.929688 11.503906 35.480469 11.359375 C 34.882812 11.1875 34.316406 10.816406 34.042969 10.246094 C 33.761719 9.675781 33.726562 9.015625 33.765625 8.390625 C 33.777344 7.890625 34.0625 7.449219 34.414062 7.105469 C 34.757812 6.800781 34.617188 6.304688 34.632812 5.90625 C 34.804688 5.457031 35.273438 5.226562 35.527344 4.828125 C 35.824219 4.460938 35.742188 5.351562 35.75 5.539062 C 35.761719 5.863281 35.40625 6.0625 35.476562 6.394531 C 35.4375 6.820312 35.941406 7.03125 35.984375 7.4375 C 36.167969 7.84375 35.746094 8.28125 36.007812 8.660156 C 36.148438 8.84375 36.507812 9.285156 36.550781 8.800781 C 36.765625 8.425781 36.738281 7.992188 36.5625 7.605469 C 36.496094 7.175781 36.128906 6.773438 36.328125 6.328125 C 36.476562 5.929688 36.960938 5.722656 36.992188 5.257812 C 37.050781 4.835938 36.765625 4.472656 36.707031 4.066406 C 36.585938 3.605469 36.796875 3.058594 37.144531 2.742188 C 37.234375 2.660156 37.320312 2.566406 37.425781 2.5 Z M 37.425781 2.5 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 134 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="svg4" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" viewBox="0 0 800 800">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<sodipodi:namedview id="namedview6" bordercolor="#000000" borderopacity="0.25" inkscape:current-layer="svg4" inkscape:cx="401.69492" inkscape:cy="400" inkscape:deskcolor="#d1d1d1" inkscape:pagecheckerboard="0" inkscape:pageopacity="0.0" inkscape:showpageshadow="2" inkscape:window-height="987" inkscape:window-maximized="1" inkscape:window-width="1536" inkscape:window-x="0" inkscape:window-y="0" inkscape:zoom="0.295" pagecolor="#ffffff" showgrid="false"/>
<path class="st0" d="M713.8,149.3v401c0,14.1-11.4,25.5-25.4,25.5h-432.2l-124.4,129.8v-129.8h-20.1c-14.1,0-25.5-11.4-25.5-25.5V149.3c0-14.1,11.3-25.5,25.4-25.5h576.7c14.1,0,25.5,11.4,25.5,25.5ZM400,317.5c-19.2,0-34.8,15.6-34.8,34.8s15.6,34.8,34.8,34.8,34.8-15.6,34.8-34.8-15.6-34.8-34.8-34.8ZM561.1,317.5c-19.2,0-34.8,15.6-34.8,34.8s15.6,34.8,34.8,34.8,34.8-15.6,34.8-34.8-15.6-34.8-34.8-34.8ZM238.9,317.5c-19.2,0-34.8,15.6-34.8,34.8s15.6,34.8,34.8,34.8,34.8-15.6,34.8-34.8-15.6-34.8-34.8-34.8h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+93
View File
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
x="0px"
y="0px"
viewBox="0 0 100 100"
enable-background="new 0 0 100 100"
xml:space="preserve"
id="svg7"
sodipodi:docname="megaphone.svg"
width="100"
height="100"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs7"><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath7"><rect
style="fill:#000000;fill-opacity:0.428571"
id="rect8"
width="124.09856"
height="112.68029"
x="-13.822115"
y="-9.0144234" /></clipPath><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath8"><rect
style="fill:#000000;fill-opacity:0.428571"
id="rect9"
width="124.09856"
height="112.68029"
x="-13.822115"
y="-9.0144234" /></clipPath><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath9"><rect
style="fill:#000000;fill-opacity:0.428571"
id="rect10"
width="124.09856"
height="112.68029"
x="-13.822115"
y="-9.0144234" /></clipPath></defs><sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="9.4130054"
inkscape:cx="49.61221"
inkscape:cy="40.529032"
inkscape:window-width="1712"
inkscape:window-height="1040"
inkscape:window-x="0"
inkscape:window-y="39"
inkscape:window-maximized="0"
inkscape:current-layer="svg7" /><g
id="g6"
clip-path="url(#clipPath9)"><g
id="g5"><g
id="g4"><path
d="M 70.697,39.701 C 67.79,27.673 62.101,14.727 56.481,14.727 c -0.278,0 -0.556,0.033 -0.823,0.097 -1.225,0.296 -2.2,1.229 -2.916,2.744 -0.076,0.06 -0.159,0.106 -0.222,0.186 -3.146,4.022 -14.412,17.306 -27.265,20.412 l -9.82,2.534 c -0.039,0.009 -0.077,0.021 -0.115,0.033 -3.009,0.827 -5.549,2.72 -7.184,5.362 -1.71,2.766 -2.23,6.034 -1.463,9.205 0.767,3.17 2.722,5.841 5.506,7.52 2.771,1.672 6.048,2.169 9.225,1.402 L 23.44,63.73 c 0,0 10e-4,0 0.002,0 h 0.001 l 2.449,-0.592 8.126,16.697 c 1.058,2.172 3.24,3.438 5.509,3.438 0.897,0 1.808,-0.197 2.667,-0.615 3.031,-1.477 4.297,-5.144 2.821,-8.176 l -6.57,-13.5 c 11.731,-0.099 23.711,5.353 26.449,6.681 0.98,0.73 1.973,1.135 2.964,1.135 0.278,0 0.556,-0.033 0.823,-0.098 2.594,-0.627 4.085,-4.076 4.312,-9.975 0.204,-5.297 -0.611,-12.053 -2.296,-19.024 z M 20.792,61.695 C 18.287,62.302 15.704,61.91 13.521,60.593 11.337,59.276 9.801,57.178 9.2,54.688 8.598,52.198 9.005,49.63 10.348,47.46 11.671,45.319 13.754,43.8 16.22,43.179 l 0.866,-0.21 4.481,18.538 z M 41.056,80.32 c -1.742,0.848 -3.853,0.12 -4.7,-1.623 l -2.949,-6.059 6.409,-2.895 2.861,5.878 c 0.849,1.742 0.121,3.85 -1.621,4.699 z m -2.378,-12.916 -6.409,2.895 -3.789,-7.786 3.01,-0.728 c 1.351,-0.326 2.733,-0.541 4.129,-0.667 z m -7.8,-8.146 -6.784,1.639 -4.481,-18.539 6.255,-1.515 c 10.808,-2.612 20.417,-11.869 25.586,-17.701 -0.046,0.531 -0.084,1.079 -0.107,1.657 -0.107,2.758 0.069,5.917 0.495,9.291 0.006,0.048 -0.006,0.094 0.005,0.143 0.004,0.016 0.016,0.027 0.02,0.043 0.392,3.051 0.984,6.273 1.776,9.548 0.776,3.211 1.752,6.486 2.87,9.564 0.006,0.061 -0.008,0.119 0.007,0.18 0.043,0.177 0.127,0.328 0.23,0.465 1.283,3.438 2.746,6.592 4.308,9.1 C 54.066,60.36 41.753,56.628 30.878,59.258 Z M 54.622,35.237 c 1.959,0.539 4.284,3.213 5.269,7.284 0.977,4.046 0.149,7.465 -1.333,8.859 -0.85,-2.446 -1.662,-5.163 -2.388,-8.168 -0.659,-2.726 -1.173,-5.407 -1.548,-7.975 z m 15.774,23.388 c -0.189,4.871 -1.304,7.301 -2.326,7.548 -0.068,0.017 -0.138,0.024 -0.212,0.024 -0.23,0 -0.486,-0.071 -0.764,-0.208 -0.109,-0.137 -0.245,-0.255 -0.41,-0.341 -0.054,-0.028 -0.194,-0.101 -0.404,-0.205 -1.881,-1.551 -4.42,-5.476 -6.779,-11.51 2.93,-1.8 4.207,-6.688 2.917,-12.024 -1.297,-5.364 -4.683,-9.147 -8.139,-9.372 -0.3,-2.763 -0.423,-5.352 -0.335,-7.639 0.188,-4.871 1.303,-7.3 2.325,-7.547 0.068,-0.017 0.138,-0.024 0.212,-0.024 2.507,0 8.112,8.185 11.689,22.985 1.631,6.745 2.421,13.248 2.226,18.313 z"
id="path1" /><path
d="m 80.626,38.899 c -0.574,-0.012 -1.091,-0.407 -1.234,-0.991 -0.171,-0.697 0.256,-1.401 0.953,-1.572 l 8.494,-2.084 c 0.696,-0.172 1.401,0.256 1.572,0.953 0.171,0.697 -0.256,1.401 -0.953,1.572 l -8.494,2.084 c -0.113,0.028 -0.227,0.041 -0.338,0.038 z"
id="path2" /><path
d="m 74.191,24.693 c -0.348,-0.015 -0.689,-0.17 -0.933,-0.456 -0.466,-0.547 -0.399,-1.368 0.148,-1.833 l 7.659,-6.514 c 0.546,-0.464 1.368,-0.399 1.832,0.148 0.466,0.547 0.399,1.368 -0.148,1.833 l -7.659,6.514 c -0.26,0.222 -0.582,0.322 -0.899,0.308 z"
id="path3" /><path
d="m 92.061,56.782 c -0.171,0.029 -0.351,0.025 -0.532,-0.02 l -9.416,-2.326 c -0.698,-0.172 -1.122,-0.877 -0.95,-1.573 0.17,-0.697 0.873,-1.126 1.573,-0.95 l 9.416,2.326 c 0.698,0.172 1.122,0.877 0.95,1.573 -0.126,0.517 -0.547,0.885 -1.041,0.97 z"
id="path4" /></g></g></g><text
x="0"
y="115"
fill="#000000"
font-size="5px"
font-weight="bold"
font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif"
id="text6"
clip-path="url(#clipPath8)">Created by Jacopo Bonacci</text><text
x="0"
y="120"
fill="#000000"
font-size="5px"
font-weight="bold"
font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif"
id="text7"
clip-path="url(#clipPath7)">from the Noun Project</text><path
style="fill:#800000;fill-opacity:0.428571;stroke-width:0.300481"
d=""
id="path10" /><path
style="fill:#800000;fill-opacity:0.428571;stroke-width:0.300481"
d=""
id="path11" /></svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

+91 -2
View File
@@ -101,6 +101,7 @@
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
"disable_nukes": "Disable Nukes",
"enables_title": "Enable Settings",
"start": "Start Game"
},
"map": {
@@ -126,7 +127,9 @@
"knownworld": "Known World",
"faroeislands": "Faroe Islands",
"deglaciatedantarctica": "Deglaciated Antarctica",
"europeclassic": "Europe (classic)"
"europeclassic": "Europe (classic)",
"falklandislands": "Falkland Islands",
"baikal": "Baikal"
},
"map_categories": {
"continental": "Continental",
@@ -167,7 +170,7 @@
"instant_build": "Instant build",
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
"disable_nukes": "Disable Nukes",
"enables_title": "Enable Settings",
"player": "Player",
"players": "Players",
"waiting": "Waiting for players...",
@@ -191,6 +194,17 @@
"select_lang": {
"title": "Select Language"
},
"unit_type": {
"city": "City",
"defense_post": "Defense Post",
"port": "Port",
"warship": "Warship",
"missile_silo": "Missile Silo",
"sam_launcher": "SAM Launcher",
"atom_bomb": "Atom Bomb",
"hydrogen_bomb": "Hydrogen Bomb",
"mirv": "MIRV"
},
"user_setting": {
"title": "User Settings",
"tab_basic": "Basic Settings",
@@ -199,6 +213,8 @@
"dark_mode_desc": "Toggle the sites appearance between light and dark themes",
"emojis_label": "😊 Emojis",
"emojis_desc": "Toggle whether emojis are shown in game",
"anonymous_names_label": "🥷 Hidden Names",
"anonymous_names_desc": "Hide real player names with random ones on your screen.",
"left_click_label": "🖱️ Left Click to Open Menu",
"left_click_desc": "When ON, left-click opens menu and sword button attacks. When OFF, left-click attacks directly.",
"attack_ratio_label": "⚔️ Attack Ratio",
@@ -230,5 +246,78 @@
"move_right_desc": "Move the camera to the right",
"reset": "Reset",
"unbind": "Unbind"
},
"chat": {
"title": "Quick Chat",
"to": "Sent {user}: {msg}",
"from": "From {user}: {msg}",
"category": "Category",
"phrase": "Phrase",
"player": "Player",
"send": "Send",
"search": "Search player...",
"build": "Build your message...",
"cat": {
"help": "Help",
"attack": "Attack",
"defend": "Defend",
"greet": "Greetings",
"misc": "Miscellaneous",
"warnings": "Warnings"
},
"help": {
"troops": "Please give me troops!",
"gold": "Please give me gold!",
"no_attack": "Please don't attack me!",
"sorry_attack": "Sorry, I didnt mean to attack.",
"alliance": "Alliance?",
"help_defend": "Help me defend against [P1]!",
"team_up": "Lets team up against [P1]!"
},
"attack": {
"attack": "Attack [P1]!",
"mirv": "Launch a MIRV at [P1]!",
"focus": "Focus fire on [P1]!",
"finish": "Let's finish off [P1]!"
},
"defend": {
"defend": "Defend [P1]!",
"dont_attack": "Dont attack [P1]!",
"ally": "[P1] is my ally!"
},
"greet": {
"hello": "Hello!",
"good_luck": "Good luck!",
"have_fun": "Have fun!",
"gg": "GG!",
"nice_to_meet": "Nice to meet you!",
"well_played": "Well played!",
"hi_again": "Hi again!",
"bye": "Bye!",
"thanks": "Thanks!",
"oops": "Oops, wrong button!",
"trust_me": "You can trust me. Promise!",
"trust_broken": "I trusted you..."
},
"misc": {
"go": "Lets go!",
"strategy": "Nice strategy!",
"fun": "This game is fun!",
"pr": "When will my PR finally get merged...?"
},
"warnings": {
"strong": "[P1] is strong.",
"weak": "[P1] is weak.",
"mirv_soon": "[P1] can launch a MIRV soon!",
"number1_warning": "The #1 player will win soon unless we team up!",
"stalemate": "Let's make peace. This is a stalemate, we will both lose.",
"has_allies": "[P1] has many allies.",
"no_allies": "[P1] has no allies.",
"betrayed": "[P1] betrayed their ally!",
"getting_big": "[P1] is growing too fast!",
"danger_base": "[P1] is unprotected!",
"saving_for_mirv": "[P1] is saving up to launch a MIRV.",
"mirv_ready": "[P1] has enough gold to launch a MIRV!"
}
}
}
+88 -2
View File
@@ -97,7 +97,7 @@
"instant_build": "即時建設",
"infinite_gold": "資金無限",
"infinite_troops": "兵士無限",
"disable_nukes": "核兵器使用禁止",
"enables_title": "有効化設定",
"start": "ゲーム開始"
},
"map": {
@@ -158,7 +158,7 @@
"instant_build": "実在する国家を無効化",
"infinite_gold": "資金無限",
"infinite_troops": "兵士無限",
"disable_nukes": "核兵器使用禁止",
"enables_title": "有効化設定",
"player": "プレイヤー",
"players": "プレイヤー",
"waiting": "他のプレイヤーの参加を待っています...",
@@ -182,6 +182,17 @@
"select_lang": {
"title": "言語を選択"
},
"unit_type": {
"city": "都市",
"defense_post": "防衛ポスト",
"port": "港",
"warship": "戦艦",
"missile_silo": "ミサイル格納庫",
"sam_launcher": "SAMランチャー",
"atom_bomb": "原子爆弾",
"hydrogen_bomb": "水素爆弾",
"mirv": "MIRV"
},
"user_setting": {
"title": "ユーザー設定",
"tab_basic": "基本設定",
@@ -190,6 +201,8 @@
"dark_mode_desc": "ダークモードを切り替えます。",
"emojis_label": "😊 絵文字",
"emojis_desc": "ゲーム内での絵文字を表示します。",
"anonymous_names_label": "🥷 表示名を隠す",
"anonymous_names_desc": "実際のプレイヤー名を隠し、自分の画面ではランダムな名前で表示します。",
"left_click_label": "🖱️ 左クリックでメニューを開く",
"left_click_desc": "オンにすると左クリックでメニューを開き、剣ボタンで攻撃します。オフにすると右クリックで直接攻撃します。",
"attack_ratio_label": "⚔️ 攻撃比率",
@@ -226,5 +239,78 @@
"continental": "大陸",
"regional": "地域",
"fantasy": "その他"
},
"chat": {
"title": "クイックチャット",
"category": "カテゴリ",
"phrase": "フレーズ",
"player": "プレイヤー",
"to": "{user}に送信: {msg}",
"from": "{user}から着信: {msg}",
"send": "送信",
"search": "プレイヤーを検索...",
"build": "チャットを作成...",
"cat": {
"help": "支援要請",
"attack": "攻撃",
"defend": "防御",
"greet": "挨拶",
"misc": "その他",
"warnings": "警告"
},
"help": {
"troops": "援軍をください!",
"gold": "お金をください!",
"no_attack": "攻撃しないでください!",
"sorry_attack": "ごめん、攻撃するつもりはなかった。",
"alliance": "同盟を組みませんか?",
"help_defend": "[P1] からの防衛を手伝って!",
"team_up": "[P1] に対抗して協力しよう!"
},
"attack": {
"attack": "[P1] を攻撃しよう!",
"mirv": "[P1] にMIRVを撃とう!",
"focus": "[P1] に集中攻撃しよう!",
"finish": "[P1] を仕留めよう!"
},
"defend": {
"defend": "[P1] を守って!",
"dont_attack": "[P1] を攻撃しないで!",
"ally": "[P1] は味方だ!"
},
"greet": {
"hello": "こんにちは!",
"good_luck": "頑張って!",
"have_fun": "楽しもう!",
"gg": "GG",
"nice_to_meet": "よろしく!",
"well_played": "ナイスプレイ!",
"hi_again": "また会ったね!",
"bye": "バイバイ!",
"thanks": "ありがとう!",
"oops": "あっ、間違えた!",
"trust_me": "信じてくれ、本当だ!",
"trust_broken": "信じてたのに..."
},
"misc": {
"go": "行こう!",
"strategy": "いい作戦だ!",
"fun": "このゲーム楽しい!",
"pr": "わたしのPRいつマージされるんだろう...?"
},
"warnings": {
"strong": "[P1] は強い。",
"weak": "[P1] は弱い。",
"mirv_soon": "[P1] がもうすぐMIRVを撃つぞ!",
"number1_warning": "1位のプレイヤーが勝ちそうだ、協力しよう!",
"stalemate": "和平しよう。膠着状態だ、両方負ける。",
"has_allies": "[P1] には味方が多い。",
"no_allies": "[P1] には味方がいない。",
"betrayed": "[P1] は味方を裏切った!",
"getting_big": "[P1] の勢力が急拡大中!",
"danger_base": "[P1] の本拠地が無防備だ!",
"saving_for_mirv": "[P1] はMIRVのために貯金してる。",
"mirv_ready": "[P1] はMIRVを撃てるだけの金を持ってる!"
}
}
}
File diff suppressed because one or more lines are too long
+73
View File
@@ -0,0 +1,73 @@
{
"name": "Baikal",
"width": 2500,
"height": 1565,
"nations": [
{
"coordinates": [695, 665],
"name": "Irkutsk Oblast",
"strength": 2,
"flag": "bai_irk"
},
{
"coordinates": [2188, 1001],
"name": "Republic of Buryatia",
"strength": 2,
"flag": "bai_bur"
},
{
"coordinates": [754, 1170],
"name": "Olkhon Island",
"strength": 1,
"flag": "ru"
},
{
"coordinates": [1025, 831],
"name": "Cape Khoboy",
"strength": 1,
"flag": "ru"
},
{
"coordinates": [361, 1195],
"name": "Ogoi Island",
"strength": 1,
"flag": "ru"
},
{
"coordinates": [1805, 115],
"name": "Bolshoy Ushkan Island",
"strength": 1,
"flag": "ru"
},
{
"coordinates": [2030, 335],
"name": "Svyatoy Nos Peninsula",
"strength": 2,
"flag": "ru"
},
{
"coordinates": [2194, 659],
"name": "Chivyrkuisky Bay",
"strength": 1,
"flag": "ru"
},
{
"coordinates": [309, 230],
"name": "Chanchur",
"strength": 2,
"flag": "ru"
},
{
"coordinates": [2165, 1439],
"name": "Zabaykalsky National Park",
"strength": 1,
"flag": "ru"
},
{
"coordinates": [132, 751],
"name": "Listvyanka",
"strength": 1,
"flag": "ru"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

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

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long
+79
View File
@@ -0,0 +1,79 @@
{
"name": "Falkland Islands",
"width": 2100,
"height": 1400,
"nations": [
{
"coordinates": [484, 987],
"name": "Albermarle",
"strength": 2,
"flag": "fk"
},
{
"coordinates": [228, 804],
"name": "Weddell",
"strength": 1,
"flag": "fk"
},
{
"coordinates": [818, 873],
"name": "Fox Bay",
"strength": 1,
"flag": "fk"
},
{
"coordinates": [994, 541],
"name": "East Falkland",
"strength": 2,
"flag": "fk"
},
{
"coordinates": [633, 518],
"name": "Saunders and Dunbar",
"strength": 1,
"flag": "fk"
},
{
"coordinates": [1063, 1036],
"name": "South Lefonia",
"strength": 1,
"flag": "fk"
},
{
"coordinates": [1298, 860],
"name": "North Lefonia",
"strength": 2,
"flag": "fk"
},
{
"coordinates": [1587, 743],
"name": "Wickham and Fitzroy",
"strength": 1,
"flag": "fk"
},
{
"coordinates": [1831, 456],
"name": "Berkeley",
"strength": 1,
"flag": "fk"
},
{
"coordinates": [1984, 657],
"name": "Stanley",
"strength": 1,
"flag": "fk"
},
{
"coordinates": [1468, 398],
"name": "Concordia",
"strength": 2,
"flag": "fk"
},
{
"coordinates": [1381, 624],
"name": "San Carlos",
"strength": 1,
"flag": "fk"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

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

After

Width:  |  Height:  |  Size: 11 KiB

+6 -5
View File
@@ -43,7 +43,7 @@ export interface LobbyConfig {
playerName: string;
clientID: ClientID;
gameID: GameID;
persistentID: string;
token: string;
// GameStartInfo only exists when playing a singleplayer game.
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
@@ -59,7 +59,7 @@ export function joinLobby(
initRemoteSender(eventBus);
consolex.log(
`joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}, persistentID: ${lobbyConfig.persistentID.slice(0, 5)}`,
`joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
);
const userSettings: UserSettings = new UserSettings();
@@ -82,7 +82,7 @@ export function joinLobby(
if (message.type === "start") {
// Trigger prestart for singleplayer games
onPrestart();
consolex.log(`lobby: game started: ${JSON.stringify(message)}`);
consolex.log(`lobby: game started: ${JSON.stringify(message, null, 2)}`);
onJoin();
// For multiplayer games, GameStartInfo is not known until game starts.
lobbyConfig.gameStartInfo = message.gameStartInfo;
@@ -115,6 +115,7 @@ export async function createClientGame(
const config = await getConfig(
lobbyConfig.gameStartInfo.config,
userSettings,
lobbyConfig.gameRecord != null,
);
let gameMap: TerrainMapData | null = null;
@@ -248,10 +249,11 @@ export class ClientGameRunner {
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
);
console.error(gu.stack);
this.stop(true);
return;
}
if (gu.updates === null) return;
this.transport.turnComplete();
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
@@ -285,7 +287,6 @@ export class ClientGameRunner {
while (turn.turnNumber - 1 > this.turnsSeen) {
this.worker.sendTurn({
turnNumber: this.turnsSeen,
gameID: turn.gameID,
intents: [],
});
this.turnsSeen++;
+68 -13
View File
@@ -9,6 +9,7 @@ import {
Duos,
GameMapType,
GameMode,
UnitType,
mapCategories,
} from "../core/game/Game";
import { GameConfig, GameInfo } from "../core/Schemas";
@@ -39,6 +40,7 @@ export class HostLobbyModal extends LitElement {
@state() private copySuccess = false;
@state() private players: string[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: string[] = [];
private playersInterval: NodeJS.Timeout | null = null;
// Add a new timer for debouncing bot changes
@@ -302,21 +304,72 @@ export class HostLobbyModal extends LitElement {
</div>
</label>
<label
for="disable-nukes"
class="option-card ${this.disableNukes ? "selected" : ""}"
<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />
<!-- Individual disables for structures/weapons -->
<div
style="margin: 8px 0 12px 0; font-weight: bold; color: #ccc; text-align: center;"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="disable-nukes"
@change=${this.handleDisableNukesChange}
.checked=${this.disableNukes}
/>
<div class="option-card-title">
${translateText("host_modal.disable_nukes")}
${translateText("host_modal.enables_title")}
</div>
<div
style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;"
>
${[
[UnitType.City, "unit_type.city"],
[UnitType.DefensePost, "unit_type.defense_post"],
[UnitType.Port, "unit_type.port"],
[UnitType.Warship, "unit_type.warship"],
[UnitType.MissileSilo, "unit_type.missile_silo"],
[UnitType.SAMLauncher, "unit_type.sam_launcher"],
[UnitType.AtomBomb, "unit_type.atom_bomb"],
[UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"],
[UnitType.MIRV, "unit_type.mirv"],
].map(
([unitType, translationKey]) => html`
<label
class="option-card ${this.disabledUnits.includes(
unitType,
)
? ""
: "selected"}"
style="width: 140px;"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement)
.checked;
const parsedUnitType =
UnitType[unitType as keyof typeof UnitType];
if (parsedUnitType) {
if (checked) {
this.disabledUnits = [
...this.disabledUnits,
parsedUnitType,
];
} else {
this.disabledUnits = this.disabledUnits.filter(
(u) => u !== parsedUnitType,
);
}
this.putGameConfig();
}
}}
.checked=${this.disabledUnits.includes(unitType)}
/>
<div
class="option-card-title"
style="text-align: center;"
>
${translateText(translationKey)}
</div>
</label>
`,
)}
</div>
</label>
</div>
</div>
</div>
</div>
@@ -490,6 +543,8 @@ export class HostLobbyModal extends LitElement {
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
} as GameConfig),
},
+36 -16
View File
@@ -16,35 +16,51 @@ import { LobbyConfig } from "./ClientGameRunner";
import { getPersistentIDFromCookie } from "./Main";
export class LocalServer {
// All turns from the game record on replay.
private replayTurns: Turn[] = [];
private turns: Turn[] = [];
private intents: Intent[] = [];
private startedAt: number;
private endTurnIntervalID: NodeJS.Timeout;
private paused = false;
private winner: ClientSendWinnerMessage | null = null;
private allPlayersStats: AllPlayersStats = {};
private turnsExecuted = 0;
private lastTurnCompletedTime = 0;
private turnCheckInterval: NodeJS.Timeout;
constructor(
private lobbyConfig: LobbyConfig,
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void,
private isReplay: boolean,
) {}
start() {
this.turnCheckInterval = setInterval(() => {
if (this.turnsExecuted == this.turns.length) {
if (
this.isReplay ||
Date.now() >
this.lastTurnCompletedTime +
this.lobbyConfig.serverConfig.turnIntervalMs()
) {
this.endTurn();
}
}
}, 5);
this.startedAt = Date.now();
if (!this.lobbyConfig.gameRecord) {
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.lobbyConfig.serverConfig.turnIntervalMs(),
);
}
this.clientConnect();
if (this.lobbyConfig.gameRecord) {
this.turns = decompressGameRecord(this.lobbyConfig.gameRecord).turns;
console.log(`loaded turns: ${JSON.stringify(this.turns)}`);
this.replayTurns = decompressGameRecord(
this.lobbyConfig.gameRecord,
).turns;
}
if (typeof this.lobbyConfig.gameStartInfo === "undefined") {
throw new Error("missing gameStartInfo");
@@ -54,7 +70,7 @@ export class LocalServer {
type: "start",
gameID: this.lobbyConfig.gameStartInfo.gameID,
gameStartInfo: this.lobbyConfig.gameStartInfo,
turns: this.turns,
turns: [],
}),
);
}
@@ -93,7 +109,7 @@ export class LocalServer {
return;
}
// If we are replaying a game then verify hash.
const archivedHash = this.turns[clientMsg.turnNumber].hash;
const archivedHash = this.replayTurns[clientMsg.turnNumber].hash;
if (!archivedHash) {
console.warn(
`no archived hash found for turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}`,
@@ -124,16 +140,20 @@ export class LocalServer {
}
}
public turnComplete() {
this.turnsExecuted++;
this.lastTurnCompletedTime = Date.now();
}
private endTurn() {
if (this.paused) {
return;
}
if (typeof this.lobbyConfig.gameStartInfo === "undefined") {
throw new Error("missing gameStartInfo");
if (this.replayTurns.length > 0) {
this.intents = this.replayTurns[this.turns.length].intents;
}
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.lobbyConfig.gameStartInfo.gameID,
intents: this.intents,
};
this.turns.push(pastTurn);
@@ -146,7 +166,7 @@ export class LocalServer {
public endGame(saveFullGame: boolean = false) {
consolex.log("local server ending game");
clearInterval(this.endTurnIntervalID);
clearInterval(this.turnCheckInterval);
const players: PlayerRecord[] = [
{
ip: null,
+28 -12
View File
@@ -19,15 +19,16 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
import { LanguageModal } from "./LanguageModal";
import { NewsModal } from "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import "./RandomNameButton";
import { RandomNameButton } from "./RandomNameButton";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
import { generateCryptoRandomUUID } from "./Utils";
import "./components/NewsButton";
import { NewsButton } from "./components/NewsButton";
import "./components/baseComponents/Button";
import { OButton } from "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
@@ -50,7 +51,6 @@ class Client {
private usernameInput: UsernameInput | null = null;
private flagInput: FlagInput | null = null;
private darkModeButton: DarkModeButton | null = null;
private randomNameButton: RandomNameButton | null = null;
private joinModal: JoinPrivateLobbyModal;
private publicLobby: PublicLobby;
@@ -60,6 +60,23 @@ class Client {
constructor() {}
initialize(): void {
const newsModal = document.querySelector("news-modal") as NewsModal;
if (!newsModal) {
consolex.warn("News modal element not found");
} else {
consolex.log("News modal element found");
}
newsModal instanceof NewsModal;
const newsButton = document.querySelector("news-button") as NewsButton;
if (!newsButton) {
consolex.warn("News button element not found");
} else {
consolex.log("News button element found");
}
// Comment out to show news button.
newsButton.hidden = true;
const langSelector = document.querySelector(
"lang-selector",
) as LangSelector;
@@ -85,13 +102,6 @@ class Client {
consolex.warn("Dark mode button element not found");
}
this.randomNameButton = document.querySelector(
"random-name-button",
) as RandomNameButton;
if (!this.randomNameButton) {
consolex.warn("Random name button element not found");
}
const loginDiscordButton = document.getElementById(
"login-discord",
) as OButton;
@@ -134,6 +144,12 @@ class Client {
}
});
// const ctModal = document.querySelector("chat-modal") as ChatModal;
// ctModal instanceof ChatModal;
// document.getElementById("chat-button").addEventListener("click", () => {
// ctModal.open();
// });
const hlpModal = document.querySelector("help-modal") as HelpModal;
hlpModal instanceof HelpModal;
const helpButton = document.getElementById("help-button");
@@ -263,8 +279,8 @@ class Client {
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
playerName: this.usernameInput?.getCurrentUsername() ?? "",
persistentID: getPersistentIDFromCookie(),
playerName: this.usernameInput.getCurrentUsername(),
token: localStorage.getItem("token") ?? getPersistentIDFromCookie(),
clientID: lobby.clientID,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.gameStartInfo,
gameRecord: lobby.gameRecord,
+65
View File
@@ -0,0 +1,65 @@
import { LitElement, css, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
@customElement("news-modal")
export class NewsModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
static styles = css`
.news-container {
max-height: 60vh;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.news-content {
color: #ddd;
line-height: 1.5;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1rem;
}
`;
render() {
return html`
<o-modal title=${translateText("news.title")}>
<div class="options-layout">
<div class="options-section">
<div class="news-container">
<div class="news-content">INSERT NEWS HERE</div>
</div>
</div>
</div>
<o-button
title=${translateText("common.close")}
@click=${this.close}
blockDesktop
></o-button>
</o-modal>
`;
}
public open() {
this.requestUpdate();
this.modalEl?.open();
}
private close() {
this.modalEl?.close();
}
createRenderRoot() {
return this; // light DOM
}
}
-30
View File
@@ -1,30 +0,0 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserSettings } from "../core/game/UserSettings";
@customElement("random-name-button")
export class RandomNameButton extends LitElement {
private userSettings: UserSettings = new UserSettings();
@state() private randomName: boolean = this.userSettings.anonymousNames();
createRenderRoot() {
return this;
}
toggleRandomName() {
this.userSettings.toggleRandomName();
this.randomName = this.userSettings.anonymousNames();
}
render() {
return html`
<button
title="Random Name"
class="absolute top-0 left-0 md:top-[10px] md:left-[10px] border-none bg-none cursor-pointer text-2xl"
@click=${() => this.toggleRandomName()}
>
${this.randomName ? "🥷" : "🕵️"}
</button>
`;
}
}
+58 -15
View File
@@ -9,6 +9,7 @@ import {
GameMapType,
GameMode,
GameType,
UnitType,
mapCategories,
} from "../core/game/Game";
import { generateID } from "../core/Util";
@@ -39,6 +40,8 @@ export class SinglePlayerModal extends LitElement {
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: number | typeof Duos = 2;
@state() private disabledUnits: string[] = [];
render() {
return html`
<o-modal title=${translateText("single_modal.title")}>
@@ -269,22 +272,61 @@ export class SinglePlayerModal extends LitElement {
${translateText("single_modal.infinite_troops")}
</div>
</label>
</div>
<label
for="singleplayer-modal-disable-nukes"
class="option-card ${this.disableNukes ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-disable-nukes"
@change=${this.handleDisableNukesChange}
.checked=${this.disableNukes}
/>
<div class="option-card-title">
${translateText("single_modal.disable_nukes")}
</div>
</label>
<hr
style="width: 100%; border-top: 1px solid #444; margin: 16px 0;"
/>
<div
style="margin: 8px 0 12px 0; font-weight: bold; color: #ccc; text-align: center;"
>
${translateText("single_modal.enables_title")}
</div>
<div
style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;"
>
${[
[UnitType.City, "unit_type.city"],
[UnitType.DefensePost, "unit_type.defense_post"],
[UnitType.Port, "unit_type.port"],
[UnitType.Warship, "unit_type.warship"],
[UnitType.MissileSilo, "unit_type.missile_silo"],
[UnitType.SAMLauncher, "unit_type.sam_launcher"],
[UnitType.AtomBomb, "unit_type.atom_bomb"],
[UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"],
[UnitType.MIRV, "unit_type.mirv"],
].map(
([unitType, translationKey]) => html`
<label
class="option-card ${this.disabledUnits.includes(unitType)
? ""
: "selected"}"
style="width: 140px;"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
if (checked) {
this.disabledUnits = [
...this.disabledUnits,
unitType,
];
} else {
this.disabledUnits = this.disabledUnits.filter(
(u) => u !== unitType,
);
}
}}
.checked=${this.disabledUnits.includes(unitType)}
/>
<div class="option-card-title" style="text-align: center;">
${translateText(translationKey)}
</div>
</label>
`,
)}
</div>
</div>
</div>
@@ -419,6 +461,7 @@ export class SinglePlayerModal extends LitElement {
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
disabledUnits: this.disabledUnits,
},
},
} as JoinLobbyEvent,
+66 -55
View File
@@ -13,13 +13,13 @@ import {
import { PlayerView } from "../core/game/GameView";
import {
AllPlayersStats,
ClientHashMessage,
ClientID,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientLogMessageSchema,
ClientMessageSchema,
ClientPingMessageSchema,
ClientSendWinnerSchema,
ClientIntentMessage,
ClientJoinMessage,
ClientLogMessage,
ClientPingMessage,
ClientSendWinnerMessage,
Intent,
ServerMessage,
ServerMessageSchema,
@@ -108,6 +108,15 @@ export class SendDonateTroopsIntentEvent implements GameEvent {
) {}
}
export class SendQuickChatEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
public readonly recipient: PlayerView,
public readonly quickChatKey: string,
public readonly variables: { [key: string]: string },
) {}
}
export class SendEmbargoIntentEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
@@ -196,6 +205,7 @@ export class Transport {
this.eventBus.on(SendDonateTroopsIntentEvent, (e) =>
this.onSendDonateTroopIntent(e),
);
this.eventBus.on(SendQuickChatEvent, (e) => this.onSendQuickChatIntent(e));
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
this.onSendEmbargoIntent(e),
);
@@ -222,14 +232,9 @@ export class Transport {
this.pingInterval = window.setInterval(() => {
if (this.socket !== null && this.socket.readyState === WebSocket.OPEN) {
this.sendMsg(
JSON.stringify(
ClientPingMessageSchema.parse({
type: "ping",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
}),
),
JSON.stringify({
type: "ping",
} satisfies ClientPingMessage),
);
}
}, 5 * 1000);
@@ -258,7 +263,12 @@ export class Transport {
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
this.localServer = new LocalServer(this.lobbyConfig, onconnect, onmessage);
this.localServer = new LocalServer(
this.lobbyConfig,
onconnect,
onmessage,
this.lobbyConfig.gameRecord != null,
);
this.localServer.start();
}
@@ -319,34 +329,33 @@ export class Transport {
this.connect(this.onconnect, this.onmessage);
}
public turnComplete() {
if (this.isLocal) {
this.localServer.turnComplete();
}
}
private onSendLogEvent(event: SendLogEvent) {
this.sendMsg(
JSON.stringify(
ClientLogMessageSchema.parse({
type: "log",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
log: event.log,
severity: event.severity,
}),
),
JSON.stringify({
type: "log",
log: event.log,
severity: event.severity,
} satisfies ClientLogMessage),
);
}
joinGame(numTurns: number) {
this.sendMsg(
JSON.stringify(
ClientJoinMessageSchema.parse({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: numTurns,
persistentID: this.lobbyConfig.persistentID,
username: this.lobbyConfig.playerName,
flag: this.lobbyConfig.flag,
}),
),
JSON.stringify({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: numTurns,
token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName,
flag: this.lobbyConfig.flag,
} satisfies ClientJoinMessage),
);
}
@@ -465,6 +474,16 @@ export class Transport {
});
}
private onSendQuickChatIntent(event: SendQuickChatEvent) {
this.sendIntent({
type: "quick_chat",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
quickChatKey: event.quickChatKey,
variables: event.variables,
});
}
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
this.sendIntent({
type: "embargo",
@@ -507,15 +526,12 @@ export class Transport {
private onSendWinnerEvent(event: SendWinnerEvent) {
if (this.socket === null) return;
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientSendWinnerSchema.parse({
const msg = {
type: "winner",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
winner: event.winner,
allPlayersStats: event.allPlayersStats,
winnerType: event.winnerType,
});
} satisfies ClientSendWinnerMessage;
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
@@ -529,15 +545,13 @@ export class Transport {
private onSendHashEvent(event: SendHashEvent) {
if (this.socket === null) return;
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,
turnNumber: event.tick,
hash: event.hash,
});
this.sendMsg(JSON.stringify(msg));
this.sendMsg(
JSON.stringify({
type: "hash",
turnNumber: event.tick,
hash: event.hash,
} satisfies ClientHashMessage),
);
} else {
console.log(
"WebSocket is not open. Current state:",
@@ -567,13 +581,10 @@ export class Transport {
private sendIntent(intent: Intent) {
if (this.socket === null) return;
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
const msg = {
type: "intent",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
intent: intent,
});
} satisfies ClientIntentMessage;
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
+18
View File
@@ -102,6 +102,15 @@ export class UserSettingModal extends LitElement {
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
}
private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.anonymousNames", enabled);
console.log("🙈 Anonymous Names:", enabled ? "ON" : "OFF");
}
private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
@@ -226,6 +235,15 @@ export class UserSettingModal extends LitElement {
@change=${this.toggleLeftClickOpensMenu}
></setting-toggle>
<!-- 🙈 Anonymous Names -->
<setting-toggle
label="${translateText("user_setting.anonymous_names_label")}"
description="${translateText("user_setting.anonymous_names_desc")}"
id="anonymous-names-toggle"
.checked=${this.userSettings.anonymousNames()}
@change=${this.toggleAnonymousNames}
></setting-toggle>
<!-- ⚔️ Attack Ratio -->
<setting-slider
label="${translateText("user_setting.attack_ratio_label")}"
+2
View File
@@ -26,6 +26,8 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
KnownWorld: "Known World",
FaroeIslands: "Faroe Islands",
DeglaciatedAntarctica: "Deglaciated Antarctica",
FalklandIslands: "Falkland Islands",
Baikal: "Baikal",
};
@customElement("map-display")
+64
View File
@@ -0,0 +1,64 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import megaphone from "../../../resources/images/Megaphone.svg";
import { NewsModal } from "../NewsModal";
import { translateText } from "../Utils";
@customElement("news-button")
export class NewsButton extends LitElement {
@property({ type: Boolean })
hidden = false;
static styles = css`
.news-button {
opacity: 0.75;
transition: opacity 0.2s ease;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
}
.news-button:hover {
opacity: 1;
}
.news-button img {
width: 24px;
height: 24px;
display: block;
margin-left: 12px;
}
.hidden {
display: none !important;
}
`;
private handleClick() {
const newsModal = document.querySelector("news-modal") as NewsModal;
if (newsModal) {
newsModal.open();
}
}
render() {
return html`
<div class="text-center mb-0.5 ${this.hidden ? "hidden" : ""}">
<button class="news-button" @click=${this.handleClick}>
<img src="${megaphone}" alt=${translateText("news.title")} />
</button>
</div>
`;
}
createRenderRoot() {
return this;
}
}
@@ -12,7 +12,7 @@ export class OModal extends LitElement {
.c-modal {
position: fixed;
padding: 1rem;
z-index: 1000;
z-index: 9999;
left: 0;
bottom: 0;
right: 0;
+18
View File
@@ -7,6 +7,8 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
@@ -87,6 +89,14 @@ export function createRenderer(
eventsDisplay.game = game;
eventsDisplay.clientID = clientID;
const chatDisplay = document.querySelector("chat-display") as ChatDisplay;
if (!(chatDisplay instanceof ChatDisplay)) {
consolex.error("chat display not found");
}
chatDisplay.eventBus = eventBus;
chatDisplay.game = game;
chatDisplay.clientID = clientID;
const playerInfo = document.querySelector(
"player-info-overlay",
) as PlayerInfoOverlay;
@@ -126,6 +136,13 @@ export function createRenderer(
playerPanel.eventBus = eventBus;
playerPanel.emojiTable = emojiTable;
const chatModal = document.querySelector("chat-modal") as ChatModal;
if (!(chatModal instanceof ChatModal)) {
console.error("chat modal not found");
}
chatModal.g = game;
chatModal.eventBus = eventBus;
const multiTabModal = document.querySelector(
"multi-tab-modal",
) as MultiTabModal;
@@ -142,6 +159,7 @@ export function createRenderer(
new UILayer(game, eventBus, clientID, transformHandler),
new NameLayer(game, transformHandler, clientID),
eventsDisplay,
chatDisplay,
buildMenu,
new RadialMenu(
eventBus,
+3 -15
View File
@@ -408,21 +408,9 @@ export class BuildMenu extends LitElement implements Layer {
}
private getBuildableUnits(): BuildItemDisplay[][] {
if (this.game?.config()?.disableNukes()) {
return buildTable.map((row) =>
row.filter(
(item) =>
![
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.MissileSilo,
UnitType.SAMLauncher,
].includes(item.unitType),
),
);
}
return buildTable;
return buildTable.map((row) =>
row.filter((item) => !this.game?.config()?.isUnitDisabled(item.unitType)),
);
}
get isVisible() {
+193
View File
@@ -0,0 +1,193 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { DirectiveResult } from "lit/directive.js";
import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js";
import { EventBus } from "../../../core/EventBus";
import { MessageType } from "../../../core/game/Game";
import {
DisplayMessageUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { onlyImages } from "../../../core/Util";
import { Layer } from "./Layer";
interface ChatEvent {
description: string;
unsafeDescription?: boolean;
createdAt: number;
highlight?: boolean;
}
@customElement("chat-display")
export class ChatDisplay extends LitElement implements Layer {
public eventBus: EventBus;
public game: GameView;
public clientID: ClientID;
private active: boolean = false;
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
]);
@state() private _hidden: boolean = false;
@state() private newEvents: number = 0;
@state() private chatEvents: ChatEvent[] = [];
private toggleHidden() {
this._hidden = !this._hidden;
if (this._hidden) {
this.newEvents = 0;
}
this.requestUpdate();
}
private addEvent(event: ChatEvent) {
this.chatEvents = [...this.chatEvents, event];
if (this._hidden) {
this.newEvents++;
}
this.requestUpdate();
}
private removeEvent(index: number) {
this.chatEvents = [
...this.chatEvents.slice(0, index),
...this.chatEvents.slice(index + 1),
];
}
onDisplayMessageEvent(event: DisplayMessageUpdate) {
if (event.messageType !== MessageType.CHAT) return;
const myPlayer = this.game.playerByClientID(this.clientID);
if (
event.playerID != null &&
(!myPlayer || myPlayer.smallID() !== event.playerID)
) {
return;
}
this.addEvent({
description: event.message,
createdAt: this.game.ticks(),
highlight: true,
unsafeDescription: true,
});
}
init() {}
tick() {
// this.active = true;
const updates = this.game.updatesSinceLastTick();
const messages = updates[GameUpdateType.DisplayEvent] as
| DisplayMessageUpdate[]
| undefined;
if (messages) {
for (const msg of messages) {
if (msg.messageType === MessageType.CHAT) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (
msg.playerID != null &&
(!myPlayer || myPlayer.smallID() !== msg.playerID)
) {
continue;
}
this.chatEvents = [
...this.chatEvents,
{
description: msg.message,
unsafeDescription: true,
createdAt: this.game.ticks(),
},
];
}
}
}
if (this.chatEvents.length > 100) {
this.chatEvents = this.chatEvents.slice(-100);
}
this.requestUpdate();
}
private getChatContent(
chat: ChatEvent,
): string | DirectiveResult<typeof UnsafeHTMLDirective> {
return chat.unsafeDescription
? unsafeHTML(onlyImages(chat.description))
: chat.description;
}
render() {
if (!this.active) {
return html``;
}
return html`
<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-[30vw] lg:w-full lg:w-auto"
style="pointer-events: auto"
>
<div>
<div class="w-full bg-black/80 sticky top-0 px-[10px]">
<button
class="text-white cursor-pointer pointer-events-auto ${this
._hidden
? "hidden"
: ""}"
@click=${this.toggleHidden}
>
Hide
</button>
</div>
<button
class="text-white cursor-pointer pointer-events-auto ${this._hidden
? ""
: "hidden"}"
@click=${this.toggleHidden}
>
Chat
<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
._hidden
? "hidden"
: ""}"
style="pointer-events: auto;"
>
<tbody>
${this.chatEvents.map(
(chat) => html`
<tr class="border-b border-opacity-0">
<td class="lg:p-3 p-1 text-left">
${this.getChatContent(chat)}
</td>
</tr>
`,
)}
</tbody>
</table>
</div>
</div>
`;
}
createRenderRoot() {
return this;
}
}
+306
View File
@@ -0,0 +1,306 @@
import { LitElement, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { PlayerType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import quickChatData from "../../../../resources/QuickChat.json";
import { EventBus } from "../../../core/EventBus";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
type QuickChatPhrase = {
key: string;
requiresPlayer: boolean;
};
type QuickChatPhrases = Record<string, QuickChatPhrase[]>;
const quickChatPhrases: QuickChatPhrases = quickChatData;
@customElement("chat-modal")
export class ChatModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
createRenderRoot() {
return this;
}
private players: string[] = [];
private playerSearchQuery: string = "";
private previewText: string | null = null;
private requiresPlayerSelection: boolean = false;
private selectedCategory: string | null = null;
private selectedPhraseText: string | null = null;
private selectedPlayer: string | null = null;
private selectedPhraseTemplate: string | null = null;
private selectedQuickChatKey: string | null = null;
private recipient: PlayerView;
private sender: PlayerView;
public eventBus: EventBus;
public g: GameView;
quickChatPhrases: Record<
string,
Array<{ text: string; requiresPlayer: boolean }>
> = {
help: [{ text: "Please give me troops!", requiresPlayer: false }],
attack: [{ text: "Attack [P1]!", requiresPlayer: true }],
defend: [{ text: "Defend [P1]!", requiresPlayer: true }],
greet: [{ text: "Hello!", requiresPlayer: false }],
misc: [{ text: "Let's go!", requiresPlayer: false }],
};
private categories = [
{ id: "help" },
{ id: "attack" },
{ id: "defend" },
{ id: "greet" },
{ id: "misc" },
{ id: "warnings" },
];
private getPhrasesForCategory(categoryId: string) {
return quickChatPhrases[categoryId] ?? [];
}
render() {
const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b));
const filteredPlayers = sortedPlayers.filter((player) =>
player.toLowerCase().includes(this.playerSearchQuery),
);
const otherPlayers = sortedPlayers.filter(
(player) => !player.toLowerCase().includes(this.playerSearchQuery),
);
const displayPlayers = [...filteredPlayers, ...otherPlayers];
return html`
<o-modal title="${translateText("chat.title")}">
<div class="chat-columns">
<div class="chat-column">
<div class="column-title">${translateText("chat.category")}</div>
${this.categories.map(
(category) => html`
<button
class="chat-option-button ${this.selectedCategory ===
category.id
? "selected"
: ""}"
@click=${() => this.selectCategory(category.id)}
>
${translateText(`chat.cat.${category.id}`)}
</button>
`,
)}
</div>
${this.selectedCategory
? html`
<div class="chat-column">
<div class="column-title">
${translateText("chat.phrase")}
</div>
<div class="phrase-scroll-area">
${this.getPhrasesForCategory(this.selectedCategory).map(
(phrase) => html`
<button
class="chat-option-button ${this
.selectedPhraseText ===
translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
)
? "selected"
: ""}"
@click=${() => this.selectPhrase(phrase)}
>
${this.renderPhrasePreview(phrase)}
</button>
`,
)}
</div>
</div>
`
: null}
${this.requiresPlayerSelection || this.selectedPlayer
? html`
<div class="chat-column">
<div class="column-title">
${translateText("chat.player")}
</div>
<input
class="player-search-input"
type="text"
placeholder="${translateText("chat.search")}"
.value=${this.playerSearchQuery}
@input=${this.onPlayerSearchInput}
/>
<div class="player-scroll-area">
${this.getSortedFilteredPlayers().map(
(player) => html`
<button
class="chat-option-button ${this.selectedPlayer ===
player
? "selected"
: ""}"
@click=${() => this.selectPlayer(player)}
>
${player}
</button>
`,
)}
</div>
</div>
`
: null}
</div>
<div class="chat-preview">
${this.previewText
? translateText(this.previewText)
: translateText("chat.build")}
</div>
<div class="chat-send">
<button
class="chat-send-button"
@click=${this.sendChatMessage}
?disabled=${!this.previewText}
>
${translateText("chat.send")}
</button>
</div>
</o-modal>
`;
}
private selectCategory(categoryId: string) {
this.selectedCategory = categoryId;
this.selectedPhraseText = null;
this.previewText = null;
this.requiresPlayerSelection = false;
this.selectedPlayer = null;
this.requestUpdate();
}
private selectPhrase(phrase: QuickChatPhrase) {
this.selectedQuickChatKey = this.getFullQuickChatKey(
this.selectedCategory!,
phrase.key,
);
this.selectedPhraseTemplate = translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
);
this.selectedPhraseText = translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
);
this.previewText = `chat.${this.selectedCategory}.${phrase.key}`;
this.requiresPlayerSelection = phrase.requiresPlayer;
this.selectedPlayer = null;
this.requestUpdate();
}
private renderPhrasePreview(phrase: { key: string }) {
return translateText(`chat.${this.selectedCategory}.${phrase.key}`);
}
private selectPlayer(player: string) {
if (this.previewText) {
this.previewText = this.selectedPhraseTemplate.replace("[P1]", player);
this.selectedPlayer = player;
this.requiresPlayerSelection = false;
this.requestUpdate();
}
}
private sendChatMessage() {
console.log("Sent message:", this.previewText);
console.log("Sender:", this.sender);
console.log("Recipient:", this.recipient);
console.log("Key:", this.selectedQuickChatKey);
if (this.sender && this.recipient && this.selectedQuickChatKey) {
const variables = this.selectedPlayer ? { P1: this.selectedPlayer } : {};
this.eventBus.emit(
new SendQuickChatEvent(
this.sender,
this.recipient,
this.selectedQuickChatKey,
variables,
),
);
}
this.previewText = null;
this.selectedCategory = null;
this.requiresPlayerSelection = false;
this.close();
this.requestUpdate();
}
private onPlayerSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
this.playerSearchQuery = target.value.toLowerCase();
this.requestUpdate();
}
private getSortedFilteredPlayers(): string[] {
const sorted = [...this.players].sort((a, b) => a.localeCompare(b));
const filtered = sorted.filter((p) =>
p.toLowerCase().includes(this.playerSearchQuery),
);
const others = sorted.filter(
(p) => !p.toLowerCase().includes(this.playerSearchQuery),
);
return [...filtered, ...others];
}
private getFullQuickChatKey(category: string, phraseKey: string): string {
return `${category}.${phraseKey}`;
}
public open(sender?: PlayerView, recipient?: PlayerView) {
if (sender && recipient) {
console.log("Sent message:", recipient);
console.log("Sent message:", sender);
const alivePlayerNames = this.g
.players()
.filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot))
.map((p) => p.data.name);
console.log("Alive player names:", alivePlayerNames);
this.players = alivePlayerNames;
this.recipient = recipient;
this.sender = sender;
}
this.requestUpdate();
this.modalEl?.open();
}
public close() {
this.selectedCategory = null;
this.selectedPhraseText = null;
this.previewText = null;
this.requiresPlayerSelection = false;
this.selectedPlayer = null;
this.modalEl?.close();
}
public setRecipient(value: PlayerView) {
this.recipient = value;
}
public setSender(value: PlayerView) {
this.sender = value;
}
}
@@ -16,6 +16,7 @@ import {
AllianceRequestUpdate,
AttackUpdate,
BrokeAllianceUpdate,
DisplayChatMessageUpdate,
DisplayMessageUpdate,
EmojiUpdate,
GameUpdateType,
@@ -33,6 +34,8 @@ import { onlyImages } from "../../../core/Util";
import { renderTroops } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { translateText } from "../../Utils";
interface Event {
description: string;
unsafeDescription?: boolean;
@@ -77,6 +80,7 @@ export class EventsDisplay extends LitElement implements Layer {
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
[GameUpdateType.DisplayChatEvent, (u) => this.onDisplayChatEvent(u)],
[GameUpdateType.AllianceRequest, (u) => this.onAllianceRequestEvent(u)],
[
GameUpdateType.AllianceRequestReply,
@@ -189,6 +193,34 @@ export class EventsDisplay extends LitElement implements Layer {
});
}
onDisplayChatEvent(event: DisplayChatMessageUpdate) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (
event.playerID === null ||
!myPlayer ||
myPlayer.smallID() !== event.playerID
) {
return;
}
const baseMessage = translateText(`chat.${event.category}.${event.key}`);
const translatedMessage = baseMessage.replace(
/\[([^\]]+)\]/g,
(_, key) => event.variables?.[key] || `[${key}]`,
);
this.addEvent({
description: translateText(event.isFrom ? "chat.from" : "chat.to", {
user: event.recipient,
msg: translatedMessage,
}),
createdAt: this.game.ticks(),
highlight: true,
type: MessageType.CHAT,
unsafeDescription: false,
});
}
onAllianceRequestEvent(update: AllianceRequestUpdate) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (!myPlayer || update.recipientID !== myPlayer.smallID()) {
@@ -398,6 +430,8 @@ export class EventsDisplay extends LitElement implements Layer {
return "text-green-300";
case MessageType.INFO:
return "text-gray-200";
case MessageType.CHAT:
return "text-gray-200";
case MessageType.WARN:
return "text-yellow-300";
case MessageType.ERROR:
+2 -2
View File
@@ -147,7 +147,7 @@ export class Leaderboard extends LitElement implements Layer {
position: fixed;
top: 10px;
left: 10px;
z-index: 9999;
z-index: 9998;
background-color: rgb(31 41 55 / 0.7);
padding: 10px;
padding-top: 0px;
@@ -198,7 +198,7 @@ export class Leaderboard extends LitElement implements Layer {
position: fixed;
left: 10px;
top: 10px;
z-index: 9999;
z-index: 9998;
background-color: rgb(31 41 55 / 0.7);
color: white;
border: none;
+2 -1
View File
@@ -122,7 +122,8 @@ export class OptionsMenu extends LitElement implements Layer {
init() {
console.log("init called from OptionsMenu");
this.showPauseButton =
this.game.config().gameConfig().gameType === GameType.Singleplayer;
this.game.config().gameConfig().gameType == GameType.Singleplayer ||
this.game.config().isReplay();
this.isVisible = true;
this.requestUpdate();
}
+19
View File
@@ -1,6 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
import chatIcon from "../../../../resources/images/ChatIconWhite.svg";
import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg";
import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
@@ -27,6 +28,7 @@ import {
SendTargetPlayerIntentEvent,
} from "../../Transport";
import { renderNumber, renderTroops } from "../../Utils";
import { ChatModal } from "./ChatModal";
import { EmojiTable } from "./EmojiTable";
import { Layer } from "./Layer";
@@ -139,6 +141,11 @@ export class PlayerPanel extends LitElement implements Layer {
});
}
private handleChat(e: Event, sender: PlayerView, other: PlayerView) {
this.ctModal.open(sender, other);
this.hide();
}
private handleTargetClick(e: Event, other: PlayerView) {
e.stopPropagation();
this.eventBus.emit(new SendTargetPlayerIntentEvent(other.id()));
@@ -149,8 +156,12 @@ export class PlayerPanel extends LitElement implements Layer {
return this;
}
private ctModal;
init() {
this.eventBus.on(MouseUpEvent, (e: MouseEvent) => this.hide());
this.ctModal = document.querySelector("chat-modal") as ChatModal;
}
async tick() {
@@ -297,6 +308,14 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Action buttons -->
<div class="flex justify-center gap-2">
<button
@click=${(e) => this.handleChat(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${chatIcon} alt="Target" class="w-6 h-6" />
</button>
${canTarget
? html`<button
@click=${(e) => this.handleTargetClick(e, other)}
+7 -9
View File
@@ -272,15 +272,13 @@ export class TerritoryLayer implements Layer {
)
.filter((u) => u.unit.owner() === owner).length > 0
) {
const useDefendedBorderColor = playerIsFocused
? this.theme.focusedDefendedBorderColor()
: this.theme.defendedBorderColor(owner);
this.paintCell(
this.game.x(tile),
this.game.y(tile),
useDefendedBorderColor,
255,
);
const borderColors = this.theme.defendedBorderColors(owner);
const x = this.game.x(tile);
const y = this.game.y(tile);
const lightTile =
(x % 2 == 0 && y % 2 == 0) || (y % 2 == 1 && x % 2 == 1);
const borderColor = lightTile ? borderColors.light : borderColors.dark;
this.paintCell(x, y, borderColor, 255);
} else {
const useBorderColor = playerIsFocused
? this.theme.focusedBorderColor()
+130 -74
View File
@@ -6,6 +6,7 @@ import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { BezenhamLine } from "../../../core/utilities/Line";
import {
AlternateViewEvent,
MouseUpEvent,
@@ -15,7 +16,11 @@ import { MoveWarshipIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { getColoredSprite, loadAllSprites } from "../SpriteLoader";
import {
getColoredSprite,
isSpriteReady,
loadAllSprites,
} from "../SpriteLoader";
enum Relationship {
Self,
@@ -27,9 +32,9 @@ export class UnitLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private transportShipTrailCanvas: HTMLCanvasElement;
private transportShipTrailContext: CanvasRenderingContext2D;
private unitTrailContext: CanvasRenderingContext2D;
private boatToTrail = new Map<UnitView, TileRef[]>();
private unitToTrail = new Map<UnitView, TileRef[]>();
private theme: Theme;
@@ -65,13 +70,8 @@ export class UnitLayer implements Layer {
if (this.myPlayer === null) {
this.myPlayer = this.game.playerByClientID(this.clientID);
}
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates?.[GameUpdateType.Unit] ?? [];
for (const u of unitUpdates) {
const unit = this.game.unit(u.id);
if (typeof unit === "undefined") continue;
this.onUnitEvent(unit);
}
this.updateUnitsSprites();
}
init() {
@@ -193,23 +193,16 @@ export class UnitLayer implements Layer {
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.transportShipTrailCanvas = document.createElement("canvas");
const transportShipTrailContext =
this.transportShipTrailCanvas.getContext("2d");
if (transportShipTrailContext === null) {
throw new Error("2d context not supported");
}
this.transportShipTrailContext = transportShipTrailContext;
this.unitTrailContext = this.transportShipTrailCanvas.getContext("2d");
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.transportShipTrailCanvas.width = this.game.width();
this.transportShipTrailCanvas.height = this.game.height();
this.game
?.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.forEach((unit) => {
this.onUnitEvent(this.game.unit(unit.id));
});
this.boatToTrail.forEach((trail, unit) => {
this.updateUnitsSprites();
this.unitToTrail.forEach((trail, unit) => {
for (const t of trail) {
this.paintCell(
this.game.x(t),
@@ -217,12 +210,40 @@ export class UnitLayer implements Layer {
this.relationship(unit),
this.theme.territoryColor(unit.owner()),
150,
this.transportShipTrailContext,
this.unitTrailContext,
);
}
});
}
private updateUnitsSprites() {
const unitsToUpdate = this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id));
unitsToUpdate
?.filter((UnitView) => isSpriteReady(UnitView.type()))
.forEach((unitView) => {
this.clearUnitCells(unitView);
});
unitsToUpdate?.forEach((unitView) => {
this.onUnitEvent(unitView);
});
}
private clearUnitCells(unit: UnitView) {
const sprite = getColoredSprite(unit, this.theme);
const clearsize = sprite.width + 1;
const lastX = this.game.x(unit.lastTile());
const lastY = this.game.y(unit.lastTile());
this.context.clearRect(
lastX - clearsize / 2,
lastY - clearsize / 2,
clearsize,
clearsize,
);
}
private relationship(unit: UnitView): Relationship {
if (this.myPlayer === null) {
return Relationship.Enemy;
@@ -314,8 +335,85 @@ export class UnitLayer implements Layer {
this.drawSprite(unit);
}
private drawTrail(trail: number[], color: Colord, rel: Relationship) {
// Paint new trail
for (const t of trail) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
color,
150,
this.unitTrailContext,
);
}
}
private clearTrail(unit: UnitView) {
const trail = this.unitToTrail.get(unit);
const rel = this.relationship(unit);
for (const t of trail) {
this.clearCell(this.game.x(t), this.game.y(t), this.unitTrailContext);
}
this.unitToTrail.delete(unit);
// Repaint overlapping trails
const trailSet = new Set(trail);
for (const [other, trail] of this.unitToTrail) {
for (const t of trail) {
if (trailSet.has(t)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(other.owner()),
150,
this.unitTrailContext,
);
}
}
}
}
private handleNuke(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.unitToTrail.has(unit)) {
this.unitToTrail.set(unit, []);
}
let newTrailSize = 1;
const trail = this.unitToTrail.get(unit);
// It can move faster than 1 pixel, draw a line for the trail or else it will be dotted
if (trail.length >= 1) {
const cur = {
x: this.game.x(unit.lastTile()),
y: this.game.y(unit.lastTile()),
};
const prev = {
x: this.game.x(trail[trail.length - 1]),
y: this.game.y(trail[trail.length - 1]),
};
const line = new BezenhamLine(prev, cur);
let point = line.increment();
while (point !== true) {
trail.push(this.game.ref(point.x, point.y));
point = line.increment();
}
newTrailSize = line.size();
} else {
trail.push(unit.lastTile());
}
this.drawTrail(
trail.slice(-newTrailSize),
this.theme.territoryColor(unit.owner()),
rel,
);
this.drawSprite(unit);
if (!unit.isActive()) {
this.clearTrail(unit);
}
}
private handleMIRVWarhead(unit: UnitView) {
@@ -342,53 +440,22 @@ export class UnitLayer implements Layer {
private handleBoatEvent(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.boatToTrail.has(unit)) {
this.boatToTrail.set(unit, []);
if (!this.unitToTrail.has(unit)) {
this.unitToTrail.set(unit, []);
}
const trail = this.boatToTrail.get(unit);
if (typeof trail === "undefined") return;
const trail = this.unitToTrail.get(unit);
trail.push(unit.lastTile());
// Paint trail
for (const t of trail.slice(-1)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner()),
150,
this.transportShipTrailContext,
);
}
this.drawTrail(
trail.slice(-1),
this.theme.territoryColor(unit.owner()),
rel,
);
this.drawSprite(unit);
if (!unit.isActive()) {
for (const t of trail) {
this.clearCell(
this.game.x(t),
this.game.y(t),
this.transportShipTrailContext,
);
}
this.boatToTrail.delete(unit);
// Repaint overlapping trails
const trailSet = new Set(trail);
for (const [other, trail] of this.boatToTrail) {
for (const t of trail) {
if (trailSet.has(t)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(other.owner()),
150,
this.transportShipTrailContext,
);
}
}
}
this.clearTrail(unit);
}
}
@@ -430,8 +497,6 @@ export class UnitLayer implements Layer {
drawSprite(unit: UnitView, customTerritoryColor?: Colord) {
const x = this.game.x(unit.tile());
const y = this.game.y(unit.tile());
const lastX = this.game.x(unit.lastTile());
const lastY = this.game.y(unit.lastTile());
let alternateViewColor = null;
@@ -468,15 +533,6 @@ export class UnitLayer implements Layer {
alternateViewColor,
);
const clearsize = sprite.width + 1;
this.context.clearRect(
lastX - clearsize / 2,
lastY - clearsize / 2,
clearsize,
clearsize,
);
if (unit.isActive()) {
this.context.drawImage(
sprite,
+1 -38
View File
@@ -1,6 +1,5 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import mastersIcon from "../../../../resources/images/MastersIcon.png";
import { EventBus } from "../../../core/EventBus";
import { Team } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
@@ -145,43 +144,7 @@ export class WinModal extends LitElement implements Layer {
}
innerHtml() {
return html`
<div
style="
text-align: center;
margin: 10px 0;
line-height: 1.5;
background-image: url(${mastersIcon});
background-size: 100px;
background-position: center;
background-repeat: no-repeat;
background-blend-mode: overlay;
position: relative;
"
>
<div
style="
margin: 10px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.76);
border-radius: 5px;
position: relative;
z-index: 1;
font-size: 22px;
"
>
Watch the best compete in the
<br />
<a
href="https://openfrontmaster.com/"
target="_blank"
rel="noopener noreferrer"
style="color: #00bfff; font-weight: bold; text-decoration: underline;"
>OpenFront Masters</a
>
</div>
</div>
`;
return html``;
}
show() {
+49 -6
View File
@@ -121,11 +121,6 @@
gtag("js", new Date());
gtag("config", "AW-16702609763");
</script>
<script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-7035513310742290"
crossorigin="anonymous"
></script>
<!-- Google tag (gtag.js) -->
<script
async
@@ -140,6 +135,25 @@
gtag("config", "G-WQGQQ8RDN4");
</script>
<!-- AdinPlay Ads -->
<script>
var aiptag = aiptag || {};
aiptag.cmd = aiptag.cmd || [];
aiptag.cmd.display = aiptag.cmd.display || [];
aiptag.cmd.player = aiptag.cmd.player || [];
//CMP tool settings
aiptag.cmp = {
show: true,
button: true,
buttonText: "Privacy settings",
buttonPosition: "top-right", //bottom-left, bottom-right, top-left, top-right
};
</script>
<script
async
src="//api.adinplay.com/libs/aiptag/pub/OFI/openfront.io/tag.min.js"
></script>
</head>
<body
@@ -208,7 +222,26 @@
</header>
<div class="bg-image"></div>
<dark-mode-button></dark-mode-button>
<!-- Left gutter ad placement - full height, no empty space -->
<div class="left-gutter-ad ad">
<div id="openfront-io_300x600">
<script type="text/javascript">
aiptag.cmd.display.push(function () {
aipDisplayTag.display("openfront-io_300x600");
});
</script>
</div>
</div>
<div class="right-gutter-ad ad">
<div id="openfront-io_300x600_2">
<script type="text/javascript">
aiptag.cmd.display.push(function () {
aipDisplayTag.display("openfront-io_300x600_2");
});
</script>
</div>
</div>
<!-- Main container with responsive padding -->
<main class="flex justify-center flex-grow">
<div class="container pt-12">
@@ -230,6 +263,7 @@
<div class="container__row">
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
<username-input class="w-full"></username-input>
<news-button class="mt-3"></news-button>
</div>
<div></div>
<div>
@@ -250,6 +284,12 @@
block
secondary
></o-button>
<!-- <o-button
id="chat-button"
title="Chat Test"
block
secondary
></o-button> -->
</div>
<o-button
@@ -307,6 +347,7 @@
class="w-full sm:w-2/3 sm:fixed sm:right-0 sm:bottom-0 sm:flex justify-end"
style="pointer-events: none"
>
<chat-display></chat-display>
<events-display></events-display>
</div>
<div class="w-full sm:w-1/3 md:max-w-72" style="pointer-events: auto">
@@ -372,8 +413,10 @@
<player-panel></player-panel>
<help-modal></help-modal>
<dark-mode-button></dark-mode-button>
<chat-modal></chat-modal>
<user-setting></user-setting>
<multi-tab-modal></multi-tab-modal>
<news-modal></news-modal>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
+1 -1
View File
@@ -5,7 +5,7 @@ import {
TokenPayloadSchema,
UserMeResponse,
UserMeResponseSchema,
} from "./ApiSchemas";
} from "../core/ApiSchemas";
function getAudience() {
const { hostname } = new URL(window.location.href);
+1
View File
@@ -8,6 +8,7 @@
@import url("./styles/layout/container.css");
@import url("./styles/components/button.css");
@import url("./styles/components/modal.css");
@import url("./styles/modal/chat.css");
@import url("./styles/components/setting.css");
@import url("./styles/components/controls.css");
* {
+94
View File
@@ -0,0 +1,94 @@
/* .w. */
.chat-columns {
display: flex;
gap: 16px;
padding: 12px;
overflow-x: auto;
}
.chat-column {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 120px;
}
.column-title {
font-weight: bold;
margin-bottom: 4px;
}
.chat-option-button {
background: #333;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
text-align: left;
cursor: pointer;
}
.chat-option-button.selected {
background-color: #66c;
}
.chat-preview {
margin: 10px 12px;
padding: 10px;
background: #222;
color: white;
border-radius: 6px;
text-align: center;
}
.chat-send {
display: flex;
justify-content: flex-end;
padding: 0 12px 12px;
}
.chat-send-button {
background: #4caf50;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.chat-column {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 140px;
}
.player-search-input {
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #666;
font-size: 14px;
outline: none;
background-color: #fff;
color: #000;
}
.player-scroll-area {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
.phrase-scroll-area {
max-height: 280px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
+6
View File
@@ -1,12 +1,14 @@
import africa from "../../../resources/maps/AfricaThumb.webp";
import asia from "../../../resources/maps/AsiaThumb.webp";
import australia from "../../../resources/maps/AustraliaThumb.webp";
import baikal from "../../../resources/maps/BaikalThumb.webp";
import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp";
import blackSea from "../../../resources/maps/BlackSeaThumb.webp";
import britannia from "../../../resources/maps/BritanniaThumb.webp";
import deglaciatedAntarctica from "../../../resources/maps/DeglaciatedAntarcticaThumb.webp";
import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp";
import europe from "../../../resources/maps/EuropeThumb.webp";
import falklandislands from "../../../resources/maps/FalklandIslandsThumb.webp";
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
import iceland from "../../../resources/maps/IcelandThumb.webp";
@@ -66,6 +68,10 @@ export function getMapsImage(map: GameMapType): string {
return faroeislands;
case GameMapType.DeglaciatedAntarctica:
return deglaciatedAntarctica;
case GameMapType.FalklandIslands:
return falklandislands;
case GameMapType.Baikal:
return baikal;
default:
return "";
}
+68 -6
View File
@@ -1,11 +1,51 @@
export class PseudoRandom {
// Internal state (two 32-bit integers)
private state0: number;
private state1: number;
// Keep these variables to maintain the exact same interface
private m: number = 0x80000000; // 2**31
private a: number = 1103515245;
private c: number = 12345;
private state: number;
constructor(seed: number) {
// Initialize the XorShift state with seed
this.state0 = seed | 0; // Force to 32-bit integer with bitwise OR
this.state1 = 0x6e2d786c; // Fixed value as second seed (arbitrary prime)
// Ensure non-zero state
if (this.state0 === 0) this.state0 = 1;
// Also set the LCG state variable to maintain interface
this.state = seed % this.m;
if (this.state < 0) this.state += this.m;
// Warm up the generator to improve initial distribution
for (let i = 0; i < 20; i++) {
this._nextIntInternal();
}
}
/**
* Internal function that implements XorShift algorithm
* @returns A 32-bit integer
*/
private _nextIntInternal(): number {
// Get current state
let s1 = this.state0;
const s0 = this.state1;
// Update state using XorShift algorithm (all operations are bitwise)
this.state0 = s0;
s1 ^= s1 << 23;
s1 ^= s1 >>> 17;
s1 ^= s0;
s1 ^= s0 >>> 26;
this.state1 = s1;
// Generate output (force 32-bit integer)
return (this.state0 + this.state1) | 0;
}
/**
@@ -13,7 +53,14 @@ export class PseudoRandom {
* @returns A number between 0 (inclusive) and 1 (exclusive).
*/
next(): number {
this.state = (this.a * this.state + this.c) % this.m;
// Get a 32-bit integer and convert to [0,1) range
// Using >>> 0 to get unsigned interpretation (positive number)
const int = this._nextIntInternal() >>> 0;
// Update the state variable to maintain compatibility with original interface
this.state = int % this.m;
// Convert to [0,1) range - using division for same interface
return this.state / this.m;
}
@@ -31,12 +78,18 @@ export class PseudoRandom {
return this.next() * (max - min) + min;
}
/**
* Generates a random ID (8 characters, alphanumeric).
*/
nextID(): string {
return this.nextInt(0, Math.pow(36, 8)) // 36^8 possibilities
.toString(36) // Convert to base36 (0-9 and a-z)
.padStart(8, "0"); // Ensure 8 chars by padding with zeros
}
/**
* Selects a random element from an array.
*/
randElement<T>(arr: T[]): T {
if (arr.length === 0) {
throw new Error("array must not be empty");
@@ -44,16 +97,25 @@ export class PseudoRandom {
return arr[this.nextInt(0, arr.length)];
}
/**
* Returns true with probability 1/odds.
*/
chance(odds: number): boolean {
return this.nextInt(0, odds) === 0;
}
shuffleArray(array: any[]): any[] {
for (let i = array.length - 1; i >= 0; i--) {
const j = Math.floor(this.nextInt(0, i + 1));
[array[i], array[j]] = [array[j], array[i]];
/**
* Returns a shuffled copy of the array using Fisher-Yates algorithm.
*/
shuffleArray<T>(array: T[]): T[] {
// Create a copy to avoid modifying the original array
const arrayCopy = [...array];
for (let i = arrayCopy.length - 1; i >= 0; i--) {
const j = this.nextInt(0, i + 1);
[arrayCopy[i], arrayCopy[j]] = [arrayCopy[j], arrayCopy[i]];
}
return array;
return arrayCopy;
}
}
+57 -17
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import quickChatData from "../../resources/QuickChat.json" with { type: "json" };
import {
AllPlayers,
Difficulty,
@@ -29,6 +30,7 @@ export type Intent =
| TargetTroopRatioIntent
| BuildUnitIntent
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
@@ -50,6 +52,7 @@ export type TargetTroopRatioIntent = z.infer<
>;
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -116,12 +119,13 @@ const GameConfigSchema = z.object({
gameType: z.nativeEnum(GameType),
gameMode: z.nativeEnum(GameMode),
disableNPCs: z.boolean(),
disableNukes: z.boolean(),
bots: z.number().int().min(0).max(400),
infiniteGold: z.boolean(),
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
maxPlayers: z.number().optional(),
numPlayerTeams: z.number().optional(),
disabledUnits: z.array(z.nativeEnum(UnitType)).optional(),
playerTeams: z.union([z.number().optional(), z.literal(Duos)]),
});
@@ -134,6 +138,34 @@ const SafeString = z
)
.max(1000);
const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/;
// Copied from zod, modified to remove their erroneous `typ` header requirement
function isValidJWT(jwt: string, alg?: string): boolean {
if (!jwtRegex.test(jwt)) return false;
try {
const [header] = jwt.split(".");
// Convert base64url to base64
const base64 = header
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(header.length + ((4 - (header.length % 4)) % 4), "=");
const decoded = JSON.parse(atob(base64));
if (typeof decoded !== "object" || decoded === null) return false;
if (!decoded.alg) return false;
if (alg && decoded.alg !== alg) return false;
return true;
} catch {
return false;
}
}
const PersistentIdSchema = z.string().uuid();
const TokenSchema = z
.string()
.refine((v) => PersistentIdSchema.safeParse(v).success || isValidJWT(v), {
message: "Token must be a valid UUID or JWT",
});
const EmojiSchema = z
.number()
.nonnegative()
@@ -269,6 +301,19 @@ export const MoveWarshipIntentSchema = BaseIntentSchema.extend({
tile: z.number(),
});
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
entries.map((entry) => `${category}.${entry.key}`),
) as [string, ...string[]],
);
export const QuickChatIntentSchema = BaseIntentSchema.extend({
type: z.literal("quick_chat"),
recipient: ID,
quickChatKey: QuickChatKeySchema,
variables: z.record(SafeString).optional(),
});
const IntentSchema = z.union([
AttackIntentSchema,
CancelAttackIntentSchema,
@@ -285,11 +330,11 @@ const IntentSchema = z.union([
BuildUnitIntentSchema,
EmbargoIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
]);
export const TurnSchema = z.object({
turnNumber: z.number(),
gameID: ID,
intents: z.array(IntentSchema),
// The hash of the game state at the end of the turn.
hash: z.number().nullable().optional(),
@@ -354,45 +399,40 @@ export const ServerMessageSchema = z.union([
// Client
const ClientBaseMessageSchema = z.object({
type: z.enum(["winner", "join", "intent", "ping", "log", "hash"]),
clientID: ID,
persistentID: SafeString.nullable(), // WARNING: persistent id is private.
gameID: ID,
});
export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
export const ClientSendWinnerSchema = z.object({
type: z.literal("winner"),
winner: z.union([ID, TeamSchema]).nullable(),
allPlayersStats: AllPlayersStatsSchema,
winnerType: z.enum(["player", "team"]),
});
export const ClientHashSchema = ClientBaseMessageSchema.extend({
export const ClientHashSchema = z.object({
type: z.literal("hash"),
hash: z.number(),
turnNumber: z.number(),
});
export const ClientLogMessageSchema = ClientBaseMessageSchema.extend({
export const ClientLogMessageSchema = z.object({
type: z.literal("log"),
severity: z.nativeEnum(LogSeverity),
log: ID,
persistentID: SafeString,
});
export const ClientPingMessageSchema = ClientBaseMessageSchema.extend({
export const ClientPingMessageSchema = z.object({
type: z.literal("ping"),
});
export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({
export const ClientIntentMessageSchema = z.object({
type: z.literal("intent"),
intent: IntentSchema,
});
// WARNING: never send this message to clients.
export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({
export const ClientJoinMessageSchema = z.object({
type: z.literal("join"),
clientID: ID,
token: TokenSchema, // WARNING: PII
gameID: ID,
lastTurn: z.number(), // The last turn the client saw.
username: SafeString,
flag: SafeString.nullable().optional(),
@@ -411,7 +451,7 @@ export const PlayerRecordSchema = z.object({
clientID: ID,
username: SafeString,
ip: SafeString.nullable(), // WARNING: PII
persistentID: SafeString, // WARNING: PII
persistentID: PersistentIdSchema, // WARNING: PII
});
export const GameRecordSchema = z.object({
+7 -4
View File
@@ -1,4 +1,5 @@
import { Colord } from "colord";
import { JWK } from "jose";
import { GameConfig, GameID } from "../Schemas";
import {
Difficulty,
@@ -29,7 +30,6 @@ export interface ServerConfig {
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyMaxPlayers(map: GameMapType, mode: GameMode): number;
discordRedirectURI(): string;
numWorkers(): number;
workerIndex(gameID: GameID): number;
workerPath(gameID: GameID): string;
@@ -49,6 +49,9 @@ export interface ServerConfig {
otelUsername(): string;
otelPassword(): string;
otelEnabled(): boolean;
jwtAudience(): string;
jwtIssuer(): string;
jwkPublicKey(): Promise<JWK>;
}
export interface NukeMagnitude {
@@ -66,7 +69,7 @@ export interface Config {
percentageTilesOwnedToWin(): number;
numBots(): number;
spawnNPCs(): boolean;
disableNukes(): boolean;
isUnitDisabled(unitType: UnitType): boolean;
bots(): number;
infiniteGold(): boolean;
infiniteTroops(): boolean;
@@ -136,6 +139,7 @@ export interface Config {
defaultNukeSpeed(): number;
nukeDeathFactor(humans: number, tilesOwned: number): number;
structureMinDist(): number;
isReplay(): boolean;
}
export interface Theme {
@@ -143,9 +147,8 @@ export interface Theme {
territoryColor(playerInfo: PlayerView): Colord;
specialBuildingColor(playerInfo: PlayerView): Colord;
borderColor(playerInfo: PlayerView): Colord;
defendedBorderColor(playerInfo: PlayerView): Colord;
defendedBorderColors(playerInfo: PlayerView): { light: Colord; dark: Colord };
focusedBorderColor(): Colord;
focusedDefendedBorderColor(): Colord;
terrainColor(gm: GameMap, tile: TileRef): Colord;
backgroundColor(): Colord;
falloutColor(): Colord;
+3 -2
View File
@@ -12,15 +12,16 @@ export let cachedSC: ServerConfig | null = null;
export async function getConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null = null,
isReplay: boolean = false,
): Promise<Config> {
const sc = await getServerConfigFromClient();
switch (sc.env()) {
case GameEnv.Dev:
return new DevConfig(sc, gameConfig, userSettings);
return new DevConfig(sc, gameConfig, userSettings, isReplay);
case GameEnv.Preprod:
case GameEnv.Prod:
consolex.log("using prod config");
return new DefaultConfig(sc, gameConfig, userSettings);
return new DefaultConfig(sc, gameConfig, userSettings, isReplay);
default:
throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`);
}
+41 -10
View File
@@ -1,3 +1,5 @@
import { JWK } from "jose";
import { z } from "zod";
import {
Difficulty,
Duos,
@@ -24,7 +26,35 @@ import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config";
import { pastelTheme } from "./PastelTheme";
import { pastelThemeDark } from "./PastelThemeDark";
const JwksSchema = z.object({
keys: z
.object({
alg: z.literal("EdDSA"),
crv: z.literal("Ed25519"),
kty: z.literal("OKP"),
x: z.string(),
})
.array()
.min(1),
});
export abstract class DefaultServerConfig implements ServerConfig {
private publicKey: JWK;
abstract jwtAudience(): string;
jwtIssuer(): string {
const audience = this.jwtAudience();
return audience === "localhost"
? "http://localhost:8787"
: `https://api.${audience}`;
}
async jwkPublicKey(): Promise<JWK> {
if (this.publicKey) return this.publicKey;
const jwksUrl = this.jwtIssuer() + "/.well-known/jwks.json";
const response = await fetch(jwksUrl);
const jwks = JwksSchema.parse(await response.json());
this.publicKey = jwks.keys[0];
return this.publicKey;
}
otelEnabled(): boolean {
return Boolean(
this.otelEndpoint() && this.otelUsername() && this.otelPassword(),
@@ -70,7 +100,6 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
abstract numWorkers(): number;
abstract env(): GameEnv;
abstract discordRedirectURI(): string;
turnIntervalMs(): number {
return 100;
}
@@ -99,6 +128,8 @@ export abstract class DefaultServerConfig implements ServerConfig {
GameMapType.Iceland,
GameMapType.Britannia,
GameMapType.Asia,
GameMapType.FalklandIslands,
GameMapType.Baikal,
].includes(map)
) {
return Math.random() < 0.3 ? 50 : 25;
@@ -155,8 +186,12 @@ export class DefaultConfig implements Config {
constructor(
private _serverConfig: ServerConfig,
private _gameConfig: GameConfig,
private _userSettings: UserSettings | null,
private _userSettings: UserSettings,
private _isReplay: boolean,
) {}
isReplay(): boolean {
return this._isReplay;
}
samHittingChance(): number {
return 0.8;
@@ -231,9 +266,10 @@ export class DefaultConfig implements Config {
return !this._gameConfig.disableNPCs;
}
disableNukes(): boolean {
return this._gameConfig.disableNukes;
isUnitDisabled(unitType: UnitType): boolean {
return this._gameConfig.disabledUnits?.includes(unitType) ?? false;
}
bots(): number {
return this._gameConfig.bots;
}
@@ -250,12 +286,7 @@ export class DefaultConfig implements Config {
return 10000 + 150 * Math.pow(dist, 1.1);
}
tradeShipSpawnRate(numberOfPorts: number): number {
if (numberOfPorts <= 3) return 18;
if (numberOfPorts <= 5) return 25;
if (numberOfPorts <= 8) return 35;
if (numberOfPorts <= 10) return 40;
if (numberOfPorts <= 12) return 45;
return 50;
return Math.round(10 * Math.pow(numberOfPorts, 0.6));
}
unitInfo(type: UnitType): UnitInfo {
+10 -5
View File
@@ -29,20 +29,25 @@ export class DevServerConfig extends DefaultServerConfig {
return 1;
}
discordRedirectURI(): string {
return "http://localhost:3000/auth/callback";
}
numWorkers(): number {
return 2;
}
jwtAudience(): string {
return "localhost";
}
gitCommit(): string {
return "DEV";
}
}
export class DevConfig extends DefaultConfig {
constructor(sc: ServerConfig, gc: GameConfig, us: UserSettings | null) {
super(sc, gc, us);
constructor(
sc: ServerConfig,
gc: GameConfig,
us: UserSettings,
isReplay: boolean,
) {
super(sc, gc, us, isReplay);
}
// numSpawnPhaseTurns(): number {
+6 -10
View File
@@ -100,21 +100,17 @@ export const pastelTheme = new (class implements Theme {
b: Math.max(tc.b - 40, 0),
});
}
defendedBorderColor(player: PlayerView): Colord {
const bc = this.borderColor(player).rgba;
return colord({
r: Math.max(bc.r - 40, 0),
g: Math.max(bc.g - 40, 0),
b: Math.max(bc.b - 40, 0),
});
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
return {
light: this.territoryColor(player).darken(0.2),
dark: this.territoryColor(player).darken(0.4),
};
}
focusedBorderColor(): Colord {
return colord({ r: 230, g: 230, b: 230 });
}
focusedDefendedBorderColor(): Colord {
return colord({ r: 200, g: 200, b: 200 });
}
terrainColor(gm: GameMap, tile: TileRef): Colord {
const mag = gm.magnitude(tile);
+6 -10
View File
@@ -100,21 +100,17 @@ export const pastelThemeDark = new (class implements Theme {
b: Math.max(tc.b - 40, 0),
});
}
defendedBorderColor(player: PlayerView): Colord {
const bc = this.borderColor(player).rgba;
return colord({
r: Math.max(bc.r - 40, 0),
g: Math.max(bc.g - 40, 0),
b: Math.max(bc.b - 40, 0),
});
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
return {
light: this.territoryColor(player).darken(0.2),
dark: this.territoryColor(player).darken(0.4),
};
}
focusedBorderColor(): Colord {
return colord({ r: 255, g: 255, b: 255 });
}
focusedDefendedBorderColor(): Colord {
return colord({ r: 215, g: 215, b: 215 });
}
terrainColor(gm: GameMap, tile: TileRef): Colord {
const mag = gm.magnitude(tile);
+3 -3
View File
@@ -5,10 +5,10 @@ export const preprodConfig = new (class extends DefaultServerConfig {
env(): GameEnv {
return GameEnv.Preprod;
}
discordRedirectURI(): string {
return "https://openfront.dev/auth/callback";
}
numWorkers(): number {
return 3;
}
jwtAudience(): string {
return "openfront.dev";
}
})();
+3 -3
View File
@@ -3,12 +3,12 @@ import { DefaultServerConfig } from "./DefaultConfig";
export const prodConfig = new (class extends DefaultServerConfig {
numWorkers(): number {
return 6;
return 20;
}
env(): GameEnv {
return GameEnv.Prod;
}
discordRedirectURI(): string {
return "https://openfront.io/auth/callback";
jwtAudience(): string {
return "openfront.io";
}
})();
+1 -1
View File
@@ -38,7 +38,7 @@ export class CityExecution implements Execution {
this.active = false;
return;
}
this.city = this.player.buildUnit(UnitType.City, 0, spawnTile);
this.city = this.player.buildUnit(UnitType.City, spawnTile, {});
}
if (!this.city.isActive()) {
this.active = false;
+1 -1
View File
@@ -60,8 +60,8 @@ export class ConstructionExecution implements Execution {
}
this.construction = this.player.buildUnit(
UnitType.Construction,
0,
spawnTile,
{},
);
this.cost = this.mg.unitInfo(this.constructionType).cost(this.player);
this.player.removeGold(this.cost);
+1 -1
View File
@@ -65,7 +65,7 @@ export class DefensePostExecution implements Execution {
this.active = false;
return;
}
this.post = this.player.buildUnit(UnitType.DefensePost, 0, spawnTile);
this.post = this.player.buildUnit(UnitType.DefensePost, spawnTile, {});
}
if (!this.post.isActive()) {
this.active = false;
+8
View File
@@ -15,6 +15,7 @@ import { EmojiExecution } from "./EmojiExecution";
import { FakeHumanExecution } from "./FakeHumanExecution";
import { MoveWarshipExecution } from "./MoveWarshipExecution";
import { NoOpExecution } from "./NoOpExecution";
import { QuickChatExecution } from "./QuickChatExecution";
import { RetreatExecution } from "./RetreatExecution";
import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { SpawnExecution } from "./SpawnExecution";
@@ -108,6 +109,13 @@ export class Executor {
this.mg.ref(intent.x, intent.y),
intent.unit,
);
case "quick_chat":
return new QuickChatExecution(
playerID,
intent.recipient,
intent.quickChatKey,
intent.variables ?? {},
);
default:
throw new Error(`intent type ${intent} not found`);
}
+1 -3
View File
@@ -433,9 +433,7 @@ export class FakeHumanExecution implements Execution {
if (this.maybeSpawnWarship()) {
return;
}
if (!this.mg.config().disableNukes()) {
this.maybeSpawnStructure(UnitType.MissileSilo, 1);
}
this.maybeSpawnStructure(UnitType.MissileSilo, 1);
}
private maybeSpawnStructure(type: UnitType, maxNum: number) {
+15 -16
View File
@@ -10,7 +10,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { NukeExecution } from "./NukeExecution";
@@ -29,12 +29,14 @@ export class MirvExecution implements Execution {
private random: PseudoRandom;
private pathFinder: AirPathFinder;
private pathFinder: ParabolaPathFinder;
private targetPlayer: Player | TerraNullius;
private separateDst: TileRef;
private speed: number = -1;
constructor(
private senderID: PlayerID,
private dst: TileRef,
@@ -49,9 +51,10 @@ export class MirvExecution implements Execution {
this.random = new PseudoRandom(mg.ticks() + simpleHash(this.senderID));
this.mg = mg;
this.pathFinder = new AirPathFinder(mg, this.random);
this.pathFinder = new ParabolaPathFinder(mg);
this.player = mg.player(this.senderID);
this.targetPlayer = this.mg.owner(this.dst);
this.speed = this.mg.config().defaultNukeSpeed();
this.mg
.stats()
@@ -70,12 +73,13 @@ export class MirvExecution implements Execution {
this.active = false;
return;
}
this.nuke = this.player.buildUnit(UnitType.MIRV, 0, spawn);
this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {});
const x = Math.floor(
(this.mg.x(this.dst) + this.mg.x(this.mg.x(this.nuke.tile()))) / 2,
);
const y = Math.max(0, this.mg.y(this.dst) - 500) + 50;
this.separateDst = this.mg.ref(x, y);
this.pathFinder.computeControlPoints(spawn, this.separateDst);
this.mg.displayMessage(
`⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`,
@@ -84,18 +88,13 @@ export class MirvExecution implements Execution {
);
}
for (let i = 0; i < 4; i++) {
const result = this.pathFinder.nextTile(
this.nuke.tile(),
this.separateDst,
);
if (result === true) {
this.separate();
this.active = false;
return;
} else {
this.nuke.move(result);
}
const result = this.pathFinder.nextTile(this.speed);
if (result === true) {
this.separate();
this.active = false;
return;
} else {
this.nuke.move(result);
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ export class MissileSiloExecution implements Execution {
this.active = false;
return;
}
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, spawn, {
this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {
cooldownDuration: this.mg.config().SiloCooldown(),
});
+16 -13
View File
@@ -11,7 +11,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
export class NukeExecution implements Execution {
@@ -21,7 +21,7 @@ export class NukeExecution implements Execution {
private nuke: Unit | null = null;
private random: PseudoRandom;
private pathFinder: AirPathFinder;
private pathFinder: ParabolaPathFinder;
constructor(
private type: NukeType,
@@ -45,7 +45,7 @@ export class NukeExecution implements Execution {
if (this.speed === -1) {
this.speed = this.mg.config().defaultNukeSpeed();
}
this.pathFinder = new AirPathFinder(mg, this.random);
this.pathFinder = new ParabolaPathFinder(mg);
}
public target(): Player | TerraNullius {
@@ -108,7 +108,12 @@ export class NukeExecution implements Execution {
this.active = false;
return;
}
this.nuke = this.player.buildUnit(this.type, 0, spawn, {
this.pathFinder.computeControlPoints(
spawn,
this.dst,
this.type != UnitType.MIRVWarhead,
);
this.nuke = this.player.buildUnit(this.type, spawn, {
detonationDst: this.dst,
});
if (this.mg.hasOwner(this.dst)) {
@@ -159,15 +164,13 @@ export class NukeExecution implements Execution {
return;
}
for (let i = 0; i < this.speed; i++) {
// Move to next tile
const nextTile = this.pathFinder.nextTile(this.nuke.tile(), this.dst);
if (nextTile === true) {
this.detonate();
return;
} else {
this.nuke.move(nextTile);
}
// Move to next tile
const nextTile = this.pathFinder.nextTile(this.speed);
if (nextTile === true) {
this.detonate();
return;
} else {
this.nuke.move(nextTile);
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export class PortExecution implements Execution {
this.active = false;
return;
}
this.port = player.buildUnit(UnitType.Port, 0, spawn);
this.port = player.buildUnit(UnitType.Port, spawn, {});
}
if (!this.port.isActive()) {
+84
View File
@@ -0,0 +1,84 @@
import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class QuickChatExecution implements Execution {
private sender: Player;
private recipient: Player;
private mg: Game;
private active = true;
constructor(
private senderID: PlayerID,
private recipientID: PlayerID,
private quickChatKey: string,
private variables: Record<string, string>,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
if (!mg.hasPlayer(this.senderID)) {
consolex.warn(`QuickChatExecution: sender ${this.senderID} not found`);
this.active = false;
return;
}
if (!mg.hasPlayer(this.recipientID)) {
consolex.warn(
`QuickChatExecution: recipient ${this.recipientID} not found`,
);
this.active = false;
return;
}
this.sender = mg.player(this.senderID);
this.recipient = mg.player(this.recipientID);
}
tick(ticks: number): void {
const message = this.getMessageFromKey(this.quickChatKey, this.variables);
this.mg.displayChat(
message[1],
message[0],
this.variables,
this.recipient.id(),
true,
this.recipient.name(),
);
this.mg.displayChat(
message[1],
message[0],
this.variables,
this.sender.id(),
false,
this.recipient.name(),
);
consolex.log(
`[QuickChat] ${this.sender.name}${this.recipient.name}: ${message}`,
);
this.active = false;
}
owner(): Player {
return this.sender;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
private getMessageFromKey(
fullKey: string,
vars: Record<string, string>,
): string[] {
const translated = fullKey.split(".");
return translated;
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ export class SAMLauncherExecution implements Execution {
this.active = false;
return;
}
this.sam = this.player.buildUnit(UnitType.SAMLauncher, 0, spawnTile, {
this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {
cooldownDuration: this.mg.config().SAMCooldown(),
});
}
+1 -1
View File
@@ -33,8 +33,8 @@ export class SAMMissileExecution implements Execution {
if (this.SAMMissile === null) {
this.SAMMissile = this._owner.buildUnit(
UnitType.SAMMissile,
0,
this.spawn,
{},
);
}
if (!this.SAMMissile.isActive()) {
+2 -5
View File
@@ -23,11 +23,8 @@ export class ShellExecution implements Execution {
}
tick(ticks: number): void {
if (this.mg === null || this.pathFinder === null) {
throw new Error("Not initialized");
}
if (this.shell === null) {
this.shell = this._owner.buildUnit(UnitType.Shell, 0, this.spawn);
if (this.shell == null) {
this.shell = this._owner.buildUnit(UnitType.Shell, this.spawn, {});
}
if (!this.shell.isActive()) {
this.active = false;
+1 -1
View File
@@ -48,7 +48,7 @@ export class TradeShipExecution implements Execution {
this.active = false;
return;
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn, {
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, spawn, {
dstPort: this._dstPort,
lastSetSafeFromPirates: ticks,
});
+3 -5
View File
@@ -142,11 +142,9 @@ export class TransportShipExecution implements Execution {
}
}
this.boat = this.attacker.buildUnit(
UnitType.TransportShip,
this.troops,
this.src,
);
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
troops: this.troops,
});
}
tick(ticks: number) {
+7 -1
View File
@@ -57,11 +57,13 @@ export class WarshipExecution implements Execution {
switch (result.type) {
case PathFindResultType.Completed:
this.warship.setMoveTarget(null);
this.warship.move(this.warship.tile());
return;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.move(this.warship.tile());
break;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to target`);
@@ -105,11 +107,13 @@ export class WarshipExecution implements Execution {
switch (result.type) {
case PathFindResultType.Completed:
this.patrolTile = this.randomTile();
this.warship.move(this.warship.tile());
break;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.move(this.warship.tile());
return;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to patrol tile`);
@@ -128,7 +132,7 @@ export class WarshipExecution implements Execution {
this.active = false;
return;
}
this.warship = this._owner.buildUnit(UnitType.Warship, 0, spawn);
this.warship = this._owner.buildUnit(UnitType.Warship, spawn, {});
return;
}
if (!this.warship.isActive()) {
@@ -240,11 +244,13 @@ export class WarshipExecution implements Execution {
case PathFindResultType.Completed:
this._owner.captureUnit(this.target);
this.target = null;
this.warship.move(this.warship.tile());
return;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.move(this.warship.tile());
break;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to target`);
+65 -15
View File
@@ -72,8 +72,10 @@ export enum GameMapType {
Japan = "Japan",
BetweenTwoSeas = "Between Two Seas",
KnownWorld = "Known World",
FaroeIslands = "FaroeIslands",
FaroeIslands = "Faroe Islands",
DeglaciatedAntarctica = "Deglaciated Antarctica",
FalklandIslands = "Falkland Islands",
Baikal = "Baikal",
}
export const mapCategories: Record<string, GameMapType[]> = {
@@ -97,6 +99,8 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Mena,
GameMapType.Australia,
GameMapType.FaroeIslands,
GameMapType.FalklandIslands,
GameMapType.Baikal,
],
fantasy: [
GameMapType.Pangaea,
@@ -144,6 +148,51 @@ export enum UnitType {
Construction = "Construction",
}
export interface UnitParamsMap {
[UnitType.TransportShip]: {
troops?: number;
destination?: TileRef;
};
[UnitType.Warship]: {};
[UnitType.Shell]: {};
[UnitType.SAMMissile]: {};
[UnitType.Port]: {};
[UnitType.AtomBomb]: {};
[UnitType.HydrogenBomb]: {};
[UnitType.TradeShip]: {
dstPort: Unit;
lastSetSafeFromPirates?: number;
};
[UnitType.MissileSilo]: {
cooldownDuration?: number;
};
[UnitType.DefensePost]: {};
[UnitType.SAMLauncher]: {};
[UnitType.City]: {};
[UnitType.MIRV]: {};
[UnitType.MIRVWarhead]: {};
[UnitType.Construction]: {};
}
// Type helper to get params type for a specific unit type
export type UnitParams<T extends UnitType> = UnitParamsMap[T];
export type AllUnitParams = UnitParamsMap[keyof UnitParamsMap];
export const nukeTypes = [
UnitType.AtomBomb,
UnitType.HydrogenBomb,
@@ -272,15 +321,6 @@ export class PlayerInfo {
}
}
// Some units have info specific to them
export interface UnitSpecificInfos {
dstPort?: Unit; // Only for trade ships
lastSetSafeFromPirates?: number; // Only for trade ships
detonationDst?: TileRef; // Only for nukes
warshipTarget?: Unit;
cooldownDuration?: number;
}
export interface Unit {
id(): number;
@@ -387,12 +427,12 @@ export interface Player {
unitsIncludingConstruction(type: UnitType): Unit[];
buildableUnits(tile: TileRef): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit(
type: UnitType,
troops: number,
tile: TileRef,
unitSpecificInfos?: UnitSpecificInfos,
buildUnit<T extends UnitType>(
type: T,
spawnTile: TileRef,
params: UnitParams<T>,
): Unit;
captureUnit(unit: Unit): void;
// Relations & Diplomacy
@@ -505,6 +545,15 @@ export interface Game extends GameMap {
playerID: PlayerID | null,
): void;
displayChat(
message: string,
category: string,
variables: Record<string, string>,
playerID: PlayerID | null,
isFrom: boolean,
recipient: string,
): void;
// Nations
nations(): Nation[];
@@ -557,6 +606,7 @@ export enum MessageType {
INFO,
WARN,
ERROR,
CHAT,
}
export interface NameViewData {
+22
View File
@@ -614,6 +614,28 @@ export class GameImpl implements Game {
});
}
displayChat(
message: string,
category: string,
variables: Record<string, string> = {},
playerID: PlayerID | null,
isFrom: boolean | null = null,
recipient: string,
): void {
let id = null;
if (playerID != null) {
id = this.player(playerID).smallID();
}
this.addUpdate({
type: GameUpdateType.DisplayChatEvent,
key: message,
category: category,
variables: variables,
playerID: id,
isFrom: isFrom,
recipient: recipient,
});
}
addUnit(u: Unit) {
this.unitGrid.addUnit(u);
}
+12
View File
@@ -29,6 +29,7 @@ export enum GameUpdateType {
Unit,
Player,
DisplayEvent,
DisplayChatEvent,
AllianceRequest,
AllianceRequestReply,
BrokeAlliance,
@@ -48,6 +49,7 @@ export type GameUpdate =
| BrokeAllianceUpdate
| AllianceExpiredUpdate
| DisplayMessageUpdate
| DisplayChatMessageUpdate
| TargetPlayerUpdate
| EmojiUpdate
| WinUpdate
@@ -157,6 +159,16 @@ export interface DisplayMessageUpdate {
playerID: number | null;
}
export type DisplayChatMessageUpdate = {
type: GameUpdateType.DisplayChatEvent;
key: string;
category: string;
variables?: Record<string, string>;
playerID: number | null;
isFrom: boolean;
recipient: string;
};
export interface WinUpdate {
type: GameUpdateType.Win;
allPlayersStats: AllPlayersStats;
+14 -21
View File
@@ -35,7 +35,7 @@ import {
TerraNullius,
Tick,
Unit,
UnitSpecificInfos,
UnitParams,
UnitType,
} from "./Game";
import { GameImpl } from "./GameImpl";
@@ -700,25 +700,29 @@ export class PlayerImpl implements Player {
);
}
buildUnit(
type: UnitType,
troops: number,
buildUnit<T extends UnitType>(
type: T,
spawnTile: TileRef,
unitSpecificInfos: UnitSpecificInfos = {},
params: UnitParams<T>,
): UnitImpl {
if (this.mg.config().isUnitDisabled(type)) {
throw new Error(
`Attempted to build disabled unit ${type} at tile ${spawnTile} by player ${this.name()}`,
);
}
const cost = this.mg.unitInfo(type).cost(this);
const b = new UnitImpl(
type,
this.mg,
spawnTile,
troops,
this.mg.nextUnitID(),
this,
unitSpecificInfos,
params,
);
this._units.push(b);
this.removeGold(cost);
this.removeTroops(troops);
this.removeTroops("troops" in params ? params.troops : 0);
this.mg.addUpdate(b.toUpdate());
this.mg.addUnit(b);
@@ -743,19 +747,8 @@ export class PlayerImpl implements Player {
targetTile: TileRef,
validTiles: TileRef[] | null = null,
): TileRef | false {
// prevent the building of nukes and nuke related buildings
if (this.mg.config().disableNukes()) {
if (
unitType === UnitType.MissileSilo ||
unitType === UnitType.MIRV ||
unitType === UnitType.AtomBomb ||
unitType === UnitType.HydrogenBomb ||
unitType === UnitType.SAMLauncher ||
unitType === UnitType.SAMMissile ||
unitType === UnitType.MIRVWarhead
) {
return false;
}
if (this.mg.config().isUnitDisabled(unitType)) {
return false;
}
const cost = this.mg.unitInfo(unitType).cost(this);
+2
View File
@@ -44,6 +44,8 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
[GameMapType.FaroeIslands]: "FaroeIslands",
[GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica",
[GameMapType.EuropeClassic]: "EuropeClassic",
[GameMapType.FalklandIslands]: "FalklandIslands",
[GameMapType.Baikal]: "Baikal",
};
class GameMapLoader {
+11 -9
View File
@@ -1,11 +1,11 @@
import { simpleHash, toInt, withinInt } from "../Util";
import {
AllUnitParams,
MessageType,
Player,
Tick,
Unit,
UnitInfo,
UnitSpecificInfos,
UnitType,
} from "./Game";
import { GameImpl } from "./GameImpl";
@@ -24,6 +24,7 @@ export class UnitImpl implements Unit {
private _lastSetSafeFromPirates: number; // Only for trade ships
private _constructionType: UnitType = undefined;
private _troops: number;
private _cooldownTick: Tick | null = null;
private _dstPort: Unit | undefined = undefined; // Only for trade ships
private _detonationDst: TileRef | undefined = undefined; // Only for nukes
@@ -34,21 +35,22 @@ export class UnitImpl implements Unit {
private _type: UnitType,
private mg: GameImpl,
private _tile: TileRef,
private _troops: number,
private _id: number,
public _owner: PlayerImpl,
unitsSpecificInfos: UnitSpecificInfos = {},
params: AllUnitParams = {},
) {
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._lastTile = _tile;
this._dstPort = unitsSpecificInfos.dstPort;
this._detonationDst = unitsSpecificInfos.detonationDst;
this._warshipTarget = unitsSpecificInfos.warshipTarget;
this._cooldownDuration = unitsSpecificInfos.cooldownDuration;
this._lastSetSafeFromPirates = unitsSpecificInfos.lastSetSafeFromPirates;
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._safeFromPiratesCooldown = this.mg
.config()
.safeFromPiratesCooldownMax();
this._troops = "troops" in params ? params.troops : 0;
this._dstPort = "dstPort" in params ? params.dstPort : null;
this._cooldownDuration =
"cooldownDuration" in params ? params.cooldownDuration : null;
this._lastSetSafeFromPirates =
"lastSetSafeFromPirates" in params ? params.lastSetSafeFromPirates : 0;
}
id() {
+45
View File
@@ -2,9 +2,54 @@ import { consolex } from "../Consolex";
import { Game } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { DistanceBasedBezierCurve } from "../utilities/Line";
import { AStar, PathFindResultType, TileResult } from "./AStar";
import { MiniAStar } from "./MiniAStar";
const parabolaMinHeight = 50;
export class ParabolaPathFinder {
constructor(private mg: GameMap) {}
private curve: DistanceBasedBezierCurve | undefined;
computeControlPoints(
orig: TileRef,
dst: TileRef,
distanceBasedHeight = true,
) {
const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) };
const p3 = { x: this.mg.x(dst), y: this.mg.y(dst) };
const dx = p3.x - p0.x;
const dy = p3.y - p0.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const maxHeight = distanceBasedHeight
? Math.max(distance / 3, parabolaMinHeight)
: 0;
// Use a bezier curve always pointing up
const p1 = {
x: p0.x + (p3.x - p0.x) / 4,
y: Math.max(p0.y + (p3.y - p0.y) / 4 - maxHeight, 0),
};
const p2 = {
x: p0.x + ((p3.x - p0.x) * 3) / 4,
y: Math.max(p0.y + ((p3.y - p0.y) * 3) / 4 - maxHeight, 0),
};
this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3);
}
nextTile(speed: number): TileRef | true {
if (!this.curve) {
return;
}
const nextPoint = this.curve.increment(speed);
if (!nextPoint) {
return true;
}
return this.mg.ref(Math.floor(nextPoint.x), Math.floor(nextPoint.y));
}
}
export class AirPathFinder {
constructor(
private mg: GameMap,
+153
View File
@@ -0,0 +1,153 @@
type Point = { x: number; y: number };
export class BezenhamLine {
constructor(
private p1: Point,
private p2: Point,
) {
this.dx = Math.abs(p2.x - p1.x);
this.dy = Math.abs(p2.y - p1.y);
this.sx = p1.x < p2.x ? 1 : -1;
this.sy = p1.y < p2.y ? 1 : -1;
this.error = this.dx - this.dy;
}
private dx: number;
private dy: number;
private sx: number;
private sy: number;
private error: number;
size() {
return Math.max(this.dx, this.dy) + 1;
}
// Increment either by 1 in x or y
increment(): Point | true {
if (this.p1.x === this.p2.x && this.p1.y === this.p2.y) {
return true;
}
const x = this.p1.x;
const y = this.p1.y;
const err2 = 2 * this.error;
if (err2 > -this.dy) {
this.error -= this.dy;
this.p1.x += this.sx;
}
if (err2 < this.dx) {
this.error += this.dx;
this.p1.y += this.sy;
}
return { x, y };
}
}
export class CubicBezierCurve {
constructor(
private p0: Point,
private p1: Point,
private p2: Point,
private p3: Point,
) {}
getPointAt(t: number): Point {
const T = 1 - t;
const TT = T * T;
const TTT = TT * T;
const tt = t * t;
const ttt = tt * t;
const x =
TTT * this.p0.x +
3 * TT * t * this.p1.x +
3 * T * tt * this.p2.x +
ttt * this.p3.x;
const y =
TTT * this.p0.y +
3 * TT * t * this.p1.y +
3 * T * tt * this.p2.y +
ttt * this.p3.y;
return { x, y };
}
}
/**
* Use a cumulative distance LUT to approximate the traveled distance
* Useful to compute regular steps based on the curve rather than a t
*/
export class DistanceBasedBezierCurve extends CubicBezierCurve {
private totalDistance: number = 0;
private distanceLUT: Array<{ t: number; distance: number }> = [];
private lastFoundIndex: number = 0; // To keep track of the last found index
increment(distance: number): Point {
this.totalDistance += distance;
const targetDistance = Math.min(
this.totalDistance,
this.distanceLUT[this.distanceLUT.length - 1]?.distance ||
this.totalDistance,
);
const t = this.computeTForDistance(targetDistance);
if (t >= 1) {
return null; // end reached
}
return this.getPointAt(t);
}
/**
* Generate @p numSteps segments, starting from the beginning of the curve
* Each segment size is added in the LUT
*/
generateCumulativeDistanceLUT(numSteps: number = 500): void {
this.distanceLUT = [];
let cumulativeDistance = 0;
let prevPoint = this.getPointAt(0);
for (let i = 1; i <= numSteps; i++) {
const t = i / numSteps;
const currentPoint = this.getPointAt(t);
const dx = currentPoint.x - prevPoint.x;
const dy = currentPoint.y - prevPoint.y;
const segmentLength = Math.sqrt(dx * dx + dy * dy);
cumulativeDistance += segmentLength;
this.distanceLUT.push({ t, distance: cumulativeDistance });
prevPoint = currentPoint;
}
}
computeTForDistance(distance: number): number {
if (this.distanceLUT.length === 0) {
this.generateCumulativeDistanceLUT();
}
if (distance <= 0) return 0;
if (distance >= this.distanceLUT[this.distanceLUT.length - 1].distance) {
return 1;
}
let lowerIndex = this.lastFoundIndex;
let upperIndex = this.distanceLUT.length - 1;
// Binary search for the closest range
while (upperIndex - lowerIndex > 1) {
const midIndex = Math.floor((upperIndex + lowerIndex) / 2);
if (this.distanceLUT[midIndex].distance < distance) {
lowerIndex = midIndex;
} else {
upperIndex = midIndex;
}
}
const lower = this.distanceLUT[lowerIndex];
const upper = this.distanceLUT[upperIndex];
this.lastFoundIndex = lowerIndex;
// Linear interpolation of t based on the distance
const t =
lower.t +
((distance - lower.distance) * (upper.t - lower.t)) /
(upper.distance - lower.distance);
return t;
}
}
+2
View File
@@ -25,6 +25,8 @@ const maps = [
"KnownWorld",
"FaroeIslands",
"DeglaciatedAntarctica",
"FalklandIslands",
"Baikal",
];
const removeSmall = true;
+2
View File
@@ -1,4 +1,5 @@
import WebSocket from "ws";
import { TokenPayload } from "../core/ApiSchemas";
import { PlayerID, Tick } from "../core/game/Game";
import { ClientID } from "../core/Schemas";
import { generateID } from "../core/Util";
@@ -13,6 +14,7 @@ export class Client {
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
public readonly claims: TokenPayload | null,
public readonly ip: string,
public readonly username: string,
public readonly ws: WebSocket,
+1 -1
View File
@@ -34,12 +34,12 @@ export class GameManager {
gameType: GameType.Private,
difficulty: Difficulty.Medium,
disableNPCs: false,
disableNukes: false,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
...gameConfig,
});
this.games.set(id, game);
+61 -34
View File
@@ -1,3 +1,4 @@
import ipAnonymize from "ip-anonymize";
import { Logger } from "winston";
import WebSocket from "ws";
import {
@@ -57,6 +58,8 @@ export class GameServer {
private _hasPrestarted = false;
private kickedClients: Set<ClientID> = new Set();
constructor(
public readonly id: string,
readonly log_: Logger,
@@ -77,10 +80,7 @@ export class GameServer {
if (typeof gameConfig.disableNPCs !== "undefined") {
this.gameConfig.disableNPCs = gameConfig.disableNPCs;
}
if (typeof gameConfig.disableNukes !== "undefined") {
this.gameConfig.disableNukes = gameConfig.disableNukes;
}
if (typeof gameConfig.bots !== "undefined") {
if (gameConfig.bots != null) {
this.gameConfig.bots = gameConfig.bots;
}
if (typeof gameConfig.infiniteGold !== "undefined") {
@@ -95,16 +95,27 @@ export class GameServer {
if (typeof gameConfig.gameMode !== "undefined") {
this.gameConfig.gameMode = gameConfig.gameMode;
}
if (gameConfig.disabledUnits != null) {
this.gameConfig.disabledUnits = gameConfig.disabledUnits;
}
if (gameConfig.playerTeams != null) {
this.gameConfig.playerTeams = gameConfig.playerTeams;
}
}
public addClient(client: Client, lastTurn: number) {
if (this.kickedClients.has(client.clientID)) {
this.log.warn(`cannot add client, already kicked`, {
clientID: client.clientID,
});
return;
}
this.log.info("client (re)joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
clientIP: client.ip,
clientIP: ipAnonymize(client.ip),
isRejoin: lastTurn > 0,
});
@@ -116,7 +127,7 @@ export class GameServer {
) {
this.log.warn("cannot add client, already have 3 ips", {
clientID: client.clientID,
clientIP: client.ip,
clientIP: ipAnonymize(client.ip),
});
return;
}
@@ -126,11 +137,21 @@ export class GameServer {
(c) => c.clientID == client.clientID,
);
if (existing != null) {
if (client.persistentID !== existing.persistentID) {
this.log.error("persistent ids do not match", {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
clientPersistentID: client.persistentID,
existingIP: ipAnonymize(existing.ip),
existingPersistentID: existing.persistentID,
});
return;
}
existing.ws.removeAllListeners("message");
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
@@ -144,34 +165,16 @@ export class GameServer {
try {
clientMsg = ClientMessageSchema.parse(JSON.parse(message));
} catch (error) {
throw Error(`error parsing schema for ${client.ip}`);
throw Error(`error parsing schema for ${ipAnonymize(client.ip)}`);
}
const c = this.allClients.get(clientMsg.clientID);
if (typeof c !== "undefined") {
if (c.persistentID != clientMsg.persistentID) {
if (clientMsg.type == "intent") {
if (clientMsg.intent.clientID != client.clientID) {
this.log.warn(
`Client ID ${clientMsg.clientID} sent incorrect id ${clientMsg.persistentID}, does not match persistent id ${c.persistentID}`,
{
clientID: clientMsg.clientID,
persistentID: clientMsg.persistentID,
},
`client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`,
);
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 {
this.log.warn("client sent to wrong game", {
clientID: clientMsg.clientID,
persistentID: clientMsg.persistentID,
});
}
this.addIntent(clientMsg.intent);
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
@@ -321,7 +324,6 @@ export class GameServer {
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.id,
intents: this.intents,
};
this.turns.push(pastTurn);
@@ -369,7 +371,7 @@ export class GameServer {
const playerRecords: PlayerRecord[] = Array.from(
this.allClients.values(),
).map((client) => ({
ip: client.ip,
ip: ipAnonymize(client.ip),
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
@@ -499,6 +501,31 @@ export class GameServer {
return this.gameConfig.gameType == GameType.Public;
}
public kickClient(clientID: ClientID): void {
if (this.kickedClients.has(clientID)) {
this.log.warn(`cannot kick client, already kicked`, {
clientID,
});
return;
}
const client = this.activeClients.find((c) => c.clientID === clientID);
if (client) {
this.log.info("Kicking client from game", {
clientID: client.clientID,
persistentID: client.persistentID,
});
client.ws.close(1000, "Kicked from game");
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== clientID,
);
this.kickedClients.add(clientID);
} else {
this.log.warn(`cannot kick client, not found in game`, {
clientID,
});
}
}
private handleSynchronization() {
if (this.activeClients.length <= 1) {
return;
+2
View File
@@ -29,6 +29,8 @@ const frequency = {
Japan: 1,
BlackSea: 1,
FaroeIslands: 1,
FalklandIslands: 1,
Baikal: 1,
};
interface MapWithMode {
+33
View File
@@ -160,6 +160,39 @@ app.get(
}),
);
app.post(
"/api/kick_player/:gameID/:clientID",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
if (req.headers[config.adminHeader()] !== config.adminToken()) {
res.status(401).send("Unauthorized");
return;
}
const { gameID, clientID } = req.params;
try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`,
{
method: "POST",
headers: {
[config.adminHeader()]: config.adminToken(),
},
},
);
if (!response.ok) {
throw new Error(`Failed to kick player: ${response.statusText}`);
}
res.status(200).send("Player kicked successfully");
} catch (error) {
log.error(`Error kicking player from game ${gameID}:`, error);
res.status(500).send("Failed to kick player");
}
}),
);
async function fetchLobbies(): Promise<number> {
const fetchPromises: Promise<GameInfo | null>[] = [];
+53 -17
View File
@@ -1,17 +1,19 @@
import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import { GameConfig, GameRecord } from "../core/Schemas";
import { ClientMessageSchema, GameConfig, GameRecord } from "../core/Schemas";
import { archive, readGameRecord } from "./Archive";
import { Client } from "./Client";
import { GameManager } from "./GameManager";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { initWorkerMetrics } from "./WorkerMetrics";
@@ -78,9 +80,8 @@ export function startWorker() {
const id = req.params.id;
if (!id) {
log.warn(`cannot create game, id not found`);
return;
return res.status(400).json({ error: "Game ID is required" });
}
// 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 (
@@ -88,9 +89,11 @@ export function startWorker() {
req.headers[config.adminHeader()] !== config.adminToken()
) {
log.warn(
`cannot create public game ${id}, ip ${clientIP} incorrect admin token`,
`cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`,
);
return res.status(400);
return res
.status(400)
.json({ error: "Invalid admin token for public game creation" });
}
// Double-check this worker should host this game
@@ -99,13 +102,13 @@ export function startWorker() {
log.warn(
`This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
);
return res.status(400);
return res.status(400).json({ error: "Worker, game id mismatch" });
}
const game = gm.createGame(id, gc);
log.info(
`Worker ${workerId}: IP ${clientIP} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,
);
res.json(game.gameInfo());
}),
@@ -123,7 +126,7 @@ export function startWorker() {
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
log.info(
`cannot start public game ${game.id}, game is public, ip: ${clientIP}`,
`cannot start public game ${game.id}, game is public, ip: ${ipAnonymize(clientIP)}`,
);
return;
}
@@ -139,20 +142,24 @@ export function startWorker() {
const lobbyID = req.params.id;
if (req.body.gameType == GameType.Public) {
log.info(`cannot update game ${lobbyID} to public`);
return res.status(400);
return res.status(400).json({ error: "Cannot update public game" });
}
const game = gm.game(lobbyID);
if (!game) {
return res.status(400);
return res.status(400).json({ error: "Game not found" });
}
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
log.warn(`cannot update public game ${game.id}, ip: ${clientIP}`);
return res.status(400);
log.warn(
`cannot update public game ${game.id}, ip: ${ipAnonymize(clientIP)}`,
);
return res.status(400).json({ error: "Cannot update public game" });
}
if (game.hasStarted()) {
log.warn(`cannot update game ${game.id} after it has started`);
return res.status(400);
return res
.status(400)
.json({ error: "Cannot update game after it has started" });
}
game.updateGameConfig({
gameMap: req.body.gameMap,
@@ -162,7 +169,7 @@ export function startWorker() {
instantBuild: req.body.instantBuild,
bots: req.body.bots,
disableNPCs: req.body.disableNPCs,
disableNukes: req.body.disableNukes,
disabledUnits: req.body.disabledUnits,
gameMode: req.body.gameMode,
playerTeams: req.body.playerTeams,
});
@@ -250,6 +257,27 @@ export function startWorker() {
}),
);
app.post(
"/api/kick_player/:gameID/:clientID",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
if (req.headers[config.adminHeader()] !== config.adminToken()) {
res.status(401).send("Unauthorized");
return;
}
const { gameID, clientID } = req.params;
const game = gm.game(gameID);
if (!game) {
res.status(404).send("Game not found");
return;
}
game.kickClient(clientID);
res.status(200).send("Player kicked successfully");
}),
);
// WebSocket handling
wss.on("connection", (ws: WebSocket, req) => {
ws.on(
@@ -263,7 +291,9 @@ export function startWorker() {
try {
// Process WebSocket messages as in your original code
// Parse and handle client messages
const clientMsg = JSON.parse(message.toString());
const clientMsg = ClientMessageSchema.parse(
JSON.parse(message.toString()),
);
if (clientMsg.type == "join") {
// Verify this worker should handle this game
@@ -275,10 +305,16 @@ export function startWorker() {
return;
}
const { persistentId, claims } = await verifyClientToken(
clientMsg.token,
config,
);
// Create client and add to game
const client = new Client(
clientMsg.clientID,
clientMsg.persistentID,
persistentId,
claims ?? null,
ip,
clientMsg.username,
ws,
@@ -302,7 +338,7 @@ export function startWorker() {
// Handle other message types
} catch (error) {
log.warn(
`error handling websocket message for ${ip}: ${error}`.substring(
`error handling websocket message for ${ipAnonymize(ip)}: ${error}`.substring(
0,
250,
),
+29
View File
@@ -0,0 +1,29 @@
import { jwtVerify } from "jose";
import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
import { ServerConfig } from "../core/configuration/Config";
type TokenVerificationResult = {
persistentId: string;
claims: TokenPayload | null;
};
export async function verifyClientToken(
token: string,
config: ServerConfig,
): Promise<TokenVerificationResult> {
if (token.length === 36) {
return { persistentId: token, claims: null };
}
const issuer = config.jwtIssuer();
const audience = config.jwtAudience();
const key = await config.jwkPublicKey();
const { payload, protectedHeader } = await jwtVerify(token, key, {
algorithms: ["EdDSA"],
issuer,
audience,
maxTokenAge: "6 days",
});
const claims = TokenPayloadSchema.parse(payload);
const persistentId = claims.sub;
return { persistentId, claims };
}
+20 -10
View File
@@ -50,9 +50,9 @@ describe("SAM", () => {
});
test("one sam should take down one nuke", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(1, 1));
attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {});
executeTicks(game, 3);
@@ -60,10 +60,14 @@ describe("SAM", () => {
});
test("sam should only get one nuke at a time", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(2, 1));
attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(1, 2));
attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), {
detonationDst: game.ref(2, 1),
});
attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
detonationDst: game.ref(1, 2),
});
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(2);
executeTicks(game, 3);
@@ -72,10 +76,12 @@ describe("SAM", () => {
});
test("sam should cooldown as long as configured", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
expect(sam.isCooldown()).toBeFalsy();
const nuke = attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(1, 2));
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
detonationDst: game.ref(1, 2),
});
executeTicks(game, 3);
@@ -91,11 +97,15 @@ describe("SAM", () => {
});
test("two sams should not target twice same nuke", async () => {
const sam1 = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {
cooldownDuration: 10,
});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam1));
const sam2 = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 2));
const sam2 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 2), {});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam2));
const nuke = attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(2, 2));
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 2), {
detonationDst: game.ref(2, 2),
});
executeTicks(game, 3);
+8 -4
View File
@@ -63,11 +63,11 @@ describe("Warship", () => {
throw new Error("unreachable");
}
const port = player1.buildUnit(UnitType.Port, 0, game.ref(coastX, 10));
const port = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
UnitType.Warship,
0,
game.ref(coastX + 1, 10),
{},
);
game.executeNextTick();
@@ -95,8 +95,10 @@ describe("Warship", () => {
// we can obviously directly add it to the player)
const tradeShip = player2.buildUnit(
UnitType.TradeShip,
0,
game.ref(coastX + 1, 7),
{
dstPort: null,
},
);
expect(tradeShip.owner().id()).toBe(player2.id());
@@ -117,8 +119,10 @@ describe("Warship", () => {
// we can obviously directly add it to the player)
const tradeShip = player2.buildUnit(
UnitType.TradeShip,
0,
game.ref(coastX + 1, 11),
{
dstPort: null,
},
);
expect(tradeShip.owner().id()).toBe(player2.id());
+6 -1
View File
@@ -46,7 +46,12 @@ export async function setup(
instantBuild: false,
..._gameConfig,
};
const config = new TestConfig(serverConfig, gameConfig, new UserSettings());
const config = new TestConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
// Create and return the game
return createGame(humans, [], gameMap, miniGameMap, config);
+10 -3
View File
@@ -1,8 +1,18 @@
import { JWK } from "jose";
import { GameEnv, ServerConfig } from "../../src/core/configuration/Config";
import { GameMapType } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
jwtAudience(): string {
throw new Error("Method not implemented.");
}
jwtIssuer(): string {
throw new Error("Method not implemented.");
}
jwkPublicKey(): Promise<JWK> {
throw new Error("Method not implemented.");
}
otelEnabled(): boolean {
throw new Error("Method not implemented.");
}
@@ -27,9 +37,6 @@ export class TestServerConfig implements ServerConfig {
lobbyMaxPlayers(map: GameMapType): number {
throw new Error("Method not implemented.");
}
discordRedirectURI(): string {
throw new Error("Method not implemented.");
}
numWorkers(): number {
throw new Error("Method not implemented.");
}
+1
View File
@@ -228,6 +228,7 @@ export default async (env, argv) => {
"/api/archive_singleplayer_game",
"/api/auth/callback",
"/api/auth/discord",
"/api/kick_player",
],
target: "http://localhost:3000",
secure: false,