Merge branch 'main' into trade3

This commit is contained in:
Ryan
2026-03-20 23:05:13 +00:00
committed by GitHub
111 changed files with 3676 additions and 1756 deletions
+13 -13
View File
@@ -17,16 +17,16 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
submodules: false
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
node-version: 24
- run: npm ci
- run: npm run build-prod
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
path: out/index.html
retention-days: 1
@@ -36,13 +36,13 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
submodules: false
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
node-version: 24
- run: npm ci
- run: npm run test:coverage
@@ -51,10 +51,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: "20"
node-version: "24"
cache: "npm"
- run: npm ci
- run: npx eslint --format gha
@@ -64,10 +64,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: "20"
node-version: "24"
cache: "npm"
- run: npm ci
- run: npx prettier --check .
+43 -22
View File
@@ -54,7 +54,7 @@ jobs:
DOMAIN: ${{ inputs.target_domain || 'openfront.dev' }}
SUBDOMAIN: ${{ github.event_name == 'push' && github.ref_name || inputs.target_subdomain || 'main' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 📝 Update job summary
env:
FQDN: ${{ env.SUBDOMAIN && format('{0}.{1}', env.SUBDOMAIN, env.DOMAIN) || env.DOMAIN || 'openfront.dev' }}
@@ -65,7 +65,7 @@ jobs:
Deploying from $GITHUB_REF to $FQDN
EOF
- uses: actions/create-github-app-token@v2
- uses: actions/create-github-app-token@v3
id: generate-token
if: ${{ github.repository == 'openfrontio/OpenFrontIO' }}
with:
@@ -79,14 +79,34 @@ jobs:
echo "GH_TOKEN=$GH_TOKEN" >> $GITHUB_ENV
gh api octocat
- name: 📝 Create deployment
uses: chrnorm/deployment-action@v2
if: ${{ github.repository == 'openfrontio/OpenFrontIO' && steps.generate-token.outputs.token != '' }}
uses: actions/github-script@v8
id: deployment
env:
ENVIRONMENT: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }}
FQDN: ${{ env.FQDN }}
with:
token: ${{ steps.generate-token.outputs.token }}
environment-url: https://${{ env.FQDN }}
environment: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const response = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: process.env.GITHUB_SHA,
environment: process.env.ENVIRONMENT,
description: 'Deployment to ' + process.env.FQDN,
auto_merge: false,
required_contexts: [],
transient_environment: process.env.ENVIRONMENT === 'staging' && context.ref !== 'refs/heads/main',
production_environment: process.env.ENVIRONMENT === 'prod'
});
const deployment = response.data;
if (!deployment || !deployment.id) {
core.setFailed('Failed to create deployment');
return;
}
core.setOutput('deployment_id', deployment.id);
- name: 🔗 Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ vars.GHCR_USERNAME }}
@@ -143,14 +163,23 @@ jobs:
done
echo "Deployment started in ${SECONDS} seconds" >> $GITHUB_STEP_SUMMARY
echo "::endgroup::"
- name: Update deployment status
if: success()
uses: chrnorm/deployment-status@v2
- name: 🔄 Update deployment status
if: ${{ always() && github.repository == 'openfrontio/OpenFrontIO' && steps.generate-token.outputs.token != '' && steps.deployment.outcome == 'success' && steps.deployment.outputs.deployment_id != '' }}
uses: actions/github-script@v8
env:
FQDN: ${{ env.FQDN }}
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
STATUS: ${{ job.status }}
with:
token: ${{ steps.generate-token.outputs.token }}
environment-url: https://${{ env.FQDN }}
state: success
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: process.env.DEPLOYMENT_ID,
state: process.env.STATUS === 'success' ? 'success' : 'failure',
environment_url: 'https://' + process.env.FQDN
});
- name: ✅ Update job summary
if: success()
run: |
@@ -159,14 +188,6 @@ jobs:
Deployed from $GITHUB_REF to $FQDN
EOF
- 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: |
+1 -1
View File
@@ -13,4 +13,4 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: toshimaru/auto-author-assign@7e15cd70c245ad136377c3fab3479815df10d844
- uses: toshimaru/auto-author-assign@4d585cc37690897bd9015942ed6e766aa7cdb97f
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v8
with:
script: |
if (context.eventName === 'merge_group') {
@@ -64,7 +64,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v8
with:
script: |
if (context.eventName === 'merge_group') {
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: actions/stale@8f717f0dfca33b78d3c933452e42558e4456c8e7
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f
with:
days-before-pr-close: 14
days-before-pr-stale: 14
+6 -6
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 🔗 Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ vars.GHCR_USERNAME }}
@@ -51,7 +51,7 @@ jobs:
timeout-minutes: 30
needs: [build]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 🔑 Create SSH private key
env:
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
@@ -102,7 +102,7 @@ jobs:
timeout-minutes: 30
environment: prod-beta
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 🔑 Create SSH private key
env:
SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }}
@@ -153,7 +153,7 @@ jobs:
timeout-minutes: 30
environment: prod-blue
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 🔑 Create SSH private key
env:
SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }}
@@ -204,7 +204,7 @@ jobs:
timeout-minutes: 30
environment: prod-green
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: 🔑 Create SSH private key
env:
SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }}
+2 -3
View File
@@ -284,7 +284,7 @@
class="w-full pointer-events-auto order-1 sm:order-none"
></attacks-display>
<div
class="pointer-events-auto bg-gray-800/70 backdrop-blur-xs sm:rounded-tr-lg lg:rounded-t-lg min-[1200px]:rounded-lg shadow-lg order-3 sm:order-none"
class="pointer-events-auto bg-gray-800/92 backdrop-blur-sm sm:rounded-tr-lg lg:rounded-t-lg min-[1200px]:rounded-lg shadow-lg order-3 sm:order-none"
>
<control-panel class="w-full"></control-panel>
<unit-display class="hidden lg:block w-full"></unit-display>
@@ -319,8 +319,7 @@
<player-panel></player-panel>
<spawn-timer></spawn-timer>
<immunity-timer></immunity-timer>
<in-game-header-ad></in-game-header-ad>
<spawn-video-ad></spawn-video-ad>
<in-game-promo></in-game-promo>
<game-info-modal></game-info-modal>
<alert-frame></alert-frame>
<chat-modal></chat-modal>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 533 KiB

+63 -19
View File
@@ -6,66 +6,62 @@
"name": "Crete"
},
{
"coordinates": [1554, 1530],
"coordinates": [1598, 1550],
"name": "Rhodes"
},
{
"coordinates": [1051, 539],
"coordinates": [1126, 552],
"name": "Lesbos"
},
{
"coordinates": [1070, 820],
"coordinates": [1128, 812],
"name": "Chios"
},
{
"coordinates": [1235, 1023],
"coordinates": [1330, 1039],
"name": "Samos"
},
{
"coordinates": [1193, 301],
"coordinates": [1275, 263],
"name": "Troy"
},
{
"coordinates": [1446, 954],
"coordinates": [1567, 808],
"name": "Ephesus"
},
{
"coordinates": [1515, 1223],
"coordinates": [1635, 1139],
"name": "Miletus"
},
{
"coordinates": [824, 305],
"coordinates": [891, 295],
"name": "Lemnos"
},
{
"coordinates": [1312, 37],
"coordinates": [1382, 27],
"name": "Thrace"
},
{
"coordinates": [1473, 509],
"coordinates": [1599, 431],
"name": "Achaemenid Empire",
"flag": "Achaemenid Empire"
},
{
"coordinates": [702, 40],
"coordinates": [762, 35],
"name": "Thasos"
},
{
"coordinates": [832, 1253],
"name": "Cyclades"
},
{
"coordinates": [479, 943],
"coordinates": [491, 894],
"name": "Athens",
"flag": "Athens"
},
{
"coordinates": [110, 1157],
"coordinates": [222, 1183],
"name": "Sparta",
"flag": "Sparta"
},
{
"coordinates": [348, 56],
"coordinates": [446, 104],
"name": "Macedonia",
"flag": "Macedonia"
},
@@ -74,8 +70,56 @@
"name": "Thessaly"
},
{
"coordinates": [71, 742],
"coordinates": [71, 713],
"name": "Aetolia"
},
{
"coordinates": [534, 761],
"name": "Euboea"
},
{
"coordinates": [26, 984],
"name": "Olympia"
},
{
"coordinates": [826, 1020],
"name": "Andros"
},
{
"coordinates": [1146, 1078],
"name": "Icaria"
},
{
"coordinates": [989, 1250],
"name": "Naxos"
},
{
"coordinates": [969, 1475],
"name": "Thera"
},
{
"coordinates": [937, 1371],
"name": "Ios"
},
{
"coordinates": [1403, 1337],
"name": "Cos"
},
{
"coordinates": [688, 1377],
"name": "Melos"
},
{
"coordinates": [723, 643],
"name": "Scyros"
},
{
"coordinates": [324, 1520],
"name": "Cythera"
},
{
"coordinates": [1416, 1755],
"name": "Carpathos"
}
]
}
+17 -1
View File
@@ -56,5 +56,21 @@
"name": "Listvyanka",
"flag": "ru"
}
]
],
"teamGameSpawnAreas": {
"2": [
{
"height": 1564,
"width": 1330,
"x": 0,
"y": 0
},
{
"height": 1564,
"width": 1070,
"x": 1430,
"y": 0
}
]
}
}

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 334 KiB

