Merge main into strict
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 134 KiB |
@@ -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 |
@@ -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 |
@@ -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 site’s 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 didn’t mean to attack.",
|
||||
"alliance": "Alliance?",
|
||||
"help_defend": "Help me defend against [P1]!",
|
||||
"team_up": "Let’s 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": "Don’t 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": "Let’s 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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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を撃てるだけの金を持ってる!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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++;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")}"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TokenPayloadSchema,
|
||||
UserMeResponse,
|
||||
UserMeResponseSchema,
|
||||
} from "./ApiSchemas";
|
||||
} from "../core/ApiSchemas";
|
||||
|
||||
function getAudience() {
|
||||
const { hostname } = new URL(window.location.href);
|
||||
|
||||
@@ -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");
|
||||
* {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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";
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ const maps = [
|
||||
"KnownWorld",
|
||||
"FaroeIslands",
|
||||
"DeglaciatedAntarctica",
|
||||
"FalklandIslands",
|
||||
"Baikal",
|
||||
];
|
||||
|
||||
const removeSmall = true;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,8 @@ const frequency = {
|
||||
Japan: 1,
|
||||
BlackSea: 1,
|
||||
FaroeIslands: 1,
|
||||
FalklandIslands: 1,
|
||||
Baikal: 1,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
|
||||
@@ -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>[] = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||