+39 -32
View File
@@ -85,8 +85,8 @@
"lit": "^3.3.1",
"lit-markdown": "^1.3.2",
"mrmime": "^2.0.0",
"pixi-filters": "^6.1.4",
"pixi.js": "^8.11.0",
"pixi-filters": "^6.1.5",
"pixi.js": "^8.17.1",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-sh": "^0.17.4",
@@ -4198,13 +4198,6 @@
"@types/node": "*"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
"integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -5219,16 +5212,16 @@
}
},
"node_modules/@webgpu/types": {
"version": "0.1.61",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.61.tgz",
"integrity": "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==",
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7108,9 +7101,9 @@
}
},
"node_modules/earcut": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz",
"integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"dev": true,
"license": "ISC"
},
@@ -7913,9 +7906,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -10277,9 +10270,9 @@
}
},
"node_modules/pixi-filters": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.4.tgz",
"integrity": "sha512-6QdkhR8hZ/jXyV7GZG8R0UKkRy9jPeZsOnHaQiKSFEe4tGJ4PfUG90vaC9eyi7g+YKxhKLpNOXu6tmO1+R2tpQ==",
"version": "6.1.5",
"resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.5.tgz",
"integrity": "sha512-Ewb/J+kxAbaNN+0/ATJbglAJG+skGJfh7BIDP3ILIDdD6wWk1p0pGa25pVf1T8hGBOQSUNVAmwwJBwkj+cyLLA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10290,22 +10283,26 @@
}
},
"node_modules/pixi.js": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.11.0.tgz",
"integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.17.1.tgz",
"integrity": "sha512-OB4TpZHrP5RYy+7FqmFrAc0IHRhfOoNIfF4sVeinvK3aG1r2pYrSMneJAKi9+WvGKC70Dj7GEpZ2OZGB6o/xdg==",
"dev": true,
"license": "MIT",
"workspaces": [
"examples",
"playground"
],
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
"@types/earcut": "^3.0.0",
"@webgpu/types": "^0.1.40",
"@xmldom/xmldom": "^0.8.10",
"earcut": "^3.0.1",
"@webgpu/types": "^0.1.69",
"@xmldom/xmldom": "^0.8.11",
"earcut": "^3.0.2",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
"parse-svg-path": "^0.1.2"
"parse-svg-path": "^0.1.2",
"tiny-lru": "^11.4.7"
},
"funding": {
"type": "opencollective",
@@ -11629,6 +11626,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/tiny-lru": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+2 -2
View File
@@ -69,8 +69,8 @@
"lit": "^3.3.1",
"lit-markdown": "^1.3.2",
"mrmime": "^2.0.0",
"pixi-filters": "^6.1.4",
"pixi.js": "^8.11.0",
"pixi-filters": "^6.1.5",
"pixi.js": "^8.17.1",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-sh": "^0.17.4",
+132
View File
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" preserveAspectRatio="xMidYMid meet" class="standalone-flag">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<path d="M5,17h62v38H5V17Z" fill="#ffffff"/>
<defs>
<clipPath id="flag-layer-1-0" clipPathUnits="userSpaceOnUse">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<path xmlns="http://www.w3.org/2000/svg" d="M5,17h62v38H5V17Z" fill="#e60012"/>
</clipPath>
</defs>
<g clip-path="url(#flag-layer-1-0)">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<rect xmlns="http://www.w3.org/2000/svg" x="5" y="17" width="62" height="12.7" fill="#000000"/>
</g>
<defs>
<clipPath id="flag-layer-2-0" clipPathUnits="userSpaceOnUse">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<path xmlns="http://www.w3.org/2000/svg" d="M5,17h62v38H5V17Z" fill="#e60012"/>
</clipPath>
</defs>
<g clip-path="url(#flag-layer-2-0)">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<rect xmlns="http://www.w3.org/2000/svg" x="5" y="29.7" width="62" height="12.7" fill="#dd0000"/>
</g>
<defs>
<clipPath id="flag-layer-3-0" clipPathUnits="userSpaceOnUse">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<path xmlns="http://www.w3.org/2000/svg" d="M5,17h62v38H5V17Z" fill="#e60012"/>
</clipPath>
</defs>
<g clip-path="url(#flag-layer-3-0)">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<rect xmlns="http://www.w3.org/2000/svg" x="5" y="42.3" width="62" height="12.7" fill="#ffcf00"/>
</g>
<defs>
<clipPath id="flag-layer-4-0" clipPathUnits="userSpaceOnUse">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<path xmlns="http://www.w3.org/2000/svg" d="M5,17h62v38H5V17Z" fill="#e60012"/>
</clipPath>
</defs>
<g clip-path="url(#flag-layer-4-0)">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<path xmlns="http://www.w3.org/2000/svg" d="M67,17v38H5" fill="#ff0000"/>
</g>
<defs>
<clipPath id="flag-layer-5-0" clipPathUnits="userSpaceOnUse">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<path xmlns="http://www.w3.org/2000/svg" d="M5,17h62v38H5V17Z" fill="#e60012"/>
</clipPath>
</defs>
<g clip-path="url(#flag-layer-5-0)">
<g transform="translate(18 4)">
<g transform="translate(36 36) scale(0.5 0.5) translate(-36 -36)">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<rect xmlns="http://www.w3.org/2000/svg" x="30.7" y="17" width="10.7" height="38" fill="#ffffff"/>
</g>
</g>
</g>
<defs>
<clipPath id="flag-layer-6-0" clipPathUnits="userSpaceOnUse">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<path xmlns="http://www.w3.org/2000/svg" d="M5,17h62v38H5V17Z" fill="#e60012"/>
</clipPath>
</defs>
<g clip-path="url(#flag-layer-6-0)">
<g transform="translate(18 4)">
<g transform="rotate(90 36 36)">
<g transform="translate(36 36) scale(0.5 0.5) translate(-36 -36)">
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<rect xmlns="http://www.w3.org/2000/svg" x="30.7" y="17" width="10.7" height="38" fill="#ffffff"/>
</g>
</g>
</g>
</g>
<!-- Generator: Adobe Illustrator 29.4.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 152) -->
<path d="M5,17h62v38H5V17Z" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2px"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+990
View File
@@ -0,0 +1,990 @@
{
"common": {
"close": "Schliesse",
"copy": "Kopiere",
"paste": "Ifüege",
"back": "Zrügg",
"available": "Verfüegbar",
"preset_max": "Maximau",
"summary_send": "Sände",
"summary_keep": "Bhaute",
"cancel": "Abbräche",
"send": "Sände",
"cap_label": "Kapazität",
"cap_tooltip": "Verblibendi Kapazität vom Empfänger",
"target_dead": "Zieu usgschaute",
"target_dead_note": "Du chasch keini Resourse am ne eliminierte Spiler sände.",
"none": "Nüt",
"copied": "Kopiert!",
"click_to_copy": "Klick zum Kopiere",
"enabled": "Igschaute",
"map_default": "Kartestandard"
},
"main": {
"title": "OpenFront (ALPHA)",
"login_discord": "Mäud di a mit Discord",
"sign_in": "Amäude",
"discord_avatar_alt": "Discord Profiubüud",
"user_avatar_alt": "{username}'s Profiubüud",
"create": "Lobby erstelle",
"join": "Lobby biträtte",
"solo": "Elei",
"game_info": "Spieuinfo",
"wiki": "Wiki",
"privacy_policy": "Dateschutzerklärig",
"terms_of_service": "Nutzigsbedingige",
"copyright": "©️ OpenFront™ und Mitwirkendi",
"play": "Spiele",
"news": "Neuigkeite",
"store": "Shop",
"settings": "Isteuige",
"leaderboard": "Ranglischte",
"account": "Account",
"help": "Hiuf",
"menu": "Menü",
"troubleshooting": "Fählerbehäbig",
"go_to_troubleshooting": "Gang zu üsere Fehlerbehebigssitte"
},
"news": {
"github_link": "uf GitHub",
"title": "Versionshiwise"
},
"troubleshooting": {
"title": "Fählerbehäbig",
"environment": "Umgäbig",
"rendering": "Rendering",
"power": "Leistig",
"browser": "Browser",
"platform": "Plattform",
"copied_to_clipboard": "Informatione i d Zwüscheablag kopiert! Du chasch se im Discord teile, wenn du Hüuf bruchsch.",
"os": "Betriebssystem",
"device_pixel_ratio": "Pixelverhäutniss vom Grät",
"chromium_tip": "OpenFront louft am beschte uf Chromium-basierte Browser.",
"hardware_acceleration_tip": "Steu sicher, dass d Hardwarebeschleunigung in dine Browseristellige aktiviert ist, um e optimale Leistig z erziele.",
"renderer": "Renderer",
"max_texture_size": "Maximali Texturgrössi",
"high_precision_shaders": "Hochpräzisionshaders",
"gpu": "Grafikkarte",
"unavailable": "Unverfüegbar",
"gpu_tip": "Bestätig, dass das die dedizierti Grafikkarte isch, faus eini vorhande isch.",
"battery": "Batterie",
"charging": "Am uflade",
"battery_level": "Batteriestand",
"power_saving_tip": "Sorg derfür, dass di Browser nid im Energiesparmodus isch.",
"yes": "Ja",
"no": "Nei",
"unknown": "Unbekannt",
"software_rendering": "Software-Rendering",
"canvas_2d_no_gpu": "2D-Linwang (keini GPU)"
},
"help_modal": {
"video_tutorial": "Video-Aleitig",
"video_tutorial_title": "OpenFront.io Aleitig",
"hotkeys": "Tastaturbelegige",
"table_key": "Schlüssu",
"table_action": "Aktion",
"action_esc": "Schliesst s Menü. Bircht d Bouvorschou ab.",
"action_enter": "Bout e Einheit ungerem Muszeiger",
"action_alt_view": "Alternativi Asicht (Bode/Länder)",
"action_coordinate_grid": "Koordinateraster-Overlay umschalute",
"action_attack_altclick": "Agriff usfüehre (wenn Linksklick ufs \"öffne Menü\" gsteut isch)",
"action_build": "Baumenü ufmache",
"action_emote": "Emote-Menü ufmache",
"action_center": "Karte ufe Spiler zentriere",
"action_zoom": "Asicht vergrössere/verchlinere",
"action_move_camera": "Kamera bewege",
"action_ratio_change": "Agriffsrate verringere/erhöhe",
"action_reset_gfx": "Grafike zrüggsetze",
"action_auto_upgrade": "Automatisch s nöchschte Gebäude verbessere",
"ui_section": "Spieuoberflächi",
"ui_leaderboard": "Ranglischte",
"ui_your_team": "Dis Team:",
"ui_leaderboard_desc": "Zeigt d beste Spieler vom Spieu und ihri Namen, % vom Besitz vom Land, Gold u Truppene a. Mit \"Alle azeige\" werde alle Spieler im Spiel azeigt. Wenn du d Rangliste nid gseh möchtisch, drück uf \"Verstecke\".",
"ui_control": "Aktionsmenü",
"ui_control_desc": "Der Kontrollbereich beinhautet fougendi Element:",
"ui_gold": "Gold - D Menge a Gold wo du bsizisch, und d' Geschwindigkeit mit der me Gold überchunt.",
"ui_attack_ratio": "Agriffsverhäutniss - D Anzahl vo de Truppene, wo bim Agriff verwendet werden, cha mit em Schieberegler apasst werde. Desto meh Truppe bim Agriff verwendet werde, desto geringer si die eigene Verluste. Während weniger Truppen zu grössere Verluste füehre. Dä Effekt geit nid über s Verhältnis vo 2:1 use.",
"ui_events": "Ereignis - Fäud",
"ui_events_desc": "S Ereignis - Fäud zeigt die neuesten Ereignisse, Anfragen und Schnell-Nachrichten a. Es bar Beispiele sind:",
"ui_events_alliance": "Allianz - Allianzafroge chöi akzeptiert oder abgelehnt werde. Alliierti chöi Ressourcen und Truppen teile, chöi sich aber nicht gegensitig angreifen. Dür Klicke auf \"Fokussiere\" wird d Ansicht uf dä Spieler verschoben, wo d Anfrag gesendet het.",
"ui_events_attack": "Agriff - Igehendi agriff und dini usgehende Attackene werde azeigt. Klick d Nachricht a, um dini Asicht uf e Agriff, Atomraggete oder (Transport-) Boot z zentriere. Du chasch Truppene zrüggzieh indäm du uf s rote X drücksch. Das choschtet s läbe vo 25% vo dine agrifende Truppene. Wenn du a Boot attacke zrüggziesch, geits Boot zrügg zum Startpunkt und wird dörte agriffe, wenn Land sit er abfahrt bsetzt worde isch. Atomwaffe chöi nid zrüggzoge wärde, wenn si si gstartet worde.",
"ui_events_quickchat": "Schnäuchat - Hie chöit der gesendete und empfangene Chat-Nachrichten gseh. Sendet e Nachricht a ne Spieler, indem du im Info-Menü auf s Schnell-Chat-Symbol klicksch.",
"ui_options": "Optione",
"ui_options_desc": "Die fougende Schautflächene si i de Optione verfüegbar:",
"ui_playeroverlay": "Spieler-Info-Overlay",
"ui_playeroverlay_desc": "Wenn du über es Land fahrsch, wird d Info-Übersicht vom ne Spieler unger de Optione azeigt. Es zeigt der Typ vom Spieler a: Mönschleche Spieler, Nation (schlaue Bot) oder Bot, D Hautig vore Nation zu dir, vo findsehlig bis fündlech und verteidigendi Truppene, Gold, sowie d Azau vo Chriegsschiff und verschideni Gebäude wo der spieler het.",
"ui_wilderness": "Wüudniss",
"option_pause": "Spiel pausiere/fortsetze - Nume im Einzuspielermodus verfüegbar.",
"option_timer": "Timer - D Zit wo sitem spieustart vergange isch.",
"option_exit": "Spieu verlasse chnopf.",
"option_settings": "Istellige - Öffnet d Istellige. Dörte chasch die auternativi Asicht, Emojis, Dunkumodus, Ninja (anonyme/zufällige namen) und d Aktione bi Linksklick igsteut wärde.",
"radial_title": "Radialmenü",
"radial_desc": "Rechtsklick (oder Touch ufem Smartphone) öffnet s Menü. Rechtsklick usserhaub em Menü schliesst es. Us em menü use cha me:",
"radial_build": "Baumenü ufdue.",
"radial_info": "Infobereich ufdue.",
"radial_boat": "Sänd es Boot (transportschiff) um ar usgwäute stöu azgriffe. Nume verfüegbar, wenn du zuegriff zu wasser hesch.",
"radial_donate_troops": "Send Truppene ir Höhi vo dim Agriffsverhäutniss a e Verbündete, uf dem du das Menü göffnet hesch.",
"radial_donate_gold": "Öffnet s Goldspändemenü, um Verbündete schnäu Gold z sände.",
"info_title": "Infobereich",
"info_enemy_desc": "Enthautet Informatione wie der Name vom usgwäute Spieler, Gold, Truppene, op der Handu gstoppt worde isch, Azau vo de Atombimbene wo er gsändet het und ob der Spieler e Verräter isch. Gstoppte Hanu bedütet, dass du kei Gold vo ihm überchunsch, und du kei Gold über Handusschiff schicksch. Manuell (wenn der Spieler uf \"Handu stoppe\" klickt, wo güut bis beidi wieder \"Hanu starte\" klicke) oder automatisch (wenn du dini Allianz verrote hesch und die ernöierisch oder nach 5 Minute). Verräter zeigt Ja für 30 sekundene, wenn der spieler betroge und agriffe het, wo sech ire allianz mit ihne het befunde. Die fougende Symbou repräsentiere die fougende Interaktione:",
"info_chat": "Send e churzi chatnachricht a ne Spieler. Wäu e Kategorie, e Usdruck, und wenn der Usdruck [P1] enthautet, wäu e Spielername us, um ihn z ersetze. Klick uf sände.",
"info_target": "Platziert e Zieuschibe uf em Spieler, aus Markierig für aui Verbündete zur Koordination vo gmeinsame Agriffe.",
"info_alliance": "Sendet e Bündnissafrog zum Spieler. Verbündeti chöi Ressourcene und Truppene teile, aber sech nid agriffe.",
"info_emoji": "Send es Emoji a Spieler.",
"info_trade": "Benutz \"Hanu stoppe\" um ufzhöre em Spieler Gold über euchi Handusschiff z gäh und Gold vo Handusschiffe vom Spieler z erhaute. Wenn dir beidi wieder uf \"Handu beginne\" drücket, geit es wieder los.",
"info_ally_panel": "Bündnis Infoberich",
"info_ally_desc": "Wenn me sech mit emne Spieler verbündet, wärde fougendi Icons sichtbar:",
"ally_betray": "Verrat di Verbündete, beänd d Alliant, stopp der Handu und schwäch dini Verteidigung. Der Handu zwüsche Ihne wird für 5 Minute ungerbroche (oder bis du di wieder verbündisch) und angeri chöi ou der Handu isteue. Und wenn der anger Spieler nid säuber Verräter isch gsi, wirsch du 30 Sekunge lang aus Verräter markiert. Während dere Zit wird es Symbou oberhaub vo dim Name azeigt und dini Verteidigung wird um 50% reduziert. Dass Bots sech verbünde isch eher unwahrschinlech aber Spieler werde es sech sicher vorher zwöimou überlege.",
"ally_donate": "Spend einigi vo dine truppe a emne verbündete. Cha brucht wärde, wenn si weni truppe hei, oder um se bi emne vernichtende Schlag z ungerschtütze.",
"ally_donate_gold": "Spend e Teil vo dim Gold a dini verbündete. Wird brucht, wenn si kei Gold für gebäude hei, oder wenn di Teampartner isch am Spare für e MIRV.",
"build_menu_title": "Baumenü",
"build_menu_desc": "Bou eis vo dene oder gseh wievüu du scho bout hesch:",
"build_name": "Name",
"build_icon": "Symbou",
"build_desc": "Beschribig",
"build_city": "Stadt",
"build_city_desc": "Erhöht dini maximali Bevöukerigsazau. Nützlech, wenn s territorium nid cha erwiteret werde oder s Bevöukerigslimit erreicht wird.",
"build_factory": "Fabrigg",
"build_factory_desc": "Bout automatisch Isebahnverbindige zu nahglägene Städt, Häfe und angere Fabrigge und cha sech ou mit befründete Nachbare vürbinge. Züg erschine regumässig und bringe für jedes Gebäude, wo si uf ihrem wäg bsueche e feste Goldbetrag, mit zuesätzlechem Goud füre besuech vo gebäude vo dine Nochbere.",
"build_defense": "Verteidigungs poschte",
"build_defense_desc": "Erhöht de Verteidigung vo aliegende Gränze, markiert mit em ne karierte Muster. Agriffe von Finde si langsamer und sorge für meh Verluste bim Find.",
"build_port": "Hafe",
"build_port_desc": "Cha nume ir nöchi vo Wasser bout wärde. Erloubt der Bou vo Chroegsschiffe. Sändet outomatisch Handusschiff zwische de Häfe vo dim Land und angere länder (usser wenn der Handu gstoppt isch), wodüre beidi Sitte Gold erhaute. Der Handu stoppt outomatisch, wenn du attackiert wirsch oder e Spieler agriffsch. Es fahrt witer nach 5 Minutene oder wenn du Alliert wirsch. Du chasch manuell der Handu i oder usschaute mit \"Handu starte\" oder \"Handu stoppe\".",
"build_warship": "Kriegsschiff",
"build_warship_desc": "Patrouilliert i emne Gebiet und kapert Handuschiff, zerstört findlechi Kriegsschiff und Boote (Transportschiff). Erschint bim nächschtglägene Hafe und patrouilliert im Gebiet wo es bout worde isch. Mit emne Klick uf s Kriegsschiff cha es gstürt und mit em ne witere Klick ines angeres gebiet gschickt werde.",
"build_silo": "Raggetesilo",
"build_silo_desc": "Ermüglechts abführe vo Raggetene.",
"build_sam": "Flugabwehr - Rageetestöuig",
"build_sam_desc": "Cha findlechi Raggetene i sim 100-Pixel-Bereich abfoh. E SAM het e Abklingzit von 7.5 Sekunden.",
"build_atom": "Atombombe",
"build_atom_desc": "Chlini explosivi bombe wo Territorium, Gebäude, Schiff und Boot zerstört. Wird vom nächstglägene Raggetesilo abgfüret und schlot am punkt wo si isch bout worde i.",
"build_hydrogen": "Wasserstoffbombe",
"build_hydrogen_desc": "Grossi explosivi bombe. Wird vom nächstglägene Raggetesilo abgfüret und schlot am punkt wo si isch bout worde i.",
"build_mirv": "Mehrfach Zieu Rageete - MIRV",
"build_mirv_desc": "Die stärchschti bombe im Spiel. Teilt sech i chlineri bombene wo es risigs territorium düe zerstöre. Duet nume dä Spieler beschädige wo du zersch hesch dermit acklickt- Wird vom nächstglägene Raggetesilo abgfüret und landet im territorium wo si isch bout worde.",
"player_icons": "Spieler-Icons",
"troubleshooting_desc": "Wenn du Leistigsproblem, Abstürz oder angeri Problem bim Spiele vo OpenFront hesch, bsuech bitte üsi sitte zur Fehlerbehebig und Diagnose vo hüfige problem:",
"icon_desc": "Beispüu für einigi ingame Icons, wo du wirsch gseh und was sie bedüte:",
"icon_crown": "Krone - Nummer 1. Das isch der Top-Spieler ir Ranglsichte.",
"icon_traitor": "Verbrochene Schild - Verräter. Dä Spieler het e Verbündete agriffe.",
"icon_ally": "Handschlag - Verbündete. Dä Spieler isch e Verbündete.",
"icon_embargo": "Dollar Stoppschild - Embargo. Dä Spieler het der Handu mit dir automatisch oder manuell gstoppt.",
"icon_request": "Umschlag - Allianzafrog. Dä Spieler het dir e Allianzafrog gschickt.",
"info_enemy_panel": "Gägner Info fäud",
"exit_confirmation": "Bisch du dir würklech sicher, dass du s Spieu wosch verlasse?",
"bomb_direction": "Atom / Wasserstoffbombe Bogerichtig",
"icon_alt_player_leaderboard": "Spieler-Rangliste-Symbou",
"icon_alt_team_leaderboard": "Team-Rangliste-Symbou"
},
"single_modal": {
"random_spawn": "Zuefäuige Spawn",
"toggle_achievements": "Errungenschaften a/usschaute",
"sign_in_for_achievements": "Log di i für Errungeschafte",
"options_title": "Optione",
"bots": "Stämm: ",
"bots_disabled": "Usgschaute",
"nations": "Natione: ",
"nations_disabled": "Usgschaute",
"instant_build": "Sofort boue",
"infinite_gold": "Unendlech gold",
"infinite_troops": "Unendlech truppe",
"compact_map": "Kompakti Karte",
"disable_alliances": "Allianze deaktiviere",
"max_timer": "Spieulängi (minute)",
"max_timer_placeholder": "Minute",
"max_timer_invalid": "Bitte gib e gültige maximale Zitwärt i (1-120 Minute)",
"enables_title": "Aktivierti Istellige",
"start": "Spieu starte",
"options_changed_no_achievements": "Benutzerdefinierte Istellige - Erfolge deaktiviert",
"gold_multiplier": "Gold Multiplikator",
"gold_multiplier_placeholder": "2.0x",
"starting_gold": "Start-Gold (Millionen)",
"starting_gold_placeholder": "5"
},
"token_login_modal": {
"title": "Amäudig louft...",
"logging_in": "Amäude ...",
"success": "Erfolgrich agmäudet aus {email}!"
},
"account_modal": {
"title": "Konto",
"connected_as": "Agmäudet aus",
"stats_overview": "Statistike Übersicht",
"link_discord": "Discord Konto verknüpfe",
"log_out": "Abmäude",
"sign_in_desc": "Mäud di a, um dini Statistike und Fortschritte z spichere",
"or": "ODER",
"email_placeholder": "Gib dini email adrässe i",
"get_magic_link": "Überchum e magische Link",
"linked_account": "Agmäudet aus {account_name}",
"fetching_account": "Kontoinformationen wärde glade...",
"recovery_email_sent": "Wiederherstelligs-E-Mail a {email} gesendet",
"not_found": "Nid gfunge",
"clear_session": "Sitzig lösche",
"failed_to_send_recovery_email": "Fehler bim Senden vor Wiederherstelligs E-Mail",
"enter_email_address": "Bitte gib e E-Mail Adresse a",
"personal_player_id": "Persönlechi Spieler ID:"
},
"leaderboard_modal": {
"title": "Ranglischte",
"ranked_tab": "1v1 Bewertet",
"clans_tab": "Clans",
"refresh_time": "Aktualisiert jedi Stund",
"loading": "Laden...",
"error": "Bim Lade vor Rangliste isch e Fehler aufträtte",
"no_stats": "Kei Statistiken verfügbar",
"no_data_yet": "No keini Date hie",
"clan": "Clan",
"player": "Spieler",
"games": "Spiele",
"elo": "ELO",
"win_score": "Siegpunkte",
"win_score_tooltip": "Gewichtete Siege basierend auf Clan Beteiligung und Spielschwierigkeit",
"loss_score": "Niederlagenpunkte",
"loss_score_tooltip": "Gewichtete Niederlagen basierend auf Clan Beteiligung und Spielschwierigkeit",
"win_loss_ratio": "Sieg/Niederla",
"ratio": "Verhältnis",
"rank": "Rang",
"try_again": "Versuch's nomou",
"your_ranking": "Di Rang"
},
"game_info_modal": {
"title": "Spieuinfo",
"players": "Spieler",
"atoms": "Atome",
"hydros": "Hydros",
"mirv": "MIRV",
"bombs": "Bombene",
"total_gold": "Total",
"all_gold": "Gsamtes Gold",
"trade": "Handel",
"train_trade": "Zug",
"naval_trade": "Handelschiff",
"conquest_gold": "Erobertes Spieler Gold",
"stolen_gold": "Mit Kriegsschiff gstohle",
"num_of_conquests_humans": "Spieler tötet",
"num_of_conquests_bots": "Stammeskills",
"duration": "Dur",
"survival_time": "Überlebenszit",
"war": "Chrieg",
"economy": "Wirtschaft",
"conquests": "Eroberige",
"pirate": "Übernäh",
"conquered": "Erobert",
"loading_game_info": "Lade Spielstatistiken",
"no_winner": "S Spieu het ohni Sieger gändet (oder Siegernation)"
},
"map": {
"map": "Charte",
"featured": "Empfohle",
"all": "Alli",
"world": "Welt",
"giantworldmap": "Riesigi Weutcharte",
"europe": "Europa",
"mena": "MENA",
"northamerica": "Nord Amerika",
"oceania": "Oceanie",
"blacksea": "Schwarzes Meer",
"africa": "Afrika",
"asia": "Asien",
"mars": "Mars",
"southamerica": "Südamerika",
"britanniaclassic": "Gross Britannien (klassisch)",
"britannia": "Gross Britannien",
"gatewaytotheatlantic": "Tor zum Atlantik",
"australia": "Australien",
"random": "Zuefäuig",
"iceland": "Islang",
"pangaea": "Pangaea",
"eastasia": "Ostasien",
"betweentwoseas": "Zwische zwöi Meere",
"faroeislands": "Färöer Inseln",
"deglaciatedantarctica": "Entisti Antarktis",
"europeclassic": "Europa (klassisch)",
"falklandislands": "Falkland Inseln",
"baikal": "Baikalsee",
"halkidiki": "Halkidiki",
"straitofgibraltar": "Stross vo Gibraltar",
"italia": "Italie",
"japan": "Japan",
"yenisei": "Yenisei",
"pluto": "Pluto",
"montreal": "Montreal",
"newyorkcity": "New York Stadt",
"achiran": "Achiran",
"baikalnukewars": "Baikal (Bombenkrieg)",
"fourislands": "Vier Inseln",
"gulfofstlawrence": "Golf von St. Lawrence",
"lisbon": "Lissabon",
"svalmel": "Svalmel",
"manicouagan": "Manicouagan",
"lemnos": "Lemnos",
"passage": "Passage",
"sierpinski": "Sierpinski",
"thebox": "Die Box",
"twolakes": "Zwöi Seen",
"straitofhormuz": "Strasse vo Hormuz",
"surrounded": "Umzinglet",
"didier": "Didier",
"didierfrance": "Didier (Frankrich)",
"amazonriver": "Amazonas Fluss",
"bosphorusstraits": "Bosphorus Strasse",
"beringstrait": "Behringer Strasse",
"tradersdream": "Händlertraum",
"hawaii": "Hawaii",
"alps": "Alpen",
"niledelta": "Nildelta",
"arctic": "Arktis",
"sanfrancisco": "San Francisco",
"aegean": "Aegean"
},
"map_categories": {
"featured": "Empfohle",
"continental": "Kontinental",
"regional": "Regional",
"fantasy": "Andere",
"special": "Spezial",
"arcade": "Arcade"
},
"map_component": {
"loading": "Laden...",
"error": "Fehler"
},
"private_lobby": {
"title": "Privater Lobby biträtte",
"enter_id": "Lobby ID eingeben",
"join_lobby": "Lobby biträtte",
"not_found": "Lobby nid gfungen. Bitte Lobby ID überprüefe und noeinisch versuche.",
"error": "Es isch e Fehler auftrette. Bitte versuche s nochmau oder wend di a Support.",
"joined_waiting": "Lobby biträtte! Warte uf e Gastgeber...",
"version_mismatch": "Das Spieu nutzt e angeri Version. Du chasch nid biträtte.",
"disabled_units": "Deaktivierti Einheite"
},
"public_lobby": {
"title": "Wart uf Spielbeginn...",
"waiting_for_players": "Warte uf Spiler",
"connecting": "Mit Lobby verbinge...",
"starting_in": "Startet i {time}",
"starting_game": "Starte…",
"teams_hvn": "Mönsche gäge Natione",
"teams_hvn_detailed": "{num} Mönsche gäge {num} Natione",
"teams": "{num} teams",
"players_per_team": "vo {num}",
"started": "Gstartet",
"status": "Status",
"join_timeout": "Du bisch am Spiu nid rechtzetig biträtte."
},
"matchmaking_modal": {
"title": "1gägä1 Ranglischte-Matchmaking (ALPHA)",
"connecting": "Verbinge mit em Server...",
"searching": "Spiel wird gsuecht...",
"waiting_for_game": "Wart ufe Spielbeginn...",
"elo": "Dini ELO: {elo}",
"no_elo": "No kei ELO"
},
"username": {
"enter_username": "Gib e Benutzername i",
"not_string": "Benutzername muss ein Text sein.",
"too_short": "Benutzername muss mindestens {min} Zeichen lang sein.",
"too_long": "Benutzername darf {max} Zeichen nicht überschreiten.",
"invalid_chars": "Benutzername darf nur Buchstaben, Zahlen, Leerzeichen und Unterstriche enthalten.",
"tag": "TAG",
"tag_too_short": "Clan-Tag mues us 2-5 alphanumerischen Zeichen bestoh.",
"tag_invalid_chars": "Clan-Tag darf nume us Buchstaben und Zahlen bestoh."
},
"host_modal": {
"title": "Privati Lobby erstellen",
"mode": "Modus",
"team_count": "Teamazau",
"team_type": "Team Typ",
"options_title": "Optione",
"bots": "Stämm: ",
"bots_disabled": "Usgschaute",
"nations": "Natione: ",
"nations_disabled": "Usgschaute",
"player_immunity_duration": "PVP Immunitätsdur (Minute)",
"max_timer": "Spieulängi (minute)",
"mins_placeholder": "Minute",
"instant_build": "Sofort boue",
"infinite_gold": "Unendlech gold",
"donate_gold": "Gold spenden",
"infinite_troops": "Unändlech truppene",
"donate_troops": "Truppe spende",
"compact_map": "Kompakti Karte",
"disable_alliances": "Allianze deaktiviere",
"enables_title": "Aktivierti Istellige",
"player": "Spieler",
"players": "Spieler",
"nation_players": "Natione",
"nation_player": "Nation",
"waiting": "Warte uf Spiler...",
"random_spawn": "Zuefäuige Spawn",
"start": "Spieu starte",
"host_badge": "Host",
"assigned_teams": "Zuegwiseni Teams",
"empty_teams": "Leeri Teams",
"empty_team": "Leer",
"remove_player": "{username} entfärne",
"teams_Duos": "Duos (Teams vo 2)",
"teams_Trios": "Trios (Teams vo 3)",
"teams_Quads": "Quads (Teams vo 4)",
"teams_Humans Vs Nations": "Mönsche gäge Natione",
"crowded": "Überfüut Modifikator",
"hard_nations": "Schweeri Natione",
"gold_multiplier": "Gold Multiplikator",
"gold_multiplier_placeholder": "2.0x",
"starting_gold": "Start-Gold (Millionen)",
"starting_gold_placeholder": "5",
"leave_confirmation": "Bisch du dir würklech sicher, dass du s Spieu wosch verlasse?"
},
"team_colors": {
"red": "Rot",
"blue": "Blau",
"teal": "Türkis",
"purple": "Lila",
"yellow": "Gelb",
"orange": "Orange",
"green": "Grün",
"bot": "Stämm",
"humans": "Mönsche",
"nations": "Natione"
},
"game_starting_modal": {
"title": "s Spiel startet...",
"credits": "Credits",
"code_license": "Code lizenziert unter AGPL-3.0 (ohne Gewährleistung)"
},
"difficulty": {
"difficulty": "Schwierigkeitsgrad vo de Natione",
"easy": "Eifach",
"medium": "Mittu",
"hard": "Schwer",
"impossible": "Unmöglech"
},
"game_mode": {
"ffa": "Jede gäge jede",
"teams": "Teams"
},
"mode_selector": {
"teams_title": "Teams",
"teams_count": "{teamCount} teams",
"teams_of": "{teamCount} teams vo {playersPerTeam}",
"ranked_title": "Bewärtet",
"ranked_1v1_title": "1v1",
"ranked_2v2_title": "2v2",
"coming_soon": "Chunt gli"
},
"public_game_modifier": {
"random_spawn": "Zuefäuige Spawn",
"compact_map": "Kompakti Karte",
"crowded": "Überfüllt",
"hard_nations": "Schweeri Natione",
"starting_gold": "{amount}M Start Gold",
"gold_multiplier": "x{amount} Gold Multiplikator",
"disable_alliances": "Allianze deaktiviert"
},
"select_lang": {
"title": "Sprach auswähle"
},
"unit_type": {
"boat": "Boot",
"city": "Stadt",
"defense_post": "Verteidigungs poschte",
"port": "Hafe",
"warship": "Kriegsschiff",
"missile_silo": "Raggetesilo",
"sam_launcher": "Flugabwehr - Rageetestöuig",
"atom_bomb": "Atombombe",
"hydrogen_bomb": "Wasserstoffbombe",
"mirv": "Mehrfach Zieu Rageete - MIRV",
"factory": "Fabrigg"
},
"user_setting": {
"title": "Isteuige",
"tab_basic": "Grundeinstellungen",
"tab_keybinds": "Tastenkombinationen",
"dark_mode_label": "Dunku - Modus",
"dark_mode_desc": "Wechsle zwüschem dunkle und helle Design vor Website",
"emojis_label": "Emojis",
"emojis_desc": "Emojis im Spiel ein-/ausblenden",
"alert_frame_label": "Warnrahmen",
"alert_frame_desc": "Aktiviert oder deaktiviert der Alarmrahme. Wenn aktiviert, wird der Rahmen azeigt, wenn du verrote wirsch oder e Landagriff erleidisch.",
"special_effects_label": "Speziaueffekte",
"special_effects_desc": "Spezialeffekte de/aktiviere. Deaktiviere um d Leistig z verbessere",
"structure_sprites_label": "Struktur-Grafike",
"structure_sprites_desc": "Struktur-Grafike i-/usschaute",
"cursor_cost_label_label": "Cursor Bauchoschte",
"cursor_cost_label_desc": "Choschte ungerem Bou-Cursor azeige",
"anonymous_names_label": "Vürborgeni näme",
"anonymous_names_desc": "Vürsteckt echti Spielernäme mit zuefäuige Näme uf dim Büudschirm.",
"lobby_id_visibility_label": "Versteckti Lobby IDs",
"lobby_id_visibility_desc": "Versteck d Lobby ID während der ersteuig vore Private Lobby",
"toggle_visibility": "Sichtbarkeit umschaute",
"left_click_label": "Linksklik, um s Menü z öffne",
"left_click_desc": "Wenn An, öffnet Linksklick s Menü und d Schwertattacke-Schautflächi. Wenn Us, grifft Linksklick direkt a.",
"left_click_menu": "Linksklick Menü",
"attack_ratio_label": "⚔️ Agriffsverhäutnis",
"attack_ratio_desc": "Prozentsatz vo dine Truppe wo i Angriff gschickt werde (1100 %)",
"territory_patterns_label": "🏳️ Gebietsmuster",
"territory_patterns_desc": "Wäu us, ob Gebietsmuster im Spieu angezeigt wärde",
"coordinate_grid_label": "Kordinategitter",
"coordinate_grid_desc": "Alphanumerische Gitterüberlagerig i/usschaute",
"performance_overlay_label": "Leistigsazeig",
"performance_overlay_desc": "Leistigsazeig i/usschaute. Wenn aktiviert, wird s Overlay azeigt.\nDrück während em Spiel Shift + D, um es umzschaute.",
"easter_writing_speed_label": "Schribgschwindikeits Multiplikator",
"easter_writing_speed_desc": "Apasse, wie schnäu du vorgisch z programmiere (x1-x100)",
"easter_bug_count_label": "Fehlerazau",
"easter_bug_count_desc": "Wie viu Bugs chasch du emotional verkraften? (01000, emotional)",
"press_a_key": "Drück e Taste",
"view_options": "Optione azeigen",
"toggle_view": "Asicht umschaute",
"toggle_view_desc": "Alternativi Asicht (Bode/Länder)",
"build_controls": "Bau-Steuerung",
"build_city": "Stadt boue",
"build_city_desc": "Bou e stadt ungerm Muszeiger.",
"build_factory": "Fabrigg boue",
"build_factory_desc": "Bou e Fabrigg ungerm Muszeiger.",
"build_defense_post": "Bou e Verteidigungsposte",
"build_defense_post_desc": "Bou e Verteidigungsposte ungerm Muszeiger.",
"build_port": "Bou e Hafe",
"build_port_desc": "Bou e Hafe ungerm Muszeiger.",
"build_warship": "Bou es Kriegsschiff",
"build_warship_desc": "Bou es Kriefsschiff ungerm Muszeiger.",
"build_missile_silo": "Bou es Raggetesilo",
"build_missile_silo_desc": "Bou es Raggetesilo ungerm Muszeiger.",
"build_sam_launcher": "Bou es Flugabwehrsystem",
"build_sam_launcher_desc": "Bou es Flugabwehrsystem ungerm Muszeiger.",
"build_atom_bomb": "Bou e Atombombe",
"build_atom_bomb_desc": "Bou e Atombombe ungerm Muszeiger.",
"build_hydrogen_bomb": "Bou e Wasserstoffbombe",
"build_hydrogen_bomb_desc": "Bou e Wasserstoffbombe ungerm Muszeiger.",
"build_mirv": "Bou e MIRV",
"build_mirv_desc": "Bou e MIRV ungerm Muszeiger.",
"menu_shortcuts": "Menü Tastekombinatione",
"build_menu_modifier": "Boumenü Modifikator",
"build_menu_modifier_desc": "Haut die Taste drückt und klick glichzitig, um s Baumenü z öffne.",
"emoji_menu_modifier": "Emoji-Menü Modifikator",
"emoji_menu_modifier_desc": "Haut die Taste drückt und klick glichzitig, um s Emojimenu z öffne.",
"attack_ratio_controls": "Agriffsverhäutnisstürig",
"attack_ratio_up": "Agriffsverhältnis erhöhe",
"attack_ratio_up_desc": "Agriffsverhältnis erhöhe um {amount}%",
"attack_ratio_down": "Agriffsverhältnis sänke",
"attack_ratio_down_desc": "Agriffsverhältnis senke um {amount}%",
"attack_ratio_increment_label": "Agriffsverhäutnis Tastekombination erhöhe",
"attack_ratio_increment_desc": "Wie fescht sechs Verhäutnis vor Taschtekombination pro Taschtedruck veränderet.",
"attack_keybinds": "Agriffs-Tastenkürzel",
"boat_attack": "Bootsagriff",
"boat_attack_desc": "Send a Bootsagriff uf s Pixel unger dim Muszeiger.",
"ground_attack": "Bodenagriff",
"ground_attack_desc": "Send a Bodeagriff uf s Pixel unger dim Muszeiger.",
"swap_direction": "Raggetenrichtig wechsle",
"swap_direction_desc": "Umschaute vor Raggetestartrichtig (ufe/abe).",
"zoom_controls": "Zoomistellige",
"zoom_out": "Usezoome",
"zoom_out_desc": "Karte verchlinere",
"zoom_in": "Inezoome",
"zoom_in_desc": "Karte vergrössere",
"camera_movement": "Kamerabewegig",
"center_camera": "Kamera zentriere",
"center_camera_desc": "Karte ufe Spiler zentriere",
"move_up": "Beweg d Kamera ufe",
"move_up_desc": "Beweg d Kamera nach obe",
"move_left": "Beweg d Kamera links",
"move_left_desc": "Beweg d Kamera nach links",
"move_down": "Beweg d Kamera abe",
"move_down_desc": "Beweg d Kamera nach unge",
"move_right": "Beweg d Kamera rächts",
"move_right_desc": "Beweg d Kamera nach rächts",
"reset": "Zrüggsetze",
"unbind": "Loslöse",
"on": "An",
"off": "Us",
"toggle_terrain": "Gländ azeige/verberge",
"exit_game_label": "Spieu verloh",
"exit_game_info": "Zrügg zum Houptmenu",
"background_music_volume": "Hingergrundmusiglutsterchi",
"sound_effects_volume": "Soundeffektlutsterchi",
"keybind_conflict_error": "D Taste {key} isch bereits a e angeri Aktion bunde."
},
"chat": {
"title": "Schnäuchat",
"to": "Gsendet a {user}: {msg}",
"from": "Vo {user}: {msg}",
"category": "Kategorie",
"phrase": "Satz",
"player": "Spieler",
"send": "Sände",
"search": "Spieler sueche...",
"build": "Ersteu dini Nachricht...",
"cat": {
"help": "Hiuf",
"attack": "Agriff",
"defend": "Verteidige",
"greet": "Grüess",
"misc": "Verschiedenes",
"warnings": "Warnige"
},
"help": {
"troops": "Bitte gib mir Truppene!",
"troops_frontlines": "Schick Truppene a d Front!",
"gold": "Bitte gib mir Gold!",
"no_attack": "Bitte griff mi nid a!",
"sorry_attack": "Entschuldigung, i ha nid wöue agriffe.",
"alliance": "Allianz?",
"help_defend": "Hüuf mir mi gäge [P1] z verteidige!",
"trade_partners": "Lass Handelspartner wärde!"
},
"attack": {
"attack": "Griff [P1] a!",
"mirv": "Start e MIRV uf [P1]!",
"focus": "Fokussier s Für uf [P1]!",
"finish": "Lass üs mit [P1] fertig wärde!",
"build_warships": "Bou Kriegsschiff!"
},
"defend": {
"defend": "Verteidig [P1]!",
"defend_from": "Verteidig gäge [P1]!",
"dont_attack": "Griff [P1] nid a!",
"ally": "[P1] isch mi Verbündete!",
"build_posts": "Bou Verteidigungspostene!"
},
"greet": {
"hello": "Hallo!",
"good_job": "Guet gmacht!",
"good_luck": "Viel Glück!",
"have_fun": "Viel Spass!",
"gg": "GG!",
"nice_to_meet": "Schön, di kennezlerne!",
"well_played": "Guet gspiut!",
"hi_again": "Hallo nomau!",
"bye": "Tschüss!",
"thanks": "Danke!",
"oops": "Hoppla, fausche chnopf drückt!",
"trust_me": "Du chasch mir vertraue. Versproche!",
"trust_broken": "I ha dir vertrout...",
"ruining_games": "Du versausch grad beidi üseri Spieu.",
"dont_do_that": "Mach das nid!",
"same_team": "I bi uf dire Sitte!"
},
"misc": {
"go": "Los geits!",
"strategy": "Gueti strategie!",
"fun": "Das Spiel macht Spass!",
"team_up": "Lass zäme gäge [P1] teame!",
"pr": "Wenn wird mi PR endlech übernoh...?",
"build_closer": "Bou dichter a mir, damit mini Züg zu dir chöme!",
"coastline": "Bitte lass mi e Küste übercho."
},
"warnings": {
"strong": "[P1] isch stark.",
"weak": "[P1] isch schwach.",
"mirv_soon": "[P1] cha gli e MIRV starte!",
"number1_warning": "Der #1 Spieler wird gli gwünne, usser mir werde es Team!",
"stalemate": "Lass friede schliesse. Das isch e Sackgass, mir werde beidi verliere.",
"has_allies": "[P1] het viu Verbündeti.",
"no_allies": "[P1] het keini Verbündeti.",
"betrayed": "[P1] het si Verbündete verrote!",
"betrayed_me": "[P1] het mi verrote!",
"getting_big": "[P1] wachset z schnäu!",
"danger_base": "[P1] isch ungschützt!",
"saving_for_mirv": "[P1] spart, um e MIRV-Rakete z starte.",
"mirv_ready": "[P1] het gnue Gold, um e MIRV-Rakete z starte!",
"snowballing": "[P1] wachst z schnäu!",
"cheating": "[P1] betrüegt!",
"stop_trading": "Stoppe de Handu mit [P1]!",
"stop_trading_all": "Bitte stopp der Handu mit aue!"
}
},
"build_menu": {
"desc": {
"atom_bomb": "Chlini explosion",
"hydrogen_bomb": "Grossi explosion",
"mirv": "Riesigi explosion, trifft nume usgwäuti spieler",
"missile_silo": "Ermüglecht der Isatz vo Atomraggetene",
"sam_launcher": "Verteidigt gäge igehendi Atomraggetene",
"warship": "Erobert Handuschiff, zerstört Schiff und Boot",
"port": "Sendet Handuschiff, um Gold z generiere",
"defense_post": "Erhöht d Verteidigung vo umliegende Grenzene",
"city": "Erhöht die maximali Bevöukerig",
"factory": "Bout Isebahne und schickt Züg auf d Strecki"
},
"not_enough_money": "Nid gnue Gäud"
},
"win_modal": {
"support_openfront": "Ungerstütz OpenFront!",
"territory_pattern": "Chouf es Gebietsmuster, um werbefrei z spiele!",
"died": "Du bisch gstorbe",
"your_team": "Dis team het gwunne!",
"other_team": "Team {team} het gwunne!",
"you_won": "Du hesch gwunne!",
"other_won": "{player} het gewunne!",
"nation_won": "Nation {nation} het gwunne!",
"exit": "Spieu verloh",
"keep": "Witerspile",
"spectate": "Zueluege",
"requeue": "No einisch spiele",
"wishlist": "Zur Steam-Wunschliste hinzufüege!",
"join_discord": "Tritt üsere Discord-Community bi!",
"discord_description": "Verbind di mit Spieler, entdeck neui Features und gwinn Prise!",
"join_server": "Server biträtte",
"youtube_tutorial": "Bruchsch hüuf?"
},
"leaderboard": {
"hide": "Verstecke",
"player": "Spieler",
"team": "Team",
"owned": "Besitze",
"gold": "Gold",
"maxtroops": "Maximali Truppene",
"launchers": "Raggetesilos",
"sams": "Flugabwehrsysteme",
"warships": "Kriegsschiff",
"cities": "Städt",
"show_control": "Stürige azeige",
"show_units": "Einheite azeige"
},
"events_display": {
"events": "Ereigniss",
"retreating": "Zieht sech zrügg",
"alliance_request_status": "{name} het dini allianzafrog {status}",
"alliance_accepted": "agnoh",
"alliance_rejected": "abglehnt",
"alliance_nukes_destroyed_outgoing": "{count, plural, one {# Atombombe i Richtig {name} si ufgrund vor Allianz zerstört worde} other {# Atombombene i Richtig {name} si ufgrund vor Allianz zerstört worde}}",
"alliance_nukes_destroyed_incoming": "{count, plural, one {# Atombombe vo {name} si ufgrund vor Allianz zerstört worde} other {# Atombombene vo {name} si ufgrund vor Allianz zerstört worde}}",
"duration_second": "1 Sekunde",
"betrayal_description": "Du hesch dini Allianz mit {name} broche, und bisch zum VERRÄTER worde ({malusPercent}% verteidigungs malus für {durationText})",
"duration_seconds_plural": "{seconds} Sekunde",
"betrayed_you": "{name} het d Allianz mit dir broche",
"about_to_expire": "Dini Allianz mit {name} louft gli ab!",
"alliance_expired": "D Allianz mit {name} isch abgloffe",
"attack_request": "{name} bittet di, {target} azgrife",
"sent_emoji": "Gsendet a {name}: {emoji}",
"renew_alliance": "Verlängerig beatrage",
"request_alliance": "{name} bittet um e Allianz!",
"focus": "Fokus",
"accept_alliance": "Akzeptiere",
"reject_alliance": "Ablehne",
"alliance_renewed": "D Allianz mit {name} isch erneuert worde",
"wants_to_renew_alliance": "{name} möchti euri Allianz erneuere",
"ignore": "Ignoriere",
"unit_voluntarily_deleted": "Einheit freiwüuig glöscht",
"betrayal_debuff_ends": "No {time} Sekunde, bis der Verrats-Malus endet",
"attack_cancelled_retreat": "Agriff abbroche, {troops} getöteti Soldate bim Rückzug",
"received_gold_from_captured_ship": "{gold} Gold vo emne Schiff erhalten, wo von {name} erbeutet worde isch",
"received_gold_from_trade": "{gold} Gold us em Handu mit {name} erhaute",
"received_gold_from_conquest": "Du hesch {name} erobert und {gold} Gold erhaute",
"conquered_no_gold": "Du hesch {name} erobert. (Kei Gold, het nid gspiut)",
"missile_intercepted": "Raggete abgfange {unit}",
"mirv_warheads_intercepted": "{count, plural, one {{count} abgfangene MIRV-Sprengkopf} other {{count} abgefangeni MIRV-Sprengköpfe}}",
"sent_troops_to_player": "{troops} Truppe a {name} gsendet",
"received_troops_from_player": "{troops} Truppe vo {name} erhaute",
"sent_gold_to_player": "{gold} Gold a {name} gsendet",
"received_gold_from_player": "{gold} Gold vo {name} erhaute",
"unit_captured_by_enemy": "Di {unit} isch vo {name} gfange worde",
"captured_enemy_unit": "{unit} vo {name} erfasst",
"unit_destroyed": "Di {unit} isch zerstört worde",
"no_boats_available": "Kei Boot verfüegbar, max. {max}"
},
"player_type": {
"player": "Spieler",
"nation": "Nation",
"bot": "Stamm"
},
"relation": {
"hostile": "Findlech",
"distrustful": "Misstrouisch",
"neutral": "Neutrau",
"friendly": "Fründlech",
"default": "Normau"
},
"player_panel": {
"gold": "Gold",
"troops": "Truppene",
"betrayals": "Spieler verrate",
"traitor": "Verräter",
"trading": "Handu",
"active": "Aktiv",
"stopped": "Aghaute",
"alliance_time_remaining": "Allianz endet i",
"start_trade": "Handu starte",
"stop_trade": "Handu stoppe",
"stop_trade_all": "Handu mit aune stoppe",
"start_trade_all": "Handu mit aune starte",
"alliances": "Allianze",
"chat": "Chat",
"target": "Ziel",
"break_alliance": "Allianz uflöse",
"send_alliance": "Allianz sände",
"send_troops": "Truppe sände",
"send_gold": "Gold sände",
"emotes": "Emojis",
"moderation": "Moderation",
"kick": "Spieler kicke",
"kicked": "Scho kickt",
"kick_confirm": "Kick {name}?\n\nSie werde däm Spiel nid wieder biträtte chönne.",
"arc_up": "Ufwärtsboge",
"arc_down": "Abwärtsboge",
"flip_rocket_trajectory": "Raggetebahn umdräie"
},
"kick_reason": {
"duplicate_session": "Du bisch us em Spiel kickt worde. (Du hesch wahrschinlech i em ne angere Tab gspüut)",
"lobby_creator": "Du bisch vom Lobby-Ersteller us em Spiel kickt worde"
},
"send_troops_modal": {
"title_with_name": "Truppe a {name} sende",
"available_tooltip": "Verfügbari Truppe",
"slider_tooltip": "{percent}% • {amount}",
"aria_slider": "Trupperegler",
"capacity_note": "Der Empfänger cha im Moment nume {amount} empfoh."
},
"send_gold_modal": {
"title_with_name": "Gold a {name} sende",
"available_tooltip": "Verfügbares Gold",
"aria_slider": "Mengeregler",
"slider_tooltip": "{percent}% • {amount}"
},
"replay_panel": {
"replay_speed": "Wiedergabgschwindigkeit",
"game_speed": "Spiugschwindigkeit",
"fastest_game_speed": "Max"
},
"error_modal": {
"crashed": "Spiu isch abgstürzt!",
"connection_error": "Verbindigsfähler!",
"paste_discord": "Bitte füeg das i di Fehlerbericht auf Discord i:",
"copy_clipboard": "I d Zwüschenablag kopiere",
"copied": "Kopiert!",
"failed_copy": "Kopiere fäugschlage",
"spawn_failed": {
"title": "Spawn fäugschlage",
"description": "Automatischi Spawn-Uswau fäugschlage. Du chasch das Spiel nid spiele."
},
"desync_notice": "Du bisch vo angere Spieler desynchronisiert worde. Was du gsehsch, chönnt sech vo angere Spieler ungerscheide."
},
"performance_overlay": {
"reset": "Zrüggsetze",
"copy_json_title": "Aktuelli Leistigswerte aus JSON kopiere",
"copy_clipboard": "JSON kopiere",
"copied": "Kopiert!",
"failed_copy": "Kopiere fäugschlage",
"fps": "FPS:",
"avg_60s": "Durschnitt (60s):",
"frame": "Frame:",
"tps": "TPS:",
"tps_avg_60s": "Durchschnitt:",
"tick_exec": "Tick usfüerig:",
"max_label": "max:",
"tick_delay": "Tick verzögerig:",
"layers_header": "Ebine rendere",
"render_layers_table_header": "durchschnitt / maximau | tick durchschnitt",
"render_layers_summary": "Letschte Tick: {frames} Frames, {ms}ms",
"tick_layers_header": "Tick Ebine",
"tick_layers_table_header": "durchschnitt / max",
"tick_layers_summary": "Letschte Tick: {count} Ebene, {ms}ms",
"expand": "Ausklappe",
"collapse": "Iklappe"
},
"heads_up_message": {
"choose_spawn": "Wäu e Startposition",
"random_spawn": "Zuefäuige Spawn isch aktiviert. Startposition wird usgwäut...",
"singleplayer_game_paused": "Spiel pausiert",
"multiplayer_game_paused": "Spiel pausiert vom Lobby-Ersteuer",
"pvp_immunity_active": "PVP Immunität aktiv für {seconds} Sekunde",
"catching_up": "Ufhole..."
},
"territory_patterns": {
"title": "Gebietsmuschter",
"colors": "Farbene",
"purchase": "Choufe",
"show_only_owned": "Mini Designs",
"all_owned": "Du besitztisch aui Skins! Lueg später wieder ine für neui Artikle.",
"not_logged_in": "Nid agmäudet",
"pattern": {
"default": "Normau"
},
"select_skin": "Skin uswähle",
"selected": "usgwäut"
},
"flag_input": {
"title": "Flagge uswähle",
"button_title": "Wäu ä Flagge us!",
"search_flag": "Sueche..."
},
"radial_menu": {
"delete_unit_title": "Einheit lösche",
"delete_unit_description": "Klick, um die nächstgelegeni Einheit z lösche"
},
"discord_user_header": {
"avatar_alt": "Profilbüud"
},
"player_stats_table": {
"building_stats": "Gebäudestatistike",
"ship_arrivals": "Schiffsankünft",
"nuke_stats": "Atomwaffestatistike",
"player_metrics": "Spieler-Metrike",
"building": "Gebäude",
"ship_type": "Schiffstyp",
"weapon": "Waffe",
"built": "Erbout",
"destroyed": "Vernichtet",
"captured": "Ignoh",
"lost": "Verlore",
"hits": "Treffer",
"launched": "Gstartet",
"landed": "glandet",
"sent": "gsendet",
"arrived": "acho",
"attack": "agriff",
"received": "übercho",
"cancelled": "abbroche",
"count": "azau",
"gold": "Gold",
"workers": "Arbeiter",
"war": "Chrieg",
"trade": "Handu",
"steal": "Stähle",
"unit": {
"city": "Stadt",
"port": "Hafe",
"defp": "Verteidigungs poschte",
"saml": "Flugabwehr - Rageetestöuig",
"silo": "Raggetesilo",
"wshp": "Kriegsschiff",
"fact": "Fabrigg",
"trade": "Handuschiff",
"trans": "Transportschiff",
"abomb": "Atombombe",
"hbomb": "Wasserstoffbombe",
"mirv": "Mehrfach Zieu Rageete - MIRV",
"mirvw": "MIRv Sprängchopf"
}
},
"game_list": {
"recent_games": "Letschti Partie",
"game_id": "Spieu-ID",
"mode": "Modus",
"replay": "Wiederholig",
"details": "Details",
"ranking": "Plazierig",
"map": "Charte",
"difficulty": "Schwierikeitsgrad",
"type": "Typ"
},
"player_stats_tree": {
"public": "Öffentlech",
"private": "Privat",
"solo": "Elei",
"mode": "Modus",
"stats_wins": "Sieg",
"stats_losses": "Niederlage",
"stats_wlr": "Sieg/Niederlage-Verhäutnis",
"stats_games_played": "Spiel Gspüut",
"no_stats": "Kei Statistike für die Uswau ufzeichnet."
},
"matchmaking_button": {
"play_ranked": "1gägä1 Ranglischte-Matchmaking",
"description": "(ALPHA)",
"login_required": "Mäud di a um Ranked z spiele!",
"must_login": "Du muesch igloggt si, um Ranked Matchmaking z spiele."
}
}
+18 -4
View File
@@ -24,6 +24,7 @@
"copied": "Copied!",
"click_to_copy": "Click to copy",
"enabled": "Enabled",
"disabled": "Disabled",
"map_default": "Map default"
},
"main": {
@@ -97,6 +98,8 @@
"action_build": "Open build menu",
"action_emote": "Open emote menu",
"action_center": "Center camera on player",
"action_pause_game": "Pause / Resume game",
"action_game_speed": "Game speed down / up (single player)",
"action_zoom": "Zoom out/in",
"action_move_camera": "Move camera",
"action_ratio_change": "Decrease/Increase attack ratio",
@@ -118,12 +121,13 @@
"ui_options": "Options",
"ui_options_desc": "The following elements can be found inside:",
"ui_playeroverlay": "Player info overlay",
"ui_playeroverlay_desc": "When you hover over a country, the Player info overlay is displayed under Options. It shows the type of player: Human, Nation, or Tribe. A Nation's attitude towards you, ranging from Hostile to Friendly. And defending troops, gold, plus the number of Warships and various buildings the player has.",
"ui_playeroverlay_desc": "When you hover over a country, the Player info overlay appears. It shows the player type (Human, Nation, or Tribe), a Nation's attitude toward you (Hostile to Friendly), defending troops, gold, and the number of Warships and buildings they have.",
"ui_wilderness": "Wilderness",
"option_pause": "Pause/Unpause the game - Only available in single player mode.",
"option_pause": "Pause/Unpause the game - Unavailable in public games.",
"option_speed": "Speed - Adjust the game speed. Unavailable in public games.",
"option_timer": "Timer - Time passed since the start of the game.",
"option_exit": "Exit button.",
"option_settings": "Settings - Open the settings menu. Inside you can toggle the Alternate view, Emojis, Dark Mode, Ninja (anonymous/random names mode), and action on left click.",
"option_settings": "Settings - Open the settings menu. Inside you can toggle things like Alternate view, Emojis, Dark Mode, Hidden names, action on left click and more.",
"radial_title": "Radial menu",
"radial_desc": "Right clicking (or touch on mobile) opens the Radial menu. Right click outside it to close it. From the menu you can:",
"radial_build": "Open the Build menu.",
@@ -486,8 +490,10 @@
"crowded": "Crowded",
"hard_nations": "Hard Nations",
"starting_gold": "{amount}M Starting Gold",
"starting_gold_label": "Starting Gold",
"gold_multiplier": "x{amount} Gold Multiplier",
"disable_alliances": "Alliances Disabled"
"disable_alliances": "Alliances Disabled",
"disable_alliances_label": "Alliances"
},
"select_lang": {
"title": "Select Language"
@@ -535,6 +541,8 @@
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
"coordinate_grid_label": "Coordinate Grid",
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
"attacking_troops_overlay_label": "Attacking Troops Overlay",
"attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.",
"performance_overlay_label": "Performance Overlay",
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
"easter_writing_speed_label": "Writing Speed Multiplier",
@@ -571,6 +579,12 @@
"build_menu_modifier_desc": "Hold this key while clicking to open the build menu.",
"emoji_menu_modifier": "Emoji Menu Modifier",
"emoji_menu_modifier_desc": "Hold this key while clicking to open the emoji menu.",
"pause_game": "Pause",
"pause_game_desc": "Pause or resume the game (single player and custom games for host).",
"game_speed_up": "Game Speed Up",
"game_speed_up_desc": "Cycle to next game speed (0.5, 1, 2, max). Single player only.",
"game_speed_down": "Game Speed Down",
"game_speed_down_desc": "Cycle to previous game speed. Single player only.",
"attack_ratio_controls": "Attack Ratio Controls",
"attack_ratio_up": "Increase Attack Ratio",
"attack_ratio_up_desc": "Increase attack ratio by {amount}%",
+74 -39
View File
@@ -23,7 +23,8 @@
"none": "Tidak Satupun",
"copied": "Tersalin",
"click_to_copy": "Klik untuk salin",
"enabled": "Diaktifkan"
"enabled": "Diaktifkan",
"map_default": "Peta Default"
},
"main": {
"title": "OpenFront (ALPHA)",
@@ -42,7 +43,6 @@
"play": "Main",
"news": "Berita",
"store": "Toko",
"store_new_badge": "BARU",
"settings": "Pengaturan",
"leaderboard": "Papan Peringkat",
"account": "Akun",
@@ -89,7 +89,10 @@
"hotkeys": "Tombol pintas",
"table_key": "Kunci",
"table_action": "Tindakan",
"action_esc": "Menutup menu. Membatalkan pratinjau pembangunan unit.",
"action_enter": "Membangun unit di posisi kursor",
"action_alt_view": "Ganti Tampilan (Medan / Negara)",
"action_coordinate_grid": "Tampilkan/Sembunyikan grid koordinat",
"action_attack_altclick": "Serang (saat klik kiri diatur untuk membuka menu)",
"action_build": "Buka menu Pembangunan",
"action_emote": "Buka menu Ekspresi",
@@ -115,7 +118,7 @@
"ui_options": "Pilihan",
"ui_options_desc": "Elemen-elemen berikut dapat ditemukan di dalamnya:",
"ui_playeroverlay": "Overlay Info Pemain",
"ui_playeroverlay_desc": "Saat kamu mengarahkan kursor ke suatu negara, overlay Info Pemain akan ditampilkan di bawah menu Opsi. Overlay ini menampilkan jenis pemain: Manusia, Negara (bot pintar), atau Bot; sikap suatu Negara terhadapmu, mulai dari Bermusuhan hingga Ramah; serta jumlah pasukan bertahan, emas, jumlah Kapal Perang, dan berbagai bangunan yang dimiliki pemain tersebut.",
"ui_playeroverlay_desc": "Saat kursor diarahkan ke sebuah negara, overlay info pemain akan muncul di bawah Options. Informasi ini menampilkan jenis pemain: Human, Nation, atau Tribe. Ditampilkan juga sikap Nation terhadapmu, dari Hostile hingga Friendly. Selain itu terlihat jumlah pasukan pertahanan, emas, jumlah kapal perang, serta berbagai bangunan yang dimiliki pemain.",
"ui_wilderness": "Alam Liar",
"option_pause": "Jeda / Lanjutkan permainan Hanya tersedia dalam mode single-player.",
"option_timer": "Timer Waktu yang telah berlalu sejak permainan dimulai.",
@@ -137,7 +140,7 @@
"info_trade": "Gunakan “Hentikan Perdagangan” untuk berhenti memberikan emas kepada pemain tersebut dan berhenti menerima emas dari mereka melalui kapal dagang. Jika kalian berdua menekan “Mulai\".",
"info_ally_panel": "Panel Info Sekutu",
"info_ally_desc": "Saat kamu beraliansi dengan seorang pemain, ikon-ikon baru berikut akan tersedia:",
"ally_betray": "Mengkhianati sekutumu akan mengakhiri aliansi, menghentikan perdagangan, dan melemahkan pertahananmu. Perdagangan di antara kalian akan dijeda selama 5 menit (atau sampai kalian kembali menjadi sekutu), dan pemain lain juga dapat menghentikan perdagangan. Kecuali jika pemain lain tersebut memang sudah berstatus pengkhianat, kamu akan ditandai sebagai Pengkhianat selama 30 detik.\nSelama waktu ini, sebuah ikon akan muncul di atas namamu dan kamu akan menerima debuff pertahanan sebesar 50%. Bot akan lebih enggan beraliansi denganmu, dan pemain lain akan berpikir dua kali sebelum melakukannya.",
"ally_betray": "Mengkhianati sekutumu akan mengakhiri aliansi, menghentikan perdagangan, dan melemahkan pertahananmu. Perdagangan antara kalian dijeda selama 5 menit (atau sampai kalian bersekutu kembali), dan pemain lain juga bisa berhenti berdagang denganmu.\n\nJika pemain tersebut bukan pengkhianat, kamu akan ditandai sebagai pengkhianat selama 30 detik. Selama waktu ini, sebuah ikon akan muncul di atas namamu dan kamu menerima debuff pertahanan sebesar 50%. Suku (Tribe) akan lebih jarang bersekutu denganmu dan pemain lain akan berpikir dua kali sebelum melakukannya.",
"ally_donate": "Donasikan sebagian pasukanmu kepada sekutu. Digunakan ketika mereka kekurangan pasukan, sedang diserang, atau membutuhkan kekuatan tambahan untuk menghancurkan musuh.",
"ally_donate_gold": "Donasikan sebagian emasmu kepada sekutu. Digunakan saat mereka kekurangan emas dan membutuhkannya untuk membangun, atau ketika anggota tim sedang menabung untuk MIRV.",
"build_menu_title": "Menu Pembangunan",
@@ -184,13 +187,15 @@
"toggle_achievements": "Tampilkan / Sembunyikan pencapaian",
"sign_in_for_achievements": "Masuk untuk melihat pencapaian",
"options_title": "Opsi",
"bots": "Bot: ",
"bots": "Suku-suku: ",
"bots_disabled": "Dinonaktifkan",
"disable_nations": "Nonaktifkan negara",
"nations": "Negara: ",
"nations_disabled": "Nonaktif",
"instant_build": "Bangun instan",
"infinite_gold": "Emas tak terbatas",
"infinite_troops": "Pasukan tak terbatas",
"compact_map": "Peta Kecil",
"disable_alliances": "Nonaktifkan aliansi",
"max_timer": "Lama permainan (menit)",
"max_timer_placeholder": "Menit",
"max_timer_invalid": "Silakan masukkan nilai pengatur waktu maksimum yang valid (1-120 menit)",
@@ -199,8 +204,8 @@
"options_changed_no_achievements": "Pengaturan khusus pencapaian dinonaktifkan",
"gold_multiplier": "Pengganda emas",
"gold_multiplier_placeholder": "2.0x",
"starting_gold": "Emas awal",
"starting_gold_placeholder": "5000000"
"starting_gold": "Emas Awal (Juta)",
"starting_gold_placeholder": "5"
},
"token_login_modal": {
"title": "Sedang masuk...",
@@ -264,7 +269,7 @@
"conquest_gold": "Emas pemain yang ditaklukan",
"stolen_gold": "Dicuri oleh Kapal Perang",
"num_of_conquests_humans": "Pemain membunuh",
"num_of_conquests_bots": "Bot membunuh",
"num_of_conquests_bots": "Suku-suku dikalahkan",
"duration": "Durasi",
"survival_time": "Menit Bertahan",
"war": "Perang",
@@ -320,6 +325,7 @@
"svalmel": "Svalmel",
"manicouagan": "Manicouagan",
"lemnos": "Lemnos",
"passage": "Passage",
"sierpinski": "Sierpinski",
"thebox": "Kotak",
"twolakes": "Dua Danau",
@@ -328,8 +334,15 @@
"didier": "Didier",
"didierfrance": "Didier (Prancis)",
"amazonriver": "Sungai Amazon",
"bosphorusstraits": "Selat Bosphorus",
"beringstrait": "Selat Bering",
"tradersdream": "Mimpi para Pedagang",
"hawaii": "Hawaii"
"hawaii": "Hawaii",
"alps": "Alpen",
"niledelta": "Delta Sungai Nil",
"arctic": "Arktik",
"sanfrancisco": "San Francisco",
"aegean": "Aegean"
},
"map_categories": {
"featured": "Unggulan",
@@ -355,14 +368,10 @@
},
"public_lobby": {
"title": "Menunggu permainan untuk dimulai...",
"join": "Gabung ke permainan selanjutnya",
"teams_Duos": "{team_count} tim berisi 2 pemain (Berdua)",
"teams_Trios": "{team_count} tim berisi 3 pemain (Bertiga)",
"teams_Quads": "{team_count} tim berisi 4 pemain (Berempat)",
"waiting_for_players": "Menunggu pemain",
"connecting": "Menghubungkan ke lobi...",
"starting_in": "Dimulai dalam {time}",
"starting_game": "Memulai permainan…",
"starting_game": "Memulai…",
"teams_hvn": "Pemain vs Negara",
"teams_hvn_detailed": "{num} Pemain vs {num} Negara",
"teams": "{num} tim",
@@ -376,7 +385,8 @@
"connecting": "Menghubungkan ke server pencarian lawan...",
"searching": "Mencari permainan...",
"waiting_for_game": "Menunggu permainan untuk dimulai...",
"elo": "ELO anda: {elo}"
"elo": "ELO anda: {elo}",
"no_elo": "Belum ada ELO"
},
"username": {
"enter_username": "Masukkan nama pengguna",
@@ -390,15 +400,15 @@
},
"host_modal": {
"title": "Buat Lobi Tertutup",
"label": "Tertutup",
"mode": "Mode",
"team_count": "Jumlah Tim",
"team_type": "Tipe Tim",
"options_title": "Pilihan",
"bots": "Bot: ",
"bots": "Suku-suku: ",
"bots_disabled": "Nonaktif",
"nations": "Negara: ",
"nations_disabled": "Nonaktif",
"player_immunity_duration": "Durasi imunitas PVP (menit)",
"disable_nations": "Nonaktifkan Negara",
"max_timer": "Lama permainan (menit)",
"mins_placeholder": "Menit",
"instant_build": "Bangun instan",
@@ -407,6 +417,7 @@
"infinite_troops": "Pasukan tak terbatas",
"donate_troops": "Donasikan pasukan",
"compact_map": "Peta Kecil",
"disable_alliances": "Nonaktifkan aliansi",
"enables_title": "Aktifkan Pengaturan",
"player": "Pemain",
"players": "Pemain",
@@ -424,8 +435,13 @@
"teams_Trios": "Bertiga (tim yang terdiri dari 3 orang)",
"teams_Quads": "Berempat (tim yang teridri dari 4 orang)",
"teams_Humans Vs Nations": "Pemain vs Negara",
"starting_gold": "Emas awal",
"crowded": "Pengubah yang ramai"
"crowded": "Pengubah yang ramai",
"hard_nations": "Negara Sulit",
"gold_multiplier": "Pengganda emas",
"gold_multiplier_placeholder": "2.0x",
"starting_gold": "Emas Awal (Juta)",
"starting_gold_placeholder": "5",
"leave_confirmation": "Apakah Anda yakin ingin meninggalkan lobi?"
},
"team_colors": {
"red": "Merah",
@@ -435,7 +451,9 @@
"yellow": "Kuning",
"orange": "Oranye",
"green": "Hijau",
"bot": "Bot"
"bot": "Suku-suku",
"humans": "Manusia",
"nations": "Negara"
},
"game_starting_modal": {
"title": "Memulai Permainan...",
@@ -453,16 +471,29 @@
"ffa": "Siapapun bisa bergabung",
"teams": "Tim-tim"
},
"mode_selector": {
"teams_title": "Tim-tim",
"teams_count": "{teamCount} tim",
"teams_of": "{teamCount} tim dari {playersPerTeam}",
"ranked_title": "Ranked",
"ranked_1v1_title": "1v1",
"ranked_2v2_title": "2v1",
"coming_soon": "Segera Hadir"
},
"public_game_modifier": {
"random_spawn": "Kemunculan Acak",
"compact_map": "Peta Kecil",
"crowded": "Ramai",
"starting_gold": "Emas awal 5 Juta"
"hard_nations": "Negara Sulit",
"starting_gold": "{amount} Juta Emas Awal",
"gold_multiplier": "x{amount} Pengganda Emas",
"disable_alliances": "Aliansi Dinonaktifkan"
},
"select_lang": {
"title": "Pilih Bahasa"
},
"unit_type": {
"boat": "Perahu",
"city": "Kota",
"defense_post": "Pos Pertahanan",
"port": "Pelabuhan",
@@ -502,6 +533,8 @@
"attack_ratio_desc": "Persentase pasukan yang dikirim saat menyerang (1100%)",
"territory_patterns_label": "🏳️ Skin Wilayah",
"territory_patterns_desc": "Pilih apakah ingin menampilkan desain skin wilayah di dalam game",
"coordinate_grid_label": "Koordinat Grid",
"coordinate_grid_desc": "Tampilkan/Sembunyikan grid alfanumerik",
"performance_overlay_label": "Tampilan Performa",
"performance_overlay_desc": "Aktifkan / Nonaktifkan overlay performa.\nSaat diaktifkan, overlay performa akan ditampilkan. Tekan Shift + D saat permainan berlangsung untuk mengaktifkan atau menonaktifkannya.",
"easter_writing_speed_label": "Multiplier Kecepatan Menulis",
@@ -540,9 +573,11 @@
"emoji_menu_modifier_desc": "Tahan tombol ini sambil mengklik untuk membuka menu emoji.",
"attack_ratio_controls": "Kontrol Rasio Serangan",
"attack_ratio_up": "Tingkatkan Rasio Serangan",
"attack_ratio_up_desc": "Tingkatkan Rasio Serangan sebesar 10%",
"attack_ratio_up_desc": "Tambah rasio serangan sebesar {amount}%",
"attack_ratio_down": "Kurangi Rasio Serangan",
"attack_ratio_down_desc": "Kurangi Rasio Serangan sebesar 10%",
"attack_ratio_down_desc": "Kurangi rasio serangan sebesar {amount}%",
"attack_ratio_increment_label": "Kenaikan Rasio Serangan (Keybind)",
"attack_ratio_increment_desc": "Seberapa besar rasio serangan berubah setiap kali tombol keybind ditekan.",
"attack_keybinds": "Tombol pintas untuk Serangan",
"boat_attack": "Serangan Kapal Pengangkut",
"boat_attack_desc": "Kirim serangan kapal ke petak di bawah kursor Anda.",
@@ -662,7 +697,8 @@
"mirv_ready": "[P1] punya cukup emas untuk meluncurkan MIRV!",
"snowballing": "[P1] berkembang terlalu cepat!",
"cheating": "[P1] curang!",
"stop_trading": "Stop berdangan dengan [P1]!"
"stop_trading": "Stop berdangan dengan [P1]!",
"stop_trading_all": "Tolong hentikan perdagangan dengan semua pemain!"
}
},
"build_menu": {
@@ -758,7 +794,7 @@
"player_type": {
"player": "Pemain",
"nation": "Bangsa",
"bot": "Bot"
"bot": "Suku"
},
"relation": {
"hostile": "Berseteru",
@@ -840,9 +876,19 @@
"fps": "FPS:",
"avg_60s": "Rata-rata (60d):",
"frame": "Bingkai:",
"tps": "TPS:",
"tps_avg_60s": "Rata-rata:",
"tick_exec": "Eksekutif Tick:",
"max_label": "maks:",
"tick_delay": "Penundaan Detik:",
"layers_header": "Lapisan (rata-rata / maksimum, diurutkan berdasarkan total waktu):"
"layers_header": "Lapisan Render",
"render_layers_table_header": "rata-rata / max | rata-rata tick",
"render_layers_summary": "Tick terakhir: {frames} frame, {ms} ms",
"tick_layers_header": "Lapisan Tick",
"tick_layers_table_header": "rata-rata / maks",
"tick_layers_summary": "Tick terakhir: {count} lapisan, {ms} ms",
"expand": "Perluas",
"collapse": "Ciutkan"
},
"heads_up_message": {
"choose_spawn": "Pilih lokasi awal",
@@ -862,13 +908,6 @@
"pattern": {
"default": "Default"
},
"try_me": "Coba aku!",
"trial_remaining": "tersisa",
"trial_granted": "Skin trial didapatkan!",
"trial_cooldown": "Hanya satu trial dalam 24 jam. Mohon coba lagi nanti.",
"trial_login_required": "Harus masuk untuk mencoba skin",
"reward_countdown": "Hadiah dalam {seconds} detik...",
"steam_wishlist_prompt": "Dukung OpenFront dengan cara menambahkannya di wishlist Steam Anda",
"select_skin": "Pilih Skin",
"selected": "dipilih"
},
@@ -930,8 +969,6 @@
"recent_games": "Permainan Terbaru",
"game_id": "ID Permainan",
"mode": "Mode",
"mode_ffa": "Siapapun bisa bergabung",
"mode_team": "Tim",
"replay": "Tayangan ulang",
"details": "Detail",
"ranking": "Peringkat",
@@ -948,8 +985,6 @@
"stats_losses": "Jumlah Kehilangan",
"stats_wlr": "Menang:Kalah Rasio",
"stats_games_played": "Permainan Dimainkan",
"mode_ffa": "Siapapun bisa bergabung",
"mode_team": "Tim",
"no_stats": "Tidak ada statistik yang tercatat untuk pilihan ini."
},
"matchmaking_button": {
+6
View File
@@ -35,6 +35,12 @@
"en": "Danish",
"svg": "dk"
},
{
"code": "de-CH",
"native": "Schweizerdeutsch",
"en": "Swiss-German",
"svg": "gsw"
},
{
"code": "de",
"native": "Deutsch",
+74 -39
View File
@@ -23,7 +23,8 @@
"none": "Ничего",
"copied": "Скопировано!",
"click_to_copy": "Нажмите, чтобы скопировать",
"enabled": "Включено"
"enabled": "Включено",
"map_default": "Стандартно для карты"
},
"main": {
"title": "OpenFront (АЛЬФА)",
@@ -42,7 +43,6 @@
"play": "Играть",
"news": "Новости",
"store": "Магазин",
"store_new_badge": "НОВОЕ",
"settings": "Настройки",
"leaderboard": "Таблица лидеров",
"account": "Аккаунт",
@@ -89,7 +89,10 @@
"hotkeys": "Горячие клавиши",
"table_key": "Клавиша",
"table_action": "Действие",
"action_esc": "Закрывает меню. Отменяет просмотр размещения юнита.",
"action_enter": "Размещает юнит под указателем",
"action_alt_view": "Альтернативное представление (рельеф/страны)",
"action_coordinate_grid": "Переключить оверлей координатной сетки",
"action_attack_altclick": "Атака (если левая кнопка мыши назначена на открытие меню)",
"action_build": "Открыть меню строительства",
"action_emote": "Открыть меню эмодзи",
@@ -115,7 +118,7 @@
"ui_options": "Настройки",
"ui_options_desc": "Среди них можно найти следующие элементы:",
"ui_playeroverlay": "Панель информации об игроке",
"ui_playeroverlay_desc": "Когда вы наведите курсор на страну, под кнопкой «Настройки» отображается панель информации об игроке. Он показывает тип игрока: человек, нация (умный бот) или бот. Отношение нации к вам: от враждебного до дружеского. И количество войск защиты, золота, военных кораблей и разных строений в собственности игрока.",
"ui_playeroverlay_desc": "Когда вы наведите курсор на страну, под кнопкой «Настройки» отображается панель информации об игроке. Он показывает тип игрока: человек, нация или племя. Отношение нации к вам: от враждебного до дружеского. И количество войск защиты, золота, военных кораблей и разных строений в собственности игрока.",
"ui_wilderness": "Пустошь",
"option_pause": "Приостановить/Продолжить игру — Доступно только в режиме одиночной игры.",
"option_timer": "Таймер — Время, прошедшее с начала игры.",
@@ -137,7 +140,7 @@
"info_trade": "Используйте «Прекратить торговлю», чтобы перестать давать игроку золото и получать золото от него через торговые корабли. Если вы оба нажмёте «Начать торговлю», то она начнётся снова.",
"info_ally_panel": "Панель информации о союзнике",
"info_ally_desc": "Когда вы заключите союз с игроком, станут доступны следующие значки:",
"ally_betray": "Предать своего союзника, разорвав союз, прекратив торговлю и ослабив свою защиту. Торговля между вами приостановиться на 5 минут (или до возобновления союза), а другие игроки также могут перестать торговать с вами. Если другой игрок не был предателем, вы получите метку предателя на 30 секунд. В это время над вашим именем появится особый значок и ваша защита будет снижена на 50%. Боты с меньшей вероятность будут заключать с вами союзы, а игроки дважды подумают перед тем, как иметь с вами дело.",
"ally_betray": "Предать своего союзника, разорвав союз, прекратив торговлю и ослабив свою защиту. Торговля между вами приостановиться на 5 минут (или до возобновления союза), а другие игроки также могут перестать торговать с вами. Если другой игрок не был предателем, вы получите метку предателя на 30 секунд. В это время над вашим именем появится особый значок и ваша защита будет снижена на 50%. Племена с меньшей вероятность будут заключать с вами союзы, а игроки дважды подумают перед тем, как иметь с вами дело.",
"ally_donate": "Пожертвовать часть войска союзнику. Используется, когда у него мало войск и его атакуют, или когда ему нужна дополнительная мощь для уничтожения врага.",
"ally_donate_gold": "Пожертвовать немного золота союзнику. Используйте, когда у него мало золота, необходимого для сооружений, или когда член команды копит на РГЧ ИН.",
"build_menu_title": "Меню строительства",
@@ -184,13 +187,15 @@
"toggle_achievements": "Переключение достижений",
"sign_in_for_achievements": "Войдите, чтобы получать достижения",
"options_title": "Настройки",
"bots": "Боты: ",
"bots": "Племена: ",
"bots_disabled": "Отключены",
"disable_nations": "Отключить нации",
"nations": "Нации: ",
"nations_disabled": "Отключены",
"instant_build": "Мгновенная стройка",
"infinite_gold": "Неограниченное золото",
"infinite_troops": "Неограниченные войска",
"compact_map": "Компактная карта",
"disable_alliances": "Отключить союзы",
"max_timer": "Продолжительность игры (минуты)",
"max_timer_placeholder": "Минуты",
"max_timer_invalid": "Пожалуйста, введите допустимое максимальное значение таймера (1–120 минут)",
@@ -199,8 +204,8 @@
"options_changed_no_achievements": "Пользовательские настройки – достижения выключены",
"gold_multiplier": "Множитель золота",
"gold_multiplier_placeholder": "2.0x",
"starting_gold": "Начальное золото",
"starting_gold_placeholder": "5000000"
"starting_gold": "Начальное золото (миллионы)",
"starting_gold_placeholder": "5"
},
"token_login_modal": {
"title": "Вход...",
@@ -264,7 +269,7 @@
"conquest_gold": "Захваченное золото игроков",
"stolen_gold": "Украдено с помощью военных кораблей",
"num_of_conquests_humans": "Убийства игроков",
"num_of_conquests_bots": "Убийства ботов",
"num_of_conquests_bots": "Убийства племён",
"duration": "Продолжительность",
"survival_time": "Время выживания",
"war": "Война",
@@ -320,6 +325,7 @@
"svalmel": "Свалмель",
"manicouagan": "Маникуаган",
"lemnos": "Лемнос",
"passage": "Проход",
"sierpinski": "Серпинский",
"thebox": "Коробка",
"twolakes": "Два озера",
@@ -328,8 +334,15 @@
"didier": "Дидье",
"didierfrance": "Дидье (Франция)",
"amazonriver": "Река Амазонка",
"bosphorusstraits": "Босфорский пролив",
"beringstrait": "Берингов пролив",
"tradersdream": "Мечта торговца",
"hawaii": "Гавайи"
"hawaii": "Гавайи",
"alps": "Альпы",
"niledelta": "Дельта Нила",
"arctic": "Арктика",
"sanfrancisco": "Сан-Франциско",
"aegean": "Эгейск"
},
"map_categories": {
"featured": "Рекомендованные",
@@ -355,14 +368,10 @@
},
"public_lobby": {
"title": "Ожидание начала игры...",
"join": "Присоединиться к следующей игре",
"teams_Duos": "{team_count} команды по 2 (дуо)",
"teams_Trios": "{team_count} команды по 3 (трио)",
"teams_Quads": "{team_count} команды по 4 (квады)",
"waiting_for_players": "Ожидание игроков",
"connecting": "Подключение к лобби...",
"starting_in": "Начало через {time}",
"starting_game": "Запуск игры…",
"starting_game": "Запуск…",
"teams_hvn": "Люди против наций",
"teams_hvn_detailed": "{num} людей против {num} наций",
"teams": "Команд: {num}",
@@ -376,7 +385,8 @@
"connecting": "Подключение к серверу подбора игроков...",
"searching": "Поиск игры...",
"waiting_for_game": "Ожидание начала игры...",
"elo": "Ваш ELO: {elo}"
"elo": "Ваш ELO: {elo}",
"no_elo": "ELO отсутствует"
},
"username": {
"enter_username": "Введите своё имя игрока",
@@ -390,15 +400,15 @@
},
"host_modal": {
"title": "Создание приватного лобби",
"label": "Приватно",
"mode": "Режим",
"team_count": "Количество команд",
"team_type": "Тип команды",
"options_title": "Настройки",
"bots": "Боты: ",
"bots": "Племена: ",
"bots_disabled": "Отключены",
"nations": "Нации: ",
"nations_disabled": "Отключены",
"player_immunity_duration": "Продолжительность иммунитета в PVP (минуты)",
"disable_nations": "Отключить нации",
"max_timer": "Продолжительность игры (минуты)",
"mins_placeholder": "Минуты",
"instant_build": "Мгновенная стройка",
@@ -407,6 +417,7 @@
"infinite_troops": "Неограниченные войска",
"donate_troops": "Пожертвование войск",
"compact_map": "Компактная карта",
"disable_alliances": "Отключить союзы",
"enables_title": "Разрешения",
"player": "Игрок",
"players": "Игрока(-ов)",
@@ -424,8 +435,13 @@
"teams_Trios": "Трио (команды по 3)",
"teams_Quads": "Квады (команды по 4)",
"teams_Humans Vs Nations": "Люди против наций",
"starting_gold": "Начальное золото",
"crowded": "Модификатор перенаселения"
"crowded": "Модификатор перенаселения",
"hard_nations": "Сложные нации",
"gold_multiplier": "Множитель золота",
"gold_multiplier_placeholder": "2.0x",
"starting_gold": "Начальное золото (миллионы)",
"starting_gold_placeholder": "5",
"leave_confirmation": "Вы уверены, что хотите покинуть лобби?"
},
"team_colors": {
"red": "Красный",
@@ -435,7 +451,9 @@
"yellow": "Жёлтый",
"orange": "Оранжевый",
"green": "Зелёный",
"bot": "Бот"
"bot": "Племена",
"humans": "Люди",
"nations": "Нации"
},
"game_starting_modal": {
"title": "Игра начинается...",
@@ -453,16 +471,29 @@
"ffa": "Каждый против каждого (FFA)",
"teams": "Команды"
},
"mode_selector": {
"teams_title": "Команды",
"teams_count": "Команды: {teamCount}",
"teams_of": "Команды: {teamCount} по {playersPerTeam}",
"ranked_title": "Рейтинговая игра",
"ranked_1v1_title": "1v1",
"ranked_2v2_title": "2v2",
"coming_soon": "Скоро"
},
"public_game_modifier": {
"random_spawn": "Случайное появления",
"compact_map": "Компактная карта",
"crowded": "Перенаселение",
"starting_gold": "5 млн начального золота"
"hard_nations": "Сложные нации",
"starting_gold": "Начальное золото: {amount} млн",
"gold_multiplier": "Множитель золота: x{amount}",
"disable_alliances": "Союзы отключены"
},
"select_lang": {
"title": "Выбор языка"
},
"unit_type": {
"boat": "Судно",
"city": "Город",
"defense_post": "Укрепление",
"port": "Порт",
@@ -502,6 +533,8 @@
"attack_ratio_desc": "Какой процент ваших войск отправлять в бой (1–100%)",
"territory_patterns_label": "🏳️ Скины территории",
"territory_patterns_desc": "Выберите, показывать ли скины территорий в игре",
"coordinate_grid_label": "Координатная сетка",
"coordinate_grid_desc": "Переключить оверлей буквенно-цифровой сетки",
"performance_overlay_label": "Оверлей производительности",
"performance_overlay_desc": "Переключить оверлей производительности. При включении будет показан оверлей производительности. Нажмите Shift+D во время игры для переключения.",
"easter_writing_speed_label": "Множитель скорости печати",
@@ -540,9 +573,11 @@
"emoji_menu_modifier_desc": "Удерживайте эту клавишу при нажатии, чтобы открыть меню эмодзи.",
"attack_ratio_controls": "Управление соотношением атаки",
"attack_ratio_up": "Увеличить соотношение атаки",
"attack_ratio_up_desc": "Увеличить соотношение атаки на 10%",
"attack_ratio_up_desc": "Увеличить соотношение атаки на {amount}%",
"attack_ratio_down": "Уменьшить соотношение атаки",
"attack_ratio_down_desc": "Уменьшить соотношение атаки на 10%",
"attack_ratio_down_desc": "Уменьшить соотношение атаки на {amount}%",
"attack_ratio_increment_label": "Разница соотношения атаки",
"attack_ratio_increment_desc": "Насколько соотношение атаки изменяется за одно нажатие клавиши.",
"attack_keybinds": "Привязки клавиш атаки",
"boat_attack": "Атака судом",
"boat_attack_desc": "Отправить атаку лодкой на ячейку под указателем.",
@@ -662,7 +697,8 @@
"mirv_ready": "У [P1] достаточно золота на запуск РГЧ ИН!",
"snowballing": "[P1] атакует слишком быстро!",
"cheating": "[P1] мухлюет!",
"stop_trading": "Прекратите торговать с [P1]!"
"stop_trading": "Прекратите торговать с [P1]!",
"stop_trading_all": "Пожалуйста, прекратите торговлю со всеми!"
}
},
"build_menu": {
@@ -758,7 +794,7 @@
"player_type": {
"player": "Игрок",
"nation": "Нация",
"bot": "Бот"
"bot": "Племя"
},
"relation": {
"hostile": "Враждебное",
@@ -840,9 +876,19 @@
"fps": "FPS:",
"avg_60s": "Среднее (60 сек):",
"frame": "Кадр:",
"tps": "TPS:",
"tps_avg_60s": "Сред.:",
"tick_exec": "Выполнение на тик:",
"max_label": "Макс.:",
"tick_delay": "Задержка на тик:",
"layers_header": "Слои (ср. / макс., отсортированы по суммарному времени):"
"layers_header": "Слои визуализации",
"render_layers_table_header": "сред. / макс. | тики",
"render_layers_summary": "Последний тик: {frames} кадров, {ms} мс",
"tick_layers_header": "Слои тиков",
"tick_layers_table_header": "сред. / макс.",
"tick_layers_summary": "Последний тик: {count} слоёв, {ms} мс",
"expand": "Развернуть",
"collapse": "Свернуть"
},
"heads_up_message": {
"choose_spawn": "Выберите стартовое местоположение",
@@ -862,13 +908,6 @@
"pattern": {
"default": "По умолчанию"
},
"try_me": "Примерь меня!",
"trial_remaining": "осталось",
"trial_granted": "Пробный скин предоставлен!",
"trial_cooldown": "Можно примерить только один скин в течение 24 часов. Пожалуйста, попробуйте ещё раз позже.",
"trial_login_required": "Необходимо войти в систему, чтобы получить пробный скин",
"reward_countdown": "Вознаграждение через {seconds} сек...",
"steam_wishlist_prompt": "Поддержите OpenFront, добавив его в список желаемого Steam",
"select_skin": "Выберете скин",
"selected": "выбрано"
},
@@ -930,8 +969,6 @@
"recent_games": "Недавние игры",
"game_id": "ID игры",
"mode": "Режим",
"mode_ffa": "Каждый против каждого",
"mode_team": "Команда",
"replay": "Повтор",
"details": "Подробности",
"ranking": "Рейтинг",
@@ -948,8 +985,6 @@
"stats_losses": "Поражения",
"stats_wlr": "Соотношение побед:поражений",
"stats_games_played": "Игр сыграно",
"mode_ffa": "Все против всех",
"mode_team": "Команда",
"no_stats": "Нет данных для этой выборки."
},
"matchmaking_button": {
+74 -39
View File
@@ -23,7 +23,8 @@
"none": "Немає",
"copied": "Скопійовано!",
"click_to_copy": "Натисніть, щоб скопіювати",
"enabled": "Увімкнено"
"enabled": "Увімкнено",
"map_default": "Усталене для мапи"
},
"main": {
"title": "OpenFront (АЛЬФА)",
@@ -42,7 +43,6 @@
"play": "Грати",
"news": "Новини",
"store": "Крамниця",
"store_new_badge": "НОВЕ",
"settings": "Налаштування",
"leaderboard": "Таблиця лідерів",
"account": "Акаунт",
@@ -89,7 +89,10 @@
"hotkeys": "Гарячі клавіші",
"table_key": "Клавіша",
"table_action": "Дія",
"action_esc": "Закриває меню. Скасовує перегляд розміщення юніту.",
"action_enter": "Розміщає юніт під указівником",
"action_alt_view": "Альтернативний вигляд (рельєф/країни)",
"action_coordinate_grid": "Перемкнути оверлей координатної сітки",
"action_attack_altclick": "Атака (коли лівий клац призначено на відкриття меню)",
"action_build": "Відкрити меню будівництва",
"action_emote": "Відкрити меню емоджі",
@@ -115,7 +118,7 @@
"ui_options": "Налаштування",
"ui_options_desc": "Серед них можна знайти наступні елементи:",
"ui_playeroverlay": "Панель інформації про гравця",
"ui_playeroverlay_desc": "Коли ви наводите вказівник на країну, під значком налаштувань відображається панель інформації про гравця. Вона показує тип гравця: людина, нація (розумний бот) або бот; ставлення нації до вас — від ворожого до товариського; а також кількість оборонних військ, золота, військових кораблів і різних будівель, що належать гравцеві.",
"ui_playeroverlay_desc": "Коли ви наводите вказівник на країну, під значком налаштувань відображається панель інформації про гравця. Вона показує тип гравця: людина, нація або племʼя; ставлення нації до вас — від ворожого до товариського; а також кількість оборонних військ, золота, військових кораблів і різних будівель, що належать гравцеві.",
"ui_wilderness": "Пустир",
"option_pause": "Призупинити/Продовжити гру — Доступно лише в режимі гри наодинці.",
"option_timer": "Таймер — Час, що минув із початку гри.",
@@ -137,7 +140,7 @@
"info_trade": "Використайте «Припинити торгівлю», щоб припинити давати гравцеві золото та отримувати золото від нього через торгові кораблі. Якщо ви обидва натиснете «Розпочати торгівлю», вона розпочнеться знову.",
"info_ally_panel": "Панель інформації про союзника",
"info_ally_desc": "Коли ви укладете союз із гравцем, буде розблоковано наступні значки:",
"ally_betray": "Зрадити свого союзника, розірвавши союз, припинивши торгівлю та послабивши свою оборону. Торгівля між вами призупиняється на 5 хвилин (або до відновлення союзу), і інші також можуть припинити торгівлю з вами. Якщо інший гравець сам не був зрадником, ви отримаєте мітку зрадника на 30 секунд. У цей час над вашим імʼям зʼявиться особливий значок, а ваша оборона знизиться на 50%. Боти рідше укладатимуть із вами союзи, а гравці двічі подумають, перш ніж мати з вами справу.",
"ally_betray": "Зрадити свого союзника, розірвавши союз, припинивши торгівлю та послабивши свою оборону. Торгівля між вами призупиняється на 5 хвилин (або до відновлення союзу), і інші також можуть припинити торгувати з вами. Якщо інший гравець сам не був зрадником, ви отримаєте мітку зрадника на 30 секунд. У цей час над вашим імʼям зʼявиться особливий значок, а ваша оборона знизиться на 50%. Племена рідше укладатимуть з вами союзи, а гравці двічі подумають, перш ніж мати з вами справу.",
"ally_donate": "Пожертвувати частину своїх військ союзнику. Використовується, коли в нього мало військ і його атакують, або коли йому необхідна додаткова сила для знищення ворога.",
"ally_donate_gold": "Пожертвувати частину свого золота союзнику. Використовуйте, коли в нього мало золота, яке він потребує для будівель, або коли член команди заощаджує на РГЧ ІН.",
"build_menu_title": "Меню будівництва",
@@ -184,13 +187,15 @@
"toggle_achievements": "Перемикання досягнень",
"sign_in_for_achievements": "Увійдіть, щоб отримувати досягнення",
"options_title": "Налаштування",
"bots": "Боти: ",
"bots": "Племена: ",
"bots_disabled": "Відключені",
"disable_nations": "Вимкнути нації",
"nations": "Нації: ",
"nations_disabled": "Відключені",
"instant_build": "Миттєве будівництво",
"infinite_gold": "Необмежене золото",
"infinite_troops": "Необмежені війська",
"compact_map": "Компактна мапа",
"disable_alliances": "Вимкнути союзи",
"max_timer": "Тривалість гри (хвилини)",
"max_timer_placeholder": "Хвилини",
"max_timer_invalid": "Будь ласка, введіть дійсне максимальне значення таймера (1–120 хвилин)",
@@ -199,8 +204,8 @@
"options_changed_no_achievements": "Власні налаштування — досягнення вимкнені",
"gold_multiplier": "Множник золота",
"gold_multiplier_placeholder": "2.0х",
"starting_gold": "Початкове золото",
"starting_gold_placeholder": "5000000"
"starting_gold": "Початкове золото (мільйони)",
"starting_gold_placeholder": "5"
},
"token_login_modal": {
"title": "Вхід...",
@@ -264,7 +269,7 @@
"conquest_gold": "Загарбане золото гравців",
"stolen_gold": "Викрадено воєнними кораблями",
"num_of_conquests_humans": "Убивства гравців",
"num_of_conquests_bots": "Убивства ботів",
"num_of_conquests_bots": "Убивства племен",
"duration": "Тривалість",
"survival_time": "Час виживання",
"war": "Війна",
@@ -320,6 +325,7 @@
"svalmel": "Свалмел",
"manicouagan": "Манікуаган",
"lemnos": "Лемнос",
"passage": "Прохід",
"sierpinski": "Серпінський",
"thebox": "Коробка",
"twolakes": "Два озера",
@@ -328,8 +334,15 @@
"didier": "Дідьє",
"didierfrance": "Дідьє (Франція)",
"amazonriver": "Річка Амазонка",
"bosphorusstraits": "Босфорська протока",
"beringstrait": "Берингова протока",
"tradersdream": "Мрія крамаря",
"hawaii": "Гаваї"
"hawaii": "Гаваї",
"alps": "Альпи",
"niledelta": "Дельта Нілу",
"arctic": "Арктика",
"sanfrancisco": "Сан-Франциско",
"aegean": "Егейськ"
},
"map_categories": {
"featured": "Рекомендовані",
@@ -355,14 +368,10 @@
},
"public_lobby": {
"title": "Очікування початку гри...",
"join": "Приєднатися до наступної гри",
"teams_Duos": "{team_count} команд по 2 (дуо)",
"teams_Trios": "{team_count} команд по 3 (тріо)",
"teams_Quads": "{team_count} команд по 4 (квади)",
"waiting_for_players": "Очікування гравців",
"connecting": "Підключення до лобі...",
"starting_in": "Початок через {time}",
"starting_game": "Початок гри…",
"starting_game": "Запуск…",
"teams_hvn": "Люди проти націй",
"teams_hvn_detailed": "{num} людей проти {num} націй",
"teams": "Команди: {num}",
@@ -376,7 +385,8 @@
"connecting": "Приєднання до сервера підбору гравців...",
"searching": "Пошук гри...",
"waiting_for_game": "Очікування початку гри...",
"elo": "Ваш ELO: {elo}"
"elo": "Ваш ELO: {elo}",
"no_elo": "ELO відсутній"
},
"username": {
"enter_username": "Введіть своє імʼя гравця",
@@ -390,15 +400,15 @@
},
"host_modal": {
"title": "Створення приватного лобі",
"label": "Приватно",
"mode": "Режим",
"team_count": "Кількість команд",
"team_type": "Тип команди",
"options_title": "Налаштування",
"bots": "Боти: ",
"bots": "Племена: ",
"bots_disabled": "Відключені",
"nations": "Нації: ",
"nations_disabled": "Відключені",
"player_immunity_duration": "Тривалість імунітету в PVP (хвилини)",
"disable_nations": "Вимкнути нації",
"max_timer": "Тривалість гри (хвилини)",
"mins_placeholder": "Хвилини",
"instant_build": "Миттєве будівництво",
@@ -407,6 +417,7 @@
"infinite_troops": "Безмежні війська",
"donate_troops": "Пожертвування військ",
"compact_map": "Компактна мапа",
"disable_alliances": "Вимкнути союзи",
"enables_title": "Дозволи",
"player": "Гравець",
"players": "Гравці(в)",
@@ -424,8 +435,13 @@
"teams_Trios": "Тріо (команди по 3)",
"teams_Quads": "Квади (команди по 4)",
"teams_Humans Vs Nations": "Люди проти націй",
"starting_gold": "Початкове золото",
"crowded": "Модифікатор перенаселення"
"crowded": "Модифікатор перенаселення",
"hard_nations": "Важкі нації",
"gold_multiplier": "Множник золота",
"gold_multiplier_placeholder": "2.0х",
"starting_gold": "Початкове золото (мільйони)",
"starting_gold_placeholder": "5",
"leave_confirmation": "Ви впевнені, що хочете вийти з лобі?"
},
"team_colors": {
"red": "Червоний",
@@ -435,7 +451,9 @@
"yellow": "Жовтий",
"orange": "Помаранчевий",
"green": "Зелений",
"bot": "Бот"
"bot": "Племена",
"humans": "Люди",
"nations": "Нації"
},
"game_starting_modal": {
"title": "Гра починається...",
@@ -453,16 +471,29 @@
"ffa": "Усі проти всіх",
"teams": "Команди"
},
"mode_selector": {
"teams_title": "Команди",
"teams_count": "Команди: {teamCount}",
"teams_of": "Команди: {teamCount} по {playersPerTeam}",
"ranked_title": "Рейтингова гра",
"ranked_1v1_title": "1v1",
"ranked_2v2_title": "2v2",
"coming_soon": "Незабаром"
},
"public_game_modifier": {
"random_spawn": "Випадкова поява",
"compact_map": "Компактна мапа",
"crowded": "Перенаселення",
"starting_gold": "5 млн початкового золота"
"hard_nations": "Важкі нації",
"starting_gold": "Початкове золото: {amount} млн",
"gold_multiplier": "Множник золота: x{amount}",
"disable_alliances": "Союзи вимкнено"
},
"select_lang": {
"title": "Вибір мови"
},
"unit_type": {
"boat": "Човен",
"city": "Місто",
"defense_post": "Пункт оборони",
"port": "Порт",
@@ -502,6 +533,8 @@
"attack_ratio_desc": "Який відсоток ваших військ відправляти в наступ (1–100%)",
"territory_patterns_label": "🏳️ Скіни території",
"territory_patterns_desc": "Виберіть, чи показувати скіни територій у грі",
"coordinate_grid_label": "Координатна сітка",
"coordinate_grid_desc": "Перемкнути оверлей літеро-цифрової сітки",
"performance_overlay_label": "Оверлей продуктивности",
"performance_overlay_desc": "Перемкнути оверлей продуктивности. Якщо увімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб перемкнути його.",
"easter_writing_speed_label": "Множник швидкості друку",
@@ -540,9 +573,11 @@
"emoji_menu_modifier_desc": "Утримуйте цю клавішу під час клацання, щоб відкрити меню емоджі.",
"attack_ratio_controls": "Керування коефіцієнтом атаки",
"attack_ratio_up": "Збільшити коефіцієнт атаки",
"attack_ratio_up_desc": "Збільшити коефіцієнт атаки на 10%",
"attack_ratio_up_desc": "Збільшити коефіцієнт атаки на {amount}%",
"attack_ratio_down": "Зменшити коефіцієнт атаки",
"attack_ratio_down_desc": "Зменшити коефіцієнт атаки на 10%",
"attack_ratio_down_desc": "Зменшити коефіцієнт атаки на {amount}%",
"attack_ratio_increment_label": "Різниця коефіцієнту атаки",
"attack_ratio_increment_desc": "Наскільки коефіцієнт атаки змінюється за один натиск клавіші.",
"attack_keybinds": "Призначення клавіш для атаки",
"boat_attack": "Атака човном",
"boat_attack_desc": "Відправити човен на клітинку під указівником.",
@@ -662,7 +697,8 @@
"mirv_ready": "[P1] має достатньо золота для запуску РГЧ ІН!",
"snowballing": "[P1] атакує занадто швидко!",
"cheating": "[P1] шахрує!",
"stop_trading": "Припиніть торгівлю з [P1]!"
"stop_trading": "Припиніть торгівлю з [P1]!",
"stop_trading_all": "Будь ласка, припиніть торгувати з усіма!"
}
},
"build_menu": {
@@ -758,7 +794,7 @@
"player_type": {
"player": "Гравець",
"nation": "Нація",
"bot": "Бот"
"bot": "Племʼя"
},
"relation": {
"hostile": "Вороже",
@@ -840,9 +876,19 @@
"fps": "FPS:",
"avg_60s": "Сер. (60 сек):",
"frame": "Кадр:",
"tps": "TPS:",
"tps_avg_60s": "Сер.:",
"tick_exec": "Виконання на тік:",
"max_label": "Макс.:",
"tick_delay": "Затримка на тік:",
"layers_header": "Шари (сер. / макс., відсортовано за загальним часом):"
"layers_header": "Шари візуалізації",
"render_layers_table_header": "сер. / макс. | тіки",
"render_layers_summary": "Останній тік: {frames} кадрів, {ms} мс",
"tick_layers_header": "Шари тіків",
"tick_layers_table_header": "сер. / макс.",
"tick_layers_summary": "Останній тік: {count} шарів, {ms} мс",
"expand": "Розгорнути",
"collapse": "Згорнути"
},
"heads_up_message": {
"choose_spawn": "Оберіть стартове розташування",
@@ -862,13 +908,6 @@
"pattern": {
"default": "Типово"
},
"try_me": "Примір мене!",
"trial_remaining": "залишилися",
"trial_granted": "Пробний скін надано!",
"trial_cooldown": "Можна приміряти лише один скін протягом 24 годин. Будь ласка, спробуйте ще раз пізніше.",
"trial_login_required": "Ви повинні увійти, щоб отримати пробний скін",
"reward_countdown": "Нагородження через {seconds} сек...",
"steam_wishlist_prompt": "Підтримайте OpenFront, додавши його до списку бажаного Steam",
"select_skin": "Оберіть скін",
"selected": "обрано"
},
@@ -930,8 +969,6 @@
"recent_games": "Нещодавні ігри",
"game_id": "ID гри",
"mode": "Режим",
"mode_ffa": "Усі проти всіх",
"mode_team": "Команда",
"replay": "Повтор",
"details": "Подробиці",
"ranking": "Рейтинг",
@@ -948,8 +985,6 @@
"stats_losses": "Поразки",
"stats_wlr": "Коефіцієнт перемог і поразок",
"stats_games_played": "Зіграні ігри",
"mode_ffa": "Усі проти всіх",
"mode_team": "Команда",
"no_stats": "Немає даних для цієї вибірки."
},
"matchmaking_button": {
+77 -33
View File
@@ -1,18 +1,18 @@
{
"map": {
"height": 2000,
"num_land_tiles": 1044110,
"width": 1600
"num_land_tiles": 1171283,
"width": 1700
},
"map16x": {
"height": 500,
"num_land_tiles": 60226,
"width": 400
"num_land_tiles": 68060,
"width": 425
},
"map4x": {
"height": 1000,
"num_land_tiles": 253795,
"width": 800
"num_land_tiles": 285421,
"width": 850
},
"name": "aegean",
"nations": [
@@ -21,76 +21,120 @@
"name": "Crete"
},
{
"coordinates": [1554, 1530],
"coordinates": [1598, 1550],
"name": "Rhodes"
},
{
"coordinates": [1051, 539],
"coordinates": [1126, 552],
"name": "Lesbos"
},
{
"coordinates": [1070, 820],
"coordinates": [1128, 812],
"name": "Chios"
},
{
"coordinates": [1235, 1023],
"coordinates": [1330, 1039],
"name": "Samos"
},
{
"coordinates": [1193, 301],
"coordinates": [1275, 263],
"name": "Troy"
},
{
"coordinates": [1446, 954],
"coordinates": [1567, 808],
"name": "Ephesus"
},
{
"coordinates": [1515, 1223],
"coordinates": [1635, 1139],
"name": "Miletus"
},
{
"coordinates": [824, 305],
"coordinates": [891, 295],
"name": "Lemnos"
},
{
"coordinates": [1312, 37],
"coordinates": [1382, 27],
"name": "Thrace"
},
{
"coordinates": [1473, 509],
"flag": "Achaemenid Empire",
"name": "Achaemenid Empire"
"coordinates": [1599, 431],
"name": "Achaemenid Empire",
"flag": "Achaemenid Empire"
},
{
"coordinates": [702, 40],
"coordinates": [762, 35],
"name": "Thasos"
},
{
"coordinates": [832, 1253],
"name": "Cyclades"
"coordinates": [491, 894],
"name": "Athens",
"flag": "Athens"
},
{
"coordinates": [479, 943],
"flag": "Athens",
"name": "Athens"
"coordinates": [222, 1183],
"name": "Sparta",
"flag": "Sparta"
},
{
"coordinates": [110, 1157],
"flag": "Sparta",
"name": "Sparta"
},
{
"coordinates": [348, 56],
"flag": "Macedonia",
"name": "Macedonia"
"coordinates": [446, 104],
"name": "Macedonia",
"flag": "Macedonia"
},
{
"coordinates": [175, 456],
"name": "Thessaly"
},
{
"coordinates": [71, 742],
"coordinates": [71, 713],
"name": "Aetolia"
},
{
"coordinates": [534, 761],
"name": "Euboea"
},
{
"coordinates": [26, 984],
"name": "Olympia"
},
{
"coordinates": [826, 1020],
"name": "Andros"
},
{
"coordinates": [1146, 1078],
"name": "Icaria"
},
{
"coordinates": [989, 1250],
"name": "Naxos"
},
{
"coordinates": [969, 1475],
"name": "Thera"
},
{
"coordinates": [937, 1371],
"name": "Ios"
},
{
"coordinates": [1403, 1337],
"name": "Cos"
},
{
"coordinates": [688, 1377],
"name": "Melos"
},
{
"coordinates": [723, 643],
"name": "Scyros"
},
{
"coordinates": [324, 1520],
"name": "Cythera"
},
{
"coordinates": [1416, 1755],
"name": "Carpathos"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+17 -1
View File
@@ -71,5 +71,21 @@
"flag": "ru",
"name": "Listvyanka"
}
]
],
"teamGameSpawnAreas": {
"2": [
{
"height": 1564,
"width": 1330,
"x": 0,
"y": 0
},
{
"height": 1564,
"width": 1070,
"x": 1430,
"y": 0
}
]
}
}
+7 -7
View File
@@ -1,20 +1,20 @@
{
"map": {
"height": 1202,
"num_land_tiles": 1258353,
"height": 1200,
"num_land_tiles": 1255327,
"width": 1800
},
"map16x": {
"height": 300,
"num_land_tiles": 77228,
"num_land_tiles": 77229,
"width": 450
},
"map4x": {
"height": 601,
"num_land_tiles": 313011,
"height": 600,
"num_land_tiles": 312219,
"width": 900
},
"name": "straitofhormuz",
"name": "Strait of Hormuz",
"nations": [
{
"coordinates": [837, 356],
@@ -99,7 +99,7 @@
{
"coordinates": [159, 756],
"flag": "",
"name": "Ar Rayy\u0101n"
"name": "Ar Rayyān"
},
{
"coordinates": [1103, 647],
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

+32 -20
View File
@@ -56,6 +56,7 @@ export interface LobbyConfig {
serverConfig: ServerConfig;
cosmetics: PlayerCosmeticRefs;
playerName: string;
playerClanTag: string | null;
gameID: GameID;
turnstileToken: string | null;
// GameStartInfo only exists when playing a singleplayer game.
@@ -64,15 +65,24 @@ export interface LobbyConfig {
gameRecord?: GameRecord;
}
export interface JoinLobbyResult {
stop: (force?: boolean) => boolean;
prestart: Promise<void>;
join: Promise<void>;
}
export function joinLobby(
eventBus: EventBus,
lobbyConfig: LobbyConfig,
onPrestart: () => void,
onJoin: () => void,
): (force?: boolean) => boolean {
): JoinLobbyResult {
// Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer)
let clientID: ClientID | undefined;
let resolvePrestart: () => void;
let resolveJoin: () => void;
const prestartPromise = new Promise<void>((r) => (resolvePrestart = r));
const joinPromise = new Promise<void>((r) => (resolveJoin = r));
console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`);
const userSettings: UserSettings = new UserSettings();
@@ -105,17 +115,17 @@ export function joinLobby(
message.gameMapSize,
terrainMapFileLoader,
);
onPrestart();
resolvePrestart();
}
if (message.type === "start") {
// Trigger prestart for singleplayer games
onPrestart();
resolvePrestart();
console.log(
`lobby: game started: ${JSON.stringify(message, replacer, 2)}`,
);
// Server tells us our assigned clientID (also sent on start for late joins)
clientID = message.myClientID;
onJoin();
resolveJoin();
// For multiplayer games, GameStartInfo is not known until game starts.
lobbyConfig.gameStartInfo = message.gameStartInfo;
createClientGame(
@@ -157,7 +167,7 @@ export function joinLobby(
if (message.error === "full-lobby") {
document.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: lobbyConfig.gameID },
detail: { lobby: lobbyConfig.gameID, cause: "full-lobby" },
bubbles: true,
composed: true,
}),
@@ -176,19 +186,19 @@ export function joinLobby(
}
};
transport.connect(onconnect, onmessage);
return (force: boolean = false) => {
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
console.log("Player is active, prevent leaving game");
return false;
}
console.log("leaving game");
currentGameRunner = null;
transport.leaveGame();
return true;
return {
stop: (force: boolean = false) => {
if (!force && currentGameRunner?.shouldPreventWindowClose()) {
console.log("Player is active, prevent leaving game");
return false;
}
console.log("leaving game");
currentGameRunner = null;
transport.leaveGame();
return true;
},
prestart: prestartPromise,
join: joinPromise,
};
}
@@ -228,6 +238,7 @@ async function createClientGame(
gameMap,
clientID,
lobbyConfig.playerName,
lobbyConfig.playerClanTag,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
);
@@ -301,6 +312,7 @@ export class ClientGameRunner {
{
persistentID: getPersistentID(),
username: this.lobby.playerName,
clanTag: this.lobby.playerClanTag ?? null,
clientID: this.clientID,
stats: update.allPlayersStats[this.clientID],
},
+4 -15
View File
@@ -4,7 +4,6 @@ import { GameEndInfo } from "../core/Schemas";
import { GameMapType } from "../core/game/Game";
import { fetchGameById } from "./Api";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { UsernameInput } from "./UsernameInput";
import { renderDuration, translateText } from "./Utils";
import {
PlayerInfo,
@@ -28,7 +27,7 @@ export class GameInfoModal extends LitElement {
@property({ type: String }) gameId: string | null = null;
@property({ type: String }) rankType = RankType.Lifetime;
@state() private username: string | null = null;
@state() private currentClientID: string | null = null;
@state() private isLoadingGame: boolean = true;
private ranking: Ranking | null = null;
@@ -152,7 +151,7 @@ export class GameInfoModal extends LitElement {
.score=${this.ranking?.score(player, this.rankType) ?? 0}
.rankType=${this.rankType}
.bestScore=${bestScore}
.currentPlayer=${this.username === player.rawUsername}
.currentPlayer=${this.currentClientID === player.id}
></player-row>
`,
)}
@@ -183,26 +182,16 @@ export class GameInfoModal extends LitElement {
}
}
public loadUserName() {
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (usernameInput) {
this.username = usernameInput.getCurrentUsername();
}
}
public async loadGame(gameId: string) {
public async loadGame(gameId: string, currentClientID: string | null = null) {
try {
this.isLoadingGame = true;
this.loadUserName();
this.currentClientID = currentClientID;
const session = await fetchGameById(gameId);
if (!session) return;
this.gameInfo = session.info;
this.ranking = new Ranking(session);
this.updateRanking();
this.isLoadingGame = false;
await this.loadMapImage(session.info.config.gameMap);
} catch (err) {
console.error("Failed to load game:", err);
+9 -21
View File
@@ -17,9 +17,12 @@ import { PublicLobbySocket } from "./LobbySocket";
import { JoinLobbyEvent } from "./Main";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { UsernameInput } from "./UsernameInput";
import {
calculateServerTimeOffset,
getMapName,
getModifierLabels,
getSecondsUntilServerTimestamp,
renderDuration,
translateText,
} from "./Utils";
@@ -46,20 +49,10 @@ export class GameModeSelector extends LitElement {
* Returns true if valid, false otherwise.
*/
private validateUsername(): boolean {
const usernameInput = document.querySelector("username-input") as any;
if (usernameInput?.isValid?.() === false) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: usernameInput.validationError,
color: "red",
duration: 3000,
},
}),
);
return false;
}
return true;
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput | null;
return usernameInput ? usernameInput.validateOrShowError() : true;
}
connectedCallback() {
@@ -81,7 +74,7 @@ export class GameModeSelector extends LitElement {
private handleLobbiesUpdate(lobbies: PublicGames) {
this.lobbies = lobbies;
this.serverTimeOffset = lobbies.serverTime - Date.now();
this.serverTimeOffset = calculateServerTimeOffset(lobbies.serverTime);
document.dispatchEvent(
new CustomEvent("public-lobbies-update", {
detail: { payload: lobbies },
@@ -279,12 +272,7 @@ export class GameModeSelector extends LitElement {
const useContain =
aspectRatio !== undefined && (aspectRatio > 4 || aspectRatio < 0.25);
const timeRemaining = lobby.startsAt
? Math.max(
0,
Math.floor(
(lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000,
),
)
? getSecondsUntilServerTimestamp(lobby.startsAt, this.serverTimeOffset)
: undefined;
let timeDisplay: string = "";
+33 -12
View File
@@ -58,6 +58,9 @@ export class HelpModal extends BaseModal {
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
resetGfx: "KeyR",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
...saved,
};
}
@@ -81,6 +84,8 @@ export class HelpModal extends BaseModal {
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
Period: ">",
Comma: "<",
};
if (specialLabels[code]) return specialLabels[code];
@@ -372,6 +377,25 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.action_center")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.pauseGame)}
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_pause_game")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
<div class="flex flex-wrap gap-2">
${this.renderKey(keybinds.gameSpeedDown)}
${this.renderKey(keybinds.gameSpeedUp)}
</div>
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_game_speed")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
<div class="flex flex-wrap gap-2">
@@ -600,10 +624,11 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.ui_options_desc")}
</p>
<ul class="space-y-2 list-disc pl-4 text-white/60">
<li>${translateText("help_modal.option_pause")}</li>
<li>${translateText("help_modal.option_timer")}</li>
<li>${translateText("help_modal.option_exit")}</li>
<li>${translateText("help_modal.option_speed")}</li>
<li>${translateText("help_modal.option_pause")}</li>
<li>${translateText("help_modal.option_settings")}</li>
<li>${translateText("help_modal.option_exit")}</li>
</ul>
</div>
</div>
@@ -694,8 +719,7 @@ export class HelpModal extends BaseModal {
<li class="flex items-center gap-3">
<img
src="/images/InfoIcon.svg"
class="w-5 h-5 opacity-80"
loading="lazy"
class="w-8 h-8 scale-75 origin-left"
/>
<span>${translateText("help_modal.radial_info")}</span>
</li>
@@ -824,14 +848,11 @@ export class HelpModal extends BaseModal {
<span>${translateText("help_modal.info_emoji")}</span>
</li>
<li class="flex items-center gap-3">
<div
class="flex items-center justify-center w-8 h-8 opacity-80"
>
<img
src="/images/helpModal/stopTrading.webp"
class="w-full h-full object-contain"
/>
</div>
<img
src="/images/StopIconWhite.png"
class="w-8 h-8 scale-75 origin-left"
loading="lazy"
/>
<span>${translateText("help_modal.info_trade")}</span>
</li>
</ul>
+22 -1
View File
@@ -123,6 +123,12 @@ export class ReplaySpeedChangeEvent implements GameEvent {
constructor(public readonly replaySpeedMultiplier: ReplaySpeedMultiplier) {}
}
export class TogglePauseIntentEvent implements GameEvent {}
export class GameSpeedUpIntentEvent implements GameEvent {}
export class GameSpeedDownIntentEvent implements GameEvent {}
export class CenterCameraEvent implements GameEvent {
constructor() {}
}
@@ -236,6 +242,9 @@ export class InputHandler {
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
...saved,
};
@@ -433,8 +442,20 @@ export class InputHandler {
this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection));
}
if (!e.repeat && e.code === this.keybinds.pauseGame) {
e.preventDefault();
this.eventBus.emit(new TogglePauseIntentEvent());
}
if (!e.repeat && e.code === this.keybinds.gameSpeedUp) {
e.preventDefault();
this.eventBus.emit(new GameSpeedUpIntentEvent());
}
if (!e.repeat && e.code === this.keybinds.gameSpeedDown) {
e.preventDefault();
this.eventBus.emit(new GameSpeedDownIntentEvent());
}
// Shift-D to toggle performance overlay
console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey);
if (e.code === "KeyD" && e.shiftKey) {
e.preventDefault();
console.log("TogglePerformanceOverlayEvent");
+14 -2
View File
@@ -1,9 +1,12 @@
import { html, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import {
calculateServerTimeOffset,
getActiveModifiers,
getGameModeLabel,
getMapName,
getSecondsUntilServerTimestamp,
getServerNow,
renderDuration,
renderNumber,
translateText,
@@ -44,6 +47,7 @@ export class JoinLobbyModal extends BaseModal {
@state() private currentClientID: string = "";
@state() private nationCount: number = 0;
@state() private lobbyStartAt: number | null = null;
@state() private serverTimeOffset: number = 0;
@state() private isConnecting: boolean = true;
@state() private lobbyCreatorClientID: string | null = null;
@@ -77,7 +81,10 @@ export class JoinLobbyModal extends BaseModal {
// Post-join state: show lobby info (identical for public & private)
const secondsRemaining =
this.lobbyStartAt !== null
? Math.max(0, Math.floor((this.lobbyStartAt - Date.now()) / 1000))
? getSecondsUntilServerTimestamp(
this.lobbyStartAt,
this.serverTimeOffset,
)
: null;
const statusLabel =
secondsRemaining === null
@@ -328,6 +335,7 @@ export class JoinLobbyModal extends BaseModal {
this.players = [];
this.nationCount = 0;
this.lobbyStartAt = null;
this.serverTimeOffset = 0;
this.lobbyCreatorClientID = null;
this.isConnecting = true;
this.handledJoinTimeout = false;
@@ -377,6 +385,7 @@ export class JoinLobbyModal extends BaseModal {
this.currentClientID = "";
this.nationCount = 0;
this.lobbyStartAt = null;
this.serverTimeOffset = 0;
this.lobbyCreatorClientID = null;
this.isConnecting = true;
this.leaveLobbyOnClose = true;
@@ -513,6 +522,9 @@ export class JoinLobbyModal extends BaseModal {
private updateFromLobby(lobby: GameInfo | PublicGameInfo) {
this.players = "clients" in lobby ? (lobby.clients ?? []) : [];
if ("serverTime" in lobby && typeof lobby.serverTime === "number") {
this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime);
}
this.lobbyStartAt = lobby.startsAt ?? null;
this.syncCountdownTimer();
if (lobby.gameConfig) {
@@ -577,7 +589,7 @@ export class JoinLobbyModal extends BaseModal {
) {
return;
}
if (Date.now() < this.lobbyStartAt) {
if (getServerNow(this.serverTimeOffset) < this.lobbyStartAt) {
return;
}
this.handledJoinTimeout = true;
+34 -3
View File
@@ -15,17 +15,28 @@ import {
import {
createPartialGameRecord,
decompressGameRecord,
getClanTag,
replacer,
} from "../core/Util";
import { getPersistentID } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { ReplaySpeedChangeEvent } from "./InputHandler";
import {
GameSpeedDownIntentEvent,
GameSpeedUpIntentEvent,
ReplaySpeedChangeEvent,
} from "./InputHandler";
import {
defaultReplaySpeedMultiplier,
ReplaySpeedMultiplier,
} from "./utilities/ReplaySpeedMultiplier";
// Order: 0.5, 1, 2, max (same as ReplayPanel)
const SPEED_ORDER: ReplaySpeedMultiplier[] = [
ReplaySpeedMultiplier.slow,
ReplaySpeedMultiplier.normal,
ReplaySpeedMultiplier.fast,
ReplaySpeedMultiplier.fastest,
];
// build a small backlog so MAX can catch up.
const MAX_REPLAY_BACKLOG_TURNS = 60;
@@ -94,6 +105,26 @@ export class LocalServer {
this.replaySpeedMultiplier = event.replaySpeedMultiplier;
});
if (!this.isReplay) {
this.eventBus.on(GameSpeedUpIntentEvent, () => {
const idx = SPEED_ORDER.indexOf(this.replaySpeedMultiplier);
if (idx < 0 || idx >= SPEED_ORDER.length - 1) return;
this.replaySpeedMultiplier = SPEED_ORDER[idx + 1];
this.eventBus.emit(
new ReplaySpeedChangeEvent(this.replaySpeedMultiplier),
);
});
this.eventBus.on(GameSpeedDownIntentEvent, () => {
const idx = SPEED_ORDER.indexOf(this.replaySpeedMultiplier);
if (idx <= 0) return;
this.replaySpeedMultiplier = SPEED_ORDER[idx - 1];
this.eventBus.emit(
new ReplaySpeedChangeEvent(this.replaySpeedMultiplier),
);
});
}
this.startedAt = Date.now();
this.clientConnect();
if (this.lobbyConfig.gameRecord) {
@@ -241,10 +272,10 @@ export class LocalServer {
{
persistentID: getPersistentID(),
username: this.lobbyConfig.playerName,
clanTag: this.lobbyConfig.playerClanTag ?? null,
clientID: this.clientID!,
stats: this.allPlayersStats[this.clientID!],
cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics,
clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined,
},
];
if (this.lobbyConfig.gameStartInfo === undefined) {
+132 -109
View File
@@ -15,7 +15,7 @@ import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
import { getUserMe } from "./Api";
import { userAuth } from "./Auth";
import { joinLobby } from "./ClientGameRunner";
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import "./FlagInput";
@@ -180,12 +180,17 @@ declare global {
ramp: {
que: Array<() => void>;
passiveMode: boolean;
spaAddAds: (ads: Array<{ type: string; selectorId: string }>) => void;
destroyUnits: (adType: string) => void;
spaAddAds: (ads: Array<{ type: string; selectorId?: string }>) => void;
destroyUnits: (adType: string | string[]) => Promise<void>;
settings?: {
slots?: any;
};
spaNewPage: (url?: string) => void;
spaAds: (config?: {
ads?: Array<{ type: string; selectorId?: string }>;
countPageview?: boolean;
path?: string;
}) => void;
// Video ad methods
onPlayerReady: (() => void) | null;
addUnits: (units: Array<{ type: string }>) => Promise<void>;
@@ -230,7 +235,7 @@ export interface JoinLobbyEvent {
}
class Client {
private gameStop: ((force?: boolean) => boolean) | null = null;
private lobbyHandle: JoinLobbyResult | null = null;
private eventBus: EventBus = new EventBus();
private currentUrl: string | null = null;
@@ -300,8 +305,8 @@ class Client {
window.addEventListener("beforeunload", async () => {
console.log("Browser is closing");
if (this.gameStop !== null) {
this.gameStop(true);
if (this.lobbyHandle !== null) {
this.lobbyHandle.stop(true);
await crazyGamesSDK.gameplayStop();
}
});
@@ -521,10 +526,10 @@ class Client {
};
const onPopState = () => {
if (this.currentUrl !== null && this.gameStop !== null) {
if (this.currentUrl !== null && this.lobbyHandle !== null) {
console.info("Game is active");
if (!this.gameStop()) {
if (!this.lobbyHandle.stop()) {
console.info("Player is active, ask before leaving game");
const isConfirmed = confirm(
@@ -552,7 +557,7 @@ class Client {
};
const onJoinChanged = () => {
if (this.gameStop !== null) {
if (this.lobbyHandle !== null) {
this.handleLeaveLobby();
}
@@ -642,7 +647,7 @@ class Client {
return;
}
const patternName = params.get("pattern");
const patternName = params.get("cosmetic");
if (!patternName) {
alert("Something went wrong. Please contact support.");
console.error("purchase-completed but no pattern name");
@@ -732,10 +737,14 @@ class Client {
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
const lobby = event.detail;
if (this.usernameInput && !this.usernameInput.validateOrShowError()) {
return;
}
console.log(`joining lobby ${lobby.gameID}`);
if (this.gameStop !== null) {
if (this.lobbyHandle !== null) {
console.log("joining lobby, stopping existing game");
this.gameStop(true);
this.lobbyHandle.stop(true);
document.body.classList.remove("in-game");
}
if (lobby.source === "public") {
@@ -746,106 +755,105 @@ class Client {
if (lobby.source !== "public") {
this.updateJoinUrlForShare(lobby.gameID, config);
}
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName:
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
},
() => {
console.log("Closing modals");
document.getElementById("settings-button")?.classList.add("hidden");
if (this.usernameInput) {
// fix edge case where username-validation-error is re-rendered and hidden tag removed
this.usernameInput.validationError = "";
this.lobbyHandle = joinLobby(this.eventBus, {
gameID: lobby.gameID,
serverConfig: config,
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
playerClanTag: this.usernameInput?.getClanTag() ?? null,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
});
this.lobbyHandle.prestart.then(() => {
console.log("Closing modals");
document.getElementById("settings-button")?.classList.add("hidden");
if (this.usernameInput) {
// fix edge case where username-validation-error is re-rendered and hidden tag removed
this.usernameInput.validationError = "";
}
document
.getElementById("username-validation-error")
?.classList.add("hidden");
this.joinModal?.closeWithoutLeaving();
[
"single-player-modal",
"host-lobby-modal",
"game-starting-modal",
"game-top-bar",
"help-modal",
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"language-modal",
"news-modal",
"flag-input-modal",
"account-button",
"leaderboard-button",
"token-login",
"matchmaking-modal",
"lang-selector",
"gutter-ads",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
isModalOpen?: boolean;
};
if (modal?.close) {
modal.close();
} else if (modal && "isModalOpen" in modal) {
modal.isModalOpen = false;
}
document
.getElementById("username-validation-error")
?.classList.add("hidden");
this.joinModal?.closeWithoutLeaving();
[
"single-player-modal",
"host-lobby-modal",
"game-starting-modal",
"game-top-bar",
"help-modal",
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"language-modal",
"news-modal",
"flag-input-modal",
"account-button",
"leaderboard-button",
"token-login",
"matchmaking-modal",
"lang-selector",
"gutter-ads",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
isModalOpen?: boolean;
};
if (modal?.close) {
modal.close();
} else if (modal && "isModalOpen" in modal) {
modal.isModalOpen = false;
}
});
this.gameModeSelector.stop();
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
});
this.gameModeSelector.stop();
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
crazyGamesSDK.loadingStart();
crazyGamesSDK.loadingStart();
// show when the game loads
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
if (startingModal && startingModal instanceof GameStartingModal) {
startingModal.show();
}
},
() => {
this.joinModal?.closeWithoutLeaving();
this.gameModeSelector.stop();
incrementGamesPlayed();
// show when the game loads
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
if (startingModal && startingModal instanceof GameStartingModal) {
startingModal.show();
}
});
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
this.lobbyHandle.join.then(() => {
this.joinModal?.closeWithoutLeaving();
this.gameModeSelector.stop();
incrementGamesPlayed();
if (window.PageOS?.session?.newPageView) {
window.PageOS.session.newPageView();
}
crazyGamesSDK.loadingStop();
crazyGamesSDK.gameplayStart();
document.body.classList.add("in-game");
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
// Ensure there's a homepage entry in history before adding the lobby entry
if (window.location.hash === "" || window.location.hash === "#") {
history.replaceState(null, "", window.location.origin + "#refresh");
}
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
history.pushState(
null,
"",
lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
if (window.PageOS?.session?.newPageView) {
window.PageOS.session.newPageView();
}
crazyGamesSDK.loadingStop();
crazyGamesSDK.gameplayStart();
document.body.classList.add("in-game");
// Store current URL for popstate confirmation
this.currentUrl = window.location.href;
},
);
// Ensure there's a homepage entry in history before adding the lobby entry
if (window.location.hash === "" || window.location.hash === "#") {
history.replaceState(null, "", window.location.origin + "#refresh");
}
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
history.pushState(
null,
"",
lobbyIdHidden
? "/streamer-mode"
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
// Store current URL for popstate confirmation
this.currentUrl = window.location.href;
});
}
private updateJoinUrlForShare(
@@ -863,13 +871,13 @@ class Client {
}
}
private async handleLeaveLobby(/* event: CustomEvent */) {
if (this.gameStop === null) {
private async handleLeaveLobby(event?: CustomEvent) {
if (this.lobbyHandle === null) {
return;
}
console.log("leaving lobby, cancelling game");
this.gameStop(true);
this.gameStop = null;
this.lobbyHandle.stop(true);
this.lobbyHandle = null;
this.currentUrl = null;
try {
@@ -880,6 +888,21 @@ class Client {
document.body.classList.remove("in-game");
if (this.joinModal.isOpen()) {
this.joinModal.close();
if (event?.detail.cause === "full-lobby") {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("public_lobby.join_timeout"),
color: "red",
duration: 3500,
},
}),
);
}
}
crazyGamesSDK.gameplayStop();
}
+2 -4
View File
@@ -654,9 +654,6 @@ export class SinglePlayerModal extends BaseModal {
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (!usernameInput) {
console.warn("Username input element not found");
}
await crazyGamesSDK.requestMidgameAd();
@@ -669,7 +666,8 @@ export class SinglePlayerModal extends BaseModal {
players: [
{
clientID,
username: usernameInput.getCurrentUsername(),
username: usernameInput.getUsername(),
clanTag: usernameInput.getClanTag() ?? null,
cosmetics: await getPlayerCosmetics(),
},
],
+1
View File
@@ -399,6 +399,7 @@ export class Transport {
gameID: this.lobbyConfig.gameID,
// Note: clientID is not sent - server assigns it based on persistentID
username: this.lobbyConfig.playerName,
clanTag: this.lobbyConfig.playerClanTag ?? null,
cosmetics: this.lobbyConfig.cosmetics,
turnstileToken: this.lobbyConfig.turnstileToken,
token: await getPlayToken(),
+33
View File
@@ -47,6 +47,9 @@ const DefaultKeybinds: Record<string, string> = {
moveRight: "KeyD",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
};
@customElement("user-setting")
@@ -634,6 +637,36 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="pauseGame"
label=${translateText("user_setting.pause_game")}
description=${translateText("user_setting.pause_game_desc")}
.defaultKey=${DefaultKeybinds.pauseGame}
.value=${this.getKeyValue("pauseGame")}
.display=${this.getKeyChar("pauseGame")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="gameSpeedUp"
label=${translateText("user_setting.game_speed_up")}
description=${translateText("user_setting.game_speed_up_desc")}
.defaultKey=${DefaultKeybinds.gameSpeedUp}
.value=${this.getKeyValue("gameSpeedUp")}
.display=${this.getKeyChar("gameSpeedUp")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="gameSpeedDown"
label=${translateText("user_setting.game_speed_down")}
description=${translateText("user_setting.game_speed_down_desc")}
.defaultKey=${DefaultKeybinds.gameSpeedDown}
.value=${this.getKeyValue("gameSpeedDown")}
.display=${this.getKeyChar("gameSpeedDown")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
+59 -62
View File
@@ -2,15 +2,19 @@ import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { v4 as uuidv4 } from "uuid";
import { translateText } from "../client/Utils";
import { getClanTagOriginalCase, sanitizeClanTag } from "../core/Util";
import { sanitizeClanTag } from "../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
MAX_USERNAME_LENGTH,
MIN_CLAN_TAG_LENGTH,
MIN_USERNAME_LENGTH,
validateClanTag,
validateUsername,
} from "../core/validations/username";
import { crazyGamesSDK } from "./CrazyGamesSDK";
const usernameKey: string = "username";
const clanTagKey: string = "clanTag";
@customElement("username-input")
export class UsernameInput extends LitElement {
@@ -27,46 +31,45 @@ export class UsernameInput extends LitElement {
return this;
}
public getCurrentUsername(): string {
return this.constructFullUsername();
public getUsername(): string {
return this.baseUsername.trim();
}
private constructFullUsername(): string {
if (this.clanTag.length >= 2) {
return `[${this.clanTag}] ${this.baseUsername}`;
}
return this.baseUsername;
public getClanTag(): string | null {
return this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
validateClanTag(this.clanTag).isValid
? this.clanTag
: null;
}
connectedCallback() {
super.connectedCallback();
const stored = this.getUsername();
this.parseAndSetUsername(stored);
this.loadStoredUsername();
crazyGamesSDK.getUsername().then((username) => {
if (username) {
this.parseAndSetUsername(username ?? genAnonUsername());
this.requestUpdate();
this.baseUsername = username;
this.validateAndStore();
}
});
crazyGamesSDK.addAuthListener((user) => {
if (user) {
this.parseAndSetUsername(user?.username);
this.baseUsername = user.username;
this.validateAndStore();
}
this.requestUpdate();
});
}
private parseAndSetUsername(fullUsername: string) {
const tag = getClanTagOriginalCase(fullUsername);
if (tag) {
this.clanTag = tag.toUpperCase();
this.baseUsername = fullUsername.replace(`[${tag}]`, "").trim();
private loadStoredUsername() {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
this.baseUsername = storedUsername;
this.validateAndStore();
} else {
this.clanTag = "";
this.baseUsername = fullUsername;
this.baseUsername = genAnonUsername();
this.validateAndStore();
}
this.validateAndStore();
}
render() {
@@ -77,7 +80,8 @@ export class UsernameInput extends LitElement {
.value=${this.clanTag}
@input=${this.handleClanTagChange}
placeholder="${translateText("username.tag")}"
maxlength="5"
minlength="${MIN_CLAN_TAG_LENGTH}"
maxlength="${MAX_CLAN_TAG_LENGTH}"
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
<input
@@ -85,6 +89,7 @@ export class UsernameInput extends LitElement {
.value=${this.baseUsername}
@input=${this.handleUsernameChange}
placeholder="${translateText("username.enter_username")}"
minlength="${MIN_USERNAME_LENGTH}"
maxlength="${MAX_USERNAME_LENGTH}"
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
/>
@@ -147,59 +152,51 @@ export class UsernameInput extends LitElement {
}
private validateAndStore() {
// Prevent empty username even if clan tag is present
const trimmedBase = this.baseUsername.trim();
if (!trimmedBase || trimmedBase.length < MIN_USERNAME_LENGTH) {
const trimmedBase = this.getUsername();
const clanTagResult = validateClanTag(this.clanTag);
if (!clanTagResult.isValid) {
this._isValid = false;
this.validationError = translateText("username.too_short", {
min: MIN_USERNAME_LENGTH,
});
this.validationError = clanTagResult.error ?? "";
return;
}
// Validate clan tag if present
if (this.clanTag.length > 0 && this.clanTag.length < 2) {
this._isValid = false;
this.validationError = translateText("username.tag_too_short");
return;
}
const full = this.constructFullUsername();
const trimmedFull = full.trim();
const result = validateUsername(trimmedFull);
const result = validateUsername(trimmedBase);
this._isValid = result.isValid;
if (result.isValid) {
this.storeUsername(trimmedFull);
localStorage.setItem(usernameKey, trimmedBase);
localStorage.setItem(clanTagKey, this.getClanTag() ?? "");
this.validationError = "";
} else {
this.validationError = result.error ?? "";
}
}
private getUsername(): string {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
return storedUsername;
}
return this.generateNewUsername();
}
private storeUsername(username: string) {
if (username) {
localStorage.setItem(usernameKey, username);
}
}
private generateNewUsername(): string {
const newUsername = genAnonUsername();
this.storeUsername(newUsername);
return newUsername;
}
public isValid(): boolean {
return this._isValid;
}
public showValidationFeedback(): void {
const message =
this.validationError || translateText("username.invalid_chars");
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message,
color: "red",
duration: 2500,
},
}),
);
}
public validateOrShowError(): boolean {
if (this.isValid()) {
return true;
}
this.showValidationFeedback();
return false;
}
}
export function genAnonUsername(): string {
+30 -2
View File
@@ -159,7 +159,7 @@ export function getActiveModifiers(
(modifiers.startingGold / 1_000_000).toPrecision(12),
);
result.push({
labelKey: "host_modal.starting_gold",
labelKey: "public_game_modifier.starting_gold_label",
badgeKey: "public_game_modifier.starting_gold",
badgeParams: {
amount: millions,
@@ -181,8 +181,9 @@ export function getActiveModifiers(
}
if (modifiers.isAlliancesDisabled) {
result.push({
labelKey: "host_modal.disable_alliances",
labelKey: "public_game_modifier.disable_alliances_label",
badgeKey: "public_game_modifier.disable_alliances",
formattedValue: translateText("common.disabled"),
});
}
return result;
@@ -628,3 +629,30 @@ export function getDiscordAvatarUrl(user: {
return null;
}
export function calculateServerTimeOffset(
serverTimeMs: number,
localNowMs: number = Date.now(),
): number {
return serverTimeMs - localNowMs;
}
export function getServerNow(
serverTimeOffsetMs: number,
localNowMs: number = Date.now(),
): number {
return localNowMs + serverTimeOffsetMs;
}
export function getSecondsUntilServerTimestamp(
targetServerTimestampMs: number,
serverTimeOffsetMs: number,
localNowMs: number = Date.now(),
): number {
return Math.max(
0,
Math.floor(
(targetServerTimestampMs - getServerNow(serverTimeOffsetMs, localNowMs)) /
1000,
),
);
}
+4
View File
@@ -35,6 +35,10 @@ export abstract class BaseModal extends LitElement {
return this;
}
public isOpen(): boolean {
return this.isModalOpen;
}
protected firstUpdated(): void {
if (this.modalEl) {
this.modalEl.onClose = () => {
+47 -17
View File
@@ -16,7 +16,7 @@ import {
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
import { UserSettings } from "../../core/game/UserSettings";
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
import { createRandomName } from "../../core/Util";
import { createRandomName, formatPlayerDisplayName } from "../../core/Util";
import { getTranslatedPlayerTeamLabel, translateText } from "../Utils";
export interface TeamPreviewData {
@@ -122,9 +122,12 @@ export class LobbyTeamView extends LitElement {
this.clients,
(c) => c.clientID ?? c.username,
(client) => {
const displayName = this.displayUsername(client);
const displayName = this.getClientDisplayName(client);
return html`<div
class="px-2 py-1 rounded-sm bg-gray-700/70 mb-1 text-xs text-white"
class="px-2 py-1 rounded-sm mb-1 text-xs text-white border
${this.isCurrentPlayer(client)
? "bg-sky-600/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
${displayName}
</div>`;
@@ -167,8 +170,12 @@ export class LobbyTeamView extends LitElement {
this.clients,
(c) => c.clientID ?? c.username,
(client) => {
const displayName = this.displayUsername(client);
return html`<span class="player-tag">
const displayName = this.getClientDisplayName(client);
return html`<span
class="player-tag ${this.isCurrentPlayer(client)
? "current-player"
: ""}"
>
<span class="text-white">${displayName}</span>
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
@@ -204,7 +211,12 @@ export class LobbyTeamView extends LitElement {
const teamLabel = getTranslatedPlayerTeamLabel(preview.team);
return html`
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
<div
class="bg-gray-800 border rounded-xl flex flex-col
${this.teamContainsCurrentPlayer(preview)
? "border-sky-500/60"
: "border-gray-700"}"
>
<div
class="px-2 py-1 font-bold flex items-center justify-between text-white rounded-t-xl text-[13px] gap-2 bg-gray-700/70"
>
@@ -226,9 +238,12 @@ export class LobbyTeamView extends LitElement {
preview.players,
(p) => p.clientID ?? p.username,
(p) => {
const displayName = this.displayUsername(p);
const displayName = this.getClientDisplayName(p);
return html` <div
class="bg-gray-700/70 px-2 py-1 rounded-sm text-xs flex items-center justify-between"
class="px-2 py-1 rounded-sm text-xs flex items-center justify-between border
${this.isCurrentPlayer(p)
? "bg-sky-600/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
<span class="truncate text-white">${displayName}</span>
${p.clientID === this.lobbyCreatorClientID
@@ -318,7 +333,14 @@ export class LobbyTeamView extends LitElement {
const players = this.clients.map(
(c) =>
new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID),
new PlayerInfo(
c.username,
PlayerType.Human,
c.clientID,
c.clientID,
false,
c.clanTag,
),
);
const assignment = assignTeamsLobbyPreview(
players,
@@ -358,17 +380,25 @@ export class LobbyTeamView extends LitElement {
}));
}
private displayUsername(client: ClientInfo): string {
private isCurrentPlayer(client: ClientInfo): boolean {
return !!this.currentClientID && client.clientID === this.currentClientID;
}
private teamContainsCurrentPlayer(preview: TeamPreviewData): boolean {
return preview.players.some((p) => this.isCurrentPlayer(p));
}
private getClientDisplayName(client: ClientInfo): string {
const full = formatPlayerDisplayName(client.username, client.clanTag);
if (!this.userSettings.anonymousNames()) {
return client.username;
return full;
}
if (this.currentClientID && client.clientID === this.currentClientID) {
return client.username;
return full;
}
return (
createRandomName(client.username, PlayerType.Human) ?? client.username
);
// Keep clan tag visible while anonymizing only the username.
const anonymizedUsername =
createRandomName(client.username, PlayerType.Human) ?? client.username;
return formatPlayerDisplayName(anonymizedUsername, client.clanTag);
}
}
-213
View File
@@ -1,213 +0,0 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
const VIDEO_AD_UNIT_TYPE = "precontent_ad_video";
@customElement("video-ad")
export class VideoAd extends LitElement {
@state()
private isVisible: boolean = true;
@property({ attribute: false })
onComplete?: () => void;
@property({ attribute: false })
onMidpoint?: () => void;
@property({ attribute: false })
onAdBlocked?: () => void;
private adLoadTimeout: ReturnType<typeof setTimeout> | null = null;
private rampCheckInterval: ReturnType<typeof setInterval> | null = null;
private rampWaitTimeout: ReturnType<typeof setTimeout> | null = null;
private adStarted = false;
// How long to wait for ad to start before assuming it's blocked
private static readonly AD_LOAD_TIMEOUT_MS = 8000;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
// Set dimensions on the custom element itself (required by Playwire)
// Playwire requires explicit pixel dimensions, use max-width for responsiveness
this.style.display = "block";
this.style.width = "100%";
this.style.maxWidth = "800px";
this.style.aspectRatio = "16/9";
this.showVideoAd();
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up timeout if component is removed
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
if (this.rampCheckInterval) {
clearInterval(this.rampCheckInterval);
this.rampCheckInterval = null;
}
if (this.rampWaitTimeout) {
clearTimeout(this.rampWaitTimeout);
this.rampWaitTimeout = null;
}
}
public showVideoAd(): void {
if (!window.ramp) {
// Wait for ramp to be available, but give up after timeout
this.rampCheckInterval = setInterval(() => {
if (window.ramp && window.ramp.que) {
if (this.rampCheckInterval) {
clearInterval(this.rampCheckInterval);
this.rampCheckInterval = null;
}
if (this.rampWaitTimeout) {
clearTimeout(this.rampWaitTimeout);
this.rampWaitTimeout = null;
}
this.loadVideoAd();
}
}, 100);
// Stop polling after timeout (e.g. adblocker preventing ramp from loading)
this.rampWaitTimeout = setTimeout(() => {
if (this.rampCheckInterval) {
clearInterval(this.rampCheckInterval);
this.rampCheckInterval = null;
}
console.log("[VideoAd] Ramp SDK never loaded - possible adblocker");
this.handleAdBlocked();
}, VideoAd.AD_LOAD_TIMEOUT_MS);
return;
}
this.loadVideoAd();
}
private loadVideoAd(): void {
// Start timeout to detect if ad doesn't load (e.g., due to adblocker)
this.adLoadTimeout = setTimeout(() => {
if (!this.adStarted) {
console.log("[VideoAd] Ad load timeout - possible adblocker detected");
this.handleAdBlocked();
}
}, VideoAd.AD_LOAD_TIMEOUT_MS);
// Set up event listeners when player is ready, chaining any existing handler
const prevOnPlayerReady = window.ramp.onPlayerReady;
window.ramp.onPlayerReady = () => {
if (prevOnPlayerReady) prevOnPlayerReady();
if (window.Bolt) {
// Listen for ad start to know ad is loading successfully
window.Bolt.on(
VIDEO_AD_UNIT_TYPE,
window.Bolt.BOLT_AD_STARTED ?? "boltAdStarted",
() => {
console.log("[VideoAd] Ad started");
this.adStarted = true;
// Clear the timeout since ad is playing
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
},
);
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_COMPLETE, () => {
console.log("[VideoAd] Ad completed");
this.hideElement();
});
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_ERROR, () => {
console.log("[VideoAd] Ad error/no fill");
this.handleAdBlocked();
});
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_MIDPOINT, () => {
console.log("[VideoAd] Ad midpoint");
if (this.onMidpoint) {
this.onMidpoint();
}
});
window.Bolt.on(
VIDEO_AD_UNIT_TYPE,
window.Bolt.SHOW_HIDDEN_CONTAINER ?? "showHiddenContainer",
() => {
console.log("[VideoAd] Ad finished");
this.hideElement();
},
);
}
};
// Queue the video ad initialization
window.ramp.que.push(() => {
const pwUnits = [{ type: VIDEO_AD_UNIT_TYPE }];
window.ramp
.addUnits(pwUnits)
.then(() => {
window.ramp.displayUnits();
})
.catch((e: Error) => {
console.error("[VideoAd] Error adding units:", e);
window.ramp.displayUnits();
});
});
}
private handleAdBlocked(): void {
// Clear timeout if still pending
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
// Call the callback if provided
if (this.onAdBlocked) {
this.onAdBlocked();
}
}
private hideElement(): void {
this.style.display = "none";
this.isVisible = false;
// Call the callback if provided
if (this.onComplete) {
this.onComplete();
}
// Also dispatch event for backwards compatibility
this.dispatchEvent(
new CustomEvent("ad-complete", {
bubbles: true,
composed: true,
}),
);
}
render() {
if (!this.isVisible) {
return html``;
}
// Provide a container for the Playwire video player to render into
// Structure matches Playwire example: wrapper > game-video-ad > precontent-video-location
return html`
<div
class="game-video-ad"
style="width: 100%; height: 100%; overflow: hidden;"
>
<div
id="precontent-video-location"
style="width: 100%; height: 100%;"
></div>
</div>
`;
}
}
@@ -26,9 +26,8 @@ export enum RankType {
export interface PlayerInfo {
id: string;
rawUsername: string;
username: string;
tag?: string;
clanTag: string | null;
killedAt?: number;
gold: bigint[];
conquests: bigint[];
@@ -77,18 +76,12 @@ export class Ranking {
for (const player of session.info.players) {
if (player === undefined || !hasPlayed(player)) continue;
const stats = player.stats!;
const match = player.username.match(/^\[(.*?)\]\s*(.*)$/);
let username = player.username;
if (player.clanTag && match) {
username = match[2];
}
const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0));
const conquests = (stats.conquests ?? []).map((v) => BigInt(v ?? 0));
players[player.clientID] = {
id: player.clientID,
rawUsername: player.username,
username,
tag: player.clanTag,
username: player.username,
clanTag: player.clanTag,
conquests,
flag: player.cosmetics?.flag ?? undefined,
killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined,
@@ -220,7 +220,7 @@ export class PlayerRow extends LitElement {
private renderPlayerName() {
return html`
<div class="flex gap-1 items-center w-50 shrink-0">
${this.player.tag ? this.renderTag(this.player.tag) : ""}
${this.player.clanTag ? this.renderTag(this.player.clanTag) : ""}
<div
class="text-xs sm:text-sm font-bold tracking-wide text-white/80 text-ellipsis w-37.5 shrink-0 overflow-hidden whitespace-nowrap"
>
@@ -249,9 +249,7 @@ export class LeaderboardPlayerList extends LitElement {
</div>`
: ""}
<span class="font-bold text-blue-300 truncate text-base"
>${player.clanTag
? player.username.replace(/^\[.*?\]\s*/, "")
: player.username}</span
>${player.username}</span
>
</div>
</td>
@@ -434,14 +432,18 @@ export class LeaderboardPlayerList extends LitElement {
"leaderboard_modal.your_ranking",
)}</span
>
<span class="font-bold text-white text-base"
>${this.currentUserEntry.clanTag
? this.currentUserEntry.username.replace(
/^\[.*?\]\s*/,
"",
)
: this.currentUserEntry.username}</span
>
<div class="flex items-center gap-2">
${this.currentUserEntry.clanTag
? html`<div
class="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-300/40 text-[10px] font-bold text-blue-100 shrink-0"
>
${this.currentUserEntry.clanTag}
</div>`
: ""}
<span class="font-bold text-white text-base"
>${this.currentUserEntry.username}</span
>
</div>
</div>
<div class="flex flex-col items-end w-20">
<div class="font-mono text-white font-bold text-lg">
+8 -16
View File
@@ -7,6 +7,7 @@ import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { AlertFrame } from "./layers/AlertFrame";
import { AttackingTroopsOverlay } from "./layers/AttackingTroopsOverlay";
import { AttacksDisplay } from "./layers/AttacksDisplay";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
@@ -21,7 +22,7 @@ import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { ImmunityTimer } from "./layers/ImmunityTimer";
import { InGameHeaderAd } from "./layers/InGameHeaderAd";
import { InGamePromo } from "./layers/InGamePromo";
import { Layer } from "./layers/Layer";
import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
@@ -36,7 +37,6 @@ import { ReplayPanel } from "./layers/ReplayPanel";
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
import { SettingsModal } from "./layers/SettingsModal";
import { SpawnTimer } from "./layers/SpawnTimer";
import { SpawnVideoAd } from "./layers/SpawnVideoReward";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
@@ -262,19 +262,11 @@ export function createRenderer(
immunityTimer.game = game;
immunityTimer.eventBus = eventBus;
const inGameHeaderAd = document.querySelector(
"in-game-header-ad",
) as InGameHeaderAd;
if (!(inGameHeaderAd instanceof InGameHeaderAd)) {
console.error("in-game header ad not found");
const inGamePromo = document.querySelector("in-game-promo") as InGamePromo;
if (!(inGamePromo instanceof InGamePromo)) {
console.error("in-game promo not found");
}
inGameHeaderAd.game = game;
const spawnVideoAd = document.querySelector("spawn-video-ad") as SpawnVideoAd;
if (!(spawnVideoAd instanceof SpawnVideoAd)) {
console.error("spawn video ad not found");
}
spawnVideoAd.game = game;
inGamePromo.game = game;
// When updating these layers please be mindful of the order.
// Try to group layers by the return value of shouldTransform.
@@ -293,6 +285,7 @@ export function createRenderer(
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new DynamicUILayer(game, transformHandler, eventBus),
new NameLayer(game, transformHandler, eventBus),
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
eventsDisplay,
attacksDisplay,
chatDisplay,
@@ -321,8 +314,7 @@ export function createRenderer(
playerPanel,
headsUpMessage,
multiTabModal,
inGameHeaderAd,
spawnVideoAd,
inGamePromo,
alertFrame,
performanceOverlay,
];
+1 -1
View File
@@ -52,7 +52,7 @@ export function placeName(game: Game, player: Player): NameViewData {
),
);
const fontSize = calculateFontSize(largestRectangle, player.name());
const fontSize = calculateFontSize(largestRectangle, player.displayName());
center = new Cell(center.x, center.y - fontSize / 3);
return {
@@ -0,0 +1,305 @@
import { EventBus } from "../../../core/EventBus";
import { Cell } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent } from "../../InputHandler";
import { renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import shieldIcon from "/images/ShieldIconWhite.svg?url";
import swordIcon from "/images/SwordIconWhite.svg?url";
export function troopAttackColor(
attackerTroops: number,
defenderTroops: number,
): string {
return attackerTroops > defenderTroops ? "#66ff66" : "#ffbe3c";
}
export function troopDefenceColor(
attackerTroops: number,
myTroops: number,
): string {
return attackerTroops > myTroops ? "#ff4444" : "#ff9944";
}
// An attack can have multiple disconnected front-line segments, so elements
// and positions are parallel arrays with one entry per segment.
interface AttackLabel {
elements: HTMLDivElement[];
positions: (Cell | null)[];
isIncoming: boolean;
attackerTroops: number;
defenderTroops: number;
}
export class AttackingTroopsOverlay implements Layer {
private container: HTMLDivElement;
private labels = new Map<string, AttackLabel>();
// Guard against queuing multiple worker requests in the same tick window.
private inFlightRequest = false;
private isVisible = true;
private onAlternateView: (e: AlternateViewEvent) => void;
constructor(
private readonly game: GameView,
private readonly transformHandler: TransformHandler,
private readonly eventBus: EventBus,
private readonly userSettings: UserSettings,
) {}
shouldTransform(): boolean {
return false;
}
init() {
this.container = document.createElement("div");
this.container.style.position = "fixed";
this.container.style.left = "50%";
this.container.style.top = "50%";
this.container.style.pointerEvents = "none";
// z-index 4 places labels above NameLayer (z-index 3).
this.container.style.zIndex = "4";
document.body.appendChild(this.container);
this.onAlternateView = (e) => {
this.isVisible = !e.alternateView;
this.container.style.display = this.isVisible ? "" : "none";
};
this.eventBus.on(AlternateViewEvent, this.onAlternateView);
}
destroy() {
if (!this.container) return;
this.clearAllLabels();
this.container.remove();
this.eventBus.off(AlternateViewEvent, this.onAlternateView);
}
getTickIntervalMs() {
return 200;
}
tick() {
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
if (this.labels.size > 0) this.clearAllLabels();
return;
}
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
this.clearAllLabels();
return;
}
const activeIDs = new Set<string>();
// Outgoing attacks — green if winning, amber if losing.
for (const attack of myPlayer.outgoingAttacks()) {
activeIDs.add(attack.id);
if (!attack.targetID) {
this.removeLabel(attack.id);
continue;
}
const defender = this.game.playerBySmallID(attack.targetID);
if (!defender || !defender.isPlayer()) {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, defender.troops(), false);
}
// Incoming attacks — red if the attacker outnumbers the player, orange otherwise.
for (const attack of myPlayer.incomingAttacks()) {
activeIDs.add(attack.id);
const attacker = this.game.playerBySmallID(attack.attackerID);
if (!attacker || !attacker.isPlayer()) {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, myPlayer.troops(), true);
}
for (const [id] of this.labels) {
if (!activeIDs.has(id)) this.removeLabel(id);
}
// Single worker request per tick; skip if the previous one is still in flight.
if (this.inFlightRequest) return;
this.inFlightRequest = true;
void myPlayer
.attackClusteredPositions()
.then((attacks) => {
for (const { id, positions } of attacks) {
const lbl = this.labels.get(id);
if (!lbl) continue;
this.reconcileLabelPositions(lbl, positions);
}
})
.catch(() => {
// On error, hide all labels until the next successful response.
for (const lbl of this.labels.values()) lbl.positions.fill(null);
})
.finally(() => {
this.inFlightRequest = false;
});
}
private ensureLabel(
attackID: string,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
) {
let label = this.labels.get(attackID);
if (!label) {
label = {
elements: [],
positions: [],
isIncoming,
attackerTroops,
defenderTroops,
};
this.labels.set(attackID, label);
} else {
label.attackerTroops = attackerTroops;
label.defenderTroops = defenderTroops;
}
for (const el of label.elements) {
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
}
}
renderLayer(_context: CanvasRenderingContext2D) {
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
);
const screenPos = new Cell(
screenPosOld.x - window.innerWidth / 2,
screenPosOld.y - window.innerHeight / 2,
);
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
for (const label of this.labels.values()) {
for (let i = 0; i < label.elements.length; i++) {
const el = label.elements[i];
const pos = label.positions[i];
if (!pos || !this.transformHandler.isOnScreen(pos)) {
el.style.display = "none";
continue;
}
el.style.display = "inline-flex";
// Centre the label on its world position and counter-scale so text
// stays the same screen size regardless of zoom level.
el.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
}
}
}
private reconcileLabelPositions(lbl: AttackLabel, positions: Cell[]) {
// Add elements for new clusters.
while (lbl.elements.length < positions.length) {
lbl.elements.push(
this.createLabelElement(
lbl.attackerTroops,
lbl.defenderTroops,
lbl.isIncoming,
),
);
lbl.positions.push(null);
}
// Remove elements for clusters that no longer exist.
while (lbl.elements.length > positions.length) {
lbl.elements.pop()!.remove();
lbl.positions.pop();
}
// Snap large jumps instantly; let the CSS transition handle small advances.
for (let i = 0; i < positions.length; i++) {
const old = lbl.positions[i];
const next = positions[i];
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 50) {
const el = lbl.elements[i];
el.style.transition = "none";
el.style.transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
requestAnimationFrame(() => {
el.style.transition = "transform 0.2s ease-out";
});
}
lbl.positions[i] = next;
}
}
private createLabelElement(
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
): HTMLDivElement {
const el = document.createElement("div");
el.style.position = "absolute";
el.style.display = "none";
el.style.alignItems = "center";
el.style.gap = "3px";
el.style.width = "max-content";
el.style.whiteSpace = "nowrap";
el.style.fontSize = "11px";
el.style.fontWeight = "bold";
el.style.fontFamily = this.game.config().theme().font();
el.style.padding = "1px 4px";
el.style.borderRadius = "3px";
el.style.backgroundColor = "rgba(0,0,0,0.55)";
el.style.pointerEvents = "none";
el.style.lineHeight = "1.3";
// Smooth the label to its new position as the front line advances.
el.style.transition = "transform 0.2s ease-out";
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
this.container.appendChild(el);
return el;
}
private updateLabelContent(
el: HTMLDivElement,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
) {
// Reuse existing children to avoid DOM churn on every tick.
let icon = el.querySelector("img") as HTMLImageElement | null;
let span = el.querySelector("span") as HTMLSpanElement | null;
if (!icon || !span) {
icon = document.createElement("img");
icon.style.width = "10px";
icon.style.height = "10px";
span = document.createElement("span");
el.replaceChildren(icon, span);
}
if (isIncoming) {
icon.src = shieldIcon;
span.style.color = troopDefenceColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
} else {
icon.src = swordIcon;
span.style.color = troopAttackColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
}
}
private removeLabel(attackID: string) {
const label = this.labels.get(attackID);
if (!label) return;
for (const el of label.elements) el.remove();
this.labels.delete(attackID);
}
private clearAllLabels() {
for (const label of this.labels.values()) {
for (const el of label.elements) el.remove();
}
this.labels.clear();
}
}
+13 -17
View File
@@ -184,17 +184,13 @@ export class AttacksDisplay extends LitElement implements Layer {
const playerView = this.game.playerBySmallID(attack.attackerID);
if (playerView !== undefined) {
if (playerView instanceof PlayerView) {
const averagePosition = await playerView.attackAveragePosition(
attack.attackerID,
attack.id,
);
const attacks = await playerView.attackClusteredPositions(attack.id);
const pos = attacks[0]?.positions[0];
if (averagePosition === null) {
if (!pos) {
this.emitGoToPlayerEvent(attack.attackerID);
} else {
this.eventBus.emit(
new GoToPositionEvent(averagePosition.x, averagePosition.y),
);
this.eventBus.emit(new GoToPositionEvent(pos.x, pos.y));
}
}
} else {
@@ -222,7 +218,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.incomingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<span class="inline-flex items-center"
@@ -235,7 +231,7 @@ export class AttacksDisplay extends LitElement implements Layer {
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.attackerID) as PlayerView
)?.name()}</span
)?.displayName()}</span
>
${attack.retreating
? `(${translateText("events_display.retreating")}...)`
@@ -269,7 +265,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<span class="inline-flex items-center"
@@ -282,7 +278,7 @@ export class AttacksDisplay extends LitElement implements Layer {
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.targetID) as PlayerView
)?.name()}</span
)?.displayName()}</span
> `,
onClick: async () => this.attackWarningOnClick(attack),
className:
@@ -310,7 +306,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingLandAttacks.map(
(landAttack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<span class="inline-flex items-center"
@@ -346,7 +342,7 @@ export class AttacksDisplay extends LitElement implements Layer {
const ownerID = this.game.ownerID(target);
if (ownerID === 0) return "";
const player = this.game.playerBySmallID(ownerID) as PlayerView;
return player?.name() ?? "";
return player?.displayName() ?? "";
}
private renderBoatIcon(boat: UnitView) {
@@ -365,7 +361,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
@@ -401,7 +397,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.incomingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/92 backdrop-blur-sm sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
@@ -409,7 +405,7 @@ export class AttacksDisplay extends LitElement implements Layer {
>${renderTroops(boat.troops())}</span
>
<span class="truncate text-xs ml-1"
>${boat.owner()?.name()}</span
>${boat.owner()?.displayName()}</span
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
className:
+6 -5
View File
@@ -147,7 +147,7 @@ export class ChatModal extends LitElement {
.toHex()};"
@click=${() => this.selectPlayer(player)}
>
${player.name()}
${player.displayName()}
</button>
`,
)}
@@ -216,7 +216,8 @@ export class ChatModal extends LitElement {
private selectPlayer(player: PlayerView) {
if (this.previewText) {
this.previewText =
this.selectedPhraseTemplate?.replace("[P1]", player.name()) ?? null;
this.selectedPhraseTemplate?.replace("[P1]", player.displayName()) ??
null;
this.selectedPlayer = player;
this.requiresPlayerSelection = false;
this.requestUpdate();
@@ -255,13 +256,13 @@ export class ChatModal extends LitElement {
private getSortedFilteredPlayers(): PlayerView[] {
const sorted = [...this.players].sort((a, b) =>
a.name().localeCompare(b.name()),
a.displayName().localeCompare(b.displayName()),
);
const filtered = sorted.filter((p) =>
p.name().toLowerCase().includes(this.playerSearchQuery),
p.displayName().toLowerCase().includes(this.playerSearchQuery),
);
const others = sorted.filter(
(p) => !p.name().toLowerCase().includes(this.playerSearchQuery),
(p) => !p.displayName().toLowerCase().includes(this.playerSearchQuery),
);
return [...filtered, ...others];
}
+12 -12
View File
@@ -283,7 +283,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.about_to_expire", {
name: other.name(),
name: other.displayName(),
}),
type: MessageType.RENEW_ALLIANCE,
duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer
@@ -296,7 +296,7 @@ export class EventsDisplay extends LitElement implements Layer {
},
{
text: translateText("events_display.renew_alliance", {
name: other.name(),
name: other.displayName(),
}),
className: "btn",
action: () =>
@@ -460,7 +460,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.request_alliance", {
name: requestor.name(),
name: requestor.displayName(),
}),
buttons: [
{
@@ -525,7 +525,7 @@ export class EventsDisplay extends LitElement implements Layer {
) as PlayerView;
this.addEvent({
description: translateText("events_display.alliance_request_status", {
name: recipient.name(),
name: recipient.displayName(),
status: update.accepted
? translateText("events_display.alliance_accepted")
: translateText("events_display.alliance_rejected"),
@@ -569,7 +569,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.betrayal_description", {
name: betrayed.name(),
name: betrayed.displayName(),
malusPercent: malusPercent,
durationText: durationText,
}),
@@ -589,7 +589,7 @@ export class EventsDisplay extends LitElement implements Layer {
];
this.addEvent({
description: translateText("events_display.betrayed_you", {
name: traitor.name(),
name: traitor.displayName(),
}),
type: MessageType.ALLIANCE_BROKEN,
highlight: true,
@@ -616,7 +616,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.alliance_expired", {
name: other.name(),
name: other.displayName(),
}),
type: MessageType.ALLIANCE_EXPIRED,
highlight: true,
@@ -641,8 +641,8 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: translateText("events_display.attack_request", {
name: other.name(),
target: target.name(),
name: other.displayName(),
target: target.displayName(),
}),
type: MessageType.ATTACK_REQUEST,
highlight: true,
@@ -809,7 +809,7 @@ export class EventsDisplay extends LitElement implements Layer {
`,
onClick: this.toggleHidden,
className:
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/92 backdrop-blur-sm",
})}
</div>
`
@@ -820,7 +820,7 @@ export class EventsDisplay extends LitElement implements Layer {
>
<!-- Button Bar -->
<div
class="w-full p-2 lg:p-3 bg-gray-800/70 sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
class="w-full p-2 lg:p-3 bg-gray-800/92 backdrop-blur-sm sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
>
<div class="flex justify-between items-center gap-3">
<div class="flex gap-4">
@@ -864,7 +864,7 @@ export class EventsDisplay extends LitElement implements Layer {
<!-- Content Area -->
<div
class="bg-gray-800/70 max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
class="bg-gray-800/92 backdrop-blur-sm max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
>
<div>
<table
@@ -102,7 +102,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
render() {
return html`
<aside
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "hidden"
}`}
style="margin-top: ${this.barOffset}px;"
+10 -1
View File
@@ -4,6 +4,7 @@ import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { TogglePauseIntentEvent } from "../../InputHandler";
import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
@@ -67,6 +68,14 @@ export class GameRightSidebar extends LitElement implements Layer {
this.requestUpdate();
});
this.eventBus.on(TogglePauseIntentEvent, () => {
const isReplayOrSingleplayer =
this._isSinglePlayer || this.game?.config()?.isReplay();
if (isReplayOrSingleplayer || this.isLobbyCreator) {
this.onPauseButtonClick();
}
});
this.requestUpdate();
}
@@ -175,7 +184,7 @@ export class GameRightSidebar extends LitElement implements Layer {
return html`
<aside
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
this._isVisible ? "translate-x-0" : "translate-x-full"
}`}
@contextmenu=${(e: Event) => e.preventDefault()}
@@ -1,119 +0,0 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_SHOW_TICKS = 10 * 60 * 10; // 2 minutes
const HEADER_AD_TYPE = "standard_iab_head1";
const HEADER_AD_CONTAINER_ID = "header-ad-container";
const TWO_XL_BREAKPOINT = 1536;
@customElement("in-game-header-ad")
export class InGameHeaderAd extends LitElement implements Layer {
public game: GameView;
private isHidden: boolean = false;
private adLoaded: boolean = false;
private shouldShow: boolean = false;
createRenderRoot() {
return this;
}
init() {
// TODO: move ad and re-enable.
// this.showHeaderAd();
}
private showHeaderAd(): void {
// Don't show header ad on screens smaller than 2xl
if (window.innerWidth < TWO_XL_BREAKPOINT) {
return;
}
if (!window.adsEnabled) {
return;
}
this.shouldShow = true;
this.requestUpdate();
// Wait for the element to render before loading the ad
this.updateComplete.then(() => {
this.loadAd();
});
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for header ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{
type: HEADER_AD_TYPE,
selectorId: HEADER_AD_CONTAINER_ID,
},
]);
this.adLoaded = true;
console.log("Header ad loaded:", HEADER_AD_TYPE);
} catch (e) {
console.error("Failed to add header ad:", e);
}
});
} catch (error) {
console.error("Failed to load header ad:", error);
}
}
private hideHeaderAd(): void {
this.shouldShow = false;
this.adLoaded = false;
try {
window.ramp.destroyUnits(HEADER_AD_TYPE);
console.log("successfully destroyed in game header ad");
} catch (e) {
console.error("error destroying in game header ad", e);
}
this.requestUpdate();
}
public tick() {
if (this.isHidden) {
return;
}
const gameTicks =
this.game.ticks() - this.game.config().numSpawnPhaseTurns();
if (gameTicks > AD_SHOW_TICKS) {
console.log("destroying header ad and refreshing PageOS");
this.hideHeaderAd();
this.isHidden = true;
if (window.PageOS?.session?.newPageView) {
window.PageOS.session.newPageView();
}
return;
}
}
shouldTransform(): boolean {
return false;
}
render() {
if (!this.shouldShow) {
return html``;
}
return html`
<div
id="${HEADER_AD_CONTAINER_ID}"
class="hidden 2xl:flex fixed top-0 left-1/2 -translate-x-1/2 z-[100] justify-center items-center pointer-events-auto p-0 -mt-[20px]"
></div>
`;
}
}
+146
View File
@@ -0,0 +1,146 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_TYPE = "standard_iab_left1";
const AD_CONTAINER_ID = "in-game-bottom-left-ad";
const BOTTOM_RAIL_TYPE = "bottom_rail";
@customElement("in-game-promo")
export class InGamePromo extends LitElement implements Layer {
public game: GameView;
private shouldShow: boolean = false;
private bottomRailActive: boolean = false;
private cornerAdShown: boolean = false;
createRenderRoot() {
return this;
}
init() {
this.showBottomRail();
}
tick() {
if (!this.game.inSpawnPhase()) {
if (this.bottomRailActive) {
this.destroyBottomRail();
}
if (!this.cornerAdShown) {
this.cornerAdShown = true;
this.showAd();
}
}
}
private showBottomRail(): void {
if (!window.adsEnabled) return;
if (!this.game.inSpawnPhase()) return;
if (!window.ramp) {
console.warn("Playwire RAMP not available for bottom_rail ad");
return;
}
this.bottomRailActive = true;
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([{ type: BOTTOM_RAIL_TYPE }]);
console.log("Bottom rail ad loaded during spawn phase");
} catch (e) {
console.error("Failed to add bottom_rail ad:", e);
}
});
} catch (error) {
console.error("Failed to load bottom_rail ad:", error);
}
}
private destroyBottomRail(): void {
if (!this.bottomRailActive) return;
this.bottomRailActive = false;
if (!window.ramp) return;
try {
window.ramp.spaAds({ ads: [], countPageview: false });
console.log("Bottom rail ad destroyed via spaAds after spawn phase");
} catch (e) {
console.error("Error destroying bottom_rail ad:", e);
}
}
private showAd(): void {
if (!window.adsEnabled) return;
if (window.innerWidth < 1100) return;
if (window.innerHeight < 750) return;
this.shouldShow = true;
this.requestUpdate();
this.updateComplete.then(() => {
this.loadAd();
});
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{
type: AD_TYPE,
selectorId: AD_CONTAINER_ID,
},
]);
console.log("In-game bottom-left ad loaded:", AD_TYPE);
} catch (e) {
console.error("Failed to add in-game ad:", e);
}
});
} catch (error) {
console.error("Failed to load in-game ad:", error);
}
}
public hideAd(): void {
this.destroyBottomRail();
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
this.shouldShow = false;
try {
window.ramp.destroyUnits(AD_TYPE);
console.log("successfully destroyed in-game bottom-left ad");
} catch (e) {
console.error("error destroying in-game ad:", e);
}
this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
render() {
if (!this.shouldShow) {
return html``;
}
return html`
<div
id="${AD_CONTAINER_ID}"
class="fixed left-0 z-[100] pointer-events-auto"
style="bottom: -0.7cm"
></div>
`;
}
}
+76 -125
View File
@@ -6,7 +6,7 @@ import { Cell } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent } from "../../InputHandler";
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
import { renderTroops } from "../../Utils";
import {
computeAllianceClipPath,
createAllianceProgressIcon,
@@ -16,11 +16,22 @@ import {
} from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import shieldIcon from "/images/ShieldIconBlack.svg?url";
const PLAYER_NAME = "player-name";
const PLAYER_NAME_SPAN = "player-name-span";
const PLAYER_TROOPS = "player-troops";
const PLAYER_ICONS = "player-icons";
const PLAYER_FLAG = "player-flag";
class RenderInfo {
public icons: Map<PlayerIconId, HTMLElement> = new Map(); // Track icon elements
public nameDiv: HTMLDivElement;
public nameSpan: HTMLSpanElement | null;
public troopsDiv: HTMLDivElement;
public flagDiv: HTMLDivElement | null;
public iconsDiv: HTMLDivElement;
constructor(
public player: PlayerView,
public lastRenderCalc: number,
@@ -28,39 +39,41 @@ class RenderInfo {
public fontSize: number,
public fontColor: string,
public element: HTMLElement,
) {}
) {
// Traverse the DOM once, upon creation
this.nameDiv = element.querySelector(`.${PLAYER_NAME}`) as HTMLDivElement;
this.nameSpan = element.querySelector(
`.${PLAYER_NAME_SPAN}`,
) as HTMLSpanElement | null;
this.troopsDiv = element.querySelector(
`.${PLAYER_TROOPS}`,
) as HTMLDivElement;
this.flagDiv = element.querySelector(
`.${PLAYER_FLAG}`,
) as HTMLDivElement | null;
this.iconsDiv = element.querySelector(`.${PLAYER_ICONS}`) as HTMLDivElement;
}
}
export class NameLayer implements Layer {
private canvas: HTMLCanvasElement;
private lastChecked = 0;
private renderCheckRate = 100;
private renderRefreshRate = 500;
private rand = new PseudoRandom(10);
private renders: RenderInfo[] = [];
private seenPlayers: Set<PlayerView> = new Set();
private shieldIconImage: HTMLImageElement;
private container: HTMLDivElement;
private theme: Theme = this.game.config().theme();
private userSettings: UserSettings = new UserSettings();
private isVisible: boolean = true;
private firstPlace: PlayerView | null = null;
private lastContainerTransform: string = "";
constructor(
private game: GameView,
private transformHandler: TransformHandler,
private eventBus: EventBus,
) {
this.shieldIconImage = new Image();
this.shieldIconImage.src = shieldIcon;
this.shieldIconImage = new Image();
this.shieldIconImage.src = shieldIcon;
}
resizeCanvas() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
) {}
shouldTransform(): boolean {
return false;
@@ -71,10 +84,6 @@ export class NameLayer implements Layer {
}
public init() {
this.canvas = createCanvas();
window.addEventListener("resize", () => this.resizeCanvas());
this.resizeCanvas();
this.container = document.createElement("div");
this.container.style.position = "fixed";
this.container.style.left = "50%";
@@ -109,12 +118,13 @@ export class NameLayer implements Layer {
}
}
private updateElementVisibility(render: RenderInfo) {
private updateElementVisibility(render: RenderInfo, baseSize?: number) {
if (!render.player.nameLocation() || !render.player.isAlive()) {
return;
}
const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size));
baseSize =
baseSize ?? Math.max(1, Math.floor(render.player.nameLocation().size));
const size = this.transformHandler.scale * baseSize;
const isOnScreen = render.location
? this.transformHandler.isOnScreen(render.location)
@@ -160,7 +170,7 @@ export class NameLayer implements Layer {
}
}
public renderLayer(mainContex: CanvasRenderingContext2D) {
public renderLayer() {
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
);
@@ -168,7 +178,11 @@ export class NameLayer implements Layer {
screenPosOld.x - window.innerWidth / 2,
screenPosOld.y - window.innerHeight / 2,
);
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
const newTransform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
if (this.lastContainerTransform !== newTransform) {
this.container.style.transform = newTransform;
this.lastContainerTransform = newTransform;
}
const now = Date.now();
if (now > this.lastChecked + this.renderCheckRate) {
@@ -177,14 +191,6 @@ export class NameLayer implements Layer {
this.renderPlayerInfo(render);
}
}
mainContex.drawImage(
this.canvas,
0,
0,
mainContex.canvas.width,
mainContex.canvas.height,
);
}
private createPlayerElement(player: PlayerView): HTMLDivElement {
@@ -196,7 +202,7 @@ export class NameLayer implements Layer {
element.style.gap = "0px";
const iconsDiv = document.createElement("div");
iconsDiv.classList.add("player-icons");
iconsDiv.classList.add(PLAYER_ICONS);
iconsDiv.style.display = "flex";
iconsDiv.style.gap = "4px";
iconsDiv.style.justifyContent = "center";
@@ -207,7 +213,7 @@ export class NameLayer implements Layer {
const nameDiv = document.createElement("div");
const applyFlagStyles = (element: HTMLElement): void => {
element.classList.add("player-flag");
element.classList.add(PLAYER_FLAG);
element.style.opacity = "0.8";
element.style.zIndex = "1";
element.style.aspectRatio = "3/4";
@@ -227,7 +233,7 @@ export class NameLayer implements Layer {
nameDiv.appendChild(flagImg);
}
}
nameDiv.classList.add("player-name");
nameDiv.classList.add(PLAYER_NAME);
nameDiv.style.color = this.theme.textColor(player);
nameDiv.style.fontFamily = this.theme.font();
nameDiv.style.whiteSpace = "nowrap";
@@ -238,13 +244,13 @@ export class NameLayer implements Layer {
nameDiv.style.alignItems = "center";
const nameSpan = document.createElement("span");
nameSpan.className = "player-name-span";
nameSpan.innerHTML = player.name();
nameSpan.className = PLAYER_NAME_SPAN;
nameSpan.textContent = player.displayName();
nameDiv.appendChild(nameSpan);
element.appendChild(nameDiv);
const troopsDiv = document.createElement("div");
troopsDiv.classList.add("player-troops");
troopsDiv.classList.add(PLAYER_TROOPS);
troopsDiv.setAttribute("translate", "no");
troopsDiv.textContent = renderTroops(player.troops());
troopsDiv.style.color = this.theme.textColor(player);
@@ -253,33 +259,6 @@ export class NameLayer implements Layer {
troopsDiv.style.marginTop = "-5%";
element.appendChild(troopsDiv);
// TODO: Remove the shield icon.
/* eslint-disable no-constant-condition */
if (false) {
const shieldDiv = document.createElement("div");
shieldDiv.classList.add("player-shield");
shieldDiv.style.zIndex = "3";
shieldDiv.style.marginTop = "-5%";
shieldDiv.style.display = "flex";
shieldDiv.style.alignItems = "center";
shieldDiv.style.gap = "0px";
const shieldImg = document.createElement("img");
shieldImg.src = this.shieldIconImage.src;
shieldImg.style.width = "16px";
shieldImg.style.height = "16px";
const shieldSpan = document.createElement("span");
shieldSpan.textContent = "0";
shieldSpan.style.color = "black";
shieldSpan.style.fontSize = "10px";
shieldSpan.style.marginTop = "-2px";
shieldDiv.appendChild(shieldImg);
shieldDiv.appendChild(shieldSpan);
element.appendChild(shieldDiv);
}
/* eslint-enable no-constant-condition */
// Start off invisible so it doesn't flash at 0,0
element.style.display = "none";
@@ -297,26 +276,27 @@ export class NameLayer implements Layer {
return;
}
const oldLocation = render.location;
render.location = new Cell(
render.player.nameLocation().x,
render.player.nameLocation().y,
);
// Update location and size, show or hide dependent on those
const nameLocation = render.player.nameLocation();
const newX = nameLocation.x;
const newY = nameLocation.y;
// Calculate base size and scale
const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size));
render.fontSize = Math.max(4, Math.floor(baseSize * 0.4));
render.fontColor = this.theme.textColor(render.player);
if (
!render.location ||
render.location.x !== newX ||
render.location.y !== newY
) {
render.location = new Cell(newX, newY);
}
// Update element visibility (handles Ctrl key, size, and screen position)
this.updateElementVisibility(render);
const baseSize = Math.max(1, Math.floor(nameLocation.size));
this.updateElementVisibility(render, baseSize);
// If element is hidden, don't continue with rendering
if (render.element.style.display === "none") {
return;
}
// Throttle updates
// Throttle further updates
const now = Date.now();
if (now - render.lastRenderCalc <= this.renderRefreshRate) {
return;
@@ -324,50 +304,20 @@ export class NameLayer implements Layer {
render.lastRenderCalc = now + this.rand.nextInt(0, 100);
// Update text sizes
const nameDiv = render.element.querySelector(
".player-name",
) as HTMLDivElement;
const flagDiv = render.element.querySelector(
".player-flag",
) as HTMLDivElement;
const troopsDiv = render.element.querySelector(
".player-troops",
) as HTMLDivElement;
nameDiv.style.fontSize = `${render.fontSize}px`;
nameDiv.style.lineHeight = `${render.fontSize}px`;
nameDiv.style.color = render.fontColor;
const span = nameDiv.querySelector(".player-name-span");
if (span) {
span.innerHTML = render.player.name();
}
if (flagDiv) {
flagDiv.style.height = `${render.fontSize}px`;
}
troopsDiv.style.fontSize = `${render.fontSize}px`;
troopsDiv.style.color = render.fontColor;
troopsDiv.textContent = renderTroops(render.player.troops());
render.fontSize = Math.max(4, Math.floor(baseSize * 0.4));
render.fontColor = this.theme.textColor(render.player);
const density = renderNumber(
render.player.troops() / render.player.numTilesOwned(),
);
const shieldDiv: HTMLDivElement | null =
render.element.querySelector(".player-shield");
const shieldImg = shieldDiv?.querySelector("img");
const shieldNumber = shieldDiv?.querySelector("span");
if (shieldImg) {
shieldImg.style.width = `${render.fontSize * 0.8}px`;
shieldImg.style.height = `${render.fontSize * 0.8}px`;
}
if (shieldNumber) {
shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`;
shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`;
shieldNumber.textContent = density;
render.nameDiv.style.fontSize = `${render.fontSize}px`;
render.nameDiv.style.lineHeight = `${render.fontSize}px`;
render.nameDiv.style.color = render.fontColor;
if (render.flagDiv) {
render.flagDiv.style.height = `${render.fontSize}px`;
}
render.troopsDiv.style.fontSize = `${render.fontSize}px`;
render.troopsDiv.style.color = render.fontColor;
render.troopsDiv.textContent = renderTroops(render.player.troops());
// Handle icons
const iconsDiv = render.element.querySelector(
".player-icons",
) as HTMLDivElement;
const iconSize = Math.min(render.fontSize * 1.5, 48);
// Compute which icons should be shown for this player using shared logic
@@ -399,7 +349,7 @@ export class NameLayer implements Layer {
emojiDiv.style.position = "absolute";
emojiDiv.style.top = "50%";
emojiDiv.style.transform = "translateY(-50%)";
iconsDiv.appendChild(emojiDiv);
render.iconsDiv.appendChild(emojiDiv);
render.icons.set(icon.id, emojiDiv);
}
@@ -436,7 +386,7 @@ export class NameLayer implements Layer {
hasExtensionRequest,
this.userSettings.darkMode(),
);
iconsDiv.appendChild(allianceWrapper);
render.iconsDiv.appendChild(allianceWrapper);
render.icons.set(icon.id, allianceWrapper);
} else {
// Update existing alliance icon
@@ -476,7 +426,7 @@ export class NameLayer implements Layer {
if (!imgElement) {
imgElement = this.createIconElement(icon.src, iconSize, icon.center);
iconsDiv.appendChild(imgElement);
render.iconsDiv.appendChild(imgElement);
render.icons.set(icon.id, imgElement);
}
@@ -519,10 +469,11 @@ export class NameLayer implements Layer {
}
// Position element with scale
if (render.location && render.location !== oldLocation) {
const scale = Math.min(baseSize * 0.25, 3);
render.element.style.transform = `translate(${render.location.x}px, ${render.location.y}px) translate(-50%, -50%) scale(${scale})`;
}
// Even when positionChanged is false: Scale update otherwise sometimes only happens after seconds which looks buggy.
// Because of sometimes overlapping delays of 20 ticks for nameLocation() (largestClusterBoundingBox in PlayerExecution)
// and the 500ms renderRefreshRate in NameLayer.
const scale = Math.min(baseSize * 0.25, 3);
render.element.style.transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`;
}
private createIconElement(
@@ -380,7 +380,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
/>`
: html``}
<span>${player.name()}</span>
<span>${player.displayName()}</span>
${playerTeam !== "" && player.type() !== PlayerType.Bot
? html`<div class="flex flex-col leading-tight">
<span class="text-gray-400 text-xs font-normal"
@@ -488,7 +488,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div class="p-2">
<div class="font-bold mb-1 ${isAlly ? "text-green-500" : "text-white"}">
${unit.owner().name()}
${unit.owner().displayName()}
</div>
<div class="mt-1">
<div class="text-sm opacity-80">${unit.type()}</div>
@@ -524,7 +524,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
class="bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
class="bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
>
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
@@ -65,7 +65,7 @@ export class PlayerModerationModal extends LitElement {
if (!targetClientID || targetClientID.length === 0) return;
const confirmed = confirm(
translateText("player_panel.kick_confirm", { name: other.name() }),
translateText("player_panel.kick_confirm", { name: other.displayName() }),
);
if (!confirmed) return;
@@ -142,9 +142,9 @@ export class PlayerModerationModal extends LitElement {
>
<div
class="text-sm font-semibold text-zinc-100 truncate"
title=${other.name()}
title=${other.displayName()}
>
${other.name()}
${other.displayName()}
</div>
</div>
+5 -5
View File
@@ -505,9 +505,9 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="flex-1 min-w-0">
<h2
class="text-xl font-bold tracking-[-0.01em] text-zinc-50 truncate"
title=${other.name()}
title=${other.displayName()}
>
${other.name()}
${other.displayName()}
</h2>
</div>
${chip
@@ -626,7 +626,7 @@ export class PlayerPanel extends LitElement implements Layer {
const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
const alliesSorted = [...allies].sort((a, b) =>
nameCollator.compare(a.name(), b.name()),
nameCollator.compare(a.displayName(), b.displayName()),
);
return html`
@@ -669,9 +669,9 @@ export class PlayerPanel extends LitElement implements Layer {
rounded-md border border-white/10 bg-white/5
px-2.5 py-1 text-[14px] text-zinc-100
hover:bg-white/8 active:scale-[0.99] transition"
title=${p.name()}
title=${p.displayName()}
>
<span class="truncate">${p.name()}</span>
<span class="truncate">${p.displayName()}</span>
</li>`,
)}
</ul>
+8 -1
View File
@@ -41,6 +41,13 @@ export class ReplayPanel extends LitElement implements Layer {
this.visible = event.visible;
this.isSingleplayer = event.isSingleplayer;
});
this.eventBus.on(
ReplaySpeedChangeEvent,
(event: ReplaySpeedChangeEvent) => {
this._replaySpeedMultiplier = event.replaySpeedMultiplier;
this.requestUpdate();
},
);
}
}
@@ -68,7 +75,7 @@ export class ReplayPanel extends LitElement implements Layer {
return html`
<div
class="p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-l-lg"
class="p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-l-lg"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<label class="block mb-2 text-white" translate="no">
@@ -18,6 +18,7 @@ import mouseIcon from "/images/MouseIconWhite.svg?url";
import ninjaIcon from "/images/NinjaIconWhite.svg?url";
import settingsIcon from "/images/SettingIconWhite.svg?url";
import sirenIcon from "/images/SirenIconWhite.svg?url";
import swordIcon from "/images/SwordIconWhite.svg?url";
import treeIcon from "/images/TreeIconWhite.svg?url";
import musicIcon from "/images/music.svg?url";
@@ -163,6 +164,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onToggleAttackingTroopsOverlayButtonClick() {
this.userSettings.toggleAttackingTroopsOverlay();
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
@@ -408,6 +414,28 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAttackingTroopsOverlayButtonClick}"
>
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText(
"user_setting.attacking_troops_overlay_label",
)}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.attacking_troops_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.attackingTroopsOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleCursorCostLabelButtonClick}"
@@ -1,67 +0,0 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { Platform } from "src/client/Platform";
import { getGamesPlayed } from "src/client/Utils";
import { GameType } from "src/core/game/Game";
import { GameView } from "../../../core/game/GameView";
import "../../components/VideoPromo";
import { Layer } from "./Layer";
@customElement("spawn-video-ad")
export class SpawnVideoAd extends LitElement implements Layer {
public game: GameView;
@state() private shouldShow = false;
@state() private adComplete = false;
createRenderRoot() {
return this;
}
init() {
if (
!window.adsEnabled ||
Platform.isMobileWidth ||
crazyGamesSDK.isOnCrazyGames() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
getGamesPlayed() < 3 // Don't show to new players
) {
return;
}
this.shouldShow = true;
}
tick() {
if (this.adComplete) return;
// Hide when spawn phase ends
if (this.shouldShow && !this.game.inSpawnPhase()) {
this.shouldShow = false;
this.requestUpdate();
}
}
private handleComplete = () => {
this.adComplete = true;
this.shouldShow = false;
};
shouldTransform(): boolean {
return false;
}
render() {
if (!this.shouldShow || this.adComplete) {
return html``;
}
return html`
<div class="fixed bottom-0 left-0 z-[9999] pointer-events-auto">
<video-ad
style="width: 400px; max-width: 400px; height: 225px; aspect-ratio: auto;"
.onComplete="${this.handleComplete}"
></video-ad>
</div>
`;
}
}
+1 -1
View File
@@ -334,7 +334,7 @@ export class WinModal extends LitElement implements Layer {
crazyGamesSDK.happytime();
} else {
this._title = translateText("win_modal.other_won", {
player: winner.name(),
player: winner.displayName(),
});
this.isWin = false;
}
+6
View File
@@ -295,6 +295,7 @@ label.option-card:hover {
align-items: center;
gap: 8px;
background: #2a2a2a;
border: 1px solid transparent;
color: #fff;
padding: 8px 12px;
margin: 4px;
@@ -302,6 +303,11 @@ label.option-card:hover {
font-size: 14px;
}
.player-tag.current-player {
background: rgba(14, 165, 233, 0.15);
border: 1px solid rgba(14, 165, 233, 0.4);
}
#bots-count,
#private-lobby-bots-count {
width: 80%;
+18 -5
View File
@@ -1,8 +1,21 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
import { ClanTagSchema } from "./Schemas";
import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas";
import { Difficulty, GameMode, GameType, RankedType } from "./game/Game";
function stripClanTagFromUsername(username: string): string {
return username.replace(/^\s*\[[a-zA-Z0-9]{2,5}\]\s*/u, "").trim();
}
// Historical leaderboard rows can include legacy usernames
// that predate current strict join-time validation rules.
const LeaderboardUsernameSchema = z
.string()
.transform(stripClanTagFromUsername)
.pipe(z.string().min(1).max(64));
const LeaderboardClanTagSchema = ClanTagSchema.unwrap();
export const RefreshResponseSchema = z.object({
token: z.string(),
});
@@ -114,7 +127,7 @@ export const PlayerProfileSchema = z.object({
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
export const ClanLeaderboardEntrySchema = z.object({
clanTag: z.string(),
clanTag: LeaderboardClanTagSchema,
games: z.number(),
wins: z.number(),
losses: z.number(),
@@ -137,8 +150,8 @@ export type ClanLeaderboardResponse = z.infer<
export const PlayerLeaderboardEntrySchema = z.object({
rank: z.number(),
playerId: z.string(),
username: z.string(),
clanTag: z.string().optional(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
flag: z.string().optional(),
elo: z.number(),
games: z.number(),
@@ -166,8 +179,8 @@ export const RankedLeaderboardEntrySchema = z.object({
total: z.number(),
public_id: z.string(),
user: DiscordUserSchema.nullable().optional(),
username: z.string(),
clanTag: z.string().nullable().optional(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
});
export type RankedLeaderboardEntry = z.infer<
typeof RankedLeaderboardEntrySchema
+14 -16
View File
@@ -5,9 +5,7 @@ import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterE
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
BuildableUnit,
Cell,
Game,
GameUpdates,
NameViewData,
@@ -52,6 +50,7 @@ export async function createGameRunner(
p.clientID,
random.nextID(),
p.isLobbyCreator ?? false,
p.clanTag,
);
});
@@ -254,24 +253,23 @@ export class GameRunner {
} as PlayerBorderTiles;
}
public attackAveragePosition(
public attackClusteredPositions(
playerID: number,
attackID: string,
): Cell | null {
attackID?: string,
): { id: string; positions: { x: number; y: number }[] }[] {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
if (!player.isPlayer())
throw new Error(`player with id ${playerID} not found`);
}
const all = [...player.outgoingAttacks(), ...player.incomingAttacks()];
const attacks = attackID ? all.filter((a) => a.id() === attackID) : all;
const condition = (a: Attack) => a.id() === attackID;
const attack =
player.outgoingAttacks().find(condition) ??
player.incomingAttacks().find(condition);
if (attack === undefined) {
return null;
}
return attack.averagePosition();
return attacks.map((a) => ({
id: a.id(),
positions: a.clusteredPositions().map((tile) => ({
x: this.game.map().x(tile),
y: this.game.map().y(tile),
})),
}));
}
public bestTransportShipSpawn(
+16 -7
View File
@@ -141,9 +141,21 @@ export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]);
export const UsernameSchema = z
.string()
.regex(/^(?=.*\S)[a-zA-Z0-9_ üÜ.]+$/u)
.min(3)
.max(27);
export const ClanTagSchema = z
.string()
.regex(/^[a-zA-Z0-9]{2,5}$/)
.nullable();
const ClientInfoSchema = z.object({
clientID: z.string(),
username: z.string(),
username: UsernameSchema,
clanTag: ClanTagSchema,
});
export const GameInfoSchema = z.object({
@@ -179,6 +191,7 @@ export class LobbyInfoEvent implements GameEvent {
export interface ClientInfo {
clientID: ClientID;
username: string;
clanTag: string | null;
}
export enum LogSeverity {
Debug = "DEBUG",
@@ -279,11 +292,6 @@ export const ID = z.string().regex(GAME_ID_REGEX);
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
export const UsernameSchema = z
.string()
.regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u)
.min(3)
.max(27);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
@@ -510,6 +518,7 @@ export const PlayerCosmeticsSchema = z.object({
export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
cosmetics: PlayerCosmeticsSchema.optional(),
isLobbyCreator: z.boolean().optional(),
});
@@ -630,6 +639,7 @@ export const ClientJoinMessageSchema = z.object({
token: TokenSchema, // WARNING: PII - server extracts persistentID from this
gameID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
// Server replaces the refs with the actual cosmetic data.
cosmetics: PlayerCosmeticRefsSchema.optional(),
turnstileToken: z.string().nullable(),
@@ -659,7 +669,6 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
export const PlayerRecordSchema = PlayerSchema.extend({
persistentID: PersistentIdSchema.nullable(), // WARNING: PII
clanTag: z.string().optional(),
stats: PlayerStatsSchema,
});
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
+5 -17
View File
@@ -340,29 +340,17 @@ export function sigmoid(
return 1 / (1 + Math.exp(-decayRate * (value - midpoint)));
}
// Compute clan from name
export function getClanTag(name: string): string | null {
const clanTag = clanMatch(name);
return clanTag ? clanTag[1].toUpperCase() : null;
}
export function getClanTagOriginalCase(name: string): string | null {
const clanTag = clanMatch(name);
return clanTag ? clanTag[1] : null;
export function formatPlayerDisplayName(
username: string,
clanTag?: string | null,
): string {
return clanTag ? `[${clanTag}] ${username}` : username;
}
const CLAN_TAG_CHARS = "a-zA-Z0-9";
const CLAN_TAG_INVALID_CHARS = new RegExp(`[^${CLAN_TAG_CHARS}]`, "g");
const CLAN_TAG_REGEX = new RegExp(`\\[([${CLAN_TAG_CHARS}]{2,5})\\]`);
export function sanitizeClanTag(tag: string): string {
return tag.replace(CLAN_TAG_INVALID_CHARS, "").substring(0, 5).toUpperCase();
}
function clanMatch(name: string): RegExpMatchArray | null {
if (!name.includes("[") || !name.includes("]")) {
return null;
}
return name.match(CLAN_TAG_REGEX);
}
+13 -2
View File
@@ -136,6 +136,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
}
/** SAM launcher construction duration in ticks (non-instant-build). */
export const SAM_CONSTRUCTION_TICKS = 30 * 10;
export class DefaultConfig implements Config {
private pastelTheme: PastelTheme = new PastelTheme();
private pastelThemeDark: PastelThemeDark = new PastelThemeDark();
@@ -421,7 +424,9 @@ export class DefaultConfig implements Config {
Math.min(3_000_000, (numUnits + 1) * 1_500_000),
UnitType.SAMLauncher,
),
constructionDuration: this.instantBuild() ? 0 : 30 * 10,
constructionDuration: this.instantBuild()
? 0
: SAM_CONSTRUCTION_TICKS,
upgradable: true,
};
break;
@@ -536,7 +541,13 @@ export class DefaultConfig implements Config {
return 3;
}
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
if (this._gameConfig.gameType === GameType.Singleplayer) {
return 100;
}
if (this.isRandomSpawn()) {
return 150;
}
return 300;
}
numBots(): number {
return this.bots();
+1 -1
View File
@@ -90,7 +90,7 @@ export class MirvExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`,
`⚠️⚠️⚠️ ${this.player.displayName()} - MIRV INBOUND ⚠️⚠️⚠️`,
MessageType.MIRV_INBOUND,
this.targetPlayer.id(),
);
+2 -2
View File
@@ -150,7 +150,7 @@ export class NukeExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - atom bomb inbound`,
`${this.player.displayName()} - atom bomb inbound`,
MessageType.NUKE_INBOUND,
target.id(),
);
@@ -158,7 +158,7 @@ export class NukeExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - hydrogen bomb inbound`,
`${this.player.displayName()} - hydrogen bomb inbound`,
MessageType.HYDROGEN_BOMB_INBOUND,
target.id(),
);
+23 -4
View File
@@ -97,8 +97,11 @@ export class PlayerExecution implements Execution {
}
}
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
if (this.player.lastTileChange() > this.lastCalc) {
if (
ticks - this.lastCalc > this.ticksPerClusterCalc ||
this.player.numTilesOwned() < 100
) {
if (this.player.lastTileChange() >= this.lastCalc) {
this.lastCalc = ticks;
const start = performance.now();
this.removeClusters();
@@ -157,6 +160,12 @@ export class PlayerExecution implements Execution {
clusterBox: { min: Cell; max: Cell },
): false | Player {
const enemies = new Set<number>();
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const tile of cluster) {
let hasUnownedNeighbor = false;
if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) {
@@ -170,6 +179,12 @@ export class PlayerExecution implements Execution {
const ownerId = this.mg.ownerID(n);
if (ownerId !== this.player.smallID()) {
enemies.add(ownerId);
const px = this.mg.x(n);
const py = this.mg.y(n);
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
}
});
if (hasUnownedNeighbor) {
@@ -182,9 +197,13 @@ export class PlayerExecution implements Execution {
if (enemies.size !== 1) {
return false;
}
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
if (inscribed(enemyBox, clusterBox)) {
const localEnemyBox = {
min: new Cell(minX, minY),
max: new Cell(maxX, maxY),
};
if (inscribed(localEnemyBox, clusterBox)) {
return enemy;
}
return false;
@@ -4,6 +4,7 @@ import {
Game,
MessageType,
Player,
PlayerType,
TerraNullius,
Unit,
UnitType,
@@ -76,6 +77,16 @@ export class TransportShipExecution implements Execution {
return;
}
if (this.target.isPlayer()) {
const targetPlayer = this.target as Player;
if (
targetPlayer.type() !== PlayerType.Bot &&
this.attacker.type() !== PlayerType.Bot
) {
this.rejectIncomingAllianceRequests(targetPlayer);
}
}
if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) {
this.active = false;
return;
@@ -290,4 +301,13 @@ export class TransportShipExecution implements Execution {
isActive(): boolean {
return this.active;
}
private rejectIncomingAllianceRequests(target: Player) {
const request = this.attacker
.incomingAllianceRequests()
.find((ar) => ar.requestor() === target);
if (request !== undefined) {
request.reject();
}
}
}
@@ -2,6 +2,7 @@ import {
Difficulty,
Game,
GameMode,
GameType,
HumansVsNations,
Player,
PlayerID,
@@ -846,6 +847,11 @@ export class AiAttackBehavior {
return false;
}
// Don't donate in public games (To balance HvN)
if (this.game.config().gameConfig().gameType === GameType.Public) {
return false;
}
// Check if donating troops is allowed
if (this.game.config().donateTroops() === false) {
return false;
+79 -18
View File
@@ -1,4 +1,4 @@
import { Attack, Cell, Player, TerraNullius } from "./Game";
import { Attack, Player, TerraNullius } from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
import { PlayerImpl } from "./PlayerImpl";
@@ -97,28 +97,89 @@ export class AttackImpl implements Attack {
}
}
averagePosition(): Cell | null {
// Returns the top 2 clustered positions of the attack's border.
// If the second cluster is too small, only returns the largest one.
clusteredPositions(): TileRef[] {
if (this._borderSize === 0) {
if (this.sourceTile() === null) {
// No border tiles and no source tile—return a default position or throw an error
return null;
const tile = this.sourceTile();
return tile !== null ? [tile] : [];
}
return this.clusterBorderTiles(30, 2);
}
// Partitions the attack's border tiles into disconnected segments using BFS,
// then returns one representative tile per segment.
//
// Border tiles naturally fragment when fighting across non-contiguous
// territory (e.g. islands, chokepoints).
//
// Results are sorted largest-first, small clusters below minSize are
// dropped (the largest is always kept as a fallback), and the list is capped
// at maxClusters to avoid label clutter on heavily fragmented borders.
private clusterBorderTiles(minSize: number, maxClusters: number): TileRef[] {
const map = this._mg.map();
const visited = new Set<TileRef>();
const clusters: { tile: TileRef; size: number }[] = [];
for (const startTile of this._border) {
if (visited.has(startTile)) continue;
const queue: TileRef[] = [startTile];
visited.add(startTile);
let qi = 0;
let sumX = 0;
let sumY = 0;
let count = 0;
while (qi < queue.length) {
const t = queue[qi++];
sumX += map.x(t);
sumY += map.y(t);
count++;
this._mg.forEachNeighborWithDiag(t, (neighbor) => {
if (this._border.has(neighbor) && !visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
});
}
// No border tiles yet—use the source tile's location
const tile: number = this.sourceTile()!;
return new Cell(this._mg.map().x(tile), this._mg.map().y(tile));
// The centroid (sumX/count, sumY/count) may not be a real border tile,
// so we pick whichever tile in the cluster is closest to it. This ensures
// the representative always sits on an actual front-line tile.
const cx = sumX / count;
const cy = sumY / count;
let best = queue[0];
let bestDist = Infinity;
for (const t of queue) {
const dx = map.x(t) - cx;
const dy = map.y(t) - cy;
const dist = dx * dx + dy * dy;
if (dist < bestDist) {
bestDist = dist;
best = t;
}
}
clusters.push({ tile: best, size: count });
}
let averageX = 0;
let averageY = 0;
clusters.sort((a, b) => b.size - a.size);
for (const t of this._border) {
averageX += this._mg.map().x(t);
averageY += this._mg.map().y(t);
switch (clusters.length) {
case 0:
return [];
case 1:
// If there's only one cluster, return it even if it's smaller than minSize.
return [clusters[0].tile];
default: {
const significant = clusters.filter((c) => c.size >= minSize);
if (significant.length === 0) {
// Always keep at least the largest cluster even if it falls below minSize.
return [clusters[0].tile];
}
return significant.slice(0, maxClusters).map((c) => c.tile);
}
}
averageX = averageX / this._borderSize;
averageY = averageY / this._borderSize;
return new Cell(averageX, averageY);
}
}
+5 -5
View File
@@ -2,7 +2,7 @@ import { Config } from "../configuration/Config";
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID } from "../Schemas";
import { getClanTag } from "../Util";
import { formatPlayerDisplayName } from "../Util";
import { GameMap, TileRef } from "./GameMap";
import {
GameUpdate,
@@ -470,7 +470,7 @@ export interface Attack {
removeBorderTile(tile: TileRef): void;
clearBorder(): void;
borderSize(): number;
averagePosition(): Cell | null;
clusteredPositions(): TileRef[];
}
export interface AllianceRequest {
@@ -503,7 +503,7 @@ export interface MutableAlliance extends Alliance {
}
export class PlayerInfo {
public readonly clan: string | null;
public readonly displayName: string;
constructor(
public readonly name: string,
@@ -513,8 +513,9 @@ export class PlayerInfo {
// TODO: make player id the small id
public readonly id: PlayerID,
public readonly isLobbyCreator: boolean = false,
public readonly clanTag: string | null = null,
) {
this.clan = getClanTag(name);
this.displayName = formatPlayerDisplayName(this.name, this.clanTag);
}
}
@@ -706,7 +707,6 @@ export interface Player {
// Either allied or on same team.
isFriendly(other: Player, treatAFKFriendly?: boolean): boolean;
team(): Team | null;
clan(): string | null;
incomingAllianceRequests(): AllianceRequest[];
outgoingAllianceRequests(): AllianceRequest[];
alliances(): MutableAlliance[];
+35 -29
View File
@@ -4,7 +4,7 @@ import { Config } from "../configuration/Config";
import { ColorPalette } from "../CosmeticSchemas";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName } from "../Util";
import { createRandomName, formatPlayerDisplayName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
BuildableUnit,
@@ -453,11 +453,10 @@ export class PlayerView {
return this.data.incomingAttacks;
}
async attackAveragePosition(
playerID: number,
attackID: string,
): Promise<Cell | null> {
return this.game.worker.attackAveragePosition(playerID, attackID);
async attackClusteredPositions(
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return this.game.worker.attackClusteredPositions(this.smallID(), attackID);
}
units(...types: UnitType[]): UnitView[] {
@@ -482,7 +481,7 @@ export class PlayerView {
displayName(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.data.name;
: this.data.displayName;
}
clientID(): ClientID | null {
@@ -659,21 +658,15 @@ export class GameView implements GameMap {
private _mapData: TerrainMapData,
private _myClientID: ClientID | undefined,
private _myUsername: string,
private _myClanTag: string | null,
private _gameID: GameID,
private humans: Player[],
humans: Player[],
) {
this._map = this._mapData.gameMap;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(this._map);
// Replace the local player's username with their own stored username.
// This way the user does not know they are being censored.
for (const h of this.humans) {
if (h.clientID === this._myClientID) {
h.username = this._myUsername;
}
}
this._cosmetics = new Map(
this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
);
for (const nation of this._mapData.nations) {
// Nations don't have client ids, so we use their name as the key instead.
@@ -763,25 +756,38 @@ export class GameView implements GameMap {
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
}
const myDisplayName = formatPlayerDisplayName(
this._myUsername,
this._myClanTag,
);
gu.updates[GameUpdateType.Player].forEach((pu) => {
// Replace the local player's name/displayName with their own stored values.
// This way the user does not know they are being censored.
if (pu.clientID === this._myClientID) {
pu.name = this._myUsername;
pu.displayName = myDisplayName;
}
this.smallIDToID.set(pu.smallID, pu.id);
const player = this._players.get(pu.id);
let player = this._players.get(pu.id);
if (player !== undefined) {
player.data = pu;
player.nameData = gu.playerNameViewData[pu.id];
const nextNameData = gu.playerNameViewData[pu.id];
if (nextNameData !== undefined) {
player.nameData = nextNameData;
}
} else {
this._players.set(
pu.id,
new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
),
player = new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
);
this._players.set(pu.id, player);
}
});
+6 -15
View File
@@ -84,9 +84,6 @@ export class PlayerImpl implements Player {
public _units: Unit[] = [];
public _tiles: Set<TileRef> = new Set();
private _name: string;
private _displayName: string;
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
private _expiredAlliances: Alliance[] = [];
@@ -115,10 +112,8 @@ export class PlayerImpl implements Player {
startTroops: number,
private readonly _team: Team | null,
) {
this._name = playerInfo.name;
this._troops = toInt(startTroops);
this._gold = mg.config().startingGold(playerInfo);
this._displayName = this._name;
this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id));
}
@@ -193,10 +188,10 @@ export class PlayerImpl implements Player {
}
name(): string {
return this._name;
return this.playerInfo.name;
}
displayName(): string {
return this._displayName;
return this.playerInfo.displayName;
}
clientID(): ClientID | null {
@@ -211,10 +206,6 @@ export class PlayerImpl implements Player {
return this.playerInfo.playerType;
}
clan(): string | null {
return this.playerInfo.clan;
}
units(...types: UnitType[]): Unit[] {
const len = types.length;
if (len === 0) {
@@ -760,14 +751,14 @@ export class PlayerImpl implements Player {
MessageType.SENT_TROOPS_TO_PLAYER,
this.id(),
undefined,
{ troops: renderTroops(troops), name: recipient.name() },
{ troops: renderTroops(troops), name: recipient.displayName() },
);
this.mg.displayMessage(
"events_display.received_troops_from_player",
MessageType.RECEIVED_TROOPS_FROM_PLAYER,
recipient.id(),
undefined,
{ troops: renderTroops(troops), name: this.name() },
{ troops: renderTroops(troops), name: this.displayName() },
);
return true;
}
@@ -784,14 +775,14 @@ export class PlayerImpl implements Player {
MessageType.SENT_GOLD_TO_PLAYER,
this.id(),
undefined,
{ gold: renderNumber(gold), name: recipient.name() },
{ gold: renderNumber(gold), name: recipient.displayName() },
);
this.mg.displayMessage(
"events_display.received_gold_from_player",
MessageType.RECEIVED_GOLD_FROM_PLAYER,
recipient.id(),
gold,
{ gold: renderNumber(gold), name: this.name() },
{ gold: renderNumber(gold), name: this.displayName() },
);
return true;
}
+8 -8
View File
@@ -16,24 +16,24 @@ export function assignTeams(
// Sort players into clan groups or no-clan list
for (const player of players) {
if (player.clan) {
if (!clanGroups.has(player.clan)) {
clanGroups.set(player.clan, []);
const clanTag = player.clanTag;
if (clanTag) {
if (!clanGroups.has(clanTag)) {
clanGroups.set(clanTag, []);
}
clanGroups.get(player.clan)!.push(player);
clanGroups.get(clanTag)!.push(player);
} else {
noClanPlayers.push(player);
}
}
// Sort clans by size (largest first)
const sortedClans = Array.from(clanGroups.entries()).sort(
(a, b) => b[1].length - a[1].length,
const sortedClanPlayers = Array.from(clanGroups.values()).sort(
(a, b) => b.length - a.length,
);
// First, assign clan players
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, clanPlayers] of sortedClans) {
for (const clanPlayers of sortedClanPlayers) {
// Try to keep the clan together on the team with fewer players
let team: Team | null = null;
let teamSize = 0;
+8
View File
@@ -89,6 +89,14 @@ export class UserSettings {
return this.get("settings.territoryPatterns", true);
}
attackingTroopsOverlay() {
return this.get("settings.attackingTroopsOverlay", true);
}
toggleAttackingTroopsOverlay() {
this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
}
cursorCostLabel() {
const legacy = this.get("settings.ghostPricePill", true);
return this.get("settings.cursorCostLabel", legacy);
+28 -1
View File
@@ -1,8 +1,10 @@
import { translateText } from "../../client/Utils";
import { UsernameSchema } from "../Schemas";
import { ClanTagSchema, UsernameSchema } from "../Schemas";
export const MIN_USERNAME_LENGTH = 3;
export const MAX_USERNAME_LENGTH = 27;
export const MIN_CLAN_TAG_LENGTH = 2;
export const MAX_CLAN_TAG_LENGTH = 5;
export function validateUsername(username: string): {
isValid: boolean;
@@ -44,3 +46,28 @@ export function validateUsername(username: string): {
// All checks passed
return { isValid: true };
}
export function validateClanTag(clanTag: string): {
isValid: boolean;
error?: string;
} {
if (clanTag.length === 0) {
return { isValid: true };
}
if (clanTag.length < MIN_CLAN_TAG_LENGTH) {
return { isValid: false, error: translateText("username.tag_too_short") };
}
if (clanTag.length > MAX_CLAN_TAG_LENGTH) {
return { isValid: false, error: translateText("username.tag_too_short") };
}
const parsed = ClanTagSchema.safeParse(clanTag);
if (!parsed.success) {
return {
isValid: false,
error: translateText("username.tag_invalid_chars"),
};
}
return { isValid: true };
}
+12 -9
View File
@@ -3,7 +3,7 @@ import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import {
AttackAveragePositionResultMessage,
AttackClusteredPositionsResultMessage,
InitializedMessage,
MainThreadMessage,
PlayerActionsResultMessage,
@@ -243,25 +243,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "attack_average_position":
case "attack_clustered_positions":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const averagePosition = (await gameRunner).attackAveragePosition(
const attacks = (await gameRunner).attackClusteredPositions(
message.playerID,
message.attackID,
);
sendMessage({
type: "attack_average_position_result",
type: "attack_clustered_positions_result",
id: message.id,
x: averagePosition ? averagePosition.x : null,
y: averagePosition ? averagePosition.y : null,
} as AttackAveragePositionResultMessage);
attacks,
} as AttackClusteredPositionsResultMessage);
} catch (error) {
console.error("Failed to get attack average position:", error);
throw error;
console.error("Failed to get attack front line centers:", error);
sendMessage({
type: "attack_clustered_positions_result",
id: message.id,
attacks: [],
} as AttackClusteredPositionsResultMessage);
}
break;
case "transport_ship_spawn":
+25 -16
View File
@@ -231,10 +231,10 @@ export class WorkerClient {
});
}
attackAveragePosition(
attackClusteredPositions(
playerID: number,
attackID: string,
): Promise<Cell | null> {
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
@@ -243,25 +243,34 @@ export class WorkerClient {
const messageId = generateID();
const timeout = setTimeout(() => {
this.messageHandlers.delete(messageId);
reject(new Error("attack_clustered_positions request timed out"));
}, 5000);
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "attack_average_position_result" &&
message.x !== undefined &&
message.y !== undefined
) {
if (message.x === null || message.y === null) {
resolve(null);
} else {
resolve(new Cell(message.x, message.y));
}
clearTimeout(timeout);
if (message.type !== "attack_clustered_positions_result") {
reject(
new Error(
`Unexpected message type for attackClusteredPositions: ${message.type}`,
),
);
return;
}
resolve(
message.attacks.map((a) => ({
id: a.id,
positions: a.positions.map((c) => new Cell(c.x, c.y)),
})),
);
});
this.worker.postMessage({
type: "attack_average_position",
type: "attack_clustered_positions",
id: messageId,
playerID: playerID,
attackID: attackID,
playerID,
attackID,
});
});
}
+11 -11
View File
@@ -24,8 +24,8 @@ export type WorkerMessageType =
| "player_profile_result"
| "player_border_tiles"
| "player_border_tiles_result"
| "attack_average_position"
| "attack_average_position_result"
| "attack_clustered_positions"
| "attack_clustered_positions_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
@@ -108,16 +108,16 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
result: PlayerBorderTiles;
}
export interface AttackAveragePositionMessage extends BaseWorkerMessage {
type: "attack_average_position";
export interface AttackClusteredPositionsMessage extends BaseWorkerMessage {
type: "attack_clustered_positions";
playerID: number;
attackID: string;
attackID?: string;
}
export interface AttackAveragePositionResultMessage extends BaseWorkerMessage {
type: "attack_average_position_result";
x: number | null;
y: number | null;
export interface AttackClusteredPositionsResultMessage
extends BaseWorkerMessage {
type: "attack_clustered_positions_result";
attacks: { id: string; positions: { x: number; y: number }[] }[];
}
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
@@ -139,7 +139,7 @@ export type MainThreadMessage =
| PlayerBuildablesMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage
| AttackAveragePositionMessage
| AttackClusteredPositionsMessage
| TransportShipSpawnMessage;
// Message send from worker
@@ -151,5 +151,5 @@ export type WorkerMessage =
| PlayerBuildablesResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage
| AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage;
+1 -1
View File
@@ -18,7 +18,7 @@ export class Client {
public readonly flares: string[] | undefined,
public readonly ip: string,
public username: string,
public readonly uncensoredUsername: string,
public clanTag: string | null,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
) {}
+22 -30
View File
@@ -3,18 +3,14 @@ import { ClientID } from "../core/Schemas";
const INTENTS_PER_SECOND = 10;
const INTENTS_PER_MINUTE = 150;
const MAX_BYTES_PER_MINUTE = 25 * 1024; // 25KB/min per client
const MAX_INTENT_BYTES = 500; // intents are stored in turns, keep them small
const MAX_INTENT_SIZE = 500;
const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client
export type RateLimitResult = "ok" | "limit" | "kick";
// Allow 3 winner messages per client since a player can rejoin and resend.
const MAX_WINNER_MSGS = 3;
interface ClientBucket {
perSecond: RateLimiter;
perMinute: RateLimiter;
bytesPerMinute: RateLimiter;
winnerMsgCount: number;
totalBytes: number;
}
export class ClientMsgRateLimiter {
@@ -22,27 +18,27 @@ export class ClientMsgRateLimiter {
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
const bucket = this.getOrCreate(clientID);
bucket.totalBytes += bytes;
// Winner message contains stats for all players and can be large (100s of KB).
// It bypasses the byte rate limit but is strictly limited to one per client.
if (type === "winner") {
if (bucket.winnerMsgCount >= MAX_WINNER_MSGS) return "kick";
bucket.winnerMsgCount++;
return "ok";
if (bucket.totalBytes >= TOTAL_BYTES) return "kick";
if (type === "intent") {
// Intents are stored in turn history for the duration of the game, so
// oversized intents would accumulate and fill up server RAM.
// Intents are also sent to all players, so it increase outgoing
// data.
// Intents should never be larger than MAX_INTENT_SIZE, so we assume the client is malicious.
if (bytes > MAX_INTENT_SIZE) {
return "kick";
}
if (
!bucket.perSecond.tryRemoveTokens(1) ||
!bucket.perMinute.tryRemoveTokens(1)
) {
return "limit";
}
}
// Intents are stored in turn history for the duration of the game, so
// oversized intents would accumulate and fill up server RAM.
if (type === "intent" && bytes > MAX_INTENT_BYTES) return "kick";
if (!bucket.bytesPerMinute.tryRemoveTokens(bytes)) return "kick";
if (
!bucket.perSecond.tryRemoveTokens(1) ||
!bucket.perMinute.tryRemoveTokens(1)
)
return "limit";
return "ok";
}
@@ -60,11 +56,7 @@ export class ClientMsgRateLimiter {
tokensPerInterval: INTENTS_PER_MINUTE,
interval: "minute",
}),
bytesPerMinute: new RateLimiter({
tokensPerInterval: MAX_BYTES_PER_MINUTE,
interval: "minute",
}),
winnerMsgCount: 0,
totalBytes: 0,
};
this.buckets.set(clientID, bucket);
return bucket;
+2 -2
View File
@@ -46,11 +46,11 @@ export class GameManager {
persistentID: string,
gameID: GameID,
lastTurn: number = 0,
newUsername?: string,
identityUpdate?: { username: string; clanTag: string | null },
): boolean {
const game = this.games.get(gameID);
if (!game) return false;
return game.rejoinClient(ws, persistentID, lastTurn, newUsername);
return game.rejoinClient(ws, persistentID, lastTurn, identityUpdate);
}
createGame(
+11 -4
View File
@@ -1,10 +1,12 @@
import { z } from "zod";
import { GameInfo } from "../core/Schemas";
import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
import { formatPlayerDisplayName } from "../core/Util";
import { GameMode } from "../core/game/Game";
export const PlayerInfoSchema = z.object({
clientID: z.string().optional(),
username: z.string().optional(),
username: UsernameSchema.optional(),
clanTag: ClanTagSchema,
stats: z.unknown().optional(),
});
@@ -85,7 +87,10 @@ function parseWinner(
if (!winnerArray || winnerArray.length < 2) return undefined;
const idToName = new Map(
(players ?? []).map((p) => [p.clientID, p.username]),
(players ?? []).map((p) => [
p.clientID,
p.username ? formatPlayerDisplayName(p.username, p.clanTag) : undefined,
]),
);
if (winnerArray[0] === "team" && winnerArray.length >= 3) {
@@ -228,7 +233,9 @@ export function buildPreview(
// Show host
const hostClient = lobby.clients?.[0];
if (hostClient?.username) {
sections.push(`Host: ${hostClient.username}`);
sections.push(
`Host: ${formatPlayerDisplayName(hostClient.username, hostClient.clanTag)}`,
);
}
const gameOptions: string[] = [];
+11 -12
View File
@@ -23,7 +23,7 @@ import {
StampedIntent,
Turn,
} from "../core/Schemas";
import { createPartialGameRecord, getClanTag } from "../core/Util";
import { createPartialGameRecord } from "../core/Util";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
@@ -266,15 +266,13 @@ export class GameServer {
}
// Attempt to reconnect a client by persistentID. Returns true if successful.
// Only the WebSocket is updated — username, cosmetics, etc. are preserved
// from the original join to maintain consistency throughout the game session.
// Exception: in the pre-game lobby, the username is updated so players can
// rename between leaving and rejoining.
// WebSocket is always updated. Optional identity updates are applied only
// before the game has started.
public rejoinClient(
ws: WebSocket,
persistentID: string,
lastTurn: number = 0,
newUsername?: string,
identityUpdate?: { username: string; clanTag: string | null },
): boolean {
const clientID = this.getClientIdForPersistentId(persistentID);
if (!clientID) return false;
@@ -294,14 +292,13 @@ export class GameServer {
(c) => c.clientID !== client.clientID,
);
this.activeClients.push(client);
if (identityUpdate && !this.hasStarted()) {
client.username = identityUpdate.username;
client.clanTag = identityUpdate.clanTag;
}
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
// Allow username updates in the pre-game lobby
if (!this._hasStarted && newUsername !== undefined) {
client.username = newUsername;
}
client.ws = ws;
this.addListeners(client);
this.startLobbyInfoBroadcast();
@@ -662,6 +659,7 @@ export class GameServer {
config: this.gameConfig,
players: this.activeClients.map((c) => ({
username: c.username,
clanTag: c.clanTag ?? null,
clientID: c.clientID,
cosmetics: c.cosmetics,
isLobbyCreator: this.lobbyCreatorID === c.clientID,
@@ -873,6 +871,7 @@ export class GameServer {
gameID: this.id,
clients: this.activeClients.map((c) => ({
username: c.username,
clanTag: c.clanTag ?? null,
clientID: c.clientID,
})),
lobbyCreatorClientID: this.lobbyCreatorID,
@@ -983,11 +982,11 @@ export class GameServer {
return {
clientID: player.clientID,
username: player.username,
clanTag: player.clanTag,
persistentID:
this.allClients.get(player.clientID)?.persistentID ?? "",
stats,
cosmetics: player.cosmetics,
clanTag: getClanTag(player.username) ?? undefined,
} satisfies PlayerRecord;
},
);
+32 -103
View File
@@ -1,3 +1,4 @@
import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/DefaultConfig";
import {
Difficulty,
Duos,
@@ -109,13 +110,13 @@ type ModifierKey =
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array<ModifierKey>(4).fill("isRandomSpawn"),
...Array<ModifierKey>(2).fill("isRandomSpawn"),
...Array<ModifierKey>(8).fill("isCompact"),
...Array<ModifierKey>(1).fill("isCrowded"),
...Array<ModifierKey>(1).fill("isHardNations"),
...Array<ModifierKey>(8).fill("startingGold"),
...Array<ModifierKey>(1).fill("startingGoldHigh"),
...Array<ModifierKey>(1).fill("goldMultiplier"),
...Array<ModifierKey>(4).fill("goldMultiplier"),
...Array<ModifierKey>(1).fill("isAlliancesDisabled"),
];
@@ -125,9 +126,6 @@ const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
["isHardNations", "startingGoldHigh"],
];
// Probability of hard nations modifier in HumansVsNations games.
const HARD_NATIONS_HVN_PROBABILITY = 0.2; // 20%
export class MapPlaylist {
private playlists: Record<PublicGameType, GameMapType[]> = {
ffa: [],
@@ -146,81 +144,35 @@ export class MapPlaylist {
const playerTeams =
mode === GameMode.Team ? this.getTeamCount(map) : undefined;
const modifiers = this.getRandomPublicGameModifiers(playerTeams);
const { startingGold } = modifiers;
let { isCompact, isRandomSpawn, isCrowded, isHardNations } = modifiers;
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
if (
playerTeams === Duos ||
playerTeams === Trios ||
playerTeams === Quads
) {
isRandomSpawn = false;
}
// Hard nations modifier only applies when nations are present
if (mode === GameMode.Team && playerTeams !== HumansVsNations) {
isHardNations = false;
}
// Check if compact map would leave every team with at least 2 players
if (
isCompact &&
mode === GameMode.Team &&
!(await this.supportsCompactMapForTeams(map, playerTeams!))
) {
isCompact = false;
}
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
// set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled)
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact);
if (crowdedMaxPlayers === undefined) {
isCrowded = false;
} else {
crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams);
}
}
// Create the default public game config (from your GameManager)
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
gameMap: map,
maxPlayers:
crowdedMaxPlayers ??
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, false),
gameType: GameType.Public,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
gameMapSize: GameMapSize.Normal,
publicGameModifiers: {
isCompact,
isRandomSpawn,
isCrowded,
isHardNations,
startingGold,
isCompact: false,
isRandomSpawn: false,
isCrowded: false,
isHardNations: false,
isAlliancesDisabled: false,
},
startingGold,
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
difficulty:
playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: isRandomSpawn,
randomSpawn: false,
nations:
mode === GameMode.Team && playerTeams !== HumansVsNations
? "disabled"
: "default",
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: this.getSpawnImmunityDuration(
playerTeams,
startingGold,
),
bots: 400,
spawnImmunityDuration: this.getSpawnImmunityDuration(playerTeams),
disabledUnits: [],
} satisfies GameConfig;
}
@@ -233,6 +185,7 @@ export class MapPlaylist {
const excludedModifiers: ModifierKey[] = [];
// Check if compact map would leave every team with at least 2 players
const supportsCompact =
mode !== GameMode.Team ||
(await this.supportsCompactMapForTeams(map, playerTeams!));
@@ -240,6 +193,7 @@ export class MapPlaylist {
excludedModifiers.push("isCompact");
}
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
if (
playerTeams === Duos ||
playerTeams === Trios ||
@@ -248,26 +202,15 @@ export class MapPlaylist {
excludedModifiers.push("isRandomSpawn");
}
// Hard nations: excluded for non-HvN team modes (no nations present).
// For HumansVsNations: rolled independently (not via pool).
// For FFA: stays in the pool for normal ticket-based selection.
let hardNationsFromIndependentRoll: boolean | undefined;
let poolCountReduction = 0;
if (mode === GameMode.Team && playerTeams !== HumansVsNations) {
excludedModifiers.push("isHardNations");
} else if (playerTeams === HumansVsNations) {
// Hard nations modifier only applies when nations are present (not HvN, which is always hard)
if (mode === GameMode.Team) {
excludedModifiers.push("isHardNations");
}
if (playerTeams === HumansVsNations) {
excludedModifiers.push("startingGoldHigh"); // Nations are disabled if that modifier is active
hardNationsFromIndependentRoll =
Math.random() < HARD_NATIONS_HVN_PROBABILITY;
poolCountReduction = hardNationsFromIndependentRoll ? 1 : 0;
}
const poolResult = this.getRandomSpecialGameModifiers(
excludedModifiers,
undefined,
poolCountReduction,
);
const poolResult = this.getRandomSpecialGameModifiers(excludedModifiers);
let {
isCrowded,
startingGold,
@@ -275,10 +218,11 @@ export class MapPlaylist {
isRandomSpawn,
goldMultiplier,
isAlliancesDisabled,
isHardNations,
} = poolResult;
let isHardNations =
hardNationsFromIndependentRoll ?? poolResult.isHardNations;
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
// set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled)
let crowdedMaxPlayers: number | undefined;
if (isCrowded) {
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact);
@@ -300,7 +244,6 @@ export class MapPlaylist {
const fallback = this.getRandomSpecialGameModifiers(
excludedModifiers,
1,
poolCountReduction,
);
({
isRandomSpawn,
@@ -309,8 +252,7 @@ export class MapPlaylist {
goldMultiplier,
isAlliancesDisabled,
} = fallback);
isHardNations =
hardNationsFromIndependentRoll ?? fallback.isHardNations;
({ isHardNations } = fallback);
}
}
}
@@ -347,7 +289,10 @@ export class MapPlaylist {
startingGold,
goldMultiplier,
disableAlliances: isAlliancesDisabled,
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
difficulty:
isHardNations || playerTeams === HumansVsNations
? Difficulty.Hard
: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
@@ -494,22 +439,6 @@ export class MapPlaylist {
return TEAM_WEIGHTS[0].config;
}
private getRandomPublicGameModifiers(
playerTeams?: TeamCountConfig,
): PublicGameModifiers {
return {
isRandomSpawn: Math.random() < 0.05, // 5% chance
isCompact: Math.random() < 0.05, // 5% chance
isCrowded: Math.random() < 0.05, // 5% chance
startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance
isHardNations:
playerTeams === HumansVsNations
? Math.random() < HARD_NATIONS_HVN_PROBABILITY
: Math.random() < 0.025, // 2.5% chance
isAlliancesDisabled: false,
};
}
private getRandomSpecialGameModifiers(
excludedModifiers: ModifierKey[] = [],
count?: number,
@@ -620,8 +549,8 @@ export class MapPlaylist {
/**
* Centralised spawn-immunity duration logic.
* - HumansVsNations: always 5s (nations can't benefit from longer PVP immunity)
* - 25M starting gold: 2:30 (extra time to compensate for high gold)
* - 5M starting gold: 30s
* - 25M starting gold: 2:30min (extra time to compensate for high gold)
* - 5M starting gold: SAM build time + 15s (enough to build a SAM)
* - Default: 5s
*/
private getSpawnImmunityDuration(
@@ -631,7 +560,7 @@ export class MapPlaylist {
if (playerTeams === HumansVsNations) return 5 * 10;
if (startingGold !== undefined && startingGold >= 25_000_000)
return 150 * 10;
if (startingGold) return 30 * 10;
if (startingGold) return SAM_CONSTRUCTION_TICKS + 15 * 10;
return 5 * 10;
}
+32 -32
View File
@@ -18,7 +18,7 @@ import {
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
import { getClanTagOriginalCase, simpleHash } from "../core/Util";
import { simpleHash } from "../core/Util";
export const shadowNames = [
"UnhuggedToday",
@@ -72,7 +72,7 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
}
/**
* Sanitizes and censors profane usernames and clan tags.
* Sanitizes and censors profane usernames and clan tags separately.
* Profane username is overwritten, profane clan tag is removed.
*
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
@@ -80,36 +80,28 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
*
* Examples:
* - "GoodName" -> "GoodName"
* - "BadName" -> "Censored"
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
* - "[CLaN]BadName" -> "[CLAN] Censored"
* - "[BAD]GoodName" -> "GoodName"
* - "[BAD]BadName" -> "Censored"
* - username="GoodName", clanTag=null -> { username: "GoodName", clanTag: null }
* - username="BadName", clanTag=null -> { username: "Censored", clanTag: null }
* - username="GoodName", clanTag="CLaN" -> { username: "GoodName", clanTag: "CLAN" }
* - username="GoodName", clanTag="BAD" -> { username: "GoodName", clanTag: null }
* - username="BadName", clanTag="BAD" -> { username: "Censored", clanTag: null }
*/
function censorUsernameWithMatcher(
username: string,
matcher: RegExpMatcher,
): string {
const clanTag = getClanTagOriginalCase(username);
const nameWithoutClan = clanTag
? username.replace(`[${clanTag}]`, "").trim()
function censorWithMatcher(
username: string,
clanTag: string | null,
matcher: RegExpMatcher,
): { username: string; clanTag: string | null } {
const usernameIsProfane = matcher.hasMatch(username);
const censoredName = usernameIsProfane
? shadowNames[simpleHash(username) % shadowNames.length]
: username;
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
const censoredClanTag =
clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null;
const censoredName = usernameIsProfane
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
: nameWithoutClan;
// Restore clan tag only if it's clean, otherwise remove it entirely
if (clanTag && !clanTagIsProfane) {
return `[${clanTag.toUpperCase()}] ${censoredName}`;
}
return censoredName;
return { username: censoredName, clanTag: censoredClanTag };
}
type CosmeticResult =
@@ -118,7 +110,10 @@ type CosmeticResult =
export interface PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
censorUsername(username: string): string;
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null };
}
export class PrivilegeCheckerImpl implements PrivilegeChecker {
@@ -217,8 +212,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
return { color };
}
censorUsername(username: string): string {
return censorUsernameWithMatcher(username, this.matcher);
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null } {
return censorWithMatcher(username, clanTag, this.matcher);
}
}
@@ -230,8 +228,10 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker {
return { type: "allowed", cosmetics: {} };
}
censorUsername(username: string): string {
// Fail open: use matcher with just the built-in English profanity dataset
return censorUsernameWithMatcher(username, defaultMatcher);
censor(
username: string,
clanTag: string | null,
): { username: string; clanTag: string | null } {
return censorWithMatcher(username, clanTag, defaultMatcher);
}
}
+39 -14
View File
@@ -3,6 +3,7 @@ import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
import { RateLimiter } from "limiter";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
@@ -50,7 +51,7 @@ export async function startWorker() {
const server = http.createServer(app);
const wss = new WebSocketServer({
noServer: true,
maxPayload: 2 * 1024 * 1024,
maxPayload: 1024 * 1024, // 1MB
});
const gm = new GameManager(config, log);
@@ -289,6 +290,11 @@ export async function startWorker() {
: // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
forwarded || req.socket.remoteAddress || "unknown";
if (!getWsIpLimiter(ip).tryRemoveTokens(1)) {
ws.close(1008, "Rate limit exceeded");
return;
}
try {
// Parse and handle client messages
const parsed = ClientMessageSchema.safeParse(
@@ -358,20 +364,21 @@ export async function startWorker() {
return;
}
// Normalize username and clan tag before any rejoin/join handling.
// If this connection maps to an existing lobby client, we still want
// the latest pre-join identity to be reflected.
const { clanTag: censoredClanTag, username: censoredUsername } =
privilegeRefresher
.get()
.censor(clientMsg.username, clientMsg.clanTag ?? null);
// Try to reconnect an existing client (e.g., page refresh)
// If successful, skip all authorization (but pass updated username
// so players can rename in the pre-game lobby)
const censoredUsername = privilegeRefresher
.get()
.censorUsername(clientMsg.username);
// If successful, skip all authorization
if (
gm.rejoinClient(
ws,
persistentId,
clientMsg.gameID,
0,
censoredUsername,
)
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
username: censoredUsername,
clanTag: censoredClanTag,
})
) {
return;
}
@@ -463,7 +470,7 @@ export async function startWorker() {
flares,
ip,
censoredUsername,
clientMsg.username,
censoredClanTag,
ws,
cosmeticResult.cosmetics,
);
@@ -609,3 +616,21 @@ function generateGameIdForWorker(): GameID | null {
log.warn(`Failed to generate game ID for worker ${workerId}`);
return null;
}
// Per-IP rate limiter for pre-join WebSocket messages.
// Prevents unauthenticated connections from spamming messages
// (e.g. pings) before joining a game.
const wsIpLimiters = new Map<string, RateLimiter>();
function getWsIpLimiter(ip: string): RateLimiter {
let limiter = wsIpLimiters.get(ip);
if (!limiter) {
limiter = new RateLimiter({
tokensPerInterval: 5,
interval: "second",
});
wsIpLimiters.set(ip, limiter);
}
return limiter;
}
// Clean up stale IP limiters every 10 minutes
setInterval(() => wsIpLimiters.clear(), 10 * 60 * 1000);
+48
View File
@@ -333,6 +333,54 @@ describe("Attack race condition with alliance requests", () => {
});
});
describe("Transport ship alliance rejection", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
const playerAInfo = new PlayerInfo(
"playerA",
PlayerType.Human,
null,
"playerA_id",
);
// close to the water to send boats
playerA = addPlayerToGame(playerAInfo, game, game.ref(7, 0));
const playerBInfo = new PlayerInfo(
"playerB",
PlayerType.Human,
null,
"playerB_id",
);
playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
test("Should cancel alliance requests if the recipient sends a transport ship", async () => {
// Player A sends alliance request to Player B
const allianceRequest = playerA.createAllianceRequest(playerB);
expect(allianceRequest).not.toBeNull();
expect(playerB.incomingAllianceRequests()).toHaveLength(1);
// Player B sends a transport ship toward Player A's territory
game.addExecution(new TransportShipExecution(playerB, game.ref(7, 0), 0));
// Execute a tick to process the transport ship launch
game.executeNextTick();
// Alliance request should be rejected since player B sent a naval invasion
expect(playerA.outgoingAllianceRequests()).toHaveLength(0);
expect(playerB.incomingAllianceRequests()).toHaveLength(0);
});
});
describe("Attack immunity", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {
+32
View File
@@ -5,7 +5,9 @@ vi.mock("../src/client/Utils", () => ({
}));
import {
MAX_CLAN_TAG_LENGTH,
MAX_USERNAME_LENGTH,
validateClanTag,
validateUsername,
} from "../src/core/validations/username";
@@ -39,4 +41,34 @@ describe("username.ts functions", () => {
expect(res.isValid).toBe(true);
});
});
describe("validateClanTag", () => {
test("accepts empty clan tag", () => {
const res = validateClanTag("");
expect(res.isValid).toBe(true);
});
test("rejects too short clan tag", () => {
const res = validateClanTag("A");
expect(res.isValid).toBe(false);
expect(res.error).toBe("username.tag_too_short");
});
test("rejects invalid clan tag characters", () => {
const res = validateClanTag("A!");
expect(res.isValid).toBe(false);
expect(res.error).toBe("username.tag_invalid_chars");
});
test("rejects too long clan tag", () => {
const res = validateClanTag("A".repeat(MAX_CLAN_TAG_LENGTH + 1));
expect(res.isValid).toBe(false);
expect(res.error).toBe("username.tag_too_short");
});
test("accepts valid clan tag", () => {
const res = validateClanTag("AB12");
expect(res.isValid).toBe(true);
});
});
});
+6 -2
View File
@@ -179,16 +179,20 @@ describe("Disconnected", () => {
beforeEach(async () => {
const player1Info = new PlayerInfo(
"[CLAN]Player1",
"Player1",
PlayerType.Human,
null,
"player_1_id",
false,
"CLAN",
);
const player2Info = new PlayerInfo(
"[CLAN]Player2",
"Player2",
PlayerType.Human,
null,
"player_2_id",
false,
"CLAN",
);
game = await setup(

Some files were not shown because too many files have changed in this diff Show More