Merge branch 'main' into local-attack

This commit is contained in:
Aotumuri
2026-02-03 17:18:15 +09:00
committed by GitHub
65 changed files with 4015 additions and 1950 deletions
+18 -17
View File
@@ -133,14 +133,14 @@
<div
id="mobile-menu-backdrop"
class="lg:!hidden [.in-game_&]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/60 [&.open]:z-[40000] transition-opacity"
class="lg:hidden! in-[.in-game]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/60 [&.open]:z-[40000] transition-opacity"
role="presentation"
aria-hidden="true"
></div>
<mobile-nav-bar
id="sidebar-menu"
class="peer [.in-game_&]:hidden z-[40001] fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/60 backdrop-blur-md transition-transform duration-500 ease-out transform -translate-x-full w-[80%] [&.open]:translate-x-0 lg:hidden"
class="peer in-[.in-game]:hidden z-40001 fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/60 backdrop-blur-md transition-transform duration-500 ease-out transform -translate-x-full w-[80%] [&.open]:translate-x-0 lg:hidden"
role="dialog"
data-i18n-aria-label="main.menu"
aria-hidden="true"
@@ -148,14 +148,14 @@
<!-- MAIN CONTENT AREA -->
<div
class="[.in-game_&]:hidden flex-1 relative overflow-hidden h-full transition-[margin] duration-500 ease-out will-change-[margin-left] flex flex-col"
class="in-[.in-game]:hidden flex-1 relative overflow-hidden h-full transition-[margin] duration-500 ease-out will-change-[margin-left] flex flex-col"
>
<!-- Desktop Top Bar -->
<desktop-nav-bar></desktop-nav-bar>
<div
id="turnstile-container"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[99999]"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-99999"
></div>
<gutter-ads></gutter-ads>
@@ -179,11 +179,11 @@
inline
class="hidden w-full h-full page-content"
></host-lobby-modal>
<join-private-lobby-modal
id="page-join-private-lobby"
<join-lobby-modal
id="page-join-lobby"
inline
class="hidden w-full h-full page-content"
></join-private-lobby-modal>
></join-lobby-modal>
<territory-patterns-modal
id="page-item-store"
inline
@@ -194,16 +194,16 @@
inline
class="hidden w-full h-full page-content"
></user-setting>
<leaderboard-modal
id="page-leaderboard"
inline
class="hidden w-full h-full page-content"
></leaderboard-modal>
<troubleshooting-modal
id="page-troubleshooting"
inline
class="hidden w-full h-full page-content"
></troubleshooting-modal>
<stats-modal
id="page-stats"
inline
class="hidden w-full h-full page-content"
></stats-modal>
<account-modal
id="page-account"
inline
@@ -239,17 +239,17 @@
<div id="app"></div>
<div
class="left-0 bottom-0 sm:left-4 sm:bottom-4 w-full flex-col-reverse sm:flex-row z-50 md:w-[320px] fixed pointer-events-none"
class="fixed left-0 bottom-0 min-[1200px]:left-4 min-[1200px]:bottom-4 w-full flex flex-col sm:flex-row sm:items-end z-50 pointer-events-none"
>
<div class="order-2 sm:order-none w-full sm:w-1/2 min-[1200px]:w-auto">
<control-panel></control-panel>
</div>
<div
class="w-full md:w-2/3 md:fixed sm:right-0 md:bottom-0 md:flex flex-col items-end pointer-events-none"
class="order-1 sm:order-none w-full sm:w-1/2 min-[1200px]:w-auto min-[1200px]:fixed min-[1200px]:right-0 min-[1200px]:bottom-0 flex flex-col sm:items-end pointer-events-none"
>
<chat-display></chat-display>
<events-display></events-display>
</div>
<div>
<control-panel></control-panel>
</div>
</div>
<!-- Game modals and overlays -->
@@ -267,6 +267,7 @@
<spawn-timer></spawn-timer>
<immunity-timer></immunity-timer>
<in-game-header-ad></in-game-header-ad>
<spawn-video-ad></spawn-video-ad>
<game-info-modal></game-info-modal>
<alert-frame></alert-frame>
<chat-modal></chat-modal>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -0,0 +1,18 @@
{
"name": "TheBox",
"nations": [
{ "coordinates": [10, 10], "flag": "", "name": "King of the Corner" },
{ "coordinates": [1024, 300], "flag": "", "name": "Suspicious Ally" },
{ "coordinates": [1650, 400], "flag": "", "name": "Evan The Dev" },
{ "coordinates": [1024, 1024], "flag": "", "name": "Middle Defender" },
{ "coordinates": [350, 1024], "flag": "", "name": "Punch Merchant" },
{ "coordinates": [1700, 1024], "flag": "", "name": "Nuke Thrower" },
{ "coordinates": [400, 1650], "flag": "", "name": "Fullsender" },
{ "coordinates": [1024, 1750], "flag": "", "name": "Factory Builder" },
{ "coordinates": [1650, 1650], "flag": "", "name": "Front Manager" },
{ "coordinates": [700, 700], "flag": "", "name": "Box Fighter" },
{ "coordinates": [1350, 700], "flag": "", "name": "Cage Liberator" },
{ "coordinates": [700, 1350], "flag": "", "name": "Train Trader" },
{ "coordinates": [1350, 1350], "flag": "", "name": "Non-peaceful Bot" }
]
}
+1
View File
@@ -63,6 +63,7 @@ var maps = []struct {
{Name: "world"},
{Name: "lemnos"},
{Name: "twolakes"},
{Name: "thebox"},
{Name: "didier"},
{Name: "didierfrance"},
{Name: "amazonriver"},
+32 -6
View File
@@ -7,6 +7,7 @@
"name": "openfront-client",
"dependencies": {
"@aws-sdk/client-s3": "^3.758.0",
"@lit-labs/virtualizer": "^2.1.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.200.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.200.0",
@@ -1188,6 +1189,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1231,6 +1233,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2100,14 +2103,22 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@lit-labs/virtualizer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@lit-labs/virtualizer/-/virtualizer-2.1.1.tgz",
"integrity": "sha512-JWxMwnlouLdwpw8spLTuax53WMnSP3xt0dCyxAS7GJr5Otda9MGgR/ghAdfwhSY75TmjbE1T2TqChwoGCw3ggw==",
"license": "BSD-3-Clause",
"dependencies": {
"lit": "^3.2.0",
"tslib": "^2.0.3"
}
},
"node_modules/@lit/reactive-element": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.0.tgz",
"integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0"
@@ -2156,6 +2167,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -4550,6 +4562,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz",
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4657,7 +4670,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/ws": {
@@ -4716,6 +4728,7 @@
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
@@ -5195,6 +5208,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5594,6 +5608,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@@ -5747,6 +5762,7 @@
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
@@ -6668,6 +6684,7 @@
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -7237,6 +7254,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8571,6 +8589,7 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -9114,7 +9133,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.1.0",
@@ -9126,7 +9144,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.0.tgz",
"integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0",
@@ -9138,7 +9155,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.0.tgz",
"integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
@@ -10169,6 +10185,7 @@
"integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
@@ -10213,6 +10230,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10315,6 +10333,7 @@
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -11137,6 +11156,7 @@
"integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@sinonjs/commons": "^3.0.1",
"@sinonjs/fake-timers": "^15.1.0",
@@ -11558,6 +11578,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11800,6 +11821,7 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -11868,6 +11890,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12014,6 +12037,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -12763,6 +12787,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12776,6 +12801,7 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
+1
View File
@@ -89,6 +89,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.758.0",
"@lit-labs/virtualizer": "^2.1.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.200.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.200.0",
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path fill="#fff" d="M90.9 17.1c-3.9-3.9-10.2-3.9-14.1 0L37.5 56.4 23.2 42.1c-3.9-3.9-10.2-3.9-14.1 0s-3.9 10.2 0 14.1l21.4 21.4c1.9 1.9 4.4 2.9 7.1 2.9s5.1-1 7.1-2.9l46.4-46.4c3.8-3.9 3.8-10.2-.2-14.1z"/>
</svg>

Before

Width:  |  Height:  |  Size: 278 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 125" xmlns:v="https://vecta.io/nano"><path d="M86.946 70.119l-2.417-15.098c-.286-1.419-1.511-2.495-2.982-2.495-.899 0-1.687.385-2.268.997L68.104 63.997c-.176.156-.352.338-.515.56-1.049 1.433-.749 3.465.684 4.527.273.215.573.345.866.462l2.808.931c-2.697 7.641-9.313 13.45-17.417 15.059l.014-45.638h5.992c1.889 0 3.412-1.538 3.412-3.426 0-1.875-1.523-3.413-3.412-3.413h-5.992v-6.78c3.979-1.687 6.721-5.634 6.721-10.206C61.264 9.95 56.314 5 50.192 5c-6.097 0-11.073 4.95-11.073 11.073 0 4.578 2.847 8.52 6.8 10.206l.007 6.78h-6.468c-1.882 0-3.413 1.538-3.413 3.413 0 1.889 1.531 3.426 3.413 3.426h6.468l.021 45.71c-8.292-1.485-15.15-7.354-17.926-15.131l2.827-.931c.299-.117.6-.247.879-.456 1.427-1.067 1.753-3.1.671-4.533-.155-.222-.313-.404-.508-.554L20.706 53.524c-.553-.612-1.361-.997-2.246-.997-1.486 0-2.743 1.075-3.009 2.495L13.027 70.12c-.053.241-.064.508-.064.762 0 1.791 1.446 3.236 3.229 3.236.339 0 .658-.032.959-.137l2.624-.853C23.956 85.816 35.901 95 49.997 95s26.053-9.184 30.221-21.891l2.619.872c.299.105.618.137.963.137 1.785 0 3.237-1.446 3.237-3.236 0-.255-.025-.522-.091-.763M45.659 16.073c0-2.521 2.044-4.553 4.533-4.553 2.527 0 4.552 2.032 4.552 4.553 0 2.514-2.024 4.559-4.552 4.559-2.489 0-4.533-2.046-4.533-4.559"/><path d="M46.821306 94.770829c-9.672125-.972647-18.52814-6.513856-23.771656-14.873922-.868866-1.385288-2.385574-4.487112-2.870545-5.870561l-.343618-.96102c-.0095-.01668-.705224.192731-1.545948.46535s-1.804312.494255-2.141309.492526c-1.598809-.0082-3.122304-1.547171-3.115278-3.146913.000864-.196879.574575-3.918388 1.274912-8.270022 1.419295-8.818969 1.428076-8.850837 2.627499-9.536632.834583-.477189 1.483957-.563323 2.337029-.309989.644845.191499 1.336272.793564 6.866372 5.978939 4.549564 4.26596 6.233135 5.932201 6.471488 6.40487.603214 1.196211.325525 2.693184-.677921 3.654548-.288018.275937-1.002559.607386-2.183926 1.013042-2.007091.68919-1.921853.529349-1.189152 2.229934 2.313522 5.369652 7.064022 9.988932 12.577448 12.230032 1.263767.513697 3.384288 1.159414 4.275471 1.30192l.550027.08795V62.733077 39.805269H42.37799c-3.958889 0-4.236614-.04887-5.126792-.902159-.76147-.729916-1.085823-1.473414-1.081006-2.477941.0071-1.483668.947583-2.699422 2.43431-3.146861.49772-.149791 1.545965-.202707 4.01562-.202707h3.342077v-3.423483-3.423485l-1.109679-.604511c-.793899-.432485-1.482722-.979822-2.420583-1.923389-1.146297-1.15327-1.401076-1.505996-2.029023-2.809061-.858345-1.781165-1.170382-3.086543-1.170382-4.896174 0-6.4184795 5.839733-11.6186738 12.164919-10.8326779 2.656895.3301574 4.668838 1.3296881 6.615711 3.286675 1.070143 1.0757025 1.339007 1.4513238 1.957431 2.7346549.865772 1.796619 1.169155 3.085051 1.169155 4.965255 0 4.023175-2.191435 7.679165-5.755536 9.602011l-.969271.522925-.0024 3.389446-.0024 3.389446 3.472222.04702c3.946037.05344 4.168331.108713 5.177124 1.287261 1.316051 1.53751.926562 3.821223-.837885 4.912821l-.700444.433339-3.555509.04279-3.555508.0428v22.902508l.107388 22.902148c.332248-.0011 2.935079-.780331 4.075018-1.219944 5.752163-2.218302 10.721311-7.144396 13.059805-12.946652.204466-.507318.322918-.966456.263226-1.020307s-.65621-.274921-1.325597-.491268c-1.73484-.560706-2.51001-1.013168-2.998016-1.749923-.680235-1.026966-.727607-2.0483-.14813-3.19361.309132-.610985 12.085183-11.683542 12.854449-12.08653 1.199252-.628243 2.81487-.181005 3.651625 1.010847.392869.559594.481176 1.003292 1.695561 8.519473.875547 5.419011 1.264717 8.190005 1.229335 8.753186-.06941 1.104804-.613524 1.897274-1.670778 2.433394-1.027413.520988-1.635513.502208-3.442467-.106313-.816705-.275039-1.512424-.472563-1.546044-.438944s-.379938.854657-.769596 1.824527c-2.13365 5.310705-5.61865 9.924534-10.123033 13.401993-6.338429 4.893379-14.56767 7.254819-22.501513 6.456977zm4.86827-74.330261c1.140586-.346728 2.230937-1.344337 2.770928-2.535241.21803-.480845.29704-.95557.301498-1.811516.0055-1.049248-.04177-1.253646-.495106-2.142377-1.123467-2.202486-3.642798-3.073429-5.980881-2.067613-.987511.424816-2.068224 1.59611-2.409954 2.611949-1.248434 3.711132 2.053637 7.087771 5.813515 5.944798z" fill="#fff"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" xmlns:v="https://vecta.io/nano"><path d="M86.946 70.119l-2.417-15.098c-.286-1.419-1.511-2.495-2.982-2.495-.899 0-1.687.385-2.268.997L68.104 63.997c-.176.156-.352.338-.515.56-1.049 1.433-.749 3.465.684 4.527.273.215.573.345.866.462l2.808.931c-2.697 7.641-9.313 13.45-17.417 15.059l.014-45.638h5.992c1.889 0 3.412-1.538 3.412-3.426 0-1.875-1.523-3.413-3.412-3.413h-5.992v-6.78c3.979-1.687 6.721-5.634 6.721-10.206C61.264 9.95 56.314 5 50.192 5c-6.097 0-11.073 4.95-11.073 11.073 0 4.578 2.847 8.52 6.8 10.206l.007 6.78h-6.468c-1.882 0-3.413 1.538-3.413 3.413 0 1.889 1.531 3.426 3.413 3.426h6.468l.021 45.71c-8.292-1.485-15.15-7.354-17.926-15.131l2.827-.931c.299-.117.6-.247.879-.456 1.427-1.067 1.753-3.1.671-4.533-.155-.222-.313-.404-.508-.554L20.706 53.524c-.553-.612-1.361-.997-2.246-.997-1.486 0-2.743 1.075-3.009 2.495L13.027 70.12c-.053.241-.064.508-.064.762 0 1.791 1.446 3.236 3.229 3.236.339 0 .658-.032.959-.137l2.624-.853C23.956 85.816 35.901 95 49.997 95s26.053-9.184 30.221-21.891l2.619.872c.299.105.618.137.963.137 1.785 0 3.237-1.446 3.237-3.236 0-.255-.025-.522-.091-.763M45.659 16.073c0-2.521 2.044-4.553 4.533-4.553 2.527 0 4.552 2.032 4.552 4.553 0 2.514-2.024 4.559-4.552 4.559-2.489 0-4.533-2.046-4.533-4.559"/><path d="M46.821306 94.770829c-9.672125-.972647-18.52814-6.513856-23.771656-14.873922-.868866-1.385288-2.385574-4.487112-2.870545-5.870561l-.343618-.96102c-.0095-.01668-.705224.192731-1.545948.46535s-1.804312.494255-2.141309.492526c-1.598809-.0082-3.122304-1.547171-3.115278-3.146913.000864-.196879.574575-3.918388 1.274912-8.270022 1.419295-8.818969 1.428076-8.850837 2.627499-9.536632.834583-.477189 1.483957-.563323 2.337029-.309989.644845.191499 1.336272.793564 6.866372 5.978939 4.549564 4.26596 6.233135 5.932201 6.471488 6.40487.603214 1.196211.325525 2.693184-.677921 3.654548-.288018.275937-1.002559.607386-2.183926 1.013042-2.007091.68919-1.921853.529349-1.189152 2.229934 2.313522 5.369652 7.064022 9.988932 12.577448 12.230032 1.263767.513697 3.384288 1.159414 4.275471 1.30192l.550027.08795V62.733077 39.805269H42.37799c-3.958889 0-4.236614-.04887-5.126792-.902159-.76147-.729916-1.085823-1.473414-1.081006-2.477941.0071-1.483668.947583-2.699422 2.43431-3.146861.49772-.149791 1.545965-.202707 4.01562-.202707h3.342077v-3.423483-3.423485l-1.109679-.604511c-.793899-.432485-1.482722-.979822-2.420583-1.923389-1.146297-1.15327-1.401076-1.505996-2.029023-2.809061-.858345-1.781165-1.170382-3.086543-1.170382-4.896174 0-6.4184795 5.839733-11.6186738 12.164919-10.8326779 2.656895.3301574 4.668838 1.3296881 6.615711 3.286675 1.070143 1.0757025 1.339007 1.4513238 1.957431 2.7346549.865772 1.796619 1.169155 3.085051 1.169155 4.965255 0 4.023175-2.191435 7.679165-5.755536 9.602011l-.969271.522925-.0024 3.389446-.0024 3.389446 3.472222.04702c3.946037.05344 4.168331.108713 5.177124 1.287261 1.316051 1.53751.926562 3.821223-.837885 4.912821l-.700444.433339-3.555509.04279-3.555508.0428v22.902508l.107388 22.902148c.332248-.0011 2.935079-.780331 4.075018-1.219944 5.752163-2.218302 10.721311-7.144396 13.059805-12.946652.204466-.507318.322918-.966456.263226-1.020307s-.65621-.274921-1.325597-.491268c-1.73484-.560706-2.51001-1.013168-2.998016-1.749923-.680235-1.026966-.727607-2.0483-.14813-3.19361.309132-.610985 12.085183-11.683542 12.854449-12.08653 1.199252-.628243 2.81487-.181005 3.651625 1.010847.392869.559594.481176 1.003292 1.695561 8.519473.875547 5.419011 1.264717 8.190005 1.229335 8.753186-.06941 1.104804-.613524 1.897274-1.670778 2.433394-1.027413.520988-1.635513.502208-3.442467-.106313-.816705-.275039-1.512424-.472563-1.546044-.438944s-.379938.854657-.769596 1.824527c-2.13365 5.310705-5.61865 9.924534-10.123033 13.401993-6.338429 4.893379-14.56767 7.254819-22.501513 6.456977zm4.86827-74.330261c1.140586-.346728 2.230937-1.344337 2.770928-2.535241.21803-.480845.29704-.95557.301498-1.811516.0055-1.049248-.04177-1.253646-.495106-2.142377-1.123467-2.202486-3.642798-3.073429-5.980881-2.067613-.987511.424816-2.068224 1.59611-2.409954 2.611949-1.248434 3.711132 2.053637 7.087771 5.813515 5.944798z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m417.86 237.12v-237.12h-40.664v432.45h78.738v-195.34z"/>
<path d="m708.33 392.05h302.69v69.617l38.762-0.14453-0.046875-5.3555s-0.5-48.523-0.5-68.785c0-113.14-85.332-204.45-189.74-204.45-104.5 0-190.45 93.094-190.45 206.26 0 20.477-0.5 70.215-0.5 70.238l-0.046875 2.0703h39.809z"/>
<path d="m735.59 418.45c-0.26172 2.9062-0.66797 5.7852-0.66797 8.7383 0 68.832 55.809 124.57 124.59 124.57 68.785 0 124.55-55.762 124.55-124.57 0-2.9766-0.45312-5.8555-0.64453-8.7383z"/>
<path d="m1030.2 573.67h-331.57c-96.855 0-169.98 56.047-169.98 154.98v409.93c0 33.191 26.953 60.023 60.094 60.023 33.191 0 60.047-26.883 60.047-60.023l0.046875-367.07h37.738v428.5h355.53v-428.52h37.547l0.19141 367.07c0 33.191 26.953 60.023 60.023 60.023 33.238 0 60.094-26.883 60.094-60.023v-409.95c0.046875-98.926-72.953-154.93-169.76-154.93zm-47.5 207-48.785-25.668-48.762 25.668 9.3086-54.309-39.43-38.43 54.5-7.9297 24.383-49.406 24.355 49.406 54.523 7.9297-39.453 38.43z"/>
<path d="m489.07 1162-0.90625-396.02-30.668 0.070312-0.71094-311.24-78.523 0.19141 0.71484 293.79s-24.262 41.57-100.19 41.57c-69.332 0-225.52-90.809-225.52-90.809l-53.262 131.71s112.19 55.883 173.67 65.43c23 3.5938 47.617 5.1172 71.191 5.4766-13.883 15.453-22.547 35.668-22.547 58.023 0 28.168 13.617 52.953 34.383 68.953l-133.91 67.977 55.07 97.906 136.24-93.93s13.93-9.8555 26.285-3.332c11.117 5.8828 12.617 20.57 12.617 20.57l0.11719 44.047 46.07-0.30859-0.57031 37.191h53.309l-0.023437-37.262zm-180-159.05c-23.43 0-42.406-19-42.406-42.406s18.977-42.406 42.406-42.406c18.023 0 33.332 11.309 39.477 27.168h-43.57v33.477h42.238c-6.832 14.238-21.262 24.168-38.145 24.168z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+27 -31
View File
@@ -21,7 +21,8 @@
"target_dead_note": "You can't send resources to an eliminated player.",
"none": "None",
"copied": "Copied!",
"click_to_copy": "Click to copy"
"click_to_copy": "Click to copy",
"enabled": "Enabled"
},
"main": {
"title": "OpenFront (ALPHA)",
@@ -50,6 +51,7 @@
"settings": "Settings",
"keys": "Keys",
"stats": "Stats",
"leaderboard": "Leaderboard",
"account": "Account",
"help": "Help",
"menu": "Menu",
@@ -91,6 +93,8 @@
"canvas_2d_no_gpu": "Canvas 2D (no GPU)"
},
"help_modal": {
"video_tutorial": "Video Tutorial",
"video_tutorial_title": "OpenFront.io Tutorial",
"hotkeys": "Hotkeys",
"table_key": "Key",
"table_action": "Action",
@@ -207,6 +211,7 @@
"disable_nukes": "Disable Nukes",
"enables_title": "Enable Settings",
"start": "Start Game",
"options_changed_no_achievements": "Custom settings achievements disabled",
"gold_multiplier": "Gold multiplier",
"gold_multiplier_placeholder": "2.0x",
"starting_gold": "Starting gold",
@@ -236,15 +241,21 @@
"enter_email_address": "Please enter an email address",
"personal_player_id": "Personal Player ID:"
},
"stats_modal": {
"title": "Stats",
"leaderboard_modal": {
"title": "Leaderboard",
"title_plural": "Leaderboards",
"clan_stats": "Clan Stats",
"player_stats": "1v1 Ranked Stats",
"ranked_tab": "1v1 Ranked",
"clans_tab": "Clans",
"loading": "Loading...",
"error": "Error loading clan stats",
"no_stats": "No clan stats available",
"error": "Error loading leaderboard",
"no_stats": "No stats available",
"no_data_yet": "No Data Yet",
"clan": "Clan",
"player": "Player",
"games": "Games",
"elo": "ELO",
"win_score": "Win Score",
"win_score_tooltip": "Weighted wins based on clan participation and match difficulty",
"loss_score": "Loss Score",
@@ -252,7 +263,8 @@
"win_loss_ratio": "Win/Loss",
"ratio": "Ratio",
"rank": "Rank",
"try_again": "Try Again"
"try_again": "Try Again",
"your_ranking": "Your Ranking"
},
"game_info_modal": {
"title": "Game info",
@@ -326,6 +338,7 @@
"manicouagan": "Manicouagan",
"lemnos": "Lemnos",
"sierpinski": "Sierpinski",
"thebox": "The Box",
"twolakes": "Two Lakes",
"straitofhormuz": "Strait of Hormuz",
"surrounded": "Surrounded",
@@ -359,18 +372,23 @@
"disabled_units": "Disabled Units"
},
"public_lobby": {
"title": "Waiting for Game Start...",
"join": "Join next Game",
"waiting": "players waiting",
"teams_Duos": "{team_count} teams of 2 (Duos)",
"teams_Trios": "{team_count} teams of 3 (Trios)",
"teams_Quads": "{team_count} teams of 4 (Quads)",
"waiting_for_players": "Waiting for players",
"connecting": "Connecting to lobby...",
"starting_in": "Starting in {time}",
"starting_game": "Starting game…",
"teams_hvn": "Humans vs Nations",
"teams_hvn_detailed": "{num} Humans vs {num} Nations",
"teams": "{num} teams",
"players_per_team": "of {num}",
"started": "Started"
"started": "Started",
"status": "Status",
"join_timeout": "You didn't enter the game in time."
},
"matchmaking_modal": {
"title": "1v1 Ranked Matchmaking (ALPHA)",
@@ -455,7 +473,8 @@
},
"game_mode": {
"ffa": "Free for All",
"teams": "Teams"
"teams": "Teams",
"humans_vs_nations": "Humans vs Nations"
},
"public_game_modifier": {
"random_spawn": "Random Spawn",
@@ -723,29 +742,6 @@
"show_control": "Show Control",
"show_units": "Show Units"
},
"player_info_overlay": {
"type": "Type",
"bot": "Bot",
"nation": "Nation",
"player": "Player",
"team": "Team",
"alliance_timeout": "Alliance ends in",
"troops": "Troops",
"maxtroops": "Max troops",
"a_troops": "Attacking troops",
"gold": "Gold",
"ports": "Ports",
"cities": "Cities",
"factories": "Factories",
"missile_launchers": "Missile launchers",
"sams": "SAMs",
"warships": "Warships",
"health": "Health",
"attitude": "Attitude",
"levels": "Levels",
"wilderness_title": "Wilderness",
"irradiated_wilderness_title": "Irradiated Wilderness"
},
"events_display": {
"retreating": "retreating",
"retaliate": "Retaliate",
+85
View File
@@ -0,0 +1,85 @@
{
"map": {
"height": 2048,
"num_land_tiles": 4194304,
"width": 2048
},
"map16x": {
"height": 512,
"num_land_tiles": 262144,
"width": 512
},
"map4x": {
"height": 1024,
"num_land_tiles": 1048576,
"width": 1024
},
"name": "The Box",
"nations": [
{
"coordinates": [10, 10],
"flag": "",
"name": "King of the Corner"
},
{
"coordinates": [1024, 300],
"flag": "",
"name": "Suspicious Ally"
},
{
"coordinates": [1650, 400],
"flag": "",
"name": "Evan The Dev"
},
{
"coordinates": [1024, 1024],
"flag": "",
"name": "Middle Defender"
},
{
"coordinates": [350, 1024],
"flag": "",
"name": "Punch Merchant"
},
{
"coordinates": [1700, 1024],
"flag": "",
"name": "Nuke Thrower"
},
{
"coordinates": [400, 1650],
"flag": "",
"name": "Fullsender"
},
{
"coordinates": [1024, 1750],
"flag": "",
"name": "Factory Builder"
},
{
"coordinates": [1650, 1650],
"flag": "",
"name": "Front Manager"
},
{
"coordinates": [700, 700],
"flag": "",
"name": "Box Fighter"
},
{
"coordinates": [1350, 700],
"flag": "",
"name": "Cage Liberator"
},
{
"coordinates": [700, 1350],
"flag": "",
"name": "Train Trader"
},
{
"coordinates": [1350, 1350],
"flag": "",
"name": "Non-peaceful Bot"
}
]
}
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.

After

Width:  |  Height:  |  Size: 536 B

+115 -26
View File
@@ -1,7 +1,11 @@
import { z } from "zod";
import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
PlayerProfile,
PlayerProfileSchema,
RankedLeaderboardResponse,
RankedLeaderboardResponseSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
@@ -47,34 +51,42 @@ export async function fetchPlayerById(
return false;
}
}
export async function getUserMe(): Promise<UserMeResponse | false> {
try {
const userAuthResult = await userAuth();
if (!userAuthResult) return false;
const { jwt } = userAuthResult;
// Get the user object
const response = await fetch(getApiBase() + "/users/@me", {
headers: {
authorization: `Bearer ${jwt}`,
},
});
if (response.status === 401) {
await logOut();
return false;
}
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid response", error);
return false;
}
return result.data;
} catch (e) {
return false;
let __userMe: Promise<UserMeResponse | false> | null = null;
export async function getUserMe(): Promise<UserMeResponse | false> {
if (__userMe !== null) {
return __userMe;
}
__userMe = (async () => {
try {
const userAuthResult = await userAuth();
if (!userAuthResult) return false;
const { jwt } = userAuthResult;
// Get the user object
const response = await fetch(getApiBase() + "/users/@me", {
headers: {
authorization: `Bearer ${jwt}`,
},
});
if (response.status === 401) {
await logOut();
return false;
}
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid response", error);
return false;
}
return result.data;
} catch (e) {
return false;
}
})();
return __userMe;
}
export async function createCheckoutSession(
@@ -177,3 +189,80 @@ export async function fetchGameById(
return false;
}
}
export async function fetchClanLeaderboard(): Promise<
ClanLeaderboardResponse | false
> {
try {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
console.warn(
"fetchClanLeaderboard: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn(
"fetchClanLeaderboard: Zod validation failed",
parsed.error.toString(),
);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchClanLeaderboard: request failed", err);
return false;
}
}
export async function fetchPlayerLeaderboard(
page: number,
): Promise<RankedLeaderboardResponse | "reached_limit" | false> {
try {
const url = new URL(`${getApiBase()}/leaderboard/ranked`);
url.searchParams.set("page", String(page));
const res = await fetch(url.toString(), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
// Handle "Page must be between X and Y" error as end of list
if (res.status === 400) {
const errorJson = await res.json().catch(() => null);
if (errorJson?.message?.includes("Page must be between")) {
return "reached_limit";
}
}
console.warn(
"fetchPlayerLeaderboard: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = RankedLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn(
"fetchPlayerLeaderboard: Zod validation failed",
parsed.error.toString(),
);
return false;
}
return parsed.data;
} catch (err) {
console.error("fetchPlayerLeaderboard: request failed", err);
return false;
}
}
+5
View File
@@ -5,6 +5,7 @@ import {
GameID,
GameRecord,
GameStartInfo,
LobbyInfoEvent,
PlayerCosmeticRefs,
PlayerRecord,
ServerMessage,
@@ -97,6 +98,10 @@ export function joinLobby(
let terrainLoad: Promise<TerrainMapData> | null = null;
const onmessage = (message: ServerMessage) => {
if (message.type === "lobby_info") {
eventBus.emit(new LobbyInfoEvent(message.lobby));
return;
}
if (message.type === "prestart") {
console.log(
`lobby: game prestarting: ${JSON.stringify(message, replacer)}`,
+22
View File
@@ -30,6 +30,18 @@ export async function handlePurchase(
}
let __cosmetics: Promise<Cosmetics | null> | null = null;
let __cosmeticsHash: string | null = null;
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash.toString(36);
}
export async function fetchCosmetics(): Promise<Cosmetics | null> {
if (__cosmetics !== null) {
return __cosmetics;
@@ -46,6 +58,11 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
console.error(`Invalid cosmetics: ${result.error.message}`);
return null;
}
const patternKeys = Object.keys(result.data.patterns).sort();
const hashInput = patternKeys
.map((k) => k + (result.data.patterns[k].product ? "sale" : ""))
.join(",");
__cosmeticsHash = simpleHash(hashInput);
return result.data;
} catch (error) {
console.error("Error getting cosmetics:", error);
@@ -55,6 +72,11 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
return __cosmetics;
}
export async function getCosmeticsHash(): Promise<string | null> {
await fetchCosmetics();
return __cosmeticsHash;
}
export function patternRelationship(
pattern: Pattern,
colorPalette: { name: string; isArchived?: boolean } | null,
+55 -2
View File
@@ -1,6 +1,6 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { customElement, query, state } from "lit/decorators.js";
import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import { modalHeader } from "./components/ui/ModalHeader";
@@ -9,6 +9,7 @@ import { TroubleshootingModal } from "./TroubleshootingModal";
@customElement("help-modal")
export class HelpModal extends BaseModal {
@state() private keybinds: Record<string, string> = this.getKeybinds();
@query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement;
private isKeybindObject(v: unknown): v is { value: string } {
return (
@@ -121,6 +122,47 @@ export class HelpModal extends BaseModal {
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold
scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
>
<!-- Video Tutorial Section -->
<div class="flex items-center gap-3 mb-3">
<div class="text-blue-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
</div>
<h3
class="text-xl font-bold uppercase tracking-widest text-white/90"
>
${translateText("help_modal.video_tutorial")}
</h3>
<div
class="flex-1 h-px bg-gradient-to-r from-blue-500/50 to-transparent"
></div>
</div>
<section
class="bg-white/5 rounded-xl border border-white/10 overflow-hidden mb-8"
>
<div class="relative w-full h-0 pb-[56.25%]">
<iframe
id="tutorial-video-iframe"
class="absolute top-0 left-0 w-full h-full"
src="${TUTORIAL_VIDEO_URL}"
title="${translateText("help_modal.video_tutorial_title")}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
</div>
</section>
<!-- Troubleshooting Section -->
<div class="flex items-center gap-3 mb-3">
<div class="text-blue-400">
@@ -1201,5 +1243,16 @@ export class HelpModal extends BaseModal {
protected onOpen(): void {
this.keybinds = this.getKeybinds();
// Restore the video src when modal opens
if (this.videoIframe) {
this.videoIframe.src = TUTORIAL_VIDEO_URL;
}
}
protected onClose(): void {
// Clear the iframe src to stop video playback
if (this.videoIframe) {
this.videoIframe.src = "";
}
}
}
+848
View File
@@ -0,0 +1,848 @@
import { html, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import {
getActiveModifiers,
getGameModeLabel,
normaliseMapKey,
renderDuration,
renderNumber,
translateText,
} from "../client/Utils";
import { EventBus } from "../core/EventBus";
import {
ClientInfo,
GAME_ID_REGEX,
GameConfig,
GameInfo,
GameRecordSchema,
LobbyInfoEvent,
} from "../core/Schemas";
import { generateID } from "../core/Util";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import {
GameMapSize,
GameMode,
GameType,
HumansVsNations,
} from "../core/game/Game";
import { getApiBase } from "./Api";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/LobbyConfigItem";
import "./components/LobbyPlayerView";
import { modalHeader } from "./components/ui/ModalHeader";
@customElement("join-lobby-modal")
export class JoinLobbyModal extends BaseModal {
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
@property({ attribute: false }) eventBus: EventBus | null = null;
@state() private players: ClientInfo[] = [];
@state() private playerCount: number = 0;
@state() private gameConfig: GameConfig | null = null;
@state() private currentLobbyId: string = "";
@state() private currentClientID: string = "";
@state() private nationCount: number = 0;
@state() private lobbyStartAt: number | null = null;
@state() private isConnecting: boolean = true;
@state() private lobbyCreatorClientID: string | null = null;
private leaveLobbyOnClose = true;
private countdownTimerId: number | null = null;
private handledJoinTimeout = false;
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
const lobby = event.lobby;
if (!this.currentLobbyId || lobby.gameID !== this.currentLobbyId) {
return;
}
// Only stop showing spinner when we have player info
if (this.isConnecting && lobby.clients) {
this.isConnecting = false;
}
this.updateFromLobby({
...lobby,
msUntilStart: lobby.msUntilStart ?? undefined,
});
};
render() {
// Pre-join state: show lobby ID input form
if (!this.currentLobbyId) {
return this.renderJoinForm();
}
// 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))
: null;
const statusLabel =
secondsRemaining === null
? translateText("public_lobby.waiting_for_players")
: secondsRemaining > 0
? translateText("public_lobby.starting_in", {
time: renderDuration(secondsRemaining),
})
: translateText("public_lobby.started");
const maxPlayers = this.gameConfig?.maxPlayers ?? 0;
const playerCount = this.playerCount;
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
>
${modalHeader({
title: translateText("public_lobby.title"),
onBack: () => this.closeAndLeave(),
ariaLabel: translateText("common.close"),
rightContent:
this.currentLobbyId &&
this.gameConfig?.gameType === GameType.Private
? html`
<copy-button .lobbyId=${this.currentLobbyId}></copy-button>
`
: undefined,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
${this.isConnecting
? html`
<div
class="min-h-[240px] flex flex-col items-center justify-center gap-4"
>
<div
class="w-12 h-12 border-4 border-white/20 border-t-white rounded-full animate-spin"
></div>
<p class="text-center text-white/80 text-sm">
${translateText("public_lobby.connecting")}
</p>
</div>
`
: html`
${this.gameConfig ? this.renderGameConfig() : html``}
${this.players.length > 0
? html`
<lobby-player-view
class="mt-6"
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
.clients=${this.players}
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.currentClientID=${this.currentClientID}
.teamCount=${this.gameConfig?.playerTeams ?? 2}
.nationCount=${this.nationCount}
.disableNations=${this.gameConfig?.disableNations ??
false}
.isCompactMap=${this.gameConfig?.gameMapSize ===
GameMapSize.Compact}
></lobby-player-view>
`
: ""}
`}
</div>
${this.gameConfig?.gameType === GameType.Private
? html`
<div
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
>
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
disabled
>
${translateText("private_lobby.joined_waiting")}
</button>
</div>
`
: html`
<div
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
>
<div
class="w-full px-4 py-3 rounded-xl border border-white/10 bg-white/5 flex items-center justify-between gap-3"
>
<div class="flex flex-col">
<span
class="text-[10px] font-bold uppercase tracking-widest text-white/40"
>${translateText("public_lobby.status")}</span
>
<span class="text-sm font-bold text-white"
>${statusLabel}</span
>
</div>
${maxPlayers > 0
? html`
<div
class="flex items-center gap-2 text-white/80 text-xs font-bold uppercase tracking-widest"
>
<span>${playerCount}/${maxPlayers}</span>
<svg
class="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.972 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
></path>
</svg>
</div>
`
: html``}
</div>
</div>
`}
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
?hideHeader=${true}
?hideCloseButton=${true}
?inline=${this.inline}
>
${content}
</o-modal>
`;
}
private renderJoinForm() {
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
>
${modalHeader({
title: translateText("private_lobby.title"),
onBack: () => this.closeAndLeave(),
ariaLabel: translateText("common.close"),
})}
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<input
type="text"
id="lobbyIdInput"
placeholder=${translateText("private_lobby.enter_id")}
@keyup=${this.handleChange}
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
/>
<button
@click=${this.pasteFromClipboard}
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
title=${translateText("common.paste")}
>
<svg
class="text-white/60 group-hover:text-white transition-colors"
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 32 32"
height="18px"
width="18px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
></path>
</svg>
</button>
</div>
<o-button
title=${translateText("private_lobby.join_lobby")}
block
@click=${this.joinLobbyFromInput}
></o-button>
</div>
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
?hideHeader=${true}
?hideCloseButton=${true}
?inline=${this.inline}
>
${content}
</o-modal>
`;
}
public open(lobbyId: string = "", lobbyInfo?: GameInfo) {
super.open();
if (lobbyId) {
this.startTrackingLobby(lobbyId, lobbyInfo);
// If opened with lobbyInfo (public lobby case), auto-join the lobby
if (lobbyInfo) {
this.joinPublicLobby(lobbyId, lobbyInfo);
} else {
// If opened with lobbyId but no lobbyInfo (URL join case), check if active and join
this.handleUrlJoin(lobbyId);
}
}
}
private async handleUrlJoin(lobbyId: string): Promise<void> {
try {
const gameExists = await this.checkActiveLobby(lobbyId);
if (gameExists) return;
// Active lobby not found, check if it's an archived game
switch (await this.checkArchivedGame(lobbyId)) {
case "success":
return;
case "not_found":
this.resetTrackingState();
this.showMessage(translateText("private_lobby.not_found"), "red");
return;
case "version_mismatch":
this.resetTrackingState();
this.showMessage(
translateText("private_lobby.version_mismatch"),
"red",
);
return;
case "error":
this.resetTrackingState();
this.showMessage(translateText("private_lobby.error"), "red");
return;
}
} catch (error) {
console.error("Error checking lobby from URL:", error);
this.resetTrackingState();
this.showMessage(translateText("private_lobby.error"), "red");
}
}
private joinPublicLobby(lobbyId: string, lobbyInfo: GameInfo) {
// Dispatch join-lobby event to actually connect to the lobby
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
clientID: this.currentClientID,
publicLobbyInfo: lobbyInfo,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) {
this.currentLobbyId = lobbyId;
this.currentClientID = generateID();
this.gameConfig = null;
this.players = [];
this.playerCount = 0;
this.nationCount = 0;
this.lobbyStartAt = null;
this.lobbyCreatorClientID = null;
this.isConnecting = true;
this.handledJoinTimeout = false;
this.startLobbyUpdates();
if (lobbyInfo) {
this.updateFromLobby(lobbyInfo);
// Only stop showing spinner when we have player info
if (lobbyInfo.clients) {
this.isConnecting = false;
}
}
}
private resetTrackingState() {
this.stopLobbyUpdates();
this.currentLobbyId = "";
this.currentClientID = "";
this.isConnecting = false;
}
private leaveLobby() {
if (!this.currentLobbyId) {
return;
}
this.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: this.currentLobbyId },
bubbles: true,
composed: true,
}),
);
}
protected onClose(): void {
this.clearCountdownTimer();
this.stopLobbyUpdates();
if (this.leaveLobbyOnClose) {
this.leaveLobby();
this.updateHistory("/");
}
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
this.gameConfig = null;
this.players = [];
this.playerCount = 0;
this.currentLobbyId = "";
this.currentClientID = "";
this.nationCount = 0;
this.lobbyStartAt = null;
this.lobbyCreatorClientID = null;
this.isConnecting = true;
this.leaveLobbyOnClose = true;
}
disconnectedCallback() {
this.clearCountdownTimer();
this.stopLobbyUpdates();
super.disconnectedCallback();
}
public closeAndLeave() {
this.leaveLobby();
try {
this.updateHistory("/");
} catch (error) {
console.warn("Failed to restore URL on leave:", error);
}
this.leaveLobbyOnClose = false;
this.close();
}
public closeWithoutLeaving() {
this.leaveLobbyOnClose = false;
this.close();
}
private updateHistory(url: string): void {
if (!crazyGamesSDK.isOnCrazyGames()) {
history.replaceState(null, "", url);
}
}
// --- Game config rendering ---
private renderGameConfig(): TemplateResult {
if (!this.gameConfig) return html``;
const c = this.gameConfig;
const mapName = translateText("map." + normaliseMapKey(c.gameMap));
const modeName = getGameModeLabel(c);
const modifiers = getActiveModifiers(c.publicGameModifiers);
return html`
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<lobby-config-item
.label=${translateText("map.map")}
.value=${mapName}
></lobby-config-item>
<lobby-config-item
.label=${translateText("host_modal.mode")}
.value=${modeName}
></lobby-config-item>
${modifiers.map(
(m) => html`
<lobby-config-item
.label=${translateText(m.labelKey)}
.value=${m.value !== undefined
? renderNumber(m.value)
: translateText("common.enabled")}
></lobby-config-item>
`,
)}
${c.gameMode !== GameMode.FFA &&
c.playerTeams &&
c.playerTeams !== HumansVsNations
? html`
<lobby-config-item
.label=${typeof c.playerTeams === "string"
? translateText("host_modal.team_type")
: translateText("host_modal.team_count")}
.value=${typeof c.playerTeams === "string"
? translateText("host_modal.teams_" + c.playerTeams)
: c.playerTeams.toString()}
></lobby-config-item>
`
: html``}
</div>
${this.renderDisabledUnits()}
`;
}
private renderDisabledUnits(): TemplateResult {
if (
!this.gameConfig ||
!this.gameConfig.disabledUnits ||
this.gameConfig.disabledUnits.length === 0
) {
return html``;
}
const unitKeys: Record<string, string> = {
City: "unit_type.city",
Port: "unit_type.port",
"Defense Post": "unit_type.defense_post",
"SAM Launcher": "unit_type.sam_launcher",
"Missile Silo": "unit_type.missile_silo",
Warship: "unit_type.warship",
Factory: "unit_type.factory",
"Atom Bomb": "unit_type.atom_bomb",
"Hydrogen Bomb": "unit_type.hydrogen_bomb",
MIRV: "unit_type.mirv",
"Trade Ship": "player_stats_table.unit.trade",
Transport: "player_stats_table.unit.trans",
"MIRV Warhead": "player_stats_table.unit.mirvw",
};
return html`
<div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<div
class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2"
>
${translateText("private_lobby.disabled_units")}
</div>
<div class="flex flex-wrap gap-2">
${this.gameConfig.disabledUnits.map((unit) => {
const key = unitKeys[unit];
const name = key ? translateText(key) : unit;
return html`
<span
class="px-2 py-1 bg-red-500/20 text-red-200 text-xs rounded font-bold border border-red-500/30"
>
${name}
</span>
`;
})}
</div>
</div>
`;
}
// --- Lobby event handling ---
private updateFromLobby(lobby: GameInfo) {
if (lobby.clients) {
this.players = lobby.clients;
this.playerCount = lobby.clients.length;
this.lobbyCreatorClientID = lobby.clients[0]?.clientID ?? null;
} else {
this.players = [];
this.playerCount = lobby.numClients ?? 0;
}
if (lobby.msUntilStart !== undefined) {
this.lobbyStartAt = lobby.msUntilStart + Date.now();
} else {
this.lobbyStartAt = null;
}
this.syncCountdownTimer();
if (lobby.gameConfig) {
const mapChanged = this.gameConfig?.gameMap !== lobby.gameConfig.gameMap;
this.gameConfig = lobby.gameConfig;
if (mapChanged) {
this.loadNationCount();
}
}
}
private startLobbyUpdates() {
this.stopLobbyUpdates();
if (!this.eventBus) {
console.warn(
"JoinLobbyModal: eventBus not set, cannot subscribe to lobby updates",
);
return;
}
this.eventBus.on(LobbyInfoEvent, this.handleLobbyInfo);
}
private stopLobbyUpdates() {
this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo);
}
// --- Countdown timer ---
private syncCountdownTimer() {
if (this.lobbyStartAt === null) {
this.clearCountdownTimer();
return;
}
if (this.countdownTimerId !== null) {
return;
}
this.countdownTimerId = window.setInterval(() => {
this.checkForJoinTimeout();
this.requestUpdate();
}, 1000);
}
private clearCountdownTimer() {
if (this.countdownTimerId === null) {
return;
}
clearInterval(this.countdownTimerId);
this.countdownTimerId = null;
}
private checkForJoinTimeout() {
if (
this.handledJoinTimeout ||
!this.isConnecting ||
this.lobbyStartAt === null ||
!this.isModalOpen
) {
return;
}
if (Date.now() < this.lobbyStartAt) {
return;
}
this.handledJoinTimeout = true;
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("public_lobby.join_timeout"),
color: "red",
duration: 3500,
},
}),
);
this.closeAndLeave();
}
// --- Nation count ---
private async loadNationCount() {
if (!this.gameConfig) {
this.nationCount = 0;
return;
}
const currentMap = this.gameConfig.gameMap;
try {
const mapData = terrainMapFileLoader.getMapData(currentMap);
const manifest = await mapData.manifest();
if (this.gameConfig?.gameMap === currentMap) {
this.nationCount = manifest.nations.length;
}
} catch (error) {
console.warn("Failed to load nation count", error);
if (this.gameConfig?.gameMap === currentMap) {
this.nationCount = 0;
}
}
}
// --- Private lobby join flow (lobby ID input) ---
private isValidLobbyId(value: string): boolean {
return GAME_ID_REGEX.test(value);
}
private normalizeLobbyId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
const extracted = this.extractLobbyIdFromUrl(trimmed).trim();
if (!this.isValidLobbyId(extracted)) return null;
return extracted;
}
private sanitizeForLog(value: string): string {
return value.replace(/[\r\n]/g, "");
}
private extractLobbyIdFromUrl(input: string): string {
if (!input.startsWith("http")) {
return input;
}
try {
const url = new URL(input);
const match = url.pathname.match(/game\/([^/]+)/);
const candidate = match?.[1];
if (candidate && GAME_ID_REGEX.test(candidate)) return candidate;
return input;
} catch (error) {
console.warn("Failed to parse lobby URL", error);
return input;
}
}
private setLobbyId(id: string) {
if (this.lobbyIdInput) {
this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id);
}
}
private handleChange(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
this.setLobbyId(value);
}
private async pasteFromClipboard() {
try {
const clipText = await navigator.clipboard.readText();
this.setLobbyId(clipText);
} catch (err) {
console.error("Failed to read clipboard contents: ", err);
}
}
private async joinLobbyFromInput(): Promise<void> {
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
if (!lobbyId) {
this.showMessage(translateText("private_lobby.not_found"), "red");
return;
}
this.lobbyIdInput.value = lobbyId;
console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`);
// Initialize tracking state before checking/joining
this.startTrackingLobby(lobbyId);
try {
const gameExists = await this.checkActiveLobby(lobbyId);
if (gameExists) return;
switch (await this.checkArchivedGame(lobbyId)) {
case "success":
return;
case "not_found":
this.resetTrackingState();
this.showMessage(translateText("private_lobby.not_found"), "red");
return;
case "version_mismatch":
this.resetTrackingState();
this.showMessage(
translateText("private_lobby.version_mismatch"),
"red",
);
return;
case "error":
this.resetTrackingState();
this.showMessage(translateText("private_lobby.error"), "red");
return;
}
} catch (error) {
console.error("Error checking lobby existence:", error);
this.resetTrackingState();
this.showMessage(translateText("private_lobby.error"), "red");
}
}
private showMessage(message: string, color: "green" | "red" = "green") {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: { message, duration: 3000, color },
}),
);
}
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
const config = await getServerConfigFromClient();
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
return false;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return false;
}
let gameInfo: { exists?: boolean };
try {
gameInfo = await response.json();
} catch (error) {
console.warn("Failed to parse active lobby response", error);
return false;
}
if (gameInfo.exists) {
this.showMessage(translateText("private_lobby.joined_waiting"));
// Use the clientID that was already set by startTrackingLobby in open()
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
clientID: this.currentClientID,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
// Event tracking is already started by open() -> startTrackingLobby()
// LobbyInfoEvents will update the UI as they arrive
return true;
}
return false;
}
private async checkArchivedGame(
lobbyId: string,
): Promise<"success" | "not_found" | "version_mismatch" | "error"> {
const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (archiveResponse.status === 404) {
return "not_found";
}
if (archiveResponse.status !== 200) {
return "error";
}
const archiveData = await archiveResponse.json();
const parsed = GameRecordSchema.safeParse(archiveData);
if (!parsed.success) {
return "version_mismatch";
}
if (
window.GIT_COMMIT !== "DEV" &&
parsed.data.gitCommit !== window.GIT_COMMIT
) {
const safeLobbyId = this.sanitizeForLog(lobbyId);
console.warn(
`Git commit hash mismatch for game ${safeLobbyId}`,
archiveData.details,
);
return "version_mismatch";
}
// If the modal closes as part of joining the replay, do not leave/reset URL
this.leaveLobbyOnClose = false;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
gameRecord: parsed.data,
clientID: this.currentClientID,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
return "success";
}
}
-549
View File
@@ -1,549 +0,0 @@
import { html, TemplateResult } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import {
ClientInfo,
GAME_ID_REGEX,
GameConfig,
GameInfo,
GameRecordSchema,
} from "../core/Schemas";
import { generateID } from "../core/Util";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { GameMapSize, GameMode } from "../core/game/Game";
import { getApiBase } from "./Api";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/Difficulties";
import "./components/LobbyPlayerView";
import { modalHeader } from "./components/ui/ModalHeader";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends BaseModal {
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
@state() private message: string = "";
@state() private hasJoined = false;
@state() private players: ClientInfo[] = [];
@state() private gameConfig: GameConfig | null = null;
@state() private lobbyCreatorClientID: string | null = null;
@state() private currentLobbyId: string = "";
@state() private currentClientID: string = "";
@state() private nationCount: number = 0;
private playersInterval: NodeJS.Timeout | null = null;
private mapLoader = terrainMapFileLoader;
private leaveLobbyOnClose = true;
updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
}
render() {
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
>
${modalHeader({
title: translateText("private_lobby.title"),
onBack: this.closeAndLeave,
ariaLabel: translateText("common.close"),
rightContent: this.hasJoined
? html`
<copy-button .lobbyId=${this.currentLobbyId}></copy-button>
`
: undefined,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
${!this.hasJoined
? html`<div class="flex flex-col gap-3">
<div class="flex gap-2">
<input
type="text"
id="lobbyIdInput"
placeholder=${translateText("private_lobby.enter_id")}
@keyup=${this.handleChange}
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
/>
<button
@click=${this.pasteFromClipboard}
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
title=${translateText("common.paste")}
>
<svg
class="text-white/60 group-hover:text-white transition-colors"
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 32 32"
height="18px"
width="18px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
></path>
</svg>
</button>
</div>
<o-button
title=${translateText("private_lobby.join_lobby")}
block
@click=${this.joinLobby}
></o-button>
</div>`
: ""}
${this.renderGameConfig()}
${this.hasJoined && this.players.length > 0
? html`
<lobby-player-view
class="mt-6"
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
.clients=${this.players}
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.currentClientID=${this.currentClientID}
.teamCount=${this.gameConfig?.playerTeams ?? 2}
.nationCount=${this.nationCount}
.disableNations=${this.gameConfig?.disableNations ?? false}
.isCompactMap=${this.gameConfig?.gameMapSize ===
GameMapSize.Compact}
></lobby-player-view>
`
: ""}
</div>
${this.hasJoined && this.players.length > 0
? html` <div
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
>
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
disabled
>
${translateText("private_lobby.joined_waiting")}
</button>
</div>`
: ""}
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
?hideHeader=${true}
?hideCloseButton=${true}
?inline=${this.inline}
>
${content}
</o-modal>
`;
}
private renderConfigItem(
label: string,
value: string | TemplateResult,
): TemplateResult {
return html`
<div
class="bg-white/5 border border-white/10 rounded-lg p-3 flex flex-col items-center justify-center gap-1 text-center min-w-[100px]"
>
<span
class="text-white/40 text-[10px] font-bold uppercase tracking-wider"
>${label}</span
>
<span
class="text-white font-bold text-sm w-full break-words hyphens-auto"
>${value}</span
>
</div>
`;
}
private renderGameConfig(): TemplateResult {
if (!this.gameConfig) return html``;
const c = this.gameConfig;
const mapName = translateText(
"map." + c.gameMap.toLowerCase().replace(/ /g, ""),
);
const modeName =
c.gameMode === "Free For All"
? translateText("game_mode.ffa")
: translateText("game_mode.teams");
const diffName = translateText(
"difficulty." + c.difficulty.toLowerCase().replace(/ /g, ""),
);
return html`
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
${this.renderConfigItem(translateText("map.map"), mapName)}
${this.renderConfigItem(translateText("host_modal.mode"), modeName)}
${this.renderConfigItem(
translateText("difficulty.difficulty"),
diffName,
)}
${this.renderConfigItem(
translateText("host_modal.bots"),
c.bots.toString(),
)}
${c.gameMode !== "Free For All" && c.playerTeams
? this.renderConfigItem(
typeof c.playerTeams === "string"
? translateText("host_modal.team_type")
: translateText("host_modal.team_count"),
typeof c.playerTeams === "string"
? translateText("host_modal.teams_" + c.playerTeams)
: c.playerTeams.toString(),
)
: html``}
</div>
${this.renderDisabledUnits()}
`;
}
private renderDisabledUnits(): TemplateResult {
if (
!this.gameConfig ||
!this.gameConfig.disabledUnits ||
this.gameConfig.disabledUnits.length === 0
) {
return html``;
}
const unitKeys: Record<string, string> = {
City: "unit_type.city",
Port: "unit_type.port",
"Defense Post": "unit_type.defense_post",
"SAM Launcher": "unit_type.sam_launcher",
"Missile Silo": "unit_type.missile_silo",
Warship: "unit_type.warship",
Factory: "unit_type.factory",
"Atom Bomb": "unit_type.atom_bomb",
"Hydrogen Bomb": "unit_type.hydrogen_bomb",
MIRV: "unit_type.mirv",
"Trade Ship": "stats_modal.unit.trade",
Transport: "stats_modal.unit.trans",
"MIRV Warhead": "stats_modal.unit.mirvw",
};
return html`
<div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<div
class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2"
>
${translateText("private_lobby.disabled_units")}
</div>
<div class="flex flex-wrap gap-2">
${this.gameConfig.disabledUnits.map((unit) => {
const key = unitKeys[unit];
const name = key ? translateText(key) : unit;
return html`
<span
class="px-2 py-1 bg-red-500/20 text-red-200 text-xs rounded font-bold border border-red-500/30"
>
${name}
</span>
`;
})}
</div>
</div>
`;
}
public open(id: string = "") {
super.open();
if (id) {
this.setLobbyId(id);
this.joinLobby();
}
}
private leaveLobby() {
if (!this.currentLobbyId || !this.hasJoined) {
return;
}
this.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: this.currentLobbyId },
bubbles: true,
composed: true,
}),
);
}
protected onClose(): void {
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
this.gameConfig = null;
this.players = [];
if (this.playersInterval) {
clearInterval(this.playersInterval);
this.playersInterval = null;
}
if (this.leaveLobbyOnClose) {
this.leaveLobby();
// Reset URL to base when modal closes
history.replaceState(null, "", window.location.origin + "/");
}
this.hasJoined = false;
this.message = "";
this.currentLobbyId = "";
this.currentClientID = "";
this.nationCount = 0;
this.leaveLobbyOnClose = true;
}
public closeAndLeave() {
this.leaveLobbyOnClose = true;
this.close();
}
private isValidLobbyId(value: string): boolean {
return GAME_ID_REGEX.test(value);
}
private normalizeLobbyId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
const extracted = this.extractLobbyIdFromUrl(trimmed).trim();
if (!this.isValidLobbyId(extracted)) return null;
return extracted;
}
private sanitizeForLog(value: string): string {
return value.replace(/[\r\n]/g, "");
}
private extractLobbyIdFromUrl(input: string): string {
if (!input.startsWith("http")) {
return input;
}
try {
const url = new URL(input);
const match = url.pathname.match(/game\/([^/]+)/);
const candidate = match?.[1];
if (candidate && GAME_ID_REGEX.test(candidate)) return candidate;
return input;
} catch (error) {
console.warn("Failed to parse lobby URL", error);
return input;
}
}
private setLobbyId(id: string) {
this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id);
}
private handleChange(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
this.setLobbyId(value);
}
private async pasteFromClipboard() {
try {
const clipText = await navigator.clipboard.readText();
this.setLobbyId(clipText);
} catch (err) {
console.error("Failed to read clipboard contents: ", err);
}
}
private async joinLobby(): Promise<void> {
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
if (!lobbyId) {
this.showMessage(translateText("private_lobby.not_found"), "red");
return;
}
this.lobbyIdInput.value = lobbyId;
this.currentLobbyId = lobbyId;
console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`);
try {
// First, check if the game exists in active lobbies
const gameExists = await this.checkActiveLobby(lobbyId);
if (gameExists) return;
// If not active, check archived games
switch (await this.checkArchivedGame(lobbyId)) {
case "success":
return;
case "not_found":
this.showMessage(translateText("private_lobby.not_found"), "red");
this.message = "";
return;
case "version_mismatch":
this.showMessage(
translateText("private_lobby.version_mismatch"),
"red",
);
this.message = "";
return;
case "error":
this.showMessage(translateText("private_lobby.error"), "red");
this.message = "";
return;
}
} catch (error) {
console.error("Error checking lobby existence:", error);
this.showMessage(translateText("private_lobby.error"), "red");
this.message = "";
}
}
private showMessage(message: string, color: "green" | "red" = "green") {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: { message, duration: 3000, color },
}),
);
}
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
const config = await getServerConfigFromClient();
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const gameInfo = await response.json();
if (gameInfo.exists) {
this.showMessage(translateText("private_lobby.joined_waiting"));
this.message = "";
this.hasJoined = true;
this.currentClientID = generateID();
// If the modal closes as part of joining the game, do not leave the lobby
this.leaveLobbyOnClose = false;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
clientID: this.currentClientID,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
this.pollPlayers();
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
return true;
}
return false;
}
private async checkArchivedGame(
lobbyId: string,
): Promise<"success" | "not_found" | "version_mismatch" | "error"> {
const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (archiveResponse.status === 404) {
return "not_found";
}
if (archiveResponse.status !== 200) {
return "error";
}
const archiveData = await archiveResponse.json();
const parsed = GameRecordSchema.safeParse(archiveData);
if (!parsed.success) {
return "version_mismatch";
}
// Allow DEV to join games created with a different version for debugging.
if (
window.GIT_COMMIT !== "DEV" &&
parsed.data.gitCommit !== window.GIT_COMMIT
) {
const safeLobbyId = this.sanitizeForLog(lobbyId);
console.warn(
`Git commit hash mismatch for game ${safeLobbyId}`,
archiveData.details,
);
return "version_mismatch";
}
this.currentClientID = generateID();
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
gameRecord: parsed.data,
clientID: this.currentClientID,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
return "success";
}
private async pollPlayers() {
const lobbyId = this.currentLobbyId;
if (!lobbyId) return;
const config = await getServerConfigFromClient();
fetch(`/${config.workerPath(lobbyId)}/api/game/${lobbyId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((data: GameInfo) => {
this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null;
this.players = data.clients ?? [];
if (data.gameConfig) {
const mapChanged =
this.gameConfig?.gameMap !== data.gameConfig.gameMap;
this.gameConfig = data.gameConfig;
if (mapChanged) {
this.loadNationCount();
}
}
})
.catch((error) => {
console.error("Error polling players:", error);
});
}
private async loadNationCount() {
if (!this.gameConfig) {
this.nationCount = 0;
return;
}
const currentMap = this.gameConfig.gameMap;
try {
const mapData = this.mapLoader.getMapData(currentMap);
const manifest = await mapData.manifest();
// Only update if the map hasn't changed
if (this.gameConfig?.gameMap === currentMap) {
this.nationCount = manifest.nations.length;
}
} catch (error) {
console.warn("Failed to load nation count", error);
// Only update if the map hasn't changed
if (this.gameConfig?.gameMap === currentMap) {
this.nationCount = 0;
}
}
}
}
+5 -2
View File
@@ -203,9 +203,12 @@ export class LangSelector extends LitElement {
const components = [
"single-player-modal",
"host-lobby-modal",
"join-private-lobby-modal",
"join-lobby-modal",
"emoji-table",
"leader-board",
"leaderboard-tabs",
"leaderboard-player-list",
"leaderboard-clan-table",
"build-menu",
"win-modal",
"game-starting-modal",
@@ -225,7 +228,7 @@ export class LangSelector extends LitElement {
"news-modal",
"news-button",
"account-modal",
"stats-modal",
"leaderboard-modal",
"flag-input-modal",
"flag-input",
"matchmaking-button",
+133
View File
@@ -0,0 +1,133 @@
import { html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { BaseModal } from "./components/BaseModal";
import "./components/leaderboard/LeaderboardClanTable";
import type { LeaderboardClanTable } from "./components/leaderboard/LeaderboardClanTable";
import "./components/leaderboard/LeaderboardPlayerList";
import type { LeaderboardPlayerList } from "./components/leaderboard/LeaderboardPlayerList";
import "./components/leaderboard/LeaderboardTabs";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
@customElement("leaderboard-modal")
export class LeaderboardModal extends BaseModal {
@state() private activeTab: "players" | "clans" = "players";
@state()
private clanDateRange: { start: string; end: string } | null = null;
@query("leaderboard-player-list")
private playerList?: LeaderboardPlayerList;
@query("leaderboard-clan-table")
private clanTable?: LeaderboardClanTable;
private loadToken = 0;
protected onOpen(): void {
this.loadActiveTabData();
}
private loadActiveTabData() {
const token = ++this.loadToken;
const run = async () => {
if (token !== this.loadToken) return;
if (this.activeTab === "players") {
await this.playerList?.ensureLoaded();
if (token !== this.loadToken) return;
this.playerList?.handleTabActivated();
} else {
await this.clanTable?.ensureLoaded();
}
queueMicrotask(() => {
if (token !== this.loadToken) return;
if (this.activeTab === "players") void this.clanTable?.ensureLoaded();
else void this.playerList?.ensureLoaded();
});
};
void (async () => {
if (!(this.activeTab === "players" ? this.playerList : this.clanTable)) {
await this.updateComplete;
}
await run();
})();
}
private handleTabChange(tab: "clans" | "players") {
this.activeTab = tab;
this.loadActiveTabData();
}
private handleClanDateRangeChange(
event: CustomEvent<{ start: string; end: string }>,
) {
this.clanDateRange = event.detail;
}
render() {
let dateRange = html``;
if (this.clanDateRange) {
const start = new Date(this.clanDateRange.start).toLocaleDateString();
const end = new Date(this.clanDateRange.end).toLocaleDateString();
dateRange = html`<span
class="text-sm font-normal text-white/40 ml-2 wrap-break-words"
>(${start} - ${end})</span
>`;
}
const content = html`
<div
class="h-full flex flex-col bg-black/80 backdrop-blur-xl rounded-2xl border border-white/10 overflow-hidden shadow-2xl"
>
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
>
${translateText("leaderboard_modal.title")}
</span>
${this.activeTab === "clans" ? dateRange : ""}
</div>
`,
onBack: this.close,
ariaLabel: translateText("common.close"),
})}
<div class="flex-1 flex flex-col min-h-0">
<leaderboard-tabs
.activeTab=${this.activeTab}
@tab-change=${(event: CustomEvent<"players" | "clans">) =>
this.handleTabChange(event.detail)}
></leaderboard-tabs>
<div class="flex-1 min-h-0">
<leaderboard-player-list
class=${this.activeTab === "players" ? "h-full" : "hidden"}
></leaderboard-player-list>
<leaderboard-clan-table
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
@date-range-change=${(
event: CustomEvent<{ start: string; end: string }>,
) => this.handleClanDateRangeChange(event)}
></leaderboard-clan-table>
</div>
</div>
</div>
`;
if (this.inline) return content;
return html`
<o-modal
id="leaderboard-modal"
?inline=${this.inline}
hideCloseButton
hideHeader
>
${content}
</o-modal>
`;
}
}
+62 -18
View File
@@ -1,7 +1,12 @@
import version from "resources/version.txt?raw";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GAME_ID_REGEX, GameRecord, GameStartInfo } from "../core/Schemas";
import {
GAME_ID_REGEX,
GameInfo,
GameRecord,
GameStartInfo,
} from "../core/Schemas";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
@@ -22,19 +27,19 @@ import "./GoogleAdElement";
import { GutterAds } from "./GutterAds";
import { HelpModal } from "./HelpModal";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import { JoinLobbyModal } from "./JoinLobbyModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
import { initLayout } from "./Layout";
import "./LeaderboardModal";
import "./Matchmaking";
import { MatchmakingModal } from "./Matchmaking";
import { initNavigation } from "./Navigation";
import "./NewsModal";
import "./PatternInput";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { PublicLobby, ShowPublicLobbyModalEvent } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import "./StatsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
@@ -178,6 +183,24 @@ declare global {
slots?: any;
};
spaNewPage: (url?: string) => void;
// Video ad methods
onPlayerReady: (() => void) | null;
addUnits: (units: Array<{ type: string }>) => Promise<void>;
displayUnits: () => void;
};
Bolt: {
on: (unitType: string, event: string, callback: () => void) => void;
BOLT_AD_REQUEST_START: string;
BOLT_AD_IMPRESSION: string;
BOLT_AD_STARTED: string;
BOLT_FIRST_QUARTILE: string;
BOLT_MIDPOINT: string;
BOLT_THIRD_QUARTILE: string;
BOLT_AD_COMPLETE: string;
BOLT_AD_ERROR: string;
BOLT_AD_PAUSED: string;
BOLT_AD_CLICKED: string;
SHOW_HIDDEN_CONTAINER: string;
};
showPage?: (pageId: string) => void;
}
@@ -185,6 +208,7 @@ declare global {
// Extend the global interfaces to include your custom events
interface DocumentEventMap {
"join-lobby": CustomEvent<JoinLobbyEvent>;
"show-public-lobby-modal": CustomEvent<ShowPublicLobbyModalEvent>;
"kick-player": CustomEvent;
"join-changed": CustomEvent;
}
@@ -198,6 +222,8 @@ export interface JoinLobbyEvent {
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
publicLobbyInfo?: GameInfo;
}
class Client {
@@ -210,7 +236,7 @@ class Client {
private flagInput: FlagInput | null = null;
private hostModal: HostPrivateLobbyModal;
private joinModal: JoinPrivateLobbyModal;
private joinModal: JoinLobbyModal;
private publicLobby: PublicLobby;
private userSettings: UserSettings = new UserSettings();
private patternsModal: TerritoryPatternsModal;
@@ -284,6 +310,10 @@ class Client {
this.gutterAds = gutterAds;
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener(
"show-public-lobby-modal",
this.handleShowPublicLobbyModal.bind(this),
);
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
document.addEventListener(
@@ -486,7 +516,6 @@ class Client {
hostLobbyButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
window.showPage?.("page-host-lobby");
this.publicLobby.leaveLobby();
} else {
window.dispatchEvent(
new CustomEvent("show-message", {
@@ -501,10 +530,12 @@ class Client {
});
this.joinModal = document.querySelector(
"join-private-lobby-modal",
) as JoinPrivateLobbyModal;
if (!this.joinModal || !(this.joinModal instanceof JoinPrivateLobbyModal)) {
console.warn("Join private lobby modal element not found");
"join-lobby-modal",
) as JoinLobbyModal;
if (!this.joinModal || !(this.joinModal instanceof JoinLobbyModal)) {
console.warn("Join lobby modal element not found");
} else {
this.joinModal.eventBus = this.eventBus;
}
const joinPrivateLobbyButton = document.getElementById(
"join-private-lobby-button",
@@ -513,7 +544,7 @@ class Client {
throw new Error("Missing join-private-lobby-button");
joinPrivateLobbyButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
window.showPage?.("page-join-private-lobby");
window.showPage?.("page-join-lobby");
} else {
window.dispatchEvent(
new CustomEvent("show-message", {
@@ -613,7 +644,7 @@ class Client {
private async handleUrl() {
// Wait for modal custom elements to be defined
await Promise.all([
customElements.whenDefined("join-private-lobby-modal"),
customElements.whenDefined("join-lobby-modal"),
customElements.whenDefined("host-lobby-modal"),
]);
@@ -626,7 +657,7 @@ class Client {
// Wait 2 seconds to ensure all elements are actually loaded,
// On low end-chromebooks the join modal was not registered in time.
await new Promise((resolve) => setTimeout(resolve, 2000));
window.showPage?.("page-join-private-lobby");
window.showPage?.("page-join-lobby");
this.joinModal?.open(lobbyId);
console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`);
return;
@@ -715,7 +746,7 @@ class Client {
const lobbyId =
pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null;
if (lobbyId) {
window.showPage?.("page-join-private-lobby");
window.showPage?.("page-join-lobby");
this.joinModal.open(lobbyId);
console.log(`joining lobby ${lobbyId}`);
return;
@@ -741,7 +772,10 @@ class Client {
document.body.classList.remove("in-game");
}
const config = await getServerConfigFromClient();
this.updateJoinUrlForShare(lobby.gameID, config);
// Only update URL immediately for private lobbies, not public ones
if (!lobby.publicLobbyInfo && lobby.source !== "public") {
this.updateJoinUrlForShare(lobby.gameID, config);
}
const pattern = this.userSettings.getSelectedPatternName(
await fetchCosmetics(),
@@ -778,10 +812,10 @@ class Client {
document
.getElementById("username-validation-error")
?.classList.add("hidden");
this.joinModal?.closeWithoutLeaving();
[
"single-player-modal",
"host-lobby-modal",
"join-private-lobby-modal",
"game-starting-modal",
"game-top-bar",
"help-modal",
@@ -792,7 +826,7 @@ class Client {
"news-modal",
"flag-input-modal",
"account-button",
"stats-button",
"leaderboard-button",
"token-login",
"matchmaking-modal",
"lang-selector",
@@ -866,6 +900,17 @@ class Client {
}
}
private handleShowPublicLobbyModal(
event: CustomEvent<ShowPublicLobbyModalEvent>,
) {
const { lobby } = event.detail;
console.log(`Opening JoinLobbyModal for public lobby ${lobby.gameID}`);
// Open the join lobby modal page and pass the lobby info
window.showPage?.("page-join-lobby");
this.joinModal?.open(lobby.gameID, lobby);
}
private async handleLeaveLobby(/* event: CustomEvent */) {
if (this.gameStop === null) {
return;
@@ -884,7 +929,6 @@ class Client {
document.body.classList.remove("in-game");
crazyGamesSDK.gameplayStop();
this.publicLobby.leaveLobby();
}
private handleKickPlayer(event: CustomEvent) {
+26 -24
View File
@@ -15,24 +15,15 @@ import { translateText } from "./Utils";
@customElement("matchmaking-modal")
export class MatchmakingModal extends BaseModal {
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
@state() private connected = false;
@state() private socket: WebSocket | null = null;
@state() private gameID: string | null = null;
private elo = "unknown";
private elo: number | "unknown" = "unknown";
constructor() {
super();
this.id = "page-matchmaking";
document.addEventListener("userMeResponse", (event: Event) => {
const customEvent = event as CustomEvent;
if (customEvent.detail) {
const userMeResponse = customEvent.detail as UserMeResponse;
this.elo =
userMeResponse.player?.leaderboard?.oneVone?.elo?.toString() ??
"unknown";
this.requestUpdate();
}
});
}
createRenderRoot() {
@@ -125,18 +116,24 @@ export class MatchmakingModal extends BaseModal {
);
this.socket.onopen = async () => {
console.log("Connected to matchmaking server");
setTimeout(() => {
this.connectTimeout = setTimeout(async () => {
if (this.socket?.readyState !== WebSocket.OPEN) {
console.warn("[Matchmaking] socket not ready");
return;
}
// Set a delay so the user can see the "connecting" message,
// otherwise the "searching" message will be shown immediately.
// Also wait so people who back out immediately aren't added
// to the matchmaking queue.
this.socket.send(
JSON.stringify({
type: "join",
jwt: await getPlayToken(),
}),
);
this.connected = true;
this.requestUpdate();
}, 1000);
this.socket?.send(
JSON.stringify({
type: "join",
jwt: await getPlayToken(),
}),
);
}, 2000);
};
this.socket.onmessage = (event) => {
console.log(event.data);
@@ -145,6 +142,7 @@ export class MatchmakingModal extends BaseModal {
this.socket?.close();
console.log(`matchmaking: got game ID: ${data.gameId}`);
this.gameID = data.gameId;
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
}
};
this.socket.onerror = (event: ErrorEvent) => {
@@ -157,7 +155,6 @@ export class MatchmakingModal extends BaseModal {
protected async onOpen(): Promise<void> {
const userMe = await getUserMe();
// Early return if modal was closed during async operation
if (!this.isModalOpen) {
return;
@@ -180,15 +177,21 @@ export class MatchmakingModal extends BaseModal {
this.close();
return;
}
this.elo = userMe.player.leaderboard?.oneVone?.elo ?? "unknown";
this.connected = false;
this.gameID = null;
this.connect();
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
}
protected onClose(): void {
this.connected = false;
this.socket?.close();
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
if (this.gameCheckInterval) {
clearInterval(this.gameCheckInterval);
this.gameCheckInterval = null;
@@ -263,7 +266,7 @@ export class MatchmakingButton extends LitElement {
}
render() {
const button = this.isLoggedIn
return this.isLoggedIn
? html`
<button
@click="${this.handleLoggedInClick}"
@@ -279,6 +282,7 @@ export class MatchmakingButton extends LitElement {
${translateText("matchmaking_button.description")}
</span>
</button>
<matchmaking-modal></matchmaking-modal>
`
: html`
<button
@@ -290,8 +294,6 @@ export class MatchmakingButton extends LitElement {
</span>
</button>
`;
return html` ${button} <matchmaking-modal></matchmaking-modal> `;
}
private handleLoggedInClick() {
+46 -253
View File
@@ -1,32 +1,25 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { renderDuration, translateText } from "../client/Utils";
import {
Duos,
GameMapType,
GameMode,
HumansVsNations,
PublicGameModifiers,
Quads,
Trios,
} from "../core/game/Game";
import { GameMapType } from "../core/game/Game";
import { GameID, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { PublicLobbySocket } from "./LobbySocket";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
getGameModeLabel,
getModifierLabels,
normaliseMapKey,
renderDuration,
translateText,
} from "./Utils";
export interface ShowPublicLobbyModalEvent {
lobby: GameInfo;
}
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private lobbies: GameInfo[] = [];
@state() public isLobbyHighlighted: boolean = false;
@state() private isButtonDebounced: boolean = false;
@state() private mapImages: Map<GameID, string> = new Map();
@state() private joiningDotIndex: number = 0;
private joiningInterval: number | null = null;
private currLobby: GameInfo | null = null;
private debounceDelay: number = 150;
private lobbyIDToStart = new Map<GameID, number>();
private lobbySocket = new PublicLobbySocket((lobbies) =>
this.handleLobbiesUpdate(lobbies),
@@ -44,7 +37,6 @@ export class PublicLobby extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
this.lobbySocket.stop();
this.stopJoiningAnimation();
}
private handleLobbiesUpdate(lobbies: GameInfo[]) {
@@ -84,52 +76,16 @@ export class PublicLobby extends LitElement {
const isStarting = timeRemaining <= 2;
const timeDisplay = renderDuration(timeRemaining);
const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? (lobby.gameConfig.playerTeams ?? 0)
: null;
const maxPlayers = lobby.gameConfig.maxPlayers ?? 0;
const teamSize = this.getTeamSize(teamCount, maxPlayers);
const teamTotal = this.getTeamTotal(teamCount, teamSize, maxPlayers);
const modeLabel = this.getModeLabel(
lobby.gameConfig.gameMode,
teamCount,
teamTotal,
teamSize,
);
// True when the detail label already includes the full mode text.
const { label: teamDetailLabel, isFullLabel: isTeamDetailFullLabel } =
this.getTeamDetailLabel(
lobby.gameConfig.gameMode,
teamCount,
teamTotal,
teamSize,
);
let fullModeLabel = modeLabel;
if (teamDetailLabel) {
fullModeLabel = isTeamDetailFullLabel
? teamDetailLabel
: `${modeLabel} ${teamDetailLabel}`;
}
const modifierLabel = this.getModifierLabels(
const modeLabel = getGameModeLabel(lobby.gameConfig);
const modifierLabels = getModifierLabels(
lobby.gameConfig.publicGameModifiers,
);
const mapImageSrc = this.mapImages.get(lobby.gameID);
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
?disabled=${this.isButtonDebounced}
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] ${this
.isLobbyHighlighted
? "ring-2 ring-blue-600 scale-[1.01] opacity-70"
: "hover:scale-[1.01]"} active:scale-[0.98] ${this.isButtonDebounced
? "cursor-not-allowed"
: ""}"
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] hover:scale-[1.01] active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
>
<div class="font-sans w-full h-full flex flex-col">
<!-- Main card gradient - stops before text -->
@@ -149,11 +105,11 @@ export class PublicLobby extends LitElement {
</div>
<!-- Mode Badge in top left -->
${fullModeLabel
${modeLabel
? html`<span
class="absolute top-4 left-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-slate-800 text-white ring-1 ring-white/10 shadow-sm"
>
${fullModeLabel}
${modeLabel}
</span>`
: ""}
@@ -175,11 +131,11 @@ export class PublicLobby extends LitElement {
<!-- Content Banner -->
<div class="absolute bottom-0 left-0 right-0 z-20">
<!-- Modifier badges placed just above the gradient overlay -->
${modifierLabel.length > 0
${modifierLabels.length > 0
? html`<div
class="absolute -top-8 left-4 z-30 flex gap-2 flex-wrap"
>
${modifierLabel.map(
${modifierLabels.map(
(label) => html`
<span
class="px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide bg-purple-600 text-white"
@@ -200,19 +156,10 @@ export class PublicLobby extends LitElement {
<!-- Header row: Status/Join on left, Player Count on right -->
<div class="flex items-center justify-between w-full">
<div class="text-base uppercase tracking-widest text-white">
${this.currLobby
? isStarting
? html`<span class="text-green-400 animate-pulse"
>${translateText("public_lobby.starting_game")}</span
>`
: html`<span class="text-orange-400"
>${translateText("public_lobby.waiting_for_players")}
${[0, 1, 2]
.map((i) =>
i === this.joiningDotIndex ? "•" : "·",
)
.join("")}</span
>`
${isStarting
? html`<span class="text-green-400 animate-pulse"
>${translateText("public_lobby.starting_game")}</span
>`
: html`${translateText("public_lobby.join")}`}
</div>
@@ -237,7 +184,7 @@ export class PublicLobby extends LitElement {
class="text-2xl lg:text-3xl font-bold text-white leading-none uppercase tracking-widest w-full"
>
${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
`map.${normaliseMapKey(lobby.gameConfig.gameMap)}`,
)}
</div>
@@ -249,190 +196,36 @@ export class PublicLobby extends LitElement {
`;
}
leaveLobby() {
this.isLobbyHighlighted = false;
this.currLobby = null;
this.stopJoiningAnimation();
}
public stop() {
this.lobbySocket.stop();
this.isLobbyHighlighted = false;
this.currLobby = null;
this.stopJoiningAnimation();
}
private startJoiningAnimation() {
if (this.joiningInterval !== null) return;
this.joiningDotIndex = 0;
this.joiningInterval = window.setInterval(() => {
this.joiningDotIndex = (this.joiningDotIndex + 1) % 3;
}, 500);
}
private stopJoiningAnimation() {
if (this.joiningInterval !== null) {
clearInterval(this.joiningInterval);
this.joiningInterval = null;
}
this.joiningDotIndex = 0;
}
private getTeamSize(
teamCount: number | string | null,
maxPlayers: number,
): number | undefined {
if (typeof teamCount === "string") {
if (teamCount === Duos) return 2;
if (teamCount === Trios) return 3;
if (teamCount === Quads) return 4;
if (teamCount === HumansVsNations) return maxPlayers;
return undefined;
}
if (typeof teamCount === "number" && teamCount > 0) {
return Math.floor(maxPlayers / teamCount);
}
return undefined;
}
private getTeamTotal(
teamCount: number | string | null,
teamSize: number | undefined,
maxPlayers: number,
): number | undefined {
if (typeof teamCount === "number") return teamCount;
if (teamCount === HumansVsNations) return 2;
if (teamSize && teamSize > 0) return Math.floor(maxPlayers / teamSize);
return undefined;
}
private getModeLabel(
gameMode: GameMode,
teamCount: number | string | null,
teamTotal: number | undefined,
teamSize: number | undefined,
): string {
if (gameMode !== GameMode.Team) return translateText("game_mode.ffa");
if (teamCount === HumansVsNations && teamSize !== undefined)
return translateText("public_lobby.teams_hvn_detailed", {
num: teamSize,
});
const totalTeams =
teamTotal ?? (typeof teamCount === "number" ? teamCount : 0);
return translateText("public_lobby.teams", { num: totalTeams });
}
private getTeamDetailLabel(
gameMode: GameMode,
teamCount: number | string | null,
teamTotal: number | undefined,
teamSize: number | undefined,
): { label: string | null; isFullLabel: boolean } {
if (gameMode !== GameMode.Team) {
return { label: null, isFullLabel: false };
}
if (typeof teamCount === "string" && teamCount === HumansVsNations) {
return { label: null, isFullLabel: false };
}
if (typeof teamCount === "string") {
const teamKey = `public_lobby.teams_${teamCount}`;
// translateText returns the key when a translation is missing.
const maybeTranslated = translateText(teamKey, {
team_count: teamTotal ?? 0,
});
if (maybeTranslated !== teamKey) {
return { label: maybeTranslated, isFullLabel: true };
}
}
if (teamTotal !== undefined && teamSize !== undefined) {
// Fallback when there's no specific team label translation.
return {
label: translateText("public_lobby.players_per_team", {
num: teamSize,
}),
isFullLabel: false,
};
}
return { label: null, isFullLabel: false };
}
private getModifierLabels(
publicGameModifiers: PublicGameModifiers | undefined,
): string[] {
if (!publicGameModifiers) {
return [];
}
const labels: string[] = [];
if (publicGameModifiers.isRandomSpawn) {
labels.push(translateText("public_game_modifier.random_spawn"));
}
if (publicGameModifiers.isCompact) {
labels.push(translateText("public_game_modifier.compact_map"));
}
if (publicGameModifiers.isCrowded) {
labels.push(translateText("public_game_modifier.crowded"));
}
if (publicGameModifiers.startingGold) {
labels.push(translateText("public_game_modifier.starting_gold"));
}
return labels;
}
private lobbyClicked(lobby: GameInfo) {
if (this.isButtonDebounced) return;
this.isButtonDebounced = true;
setTimeout(() => {
this.isButtonDebounced = false;
}, this.debounceDelay);
if (this.currLobby === null) {
// Validate username only when joining a new lobby
const usernameInput = document.querySelector("username-input") as any;
if (
usernameInput &&
typeof usernameInput.isValid === "function" &&
!usernameInput.isValid()
) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: usernameInput.validationError,
color: "red",
duration: 3000,
},
}),
);
return;
}
this.isLobbyHighlighted = true;
this.currLobby = lobby;
this.startJoiningAnimation();
this.dispatchEvent(
new CustomEvent("join-lobby", {
// Validate username before opening the modal
const usernameInput = document.querySelector("username-input") as any;
if (
usernameInput &&
typeof usernameInput.isValid === "function" &&
!usernameInput.isValid()
) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
gameID: lobby.gameID,
clientID: generateID(),
} as JoinLobbyEvent,
bubbles: true,
composed: true,
message: usernameInput.validationError,
color: "red",
duration: 3000,
},
}),
);
} else {
this.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: this.currLobby },
bubbles: true,
composed: true,
}),
);
this.leaveLobby();
return;
}
this.dispatchEvent(
new CustomEvent("show-public-lobby-modal", {
detail: { lobby } as ShowPublicLobbyModalEvent,
bubbles: true,
composed: true,
}),
);
}
}
+92 -39
View File
@@ -36,31 +36,59 @@ import {
} from "./utilities/RenderToggleInputCard";
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
const DEFAULT_OPTIONS = {
selectedMap: GameMapType.World,
selectedDifficulty: Difficulty.Easy,
disableNations: false,
bots: 400,
infiniteGold: false,
infiniteTroops: false,
compactMap: false,
maxTimer: false,
maxTimerValue: undefined as number | undefined,
instantBuild: false,
randomSpawn: false,
useRandomMap: false,
gameMode: GameMode.FFA,
teamCount: 2 as TeamCountConfig,
goldMultiplier: false,
goldMultiplierValue: undefined as number | undefined,
startingGold: false,
startingGoldValue: undefined as number | undefined,
disabledUnits: [] as UnitType[],
} as const;
@customElement("single-player-modal")
export class SinglePlayerModal extends BaseModal {
@state() private selectedMap: GameMapType = GameMapType.World;
@state() private selectedDifficulty: Difficulty = Difficulty.Easy;
@state() private disableNations: boolean = false;
@state() private bots: number = 400;
@state() private infiniteGold: boolean = false;
@state() private infiniteTroops: boolean = false;
@state() private compactMap: boolean = false;
@state() private maxTimer: boolean = false;
@state() private maxTimerValue: number | undefined = undefined;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
@state() private useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@state() private selectedMap: GameMapType = DEFAULT_OPTIONS.selectedMap;
@state() private selectedDifficulty: Difficulty =
DEFAULT_OPTIONS.selectedDifficulty;
@state() private disableNations: boolean = DEFAULT_OPTIONS.disableNations;
@state() private bots: number = DEFAULT_OPTIONS.bots;
@state() private infiniteGold: boolean = DEFAULT_OPTIONS.infiniteGold;
@state() private infiniteTroops: boolean = DEFAULT_OPTIONS.infiniteTroops;
@state() private compactMap: boolean = DEFAULT_OPTIONS.compactMap;
@state() private maxTimer: boolean = DEFAULT_OPTIONS.maxTimer;
@state() private maxTimerValue: number | undefined =
DEFAULT_OPTIONS.maxTimerValue;
@state() private instantBuild: boolean = DEFAULT_OPTIONS.instantBuild;
@state() private randomSpawn: boolean = DEFAULT_OPTIONS.randomSpawn;
@state() private useRandomMap: boolean = DEFAULT_OPTIONS.useRandomMap;
@state() private gameMode: GameMode = DEFAULT_OPTIONS.gameMode;
@state() private teamCount: TeamCountConfig = DEFAULT_OPTIONS.teamCount;
@state() private showAchievements: boolean = false;
@state() private mapWins: Map<GameMapType, Set<Difficulty>> = new Map();
@state() private userMeResponse: UserMeResponse | false = false;
@state() private goldMultiplier: boolean = false;
@state() private goldMultiplierValue: number | undefined = undefined;
@state() private startingGold: boolean = false;
@state() private startingGoldValue: number | undefined = undefined;
@state() private goldMultiplier: boolean = DEFAULT_OPTIONS.goldMultiplier;
@state() private goldMultiplierValue: number | undefined =
DEFAULT_OPTIONS.goldMultiplierValue;
@state() private startingGold: boolean = DEFAULT_OPTIONS.startingGold;
@state() private startingGoldValue: number | undefined =
DEFAULT_OPTIONS.startingGoldValue;
@state() private disabledUnits: UnitType[] = [];
@state() private disabledUnits: UnitType[] = [
...DEFAULT_OPTIONS.disabledUnits,
];
private userSettings: UserSettings = new UserSettings();
@@ -617,7 +645,14 @@ export class SinglePlayerModal extends BaseModal {
</div>
<!-- Footer Action -->
<div class="p-6 pt-4 border-t border-white/10 bg-black/20">
<div class="p-6 border-t border-white/10 bg-black/20">
${hasLinkedAccount(this.userMeResponse) && this.hasOptionsChanged()
? html`<div
class="mb-4 px-4 py-3 rounded-xl bg-yellow-500/20 border border-yellow-500/30 text-yellow-400 text-xs font-bold uppercase tracking-wider text-center"
>
${translateText("single_modal.options_changed_no_achievements")}
</div>`
: null}
<button
@click=${this.startGame}
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0"
@@ -645,6 +680,24 @@ export class SinglePlayerModal extends BaseModal {
`;
}
// Check if any options other than map and difficulty have been changed from defaults
private hasOptionsChanged(): boolean {
return (
this.disableNations !== DEFAULT_OPTIONS.disableNations ||
this.bots !== DEFAULT_OPTIONS.bots ||
this.infiniteGold !== DEFAULT_OPTIONS.infiniteGold ||
this.infiniteTroops !== DEFAULT_OPTIONS.infiniteTroops ||
this.compactMap !== DEFAULT_OPTIONS.compactMap ||
this.maxTimer !== DEFAULT_OPTIONS.maxTimer ||
this.instantBuild !== DEFAULT_OPTIONS.instantBuild ||
this.randomSpawn !== DEFAULT_OPTIONS.randomSpawn ||
this.gameMode !== DEFAULT_OPTIONS.gameMode ||
this.goldMultiplier !== DEFAULT_OPTIONS.goldMultiplier ||
this.startingGold !== DEFAULT_OPTIONS.startingGold ||
this.disabledUnits.length > 0
);
}
// Helper for consistent option buttons
private renderOptionToggle(
labelKey: string,
@@ -674,25 +727,25 @@ export class SinglePlayerModal extends BaseModal {
protected onClose(): void {
// Reset all transient form state to ensure clean slate
this.selectedMap = GameMapType.World;
this.selectedDifficulty = Difficulty.Easy;
this.gameMode = GameMode.FFA;
this.useRandomMap = false;
this.disableNations = false;
this.bots = 400;
this.infiniteGold = false;
this.infiniteTroops = false;
this.compactMap = false;
this.maxTimer = false;
this.maxTimerValue = undefined;
this.instantBuild = false;
this.randomSpawn = false;
this.teamCount = 2;
this.disabledUnits = [];
this.goldMultiplier = false;
this.goldMultiplierValue = undefined;
this.startingGold = false;
this.startingGoldValue = undefined;
this.selectedMap = DEFAULT_OPTIONS.selectedMap;
this.selectedDifficulty = DEFAULT_OPTIONS.selectedDifficulty;
this.gameMode = DEFAULT_OPTIONS.gameMode;
this.useRandomMap = DEFAULT_OPTIONS.useRandomMap;
this.disableNations = DEFAULT_OPTIONS.disableNations;
this.bots = DEFAULT_OPTIONS.bots;
this.infiniteGold = DEFAULT_OPTIONS.infiniteGold;
this.infiniteTroops = DEFAULT_OPTIONS.infiniteTroops;
this.compactMap = DEFAULT_OPTIONS.compactMap;
this.maxTimer = DEFAULT_OPTIONS.maxTimer;
this.maxTimerValue = DEFAULT_OPTIONS.maxTimerValue;
this.instantBuild = DEFAULT_OPTIONS.instantBuild;
this.randomSpawn = DEFAULT_OPTIONS.randomSpawn;
this.teamCount = DEFAULT_OPTIONS.teamCount;
this.disabledUnits = [...DEFAULT_OPTIONS.disabledUnits];
this.goldMultiplier = DEFAULT_OPTIONS.goldMultiplier;
this.goldMultiplierValue = DEFAULT_OPTIONS.goldMultiplierValue;
this.startingGold = DEFAULT_OPTIONS.startingGold;
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
}
private handleSelectRandomMap() {
-417
View File
@@ -1,417 +0,0 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
ClanLeaderboardEntry,
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
} from "../core/ApiSchemas";
import { getApiBase } from "./Api";
import { translateText } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
@customElement("stats-modal")
export class StatsModal extends BaseModal {
@state() private isLoading: boolean = false;
@state() private error: string | null = null;
@state() private data: ClanLeaderboardResponse | null = null;
@state() private sortBy: "rank" | "games" | "wins" | "losses" | "ratio" =
"rank";
@state() private sortOrder: "asc" | "desc" = "asc";
private hasLoaded = false;
private handleSort(column: "rank" | "games" | "wins" | "losses" | "ratio") {
if (this.sortBy === column) {
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
} else {
this.sortBy = column;
this.sortOrder = column === "rank" ? "asc" : "desc";
}
this.requestUpdate();
}
private getSortedClans(clans: ClanLeaderboardEntry[]) {
const sorted = [...clans];
sorted.sort((a, b) => {
let aVal: number, bVal: number;
switch (this.sortBy) {
case "games":
aVal = a.games;
bVal = b.games;
break;
case "wins":
aVal = a.weightedWins;
bVal = b.weightedWins;
break;
case "losses":
aVal = a.weightedLosses;
bVal = b.weightedLosses;
break;
case "ratio":
aVal = a.weightedWLRatio;
bVal = b.weightedWLRatio;
break;
case "rank":
default:
// Original order
return 0;
}
return this.sortOrder === "asc" ? aVal - bVal : bVal - aVal;
});
return sorted;
}
protected onOpen(): void {
if (!this.hasLoaded && !this.isLoading) {
void this.loadLeaderboard();
}
}
private async loadLeaderboard() {
this.isLoading = true;
this.error = null;
try {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: {
Accept: "application/json",
},
});
if (!res.ok) {
throw new Error(`Unexpected status ${res.status}`);
}
const json = await res.json();
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn(
"ClanLeaderboardModal: invalid response schema",
parsed.error,
);
throw new Error("Invalid response format");
}
this.data = parsed.data;
this.hasLoaded = true;
} catch (err) {
console.warn("ClanLeaderboardModal: failed to load leaderboard", err);
this.error = translateText("stats_modal.error");
} finally {
this.isLoading = false;
this.requestUpdate();
}
}
private renderBody() {
if (this.isLoading) {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
></div>
<p
class="text-blue-200/80 text-sm font-bold tracking-[0.2em] uppercase"
>
${translateText("stats_modal.loading")}
</p>
</div>
`;
}
if (this.error) {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-[0_0_30px_rgba(239,68,68,0.2)]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<p class="mb-8 text-center text-red-100/80 max-w-xs font-medium">
${this.error}
</p>
<button
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 hover:border-red-500/50 text-red-200 rounded-xl text-sm font-bold uppercase tracking-wider transition-all cursor-pointer hover:shadow-lg hover:shadow-red-500/10 active:scale-95"
@click=${() => this.loadLeaderboard()}
>
${translateText("stats_modal.try_again")}
</button>
</div>
`;
}
if (!this.data || this.data.clans.length === 0) {
return html`
<div
class="p-12 text-center text-white/40 flex flex-col items-center h-full justify-center"
>
<div class="bg-white/5 p-6 rounded-full mb-6 border border-white/5">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-white/20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<h3 class="text-xl font-bold text-white/60 mb-2">
${translateText("stats_modal.no_data_yet")}
</h3>
<p class="text-white/30 text-sm max-w-[200px]">
${translateText("stats_modal.no_stats")}
</p>
</div>
`;
}
const { clans } = this.data;
const maxGames = Math.max(...clans.map((c) => c.games), 1);
return html`
<div class="w-full pt-6">
<div
class="overflow-x-auto rounded-xl border border-white/5 bg-black/20"
>
<table class="w-full text-sm border-collapse">
<thead>
<tr
class="text-white/40 text-xs uppercase tracking-wider border-b border-white/5 bg-white/[0.02]"
>
<th class="py-4 px-4 text-center font-bold w-16">
${translateText("stats_modal.rank")}
</th>
<th class="py-4 px-4 text-left font-bold">
${translateText("stats_modal.clan")}
</th>
<th
@click=${() => this.handleSort("games")}
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors select-none"
>
<div class="flex items-center justify-end gap-1">
${translateText("stats_modal.games")}
${this.sortBy === "games"
? this.sortOrder === "asc"
? html`<span class="text-blue-400">↑</span>`
: html`<span class="text-blue-400">↓</span>`
: html`<span class="text-white/20">↕</span>`}
</div>
</th>
<th
@click=${() => this.handleSort("wins")}
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
title=${translateText("stats_modal.win_score_tooltip")}
>
<div class="flex items-center justify-end gap-1">
${translateText("stats_modal.win_score")}
${this.sortBy === "wins"
? this.sortOrder === "asc"
? html`<span class="text-blue-400">↑</span>`
: html`<span class="text-blue-400">↓</span>`
: html`<span class="text-white/20">↕</span>`}
</div>
</th>
<th
@click=${() => this.handleSort("losses")}
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
title=${translateText("stats_modal.loss_score_tooltip")}
>
<div class="flex items-center justify-end gap-1">
${translateText("stats_modal.loss_score")}
${this.sortBy === "losses"
? this.sortOrder === "asc"
? html`<span class="text-blue-400">↑</span>`
: html`<span class="text-blue-400">↓</span>`
: html`<span class="text-white/20">↕</span>`}
</div>
</th>
<th
@click=${() => this.handleSort("ratio")}
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors select-none"
>
<div class="flex items-center justify-end gap-1">
${translateText("stats_modal.win_loss_ratio")}
${this.sortBy === "ratio"
? this.sortOrder === "asc"
? html`<span class="text-blue-400">↑</span>`
: html`<span class="text-blue-400">↓</span>`
: html`<span class="text-white/20">↕</span>`}
</div>
</th>
</tr>
</thead>
<tbody>
${this.getSortedClans(clans).map((clan, index) => {
const rankColor =
index === 0
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
: index === 1
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
: index === 2
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
: "text-white/40 bg-white/5";
const rankIcon =
index === 0
? "👑"
: index === 1
? "🥈"
: index === 2
? "🥉"
: String(index + 1);
return html`
<tr
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
>
<td class="py-3 px-4 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
>
${rankIcon}
</div>
</td>
<td class="py-3 px-4">
<div class="flex items-center gap-3">
<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 font-bold text-xs tracking-wide group-hover:bg-blue-500/20 transition-colors"
>
${clan.clanTag}
</div>
</div>
</td>
<td class="py-3 px-4 text-right">
<div class="flex flex-col items-end gap-1">
<span class="text-white font-mono font-medium"
>${clan.games.toLocaleString()}</span
>
<div
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500/50 rounded-full"
style="width: ${(clan.games / maxGames) * 100}%"
></div>
</div>
</div>
</td>
<td
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
>
${clan.weightedWins}
</td>
<td
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
>
${clan.weightedLosses}
</td>
<td class="py-3 px-4 text-right pr-6">
<div class="inline-flex flex-col items-end">
<span
class="font-mono font-bold ${Number(
clan.weightedWLRatio,
) >= 1
? "text-green-400"
: "text-red-400"}"
>
${clan.weightedWLRatio}
</span>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("stats_modal.ratio")}</span
>
</div>
</td>
</tr>
`;
})}
</tbody>
</table>
</div>
</div>
`;
}
render() {
let dateRange = html``;
if (this.data) {
const start = new Date(this.data.start).toLocaleDateString();
const end = new Date(this.data.end).toLocaleDateString();
dateRange = html`<span
class="text-sm font-normal text-white/40 ml-2 break-words"
>(${start} - ${end})</span
>`;
}
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
>
${translateText("stats_modal.clan_stats")}
</span>
${dateRange}
</div>
`,
onBack: this.close,
ariaLabel: translateText("common.close"),
leftClassName: "flex flex-wrap items-center gap-4 flex-1",
})}
<div
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
>
${this.renderBody()}
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
id="stats-modal"
title="${translateText("stats_modal.clan_stats")}"
?inline=${this.inline}
hideCloseButton
hideHeader
>
${content}
</o-modal>
`;
}
}
+128 -1
View File
@@ -1,7 +1,134 @@
import IntlMessageFormat from "intl-messageformat";
import { MessageType } from "../core/game/Game";
import {
Duos,
GameMode,
HumansVsNations,
MessageType,
PublicGameModifiers,
Quads,
Trios,
} from "../core/game/Game";
import { GameConfig } from "../core/Schemas";
import type { LangSelector } from "./LangSelector";
export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs";
export function normaliseMapKey(mapName: string): string {
return mapName.toLowerCase().replace(/[\s.]+/g, "");
}
/**
* Returns a display label for the game mode (e.g. "FFA", "4 Teams", "Duos").
*/
export function getGameModeLabel(gameConfig: GameConfig): string {
const { gameMode, playerTeams, maxPlayers } = gameConfig;
if (gameMode !== GameMode.Team) {
return translateText("game_mode.ffa");
}
// Humans vs Nations
if (playerTeams === HumansVsNations) {
return translateText("public_lobby.teams_hvn_detailed", {
num: maxPlayers ?? 0,
});
}
// Named team types (Duos, Trios, Quads)
if (typeof playerTeams === "string") {
const teamKey = `public_lobby.teams_${playerTeams}`;
const teamCount = getTeamCount(playerTeams, maxPlayers ?? 0);
const translated = translateText(teamKey, { team_count: teamCount });
if (translated !== teamKey) {
return translated;
}
}
// Numeric team count
const teamCount =
typeof playerTeams === "number"
? playerTeams
: getTeamCount(playerTeams, maxPlayers ?? 0);
return translateText("public_lobby.teams", { num: teamCount });
}
function getTeamCount(
playerTeams: string | number | undefined,
maxPlayers: number,
): number {
if (typeof playerTeams === "number") return playerTeams;
const teamSize = getTeamSize(playerTeams, maxPlayers);
return teamSize > 0 ? Math.floor(maxPlayers / teamSize) : 0;
}
function getTeamSize(
playerTeams: string | number | undefined,
maxPlayers: number,
): number {
if (playerTeams === Duos) return 2;
if (playerTeams === Trios) return 3;
if (playerTeams === Quads) return 4;
if (playerTeams === HumansVsNations) return maxPlayers;
if (typeof playerTeams === "number" && playerTeams > 0) {
return Math.floor(maxPlayers / playerTeams);
}
return 0;
}
export interface ModifierInfo {
/** Translation key for detailed label (e.g. "host_modal.random_spawn") */
labelKey: string;
/** Translation key for badge/short label (e.g. "public_game_modifier.random_spawn") */
badgeKey: string;
/** The raw value if applicable (e.g. startingGold amount) */
value?: number;
}
/**
* Returns structured modifier info for both detailed config display and badges.
*/
export function getActiveModifiers(
modifiers: PublicGameModifiers | undefined,
): ModifierInfo[] {
if (!modifiers) return [];
const result: ModifierInfo[] = [];
if (modifiers.isRandomSpawn) {
result.push({
labelKey: "host_modal.random_spawn",
badgeKey: "public_game_modifier.random_spawn",
});
}
if (modifiers.isCompact) {
result.push({
labelKey: "host_modal.compact_map",
badgeKey: "public_game_modifier.compact_map",
});
}
if (modifiers.isCrowded) {
result.push({
labelKey: "host_modal.crowded",
badgeKey: "public_game_modifier.crowded",
});
}
if (modifiers.startingGold) {
result.push({
labelKey: "host_modal.starting_gold",
badgeKey: "public_game_modifier.starting_gold",
value: modifiers.startingGold,
});
}
return result;
}
/**
* Returns an array of translated modifier labels for badge display.
*/
export function getModifierLabels(
modifiers: PublicGameModifiers | undefined,
): string[] {
return getActiveModifiers(modifiers).map((m) => translateText(m.badgeKey));
}
export function renderDuration(totalSeconds: number): string {
if (totalSeconds <= 0) return "0s";
const minutes = Math.floor(totalSeconds / 60);
+10
View File
@@ -25,6 +25,16 @@ export abstract class BaseModal extends LitElement {
return this;
}
protected firstUpdated(): void {
if (this.modalEl) {
this.modalEl.onClose = () => {
if (this.isModalOpen) {
this.close();
}
};
}
}
disconnectedCallback() {
this.unregisterEscapeHandler();
super.disconnectedCallback();
+70 -12
View File
@@ -1,8 +1,16 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
import { getCosmeticsHash } from "../Cosmetics";
import { getGamesPlayed } from "../Utils";
const HELP_SEEN_KEY = "helpSeen";
const STORE_SEEN_HASH_KEY = "storeSeenHash";
@customElement("desktop-nav-bar")
export class DesktopNavBar extends LitElement {
@state() private _helpSeen = localStorage.getItem(HELP_SEEN_KEY) === "true";
@state() private _hasNewCosmetics = false;
createRenderRoot() {
return this;
}
@@ -18,6 +26,12 @@ export class DesktopNavBar extends LitElement {
this._updateActiveState(current);
});
}
// Check if cosmetics have changed
getCosmeticsHash().then((hash: string | null) => {
const seenHash = localStorage.getItem(STORE_SEEN_HASH_KEY);
this._hasNewCosmetics = hash !== null && hash !== seenHash;
});
}
disconnectedCallback() {
@@ -40,6 +54,30 @@ export class DesktopNavBar extends LitElement {
});
}
private showHelpDot(): boolean {
// Only show one dot at a time to prevent
// overwhelming users.
return getGamesPlayed() < 10 && !this._helpSeen;
}
private showStoreDot(): boolean {
return this._hasNewCosmetics && !this.showHelpDot();
}
private onHelpClick = () => {
localStorage.setItem(HELP_SEEN_KEY, "true");
this._helpSeen = true;
};
private onStoreClick = () => {
this._hasNewCosmetics = false;
getCosmeticsHash().then((hash: string | null) => {
if (hash !== null) {
localStorage.setItem(STORE_SEEN_HASH_KEY, hash);
}
});
};
render() {
return html`
<nav
@@ -109,11 +147,18 @@ export class DesktopNavBar extends LitElement {
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-item-store"
data-i18n="main.store"
@click=${this.onStoreClick}
></button>
<span
class="absolute -top-3 -right-2 bg-gradient-to-br from-red-600 to-red-700 text-white text-[9px] font-black tracking-wider px-2 py-0.5 rounded rotate-12 shadow-lg shadow-red-600/50 animate-pulse pointer-events-none"
data-i18n="main.store_new_badge"
></span>
${this.showStoreDot()
? html`
<span
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-ping"
></span>
<span
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"
></span>
`
: ""}
</div>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
@@ -122,14 +167,27 @@ export class DesktopNavBar extends LitElement {
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-stats"
data-i18n="main.stats"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-help"
data-i18n="main.help"
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<div class="relative">
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-help"
data-i18n="main.help"
@click=${this.onHelpClick}
></button>
${this.showHelpDot()
? html`
<span
class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full animate-ping"
></span>
<span
class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full"
></span>
`
: ""}
</div>
<lang-selector></lang-selector>
<button
id="nav-account-button"
+29
View File
@@ -0,0 +1,29 @@
import { LitElement, TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("lobby-config-item")
export class LobbyConfigItem extends LitElement {
@property({ type: String }) label = "";
@property({ attribute: false }) value: string | TemplateResult = "";
createRenderRoot() {
return this;
}
render() {
return html`
<div
class="bg-white/5 border border-white/10 rounded-lg p-3 flex flex-col items-center justify-center gap-1 text-center min-w-[100px]"
>
<span
class="text-white/40 text-[10px] font-bold uppercase tracking-wider"
>${this.label}</span
>
<span
class="text-white font-bold text-sm w-full break-words hyphens-auto"
>${this.value}</span
>
</div>
`;
}
}
+2 -2
View File
@@ -121,8 +121,8 @@ export class MobileNavBar extends LitElement {
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-stats"
data-i18n="main.stats"
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<div class="relative no-crazygames">
<button
+213
View File
@@ -0,0 +1,213 @@
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>
`;
}
}
@@ -0,0 +1,385 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
ClanLeaderboardEntry,
ClanLeaderboardResponse,
} from "../../../core/ApiSchemas";
import { fetchClanLeaderboard } from "../../Api";
import { translateText } from "../../Utils";
export type ClanSortColumn =
| "rank"
| "games"
| "winScore"
| "lossScore"
| "ratio";
export type ClanSortOrder = "asc" | "desc";
@customElement("leaderboard-clan-table")
export class LeaderboardClanTable extends LitElement {
@state() private clanData: ClanLeaderboardResponse | null = null;
@state() private isLoading = false;
@state() private error: string | null = null;
@state() private sortBy: ClanSortColumn = "rank";
@state() private sortOrder: ClanSortOrder = "asc";
private hasLoaded = false;
createRenderRoot() {
return this;
}
public async ensureLoaded() {
if (this.hasLoaded || this.isLoading) return;
await this.loadClanLeaderboard();
}
public async loadClanLeaderboard() {
this.isLoading = true;
this.error = null;
try {
const data = await fetchClanLeaderboard();
if (!data) throw new Error("Failed to load clan leaderboard");
this.clanData = data;
this.hasLoaded = true;
this.dispatchEvent(
new CustomEvent<{ start: string; end: string }>("date-range-change", {
detail: { start: data.start, end: data.end },
bubbles: true,
composed: true,
}),
);
} catch (error) {
console.error("loadClanLeaderboard: request failed", error);
this.error = translateText("leaderboard_modal.error");
} finally {
this.isLoading = false;
}
}
private handleSort(column: ClanSortColumn) {
if (this.sortBy === column) {
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
} else {
this.sortBy = column;
this.sortOrder = column === "rank" ? "asc" : "desc";
}
}
private getSortedClans(clans: ClanLeaderboardEntry[]) {
if (this.sortBy === "rank") {
const base = [...clans];
return this.sortOrder === "asc" ? base : base.reverse();
}
const sorted = [...clans];
sorted.sort((a, b) => {
let aVal: number, bVal: number;
switch (this.sortBy) {
case "games":
aVal = a.games;
bVal = b.games;
break;
case "winScore":
aVal = a.weightedWins;
bVal = b.weightedWins;
break;
case "lossScore":
aVal = a.weightedLosses;
bVal = b.weightedLosses;
break;
case "ratio":
aVal = a.weightedWLRatio;
bVal = b.weightedWLRatio;
break;
default:
return 0;
}
return this.sortOrder === "asc" ? aVal - bVal : bVal - aVal;
});
return sorted;
}
private renderLoading() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
></div>
<p class="text-blue-200/80 text-sm font-bold tracking-widest uppercase">
${translateText("leaderboard_modal.loading")}
</p>
</div>
`;
}
private renderError() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-lg shadow-red-500/10"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<p class="mb-8 text-center text-red-100/80 font-medium">
${this.error ?? translateText("leaderboard_modal.error")}
</p>
<button
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-xl text-sm font-bold uppercase transition-all active:scale-95"
@click=${() => this.loadClanLeaderboard()}
>
${translateText("leaderboard_modal.try_again")}
</button>
</div>
`;
}
private renderNoData() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white/40 h-full"
>
<div class="bg-white/5 p-6 rounded-full mb-6 border border-white/5">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-white/20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<h3 class="text-xl font-bold text-white/60 mb-2">
${translateText("leaderboard_modal.no_data_yet")}
</h3>
<p class="text-white/30 text-sm">
${translateText("leaderboard_modal.no_stats")}
</p>
</div>
`;
}
render() {
if (this.isLoading) return this.renderLoading();
if (this.error) return this.renderError();
if (!this.clanData || this.clanData.clans.length === 0)
return this.renderNoData();
const { clans } = this.clanData;
const sorted = this.getSortedClans(clans);
const maxGames = Math.max(...clans.map((c) => c.games), 1);
return html`
<div class="h-full px-6 pb-6">
<div
class="h-full overflow-y-auto overflow-x-auto rounded-xl border border-white/5 bg-black/20"
>
<table class="w-full text-sm border-collapse">
<thead>
<tr
class="text-white/40 text-[10px] uppercase tracking-wider border-b border-white/5 bg-white/2"
>
<th class="py-4 px-4 text-center font-bold w-16">
${translateText("leaderboard_modal.rank")}
</th>
<th class="py-4 px-4 text-left font-bold">
${translateText("leaderboard_modal.clan")}
</th>
<th
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors"
>
<button
@click=${() => this.handleSort("games")}
aria-sort=${this.sortBy === "games"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.games")}
${this.sortBy === "games"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
title=${translateText("leaderboard_modal.win_score_tooltip")}
>
<button
@click=${() => this.handleSort("winScore")}
aria-sort=${this.sortBy === "winScore"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.win_score")}
${this.sortBy === "winScore"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
title=${translateText("leaderboard_modal.loss_score_tooltip")}
>
<button
@click=${() => this.handleSort("lossScore")}
aria-sort=${this.sortBy === "lossScore"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.loss_score")}
${this.sortBy === "lossScore"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors"
>
<button
@click=${() => this.handleSort("ratio")}
aria-sort=${this.sortBy === "ratio"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.win_loss_ratio")}
${this.sortBy === "ratio"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
</tr>
</thead>
<tbody>
${sorted.map((clan, index) => {
const displayRank = index + 1;
const rankColor =
displayRank === 1
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
: displayRank === 2
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
: displayRank === 3
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
: "text-white/40 bg-white/5";
const rankIcon =
displayRank === 1
? "👑"
: displayRank === 2
? "🥈"
: displayRank === 3
? "🥉"
: String(displayRank);
return html`
<tr
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
>
<td class="py-3 px-4 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
>
${rankIcon}
</div>
</td>
<td class="py-3 px-4 font-bold text-blue-300">
<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 inline-block"
>
${clan.clanTag}
</div>
</td>
<td class="py-3 px-4 text-right">
<div class="flex flex-col items-end gap-1">
<span class="text-white font-mono font-medium"
>${clan.games.toLocaleString()}</span
>
<div
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500/50 rounded-full"
style="width: ${(clan.games / maxGames) * 100}%"
></div>
</div>
</div>
</td>
<td
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
>
${clan.weightedWins.toLocaleString("fullwide", {
maximumFractionDigits: 1,
})}
</td>
<td
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
>
${clan.weightedLosses.toLocaleString("fullwide", {
maximumFractionDigits: 1,
})}
</td>
<td class="py-3 px-4 text-right pr-6">
<div class="inline-flex flex-col items-end">
<span
class="font-mono font-bold ${clan.weightedWLRatio >= 1
? "text-green-400"
: "text-red-400"}"
>${clan.weightedWLRatio.toLocaleString("fullwide", {
maximumFractionDigits: 2,
})}</span
>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("leaderboard_modal.ratio")}</span
>
</div>
</td>
</tr>
`;
})}
</tbody>
</table>
</div>
</div>
`;
}
}
@@ -0,0 +1,453 @@
import { virtualize } from "@lit-labs/virtualizer/virtualize.js";
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { PlayerLeaderboardEntry } from "../../../core/ApiSchemas";
import { fetchPlayerLeaderboard, getUserMe } from "../../Api";
import { translateText } from "../../Utils";
@customElement("leaderboard-player-list")
export class LeaderboardPlayerList extends LitElement {
@state() private playerData: PlayerLeaderboardEntry[] = [];
@state() private currentUserEntry: PlayerLeaderboardEntry | null = null;
@state() private showStickyUser = false;
@state() private isLoading = false;
@state() private error: string | null = null;
@state() private isLoadingMore = false;
@state() private loadMoreError: string | null = null;
@state() private playerHasMore = true;
private hasLoadedPlayers = false;
private readonly playerPageSize = 50;
private currentPage = 1;
private currentUserId: string | null = null;
private currentUserIdLoaded = false;
@query(".virtualizer-container") private virtualizerContainer?: HTMLElement;
createRenderRoot() {
return this;
}
public async ensureLoaded() {
if (this.hasLoadedPlayers || this.isLoading) return;
await this.loadPlayerLeaderboard(true);
}
public async loadPlayerLeaderboard(reset = false) {
if (reset) {
this.currentPage = 1;
this.playerHasMore = true;
this.loadMoreError = null;
this.playerData = [];
this.currentUserEntry = null;
this.showStickyUser = false;
} else if (!this.playerHasMore) {
return;
}
if (this.isLoading || this.isLoadingMore) return;
if (reset) {
this.isLoading = true;
this.error = null;
} else {
this.isLoadingMore = true;
this.loadMoreError = null;
}
try {
const result = await fetchPlayerLeaderboard(this.currentPage);
if (result === false) {
throw new Error("Failed to load player leaderboard");
}
if (result === "reached_limit") {
this.playerHasMore = false;
this.hasLoadedPlayers = true;
return;
}
const nextPlayers: PlayerLeaderboardEntry[] = result["1v1"].map(
(entry) => ({
rank: entry.rank,
playerId: entry.public_id,
username: entry.username,
clanTag: entry.clanTag ?? undefined,
elo: entry.elo,
games: entry.total,
wins: entry.wins,
losses: entry.losses,
winRate: entry.total > 0 ? entry.wins / entry.total : 0,
}),
);
const receivedCount = nextPlayers.length;
if (reset) {
this.playerData = nextPlayers;
} else {
const existingIds = new Set(
this.playerData.map((player) => player.playerId),
);
const deduped = nextPlayers.filter(
(player) => !existingIds.has(player.playerId),
);
this.playerData = [...this.playerData, ...deduped];
}
if (receivedCount > 0) {
this.currentPage++;
}
if (receivedCount < this.playerPageSize) {
this.playerHasMore = false;
}
if (reset && !this.currentUserIdLoaded) {
this.currentUserIdLoaded = true;
const userMe = await getUserMe();
this.currentUserId = userMe ? userMe.player.publicId : null;
}
if (this.currentUserId && !this.currentUserEntry) {
this.currentUserEntry =
nextPlayers.find(
(player) => player.playerId === this.currentUserId,
) ?? null;
}
this.hasLoadedPlayers = true;
this.scheduleStickyVisibilityCheck();
this.schedulePlayerFillCheck();
} catch (err) {
console.error("loadPlayerLeaderboard: request failed", err);
if (reset) {
this.error = translateText("leaderboard_modal.error");
} else {
this.loadMoreError = translateText("leaderboard_modal.error");
}
} finally {
if (reset) {
this.isLoading = false;
} else {
this.isLoadingMore = false;
}
}
}
public handleTabActivated() {
this.scheduleStickyVisibilityCheck();
this.schedulePlayerFillCheck();
}
// TODO: consider IntersectionObserver for better visibility detection?
private isVisible() {
return this.isConnected && this.getClientRects().length > 0;
}
private updateStickyVisibility() {
if (!this.currentUserEntry) {
this.showStickyUser = false;
return;
}
if (!this.virtualizerContainer || !this.isVisible()) {
this.showStickyUser = false;
return;
}
const currentRow = this.virtualizerContainer.querySelector(
'[data-current-user="true"]',
) as HTMLElement | null;
if (!currentRow) {
this.showStickyUser = true;
return;
}
const containerRect = this.virtualizerContainer.getBoundingClientRect();
const rowRect = currentRow.getBoundingClientRect();
const isVisible =
rowRect.top >= containerRect.top &&
rowRect.bottom <= containerRect.bottom;
this.showStickyUser = !isVisible;
}
private scheduleStickyVisibilityCheck() {
void this.updateComplete.then(() => {
requestAnimationFrame(() => this.updateStickyVisibility());
});
}
private handleScroll() {
this.updateStickyVisibility();
this.maybeLoadMorePlayers();
}
private maybeLoadMorePlayers() {
if (this.isLoading || this.isLoadingMore) return;
if (!this.playerHasMore || this.error || this.loadMoreError) return;
if (!this.virtualizerContainer || !this.isVisible()) return;
const threshold = 64 * 3;
const scrollTop = this.virtualizerContainer.scrollTop;
const containerHeight = this.virtualizerContainer.clientHeight;
const scrollHeight = this.virtualizerContainer.scrollHeight;
const nearBottom = scrollTop + containerHeight >= scrollHeight - threshold;
if (containerHeight === 0 || scrollHeight === 0) return; // guard
if (nearBottom) {
void this.loadPlayerLeaderboard();
}
}
private schedulePlayerFillCheck() {
if (!this.playerHasMore || this.error || this.loadMoreError) return;
void this.updateComplete.then(() => this.maybeLoadMorePlayers());
}
private renderPlayerRow(player: PlayerLeaderboardEntry) {
const isCurrentUser = this.currentUserEntry?.playerId === player.playerId;
const displayRank = player.rank;
const winRate = player.games > 0 ? player.wins / player.games : 0;
const rankColor =
{
1: "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20",
2: "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20",
3: "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20",
}?.[displayRank] ?? "text-white/40 bg-white/5";
const rankIcon =
{
1: "👑",
2: "🥈",
3: "🥉",
}?.[displayRank] ?? String(displayRank);
return html`
<div
data-current-user=${isCurrentUser ? "true" : "false"}
class="flex items-center border-b border-white/5 py-3 px-6 hover:bg-white/[0.07] transition-colors w-full ${isCurrentUser
? "bg-blue-500/15 border-l-4 border-l-blue-500 pl-5"
: ""}"
>
<div class="w-16 shrink-0 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
>
${rankIcon}
</div>
</div>
<div class="flex-1 flex items-center gap-3 overflow-hidden ml-4">
<span class="font-bold text-blue-300 truncate text-base"
>${player.username}</span
>
${player.clanTag
? html`<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-[10px] font-bold text-blue-300 shrink-0"
>
${player.clanTag}
</div>`
: ""}
</div>
<div class="flex flex-col items-end gap-1 w-32">
<div class="text-right font-mono text-white font-medium">
${player.elo}
<span class="text-[10px] text-white/30 truncate"
>${translateText("leaderboard_modal.elo")}</span
>
</div>
</div>
<div class="flex-col items-end gap-1 w-32 hidden md:flex">
<div class="text-right font-mono text-white font-medium">
${player.games}
<span class="text-[10px] text-white/30 uppercase"
>${translateText("leaderboard_modal.games")}</span
>
</div>
</div>
<div class="inline-flex flex-col items-end pr-6 w-32">
<span
class="font-mono font-bold ${winRate >= 0.5
? "text-green-400"
: "text-red-400"}"
>${(winRate * 100).toFixed(1)}%</span
>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("leaderboard_modal.ratio")}</span
>
</div>
</div>
`;
}
private renderPlayerFooter() {
if (this.isLoadingMore) {
return html`
<div class="flex items-center justify-center py-4 text-white/50">
<div
class="w-4 h-4 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mr-2"
></div>
<span class="text-[10px] font-bold uppercase tracking-widest">
${translateText("leaderboard_modal.loading")}
</span>
</div>
`;
}
if (this.loadMoreError) {
return html`
<div class="flex items-center justify-center py-4">
<button
class="px-6 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-xl text-xs font-bold uppercase transition-all active:scale-95"
@click=${() => this.loadPlayerLeaderboard()}
>
${translateText("leaderboard_modal.try_again")}
</button>
</div>
`;
}
return "";
}
private renderLoading() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
></div>
<p class="text-blue-200/80 text-sm font-bold tracking-widest uppercase">
${translateText("leaderboard_modal.loading")}
</p>
</div>
`;
}
private renderError() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-lg shadow-red-500/10"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<p class="mb-8 text-center text-red-100/80 font-medium">
${this.error ?? translateText("leaderboard_modal.error")}
</p>
<button
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-xl text-sm font-bold uppercase transition-all active:scale-95"
@click=${() => this.loadPlayerLeaderboard(true)}
>
${translateText("leaderboard_modal.try_again")}
</button>
</div>
`;
}
render() {
if (this.isLoading && this.playerData.length === 0)
return this.renderLoading();
if (this.error) return this.renderError();
return html`
<div class="flex flex-col h-full overflow-hidden">
<div
class="flex items-center text-[10px] uppercase tracking-wider text-white/40 font-bold px-6 py-4 border-b border-white/5 bg-white/2"
>
<div class="w-16 text-center">
${translateText("leaderboard_modal.rank")}
</div>
<div class="flex-1 ml-4">
${translateText("leaderboard_modal.player")}
</div>
<div class="w-32 text-right">
${translateText("leaderboard_modal.elo")}
</div>
<div class="w-32 text-right hidden md:block">
${translateText("leaderboard_modal.games")}
</div>
<div class="w-32 text-right pr-6">
${translateText("leaderboard_modal.win_loss_ratio")}
</div>
</div>
<div class="relative flex-1 min-h-0">
<div
class="virtualizer-container h-full overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 ${this
.showStickyUser
? "pb-20"
: "pb-0"}"
@scroll=${() => this.handleScroll()}
>
${virtualize({
items: this.playerData,
renderItem: (player) => this.renderPlayerRow(player),
scroller: true,
})}
${this.renderPlayerFooter()}
</div>
${this.currentUserEntry
? html`
<div class="absolute inset-x-0 bottom-0">
<div
class="bg-blue-600/90 backdrop-blur-md border-t border-blue-400/30 py-4 px-6 shadow-2xl flex items-center transition-all duration-200 ${this
.showStickyUser
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-3 pointer-events-none"}"
aria-hidden=${this.showStickyUser ? "false" : "true"}
>
<div class="w-16 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg bg-white/20 text-white"
>
${this.currentUserEntry.rank}
</div>
</div>
<div class="flex-1 flex flex-col ml-4">
<span
class="text-[10px] uppercase font-bold text-blue-200/60 leading-tight"
>${translateText(
"leaderboard_modal.your_ranking",
)}</span
>
<span class="font-bold text-white text-base"
>${this.currentUserEntry.username}</span
>
</div>
<div class="flex flex-col items-end w-32">
<div class="font-mono text-white font-bold text-lg">
${this.currentUserEntry.elo}
<span class="text-[10px] text-white/60"
>${translateText("leaderboard_modal.elo")}</span
>
</div>
</div>
</div>
</div>
`
: ""}
</div>
</div>
`;
}
}
@@ -0,0 +1,75 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../../Utils";
export type LeaderboardTab = "players" | "clans";
@customElement("leaderboard-tabs")
export class LeaderboardTabs extends LitElement {
@property({ type: String }) activeTab: LeaderboardTab = "players";
createRenderRoot() {
return this;
}
private baseTabClass =
"px-6 py-2 rounded-full text-sm font-bold uppercase tracking-wider transition-all cursor-pointer select-none";
private activeTabClass = "bg-blue-600 text-white";
private inactiveTabClass =
"text-white/40 hover:text-white/60 hover:bg-white/5";
private getTabClass(active: boolean) {
return [
this.baseTabClass,
active ? this.activeTabClass : this.inactiveTabClass,
].join(" ");
}
@state()
private playerClass = this.getTabClass(this.activeTab === "players");
@state()
private clanClass = this.getTabClass(this.activeTab === "clans");
private handleTabChange(tab: LeaderboardTab) {
this.dispatchEvent(
new CustomEvent<LeaderboardTab>("tab-change", {
detail: tab,
bubbles: true,
composed: true,
}),
);
this.playerClass = this.getTabClass(tab === "players");
this.clanClass = this.getTabClass(tab === "clans");
}
render() {
return html`
<div
role="tablist"
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-6 w-fit mx-auto mt-4"
>
<button
type="button"
role="tab"
class="${this.playerClass}"
@click=${() => this.handleTabChange("players")}
id="player-leaderboard-tab"
aria-selected=${this.activeTab === "players"}
>
${translateText("leaderboard_modal.ranked_tab")}
</button>
<button
type="button"
role="tab"
class="${this.clanClass}"
@click=${() => this.handleTabChange("clans")}
id="clan-leaderboard-tab"
aria-selected=${this.activeTab === "clans"}
>
${translateText("leaderboard_modal.clans_tab")}
</button>
</div>
`;
}
}
+6 -2
View File
@@ -14,6 +14,7 @@ export class MapDisplay extends LitElement {
@state() private mapWebpPath: string | null = null;
@state() private mapName: string | null = null;
@state() private isLoading = true;
@state() private hasNations = true;
createRenderRoot() {
return this;
@@ -32,7 +33,10 @@ export class MapDisplay extends LitElement {
const mapValue = GameMapType[this.mapKey as keyof typeof GameMapType];
const data = terrainMapFileLoader.getMapData(mapValue);
this.mapWebpPath = await data.webpPath();
this.mapName = (await data.manifest()).name;
const manifest = await data.manifest();
this.mapName = manifest.name;
this.hasNations =
Array.isArray(manifest.nations) && manifest.nations.length > 0;
} catch (error) {
console.error("Failed to load map data:", error);
} finally {
@@ -85,7 +89,7 @@ export class MapDisplay extends LitElement {
>
${translateText("map_component.error")}
</div>`}
${this.showMedals
${this.showMedals && this.hasNations
? html`<div class="flex gap-1 justify-center w-full">
${this.renderMedals()}
</div>`
+8
View File
@@ -34,6 +34,7 @@ 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";
@@ -253,6 +254,12 @@ export function createRenderer(
}
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;
// When updating these layers please be mindful of the order.
// Try to group layers by the return value of shouldTransform.
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
@@ -297,6 +304,7 @@ export function createRenderer(
headsUpMessage,
multiTabModal,
inGameHeaderAd,
spawnVideoAd,
alertFrame,
performanceOverlay,
];
+3
View File
@@ -142,6 +142,9 @@ export class ChatModal extends LitElement {
player
? "selected"
: ""}"
style="border: 2px solid ${player
.territoryColor()
.toHex()};"
@click=${() => this.selectPlayer(player)}
>
${player.name()}
+250 -107
View File
@@ -1,6 +1,5 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Gold } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
@@ -9,6 +8,9 @@ import { AttackRatioEvent } from "../../InputHandler";
import { renderNumber, renderTroops } from "../../Utils";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
import goldCoinIcon from "/images/GoldCoinIcon.svg?url";
import soldierIcon from "/images/SoldierIcon.svg?url";
import swordIcon from "/images/SwordIcon.svg?url";
@customElement("control-panel")
export class ControlPanel extends LitElement implements Layer {
@@ -35,6 +37,12 @@ export class ControlPanel extends LitElement implements Layer {
@state()
private _gold: Gold;
@state()
private _attackingTroops: number = 0;
@state()
private _touchDragging = false;
private _troopRateIsIncreasing: boolean = true;
private _lastTroopIncreaseRate: number;
@@ -49,12 +57,7 @@ export class ControlPanel extends LitElement implements Layer {
);
this.uiState.attackRatio = this.attackRatio;
this.eventBus.on(AttackRatioEvent, (event) => {
let newAttackRatio =
(parseInt(
(document.getElementById("attack-ratio") as HTMLInputElement).value,
) +
event.attackRatio) /
100;
let newAttackRatio = this.attackRatio + event.attackRatio / 100;
if (newAttackRatio < 0.01) {
newAttackRatio = 0.01;
@@ -90,6 +93,10 @@ export class ControlPanel extends LitElement implements Layer {
this._maxTroops = this.game.config().maxTroops(player);
this._gold = player.gold();
this._troops = player.troops();
this._attackingTroops = player
.outgoingAttacks()
.map((a) => a.troops)
.reduce((a, b) => a + b, 0);
this.troopRate = this.game.config().troopIncreaseRate(player) * 10;
this.requestUpdate();
}
@@ -120,119 +127,255 @@ export class ControlPanel extends LitElement implements Layer {
this.requestUpdate();
}
private _outsideTouchHandler: ((ev: Event) => void) | null = null;
private handleAttackTouchStart(e: TouchEvent) {
e.preventDefault();
e.stopPropagation();
if (this._touchDragging) {
this.closeAttackBar();
return;
}
this._touchDragging = true;
setTimeout(() => {
this._outsideTouchHandler = () => {
this.closeAttackBar();
};
document.addEventListener("touchstart", this._outsideTouchHandler);
}, 0);
}
private closeAttackBar() {
this._touchDragging = false;
if (this._outsideTouchHandler) {
document.removeEventListener("touchstart", this._outsideTouchHandler);
this._outsideTouchHandler = null;
}
}
private handleBarTouch(e: TouchEvent) {
e.preventDefault();
e.stopPropagation();
this.setRatioFromTouch(e.touches[0]);
const onMove = (ev: TouchEvent) => {
ev.preventDefault();
this.setRatioFromTouch(ev.touches[0]);
};
const onEnd = () => {
document.removeEventListener("touchmove", onMove);
document.removeEventListener("touchend", onEnd);
};
document.addEventListener("touchmove", onMove, { passive: false });
document.addEventListener("touchend", onEnd);
}
private setRatioFromTouch(touch: Touch) {
const barEl = this.querySelector(".attack-drag-bar");
if (!barEl) return;
const rect = barEl.getBoundingClientRect();
const ratio = (rect.bottom - touch.clientY) / (rect.bottom - rect.top);
this.attackRatio =
Math.round(Math.max(1, Math.min(100, ratio * 100))) / 100;
this.onAttackRatioChange(this.attackRatio);
}
private handleRatioSliderInput(e: Event) {
const value = Number((e.target as HTMLInputElement).value);
this.attackRatio = value / 100;
this.onAttackRatioChange(this.attackRatio);
}
private renderTroopBar() {
const base = Math.max(this._maxTroops, 1);
const greenPercentRaw = (this._troops / base) * 100;
const orangePercentRaw = (this._attackingTroops / base) * 100;
const greenPercent = Math.max(0, Math.min(100, greenPercentRaw));
const orangePercent = Math.max(
0,
Math.min(100 - greenPercent, orangePercentRaw),
);
return html`
<div
class="w-full h-6 lg:h-8 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-green-500 transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-orange-400 transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-between px-1.5 lg:px-2 text-xs lg:text-sm font-bold leading-none pointer-events-none"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._troops)}</span
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._maxTroops)}</span
>
</div>
<div
class="absolute inset-0 flex items-center justify-center gap-0.5 pointer-events-none"
translate="no"
>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="12"
height="12"
class="lg:w-4 lg:h-4 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
/>
<span
class="text-[10px] lg:text-xs font-bold drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ${this
._troopRateIsIncreasing
? "text-green-400"
: "text-orange-400"}"
>+${renderTroops(this.troopRate)}/s</span
>
</div>
</div>
`;
}
render() {
return html`
<style>
input[type="range"] {
-webkit-appearance: none;
background: transparent;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: white;
border-width: 2px;
border-style: solid;
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: white;
border-width: 2px;
border-style: solid;
border-radius: 50%;
cursor: pointer;
}
.targetTroopRatio::-webkit-slider-thumb {
border-color: rgb(59 130 246);
}
.targetTroopRatio::-moz-range-thumb {
border-color: rgb(59 130 246);
}
.attackRatio::-webkit-slider-thumb {
border-color: rgb(239 68 68);
}
.attackRatio::-moz-range-thumb {
border-color: rgb(239 68 68);
}
</style>
<div
class="pointer-events-auto ${this._isVisible
? "w-full sm:max-w-[320px] text-sm sm:text-base bg-gray-800/70 p-2 pr-3 sm:p-4 shadow-lg sm:rounded-lg backdrop-blur-sm"
? "relative z-[60] w-full max-lg:landscape:fixed max-lg:landscape:bottom-0 max-lg:landscape:left-0 max-lg:landscape:w-1/2 max-lg:landscape:z-50 lg:max-w-[400px] text-sm lg:text-base bg-gray-800/70 p-1.5 pr-2 lg:p-5 shadow-lg lg:rounded-xl backdrop-blur-sm"
: "hidden"}"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div class="block bg-black/30 text-white mb-4 p-2 rounded-sm">
<div class="flex justify-between mb-1">
<span class="font-bold"
>${translateText("control_panel.troops")}:</span
>
<span translate="no"
>${renderTroops(this._troops)} / ${renderTroops(this._maxTroops)}
<span
class="${this._troopRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
<div class="flex gap-2 lg:gap-3 items-center">
<!-- Gold: 1/4 -->
<div
class="flex items-center justify-center p-1 lg:p-1.5 lg:gap-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs lg:text-sm w-1/5 lg:w-auto shrink-0"
translate="no"
>
<img
src=${goldCoinIcon}
width="13"
height="13"
class="lg:w-4 lg:h-4"
/>
<span class="px-0.5">${renderNumber(this._gold)}</span>
</div>
<!-- Troop bar: 2/4 -->
<div class="w-3/5 lg:flex-1">${this.renderTroopBar()}</div>
<!-- Attack ratio: 1/4 -->
<div
class="relative w-1/5 shrink-0 flex items-center justify-center gap-1 cursor-pointer lg:hidden"
@touchstart=${(e: TouchEvent) => this.handleAttackTouchStart(e)}
>
<div class="flex flex-col items-center w-10 shrink-0">
<div
class="flex items-center gap-0.5 text-white text-xs font-bold tabular-nums"
translate="no"
>(+${renderTroops(this.troopRate)})</span
></span
>
</div>
<div class="flex justify-between">
<span class="font-bold"
>${translateText("control_panel.gold")}:</span
>
<span translate="no">${renderNumber(this._gold)}</span>
</div>
</div>
<div class="relative mb-0 sm:mb-4">
<label class="block text-white mb-1">
${translateText("control_panel.attack_ratio")} :
<span
class="inline-flex items-center gap-1 [unicode-bidi:isolate]"
dir="ltr"
translate="no"
>
<span>${(this.attackRatio * 100).toFixed(0)}%</span>
<span>
>
<img
src=${swordIcon}
alt=""
aria-hidden="true"
width="10"
height="10"
class="brightness-0 invert sepia saturate-[10000%] hue-rotate-[0deg]"
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
/>
${(this.attackRatio * 100).toFixed(0)}%
</div>
<div class="text-[10px] text-red-400 tabular-nums" translate="no">
(${renderTroops(
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
)})
</span>
</span>
</label>
<div class="relative h-8">
<!-- Background track -->
<div
class="absolute left-0 right-0 top-3 h-2 bg-white/20 rounded-sm"
></div>
<!-- Fill track -->
<div
class="absolute left-0 top-3 h-2 bg-red-500/60 rounded-sm transition-all duration-300 w-(--width)"
style="--width: ${this.attackRatio * 100}%"
></div>
<!-- Range input - exactly overlaying the visual elements -->
<input
id="attack-ratio"
type="range"
min="1"
max="100"
.value=${(this.attackRatio * 100).toString()}
@input=${(e: Event) => {
this.attackRatio =
parseInt((e.target as HTMLInputElement).value) / 100;
this.onAttackRatioChange(this.attackRatio);
}}
class="absolute left-0 right-0 top-2 m-0 h-4 cursor-pointer attackRatio"
/>
</div>
</div>
<!-- Small red vertical bar indicator -->
<div class="relative shrink-0">
<div
class="w-1.5 h-8 bg-white/20 rounded-full relative overflow-hidden"
>
<div
class="absolute bottom-0 w-full bg-red-500 rounded-full transition-all duration-200"
style="height: ${this.attackRatio * 100}%"
></div>
</div>
${this._touchDragging
? html`
<div
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/80 backdrop-blur-sm rounded-lg p-2 w-12"
style="height: 50vh;"
@touchstart=${(e: TouchEvent) => this.handleBarTouch(e)}
>
<span
class="text-red-400 text-sm font-bold mb-1"
translate="no"
>${(this.attackRatio * 100).toFixed(0)}%</span
>
<div
class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
>
<div
class="absolute bottom-0 w-full bg-red-500 rounded-full"
style="height: ${this.attackRatio * 100}%"
></div>
</div>
</div>
`
: ""}
</div>
</div>
</div>
<!-- Attack ratio bar (desktop, always visible) -->
<div class="hidden lg:block mt-2">
<div
class="flex items-center justify-between text-sm font-bold mb-1"
translate="no"
>
<span class="text-white flex items-center gap-1"
><img
src=${swordIcon}
alt=""
aria-hidden="true"
width="14"
height="14"
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
/>Attack Ratio</span
>
<span class="text-white tabular-nums"
>${(this.attackRatio * 100).toFixed(0)}%
(${renderTroops(
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
)})</span
>
</div>
<input
type="range"
min="1"
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
class="w-full h-2 accent-red-500 cursor-pointer"
/>
</div>
</div>
`;
}
+4 -2
View File
@@ -1013,7 +1013,9 @@ export class EventsDisplay extends LitElement implements Layer {
<!-- Events Toggle (when hidden) -->
${this._hidden
? html`
<div class="relative w-fit lg:bottom-4 lg:right-4 z-50">
<div
class="relative w-fit min-[1200px]:bottom-4 min-[1200px]:right-4 z-50"
>
${this.renderButton({
content: html`
Events
@@ -1033,7 +1035,7 @@ export class EventsDisplay extends LitElement implements Layer {
: html`
<!-- Main Events Display -->
<div
class="relative w-full sm:bottom-4 sm:right-4 z-50 sm:w-96 backdrop-blur-sm"
class="relative w-full min-[1200px]:bottom-4 min-[1200px]:right-4 z-50 min-[1200px]:w-96 backdrop-blur-sm"
>
<!-- Button Bar -->
<div class="w-full p-2 lg:p-3 bg-gray-800/70 rounded-t-lg">
+2 -1
View File
@@ -21,7 +21,8 @@ export class InGameHeaderAd extends LitElement implements Layer {
}
init() {
this.showHeaderAd();
// TODO: move ad and re-enable.
// this.showHeaderAd();
}
private showHeaderAd(): void {
+111 -196
View File
@@ -32,6 +32,7 @@ import goldCoinIcon from "/images/GoldCoinIcon.svg?url";
import missileSiloIcon from "/images/MissileSiloIconWhite.svg?url";
import portIcon from "/images/PortIcon.svg?url";
import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url";
import soldierIcon from "/images/SoldierIcon.svg?url";
function euclideanDistWorld(
coord: { x: number; y: number },
@@ -73,12 +74,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@state()
private unit: UnitView | null = null;
@state()
private isWilderness: boolean = false;
@state()
private isIrradiatedWilderness: boolean = false;
@state()
private _isInfoVisible: boolean = false;
@@ -86,8 +81,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
private lastMouseUpdate = 0;
private showDetails = true;
init() {
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
this.onMouseEvent(e),
@@ -112,8 +105,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.setVisible(false);
this.unit = null;
this.player = null;
this.isWilderness = false;
this.isIrradiatedWilderness = false;
}
public maybeShow(x: number, y: number) {
@@ -134,13 +125,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.playerProfile = p;
});
this.setVisible(true);
} else if (owner && !owner.isPlayer() && this.game.isLand(tile)) {
if (this.game.hasFallout(tile)) {
this.isIrradiatedWilderness = true;
} else {
this.isWilderness = true;
}
this.setVisible(true);
} else if (!this.game.isLand(tile)) {
const units = this.game
.units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
@@ -201,28 +185,17 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
}
}
private displayUnitCount(
player: PlayerView,
type: UnitType,
icon: string,
description: string,
) {
private displayUnitCount(player: PlayerView, type: UnitType, icon: string) {
return !this.game.config().isUnitDisabled(type)
? html`<div
class="flex p-1 w-[calc(50%-0.13rem)] border rounded-md border-gray-500
items-center gap-2 text-sm"
class="flex items-center justify-center gap-0.5 lg:gap-1 p-0.5 lg:p-1 border rounded-md border-gray-500 text-[10px] lg:text-xs w-9 lg:w-12 h-6 lg:h-7"
translate="no"
>
<img
src=${icon}
width="20"
height="20"
alt="${translateText(description)}"
class="align-middle"
class="w-3 h-3 lg:w-4 lg:h-4 object-contain shrink-0"
/>
<span class="w-full text-right p-1"
>${player.totalUnitLevels(type)}</span
>
<span>${player.totalUnitLevels(type)}</span>
</div>`
: "";
}
@@ -268,7 +241,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
const myPlayer = this.game.myPlayer();
const isFriendly = myPlayer?.isFriendly(player);
const isAllied = myPlayer?.isAlliedWith(player);
let relationHtml: TemplateResult | null = null;
let allianceHtml: TemplateResult | null = null;
const maxTroops = this.game.config().maxTroops(player);
const attackingTroops = player
.outgoingAttacks()
@@ -276,34 +249,17 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
.reduce((a, b) => a + b, 0);
const totalTroops = player.troops();
if (player.type() === PlayerType.Nation && myPlayer !== null && !isAllied) {
const relation =
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
const relationClass = this.getRelationClass(relation);
const relationName = this.getRelationName(relation);
relationHtml = html`
<span class="ml-auto mr-0 ${relationClass}">${relationName}</span>
`;
}
if (isAllied) {
const alliance = myPlayer
?.alliances()
.find((alliance) => alliance.other === player.id());
if (alliance !== undefined) {
relationHtml = html` <span
class="flex gap-2 ml-auto mr-0 text-sm font-bold"
allianceHtml = html` <div
class="flex flex-col items-center ml-auto mr-0 text-sm font-bold leading-tight"
>
<img
src=${allianceIcon}
alt=${translateText("player_info_overlay.alliance_timeout")}
width="20"
height="20"
class="align-middle"
/>
<img src=${allianceIcon} width="20" height="20" />
${this.allianceExpirationText(alliance)}
</span>`;
</div>`;
}
}
let playerType = "";
@@ -320,128 +276,83 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
}
return html`
<div class="p-2">
<button
class="items-center text-bold text-sm lg:text-lg font-bold mb-1 inline-flex break-all ${isFriendly
? "text-green-500"
: "text-white"}"
@click=${() => {
this.showDetails = !this.showDetails;
this.requestUpdate?.();
}}
>
${player.cosmetics.flag
? player.cosmetics.flag!.startsWith("!")
? html`<div
class="h-8 mr-1 aspect-3/4 player-flag"
${ref((el) => {
if (el instanceof HTMLElement) {
requestAnimationFrame(() => {
renderPlayerFlag(player.cosmetics.flag!, el);
});
}
})}
></div>`
: html`<img
class="h-8 mr-1 aspect-3/4"
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
/>`
: html``}
<span>${player.name()}</span>
${this.renderPlayerNameIcons(player)}
</button>
<!-- Collapsible section -->
${this.showDetails
? html`
${player.team() !== null
? html`<div class="text-sm">
${translateText("player_info_overlay.team")}:
${player.team()}
</div>`
: ""}
<div class="flex text-sm">${playerType} ${relationHtml}</div>
${player.troops() >= 1
? html`<div class="flex gap-2 text-sm" translate="no">
${translateText("player_info_overlay.troops")}
<span class="ml-auto mr-0 font-bold">
${renderTroops(player.troops())}
</span>
</div>`
: ""}
${maxTroops >= 1
? html`<div class="flex gap-2 text-sm" translate="no">
${translateText("player_info_overlay.maxtroops")}
<span class="ml-auto mr-0 font-bold">
${renderTroops(maxTroops)}
</span>
</div>`
: ""}
${attackingTroops >= 1
? html`<div class="flex gap-2 text-sm" translate="no">
${translateText("player_info_overlay.a_troops")}
<span class="ml-auto mr-0 text-red-400 font-bold">
${renderTroops(attackingTroops)}
</span>
</div>`
: ""}
${this.renderTroopBar(totalTroops, attackingTroops, maxTroops)}
<div
class="flex p-1 mb-1 mt-1 w-full border rounded-md border-yellow-400
font-bold text-yellow-400 text-sm"
translate="no"
>
<img
src=${goldCoinIcon}
alt=${translateText("player_info_overlay.gold")}
width="15"
height="15"
class="align-middle"
/>
<span class="w-full text-center"
>${renderNumber(player.gold())}</span
>
</div>
<div class="flex flex-wrap max-w-3xl gap-1">
${this.displayUnitCount(
player,
UnitType.City,
cityIcon,
"player_info_overlay.cities",
)}
${this.displayUnitCount(
player,
UnitType.Factory,
factoryIcon,
"player_info_overlay.factories",
)}
${this.displayUnitCount(
player,
UnitType.Port,
portIcon,
"player_info_overlay.ports",
)}
${this.displayUnitCount(
player,
UnitType.MissileSilo,
missileSiloIcon,
"player_info_overlay.missile_launchers",
)}
${this.displayUnitCount(
player,
UnitType.SAMLauncher,
samLauncherIcon,
"player_info_overlay.sams",
)}
${this.displayUnitCount(
player,
UnitType.Warship,
warshipIcon,
"player_info_overlay.warships",
)}
</div>
`
: ""}
<div class="flex items-start gap-2 lg:gap-3 p-1.5 lg:p-2">
<!-- Left: Gold & Troop bar -->
<div class="flex flex-col gap-1 shrink-0 w-28">
<div
class="flex items-center justify-center p-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs w-28"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" />
<span class="px-0.5">${renderNumber(player.gold())}</span>
</div>
<div class="w-28" translate="no">
${this.renderTroopBar(totalTroops, attackingTroops, maxTroops)}
</div>
</div>
<!-- Right: Player identity + Units below -->
<div class="flex flex-col justify-between self-stretch">
<div
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${isFriendly
? "text-green-500"
: "text-white"}"
>
${player.cosmetics.flag
? player.cosmetics.flag!.startsWith("!")
? html`<div
class="h-6 aspect-3/4 player-flag"
${ref((el) => {
if (el instanceof HTMLElement) {
requestAnimationFrame(() => {
renderPlayerFlag(player.cosmetics.flag!, el);
});
}
})}
></div>`
: html`<img
class="h-6 aspect-3/4"
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
/>`
: html``}
<span>${player.name()}</span>
${player.team() !== null && player.type() !== PlayerType.Bot
? html`<div class="flex flex-col leading-tight">
<span class="text-gray-400 text-xs font-normal"
>${playerType}</span
>
<span class="text-xs font-normal text-gray-400"
>[<span
style="color: ${this.game
.config()
.theme()
.teamColor(player.team()!)
.toHex()}"
>${player.team()}</span
>]</span
>
</div>`
: html`<span class="text-gray-400 text-xs font-normal"
>${playerType}</span
>`}
${this.renderPlayerNameIcons(player)} ${allianceHtml ?? ""}
</div>
<div class="flex gap-0.5 lg:gap-1 items-center mt-1">
${this.displayUnitCount(player, UnitType.City, cityIcon)}
${this.displayUnitCount(player, UnitType.Factory, factoryIcon)}
${this.displayUnitCount(player, UnitType.Port, portIcon)}
${this.displayUnitCount(
player,
UnitType.MissileSilo,
missileSiloIcon,
)}
${this.displayUnitCount(
player,
UnitType.SAMLauncher,
samLauncherIcon,
)}
${this.displayUnitCount(player, UnitType.Warship, warshipIcon)}
</div>
</div>
</div>
`;
}
@@ -463,7 +374,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div
class="w-full mt-2 mb-2 h-5 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden"
class="w-full mt-1 lg:mt-2 h-5 lg:h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
@@ -479,6 +390,25 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-between px-1.5 text-xs font-bold leading-none pointer-events-none"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(totalTroops)}</span
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(maxTroops)}</span
>
</div>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="14"
height="14"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] pointer-events-none"
/>
</div>
`;
}
@@ -497,18 +427,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<div class="mt-1">
<div class="text-sm opacity-80">${unit.type()}</div>
${unit.hasHealth()
? html`
<div class="text-sm">
${translateText("player_info_overlay.health")}:
${unit.health()}
</div>
`
? html` <div class="text-sm">Health: ${unit.health()}</div> `
: ""}
${unit.type() === UnitType.TransportShip
? html`
<div class="text-sm">
${translateText("player_info_overlay.troops")}:
${renderTroops(unit.troops())}
Troops: ${renderTroops(unit.troops())}
</div>
`
: ""}
@@ -528,21 +452,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div
class="block lg:flex fixed top-37.5 right-4 w-full z-50 flex-col max-w-45"
class="fixed top-0 lg:top-[10px] left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
class="bg-gray-800/70 backdrop-blur-xs shadow-xs rounded-lg shadow-lg transition-all duration-300 text-white text-lg md:text-base ${containerClasses}"
class="bg-gray-800/70 backdrop-blur-xs shadow-xs lg:rounded-lg shadow-lg transition-all duration-300 text-white text-lg lg:text-base w-full sm:w-auto sm:min-w-[400px] overflow-hidden ${containerClasses}"
>
${this.isWilderness || this.isIrradiatedWilderness
? html`<div class="p-2 font-bold">
${translateText(
this.isIrradiatedWilderness
? "player_info_overlay.irradiated_wilderness_title"
: "player_info_overlay.wilderness_title",
)}
</div>`
: ""}
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
</div>
@@ -18,7 +18,6 @@ import allianceIcon from "/images/AllianceIconWhite.svg?url";
import boatIcon from "/images/BoatIconWhite.svg?url";
import buildIcon from "/images/BuildIconWhite.svg?url";
import chatIcon from "/images/ChatIconWhite.svg?url";
import checkmarkIcon from "/images/CheckmarkIconWhite.svg?url";
import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url";
import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url";
import emojiIcon from "/images/EmojiIconWhite.svg?url";
@@ -220,15 +219,6 @@ const allyBreakElement: MenuElement = {
!!params.playerActions?.interaction?.canBreakAlliance,
color: COLORS.breakAlly,
icon: traitorIcon,
subMenu: () => [allyBreakCancelElement, allyBreakConfirmElement],
};
const allyBreakConfirmElement: MenuElement = {
id: "ally_break_confirm",
name: "confirm",
disabled: () => false,
color: COLORS.breakAlly,
icon: checkmarkIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleBreakAlliance(
params.myPlayer,
@@ -238,17 +228,6 @@ const allyBreakConfirmElement: MenuElement = {
},
};
const allyBreakCancelElement: MenuElement = {
id: "ally_break_cancel",
name: "cancel",
disabled: () => false,
color: COLORS.info,
icon: xIcon,
action: (params: MenuElementParams) => {
params.closeMenu();
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyDonateGoldElement: MenuElement = {
id: "ally_donate_gold",
@@ -650,10 +629,7 @@ export const rootMenuElement: MenuElement = {
icon: infoIcon,
color: COLORS.info,
subMenu: (params: MenuElementParams) => {
let ally = allyRequestElement;
if (params.selected?.isAlliedWith(params.myPlayer)) {
ally = allyBreakElement;
}
const isAllied = params.selected?.isAlliedWith(params.myPlayer);
const tileOwner = params.game.owner(params.tile);
const isOwnTerritory =
@@ -663,10 +639,10 @@ export const rootMenuElement: MenuElement = {
const menuItems: (MenuElement | null)[] = [
infoMenuElement,
...(isOwnTerritory
? [deleteUnitElement, ally, buildMenuElement]
? [deleteUnitElement, allyRequestElement, buildMenuElement]
: [
boatMenuElement,
ally,
isAllied ? allyBreakElement : boatMenuElement,
allyRequestElement,
isFriendlyTarget(params) && !isDisconnectedTarget(params)
? donateGoldRadialElement
: attackMenuElement,
@@ -0,0 +1,67 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { getGamesPlayed } from "src/client/Utils";
import { GameType } from "src/core/game/Game";
import { GameView } from "../../../core/game/GameView";
import "../../components/VideoReward";
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 ||
window.innerWidth < 768 ||
crazyGamesSDK.isOnCrazyGames() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
getGamesPlayed() < 3 || // Don't show to new players
getGamesPlayed() % 3 !== 0 // Only show 1 in 3 times
) {
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>
`;
}
}
+3 -2
View File
@@ -13,6 +13,7 @@ import { Layer } from "./Layer";
import warshipIcon from "/images/BattleshipIconWhite.svg?url";
import cityIcon from "/images/CityIconWhite.svg?url";
import factoryIcon from "/images/FactoryIconWhite.svg?url";
import goldCoinIcon from "/images/GoldCoinIcon.svg?url";
import mirvIcon from "/images/MIRVIcon.svg?url";
import missileSiloIcon from "/images/MissileSiloIconWhite.svg?url";
import hydrogenBombIcon from "/images/MushroomCloudIconWhite.svg?url";
@@ -256,11 +257,11 @@ export class UnitDisplay extends LitElement implements Layer {
<div class="p-2">
${translateText("build_menu.desc." + structureKey)}
</div>
<div>
<div class="flex items-center justify-center gap-1">
<img src=${goldCoinIcon} width="13" height="13" />
<span class="text-yellow-300"
>${renderNumber(this.cost(unitType))}</span
>
${translateText("player_info_overlay.gold")}
</div>
</div>
`
+3 -4
View File
@@ -1,9 +1,10 @@
import { LitElement, TemplateResult, html } from "lit";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
getGamesPlayed,
isInIframe,
translateText,
TUTORIAL_VIDEO_URL,
} from "../../../client/Utils";
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
@@ -131,9 +132,7 @@ export class WinModal extends LitElement implements Layer {
<div class="relative w-full pb-[56.25%]">
<iframe
class="absolute top-0 left-0 w-full h-full rounded-sm"
src="${this.isVisible
? "https://www.youtube.com/embed/EN2oOog3pSs"
: ""}"
src="${this.isVisible ? TUTORIAL_VIDEO_URL : ""}"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+46
View File
@@ -133,3 +133,49 @@ export const ClanLeaderboardResponseSchema = z.object({
export type ClanLeaderboardResponse = z.infer<
typeof ClanLeaderboardResponseSchema
>;
export const PlayerLeaderboardEntrySchema = z.object({
rank: z.number(),
playerId: z.string(),
username: z.string(),
clanTag: z.string().optional(),
flag: z.string().optional(),
elo: z.number(),
games: z.number(),
wins: z.number(),
losses: z.number(),
winRate: z.number(),
});
export type PlayerLeaderboardEntry = z.infer<
typeof PlayerLeaderboardEntrySchema
>;
export const PlayerLeaderboardResponseSchema = z.object({
players: PlayerLeaderboardEntrySchema.array(),
});
export type PlayerLeaderboardResponse = z.infer<
typeof PlayerLeaderboardResponseSchema
>;
export const RankedLeaderboardEntrySchema = z.object({
rank: z.number(),
elo: z.number(),
peakElo: z.number().nullable(),
wins: z.number(),
losses: z.number(),
total: z.number(),
public_id: z.string(),
user: DiscordUserSchema.nullable().optional(),
username: z.string(),
clanTag: z.string().nullable().optional(),
});
export type RankedLeaderboardEntry = z.infer<
typeof RankedLeaderboardEntrySchema
>;
export const RankedLeaderboardResponseSchema = z.object({
"1v1": RankedLeaderboardEntrySchema.array(),
});
export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
>;
+18 -2
View File
@@ -6,6 +6,7 @@ import {
PatternDataSchema,
PatternNameSchema,
} from "./CosmeticSchemas";
import type { GameEvent } from "./EventBus";
import {
AllPlayers,
Difficulty,
@@ -105,7 +106,8 @@ export type ServerMessage =
| ServerPingMessage
| ServerDesyncMessage
| ServerPrestartMessage
| ServerErrorMessage;
| ServerErrorMessage
| ServerLobbyInfoMessage;
export type ServerTurnMessage = z.infer<typeof ServerTurnMessageSchema>;
export type ServerStartGameMessage = z.infer<
@@ -115,6 +117,9 @@ export type ServerPingMessage = z.infer<typeof ServerPingMessageSchema>;
export type ServerDesyncMessage = z.infer<typeof ServerDesyncSchema>;
export type ServerPrestartMessage = z.infer<typeof ServerPrestartMessageSchema>;
export type ServerErrorMessage = z.infer<typeof ServerErrorSchema>;
export type ServerLobbyInfoMessage = z.infer<
typeof ServerLobbyInfoMessageSchema
>;
export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>;
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>;
@@ -152,6 +157,11 @@ export interface GameInfo {
msUntilStart?: number;
gameConfig?: GameConfig;
}
export class LobbyInfoEvent implements GameEvent {
constructor(public lobby: GameInfo) {}
}
export interface ClientInfo {
clientID: ClientID;
username: string;
@@ -212,7 +222,7 @@ export const GameConfigSchema = z.object({
export const TeamSchema = z.string();
const SafeString = z
export const SafeString = z
.string()
.regex(
/^([a-zA-Z0-9\s.,!?@#$%&*()\-_+=[\]{}|;:"'/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|[üÜ])*$/u,
@@ -540,6 +550,11 @@ export const ServerErrorSchema = z.object({
message: z.string().optional(),
});
export const ServerLobbyInfoMessageSchema = z.object({
type: z.literal("lobby_info"),
lobby: GameInfoSchema,
});
export const ServerMessageSchema = z.discriminatedUnion("type", [
ServerTurnMessageSchema,
ServerPrestartMessageSchema,
@@ -547,6 +562,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
ServerPingMessageSchema,
ServerDesyncSchema,
ServerErrorSchema,
ServerLobbyInfoMessageSchema,
]);
//
+3 -3
View File
@@ -728,7 +728,7 @@ export class DefaultConfig implements Config {
if (playerInfo.playerType === PlayerType.Nation) {
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
return 18_750;
return 12_500;
case Difficulty.Medium:
return 25_000; // Like humans
case Difficulty.Hard:
@@ -763,7 +763,7 @@ export class DefaultConfig implements Config {
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
return maxTroops * 0.75;
return maxTroops * 0.5;
case Difficulty.Medium:
return maxTroops * 1; // Like humans
case Difficulty.Hard:
@@ -790,7 +790,7 @@ export class DefaultConfig implements Config {
if (player.type() === PlayerType.Nation) {
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
toAdd *= 0.95;
toAdd *= 0.9;
break;
case Difficulty.Medium:
toAdd *= 1; // Like humans
+9 -1
View File
@@ -360,7 +360,15 @@ export class NationExecution implements Execution {
player.addEmbargo(other, false);
} else if (
player.relation(other) >= Relation.Neutral &&
player.hasEmbargoAgainst(other)
player.hasEmbargoAgainst(other) &&
this.mg.config().gameConfig().difficulty !== Difficulty.Hard &&
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
) {
player.stopEmbargo(other);
} else if (
player.relation(other) >= Relation.Friendly &&
player.hasEmbargoAgainst(other) &&
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
) {
player.stopEmbargo(other);
}
@@ -143,10 +143,16 @@ export class NationAllianceBehavior {
return false;
}
const totalPlayers = this.game.players().length;
const totalPlayers = this.game
.players()
.filter((p) => p.type() !== PlayerType.Bot).length;
const otherPlayerAlliances = otherPlayer.alliances().length;
return otherPlayerAlliances >= totalPlayers * 0.5;
if (difficulty !== Difficulty.Hard) {
return otherPlayerAlliances >= totalPlayers * 0.5;
} else {
return otherPlayerAlliances >= totalPlayers * 0.25;
}
}
private isConfused(): boolean {
@@ -42,6 +42,7 @@ export const EMOJI_BORED = (["🥱"] as const).map(emojiId);
export const EMOJI_HANDSHAKE = (["🤝"] as const).map(emojiId);
export const EMOJI_DONATION_OK = (["👍"] as const).map(emojiId);
export const EMOJI_DONATION_TOO_SMALL = (["❓", "🥱"] as const).map(emojiId);
export const EMOJI_GREET = (["👋"] as const).map(emojiId);
export class NationEmojiBehavior {
private readonly lastEmojiSent = new Map<Player, Tick>();
@@ -63,6 +64,7 @@ export class NationEmojiBehavior {
this.charmAllies();
this.annoyTraitors();
this.findRat();
this.greetNearbyPlayers();
}
private checkOverwhelmedByAttacks(): void {
@@ -203,6 +205,22 @@ export class NationEmojiBehavior {
this.sendEmoji(smallPlayer, EMOJI_RAT);
}
private greetNearbyPlayers(): void {
if (this.game.ticks() > 600) return; // Only in the first minute
if (!this.random.chance(250)) return;
const nearbyHumans = this.player
.neighbors()
.filter(
(p): p is Player => p.isPlayer() && p.type() === PlayerType.Human,
);
if (nearbyHumans.length === 0) return;
const neighbor = this.random.randElement(nearbyHumans);
this.sendEmoji(neighbor, EMOJI_GREET);
}
maybeSendEmoji(
otherPlayer: Player | typeof AllPlayers,
emojisList: number[],
@@ -99,7 +99,11 @@ export class NationWarshipBehavior {
if (!ship.isActive()) {
// Distinguish between arrival/retreat and enemy destruction
if (ship.wasDestroyedByEnemy() && ship.destroyer() !== undefined) {
this.maybeRetaliateWithWarship(ship.tile(), ship.destroyer()!);
this.maybeRetaliateWithWarship(
ship.tile(),
ship.destroyer()!,
"transport",
);
}
this.trackedTransportShips.delete(ship);
}
@@ -121,13 +125,17 @@ export class NationWarshipBehavior {
}
if (ship.owner().id() !== this.player.id()) {
// Ship was ours and is now owned by someone else -> captured
this.maybeRetaliateWithWarship(ship.tile(), ship.owner());
this.maybeRetaliateWithWarship(ship.tile(), ship.owner(), "trade");
this.trackedTradeShips.delete(ship);
}
}
}
private maybeRetaliateWithWarship(tile: TileRef, enemy: Player): void {
private maybeRetaliateWithWarship(
tile: TileRef,
enemy: Player,
reason: "trade" | "transport",
): void {
// Don't send too many warships
if (this.player.units(UnitType.Warship).length >= 10) {
return;
@@ -148,6 +156,7 @@ export class NationWarshipBehavior {
new ConstructionExecution(this.player, UnitType.Warship, tile),
);
this.emojiBehavior.maybeSendEmoji(enemy, EMOJI_WARSHIP_RETALIATION);
this.player.updateRelation(enemy, reason === "trade" ? -7.5 : -15);
}
}
+2
View File
@@ -111,6 +111,7 @@ export enum GameMapType {
Manicouagan = "Manicouagan",
Lemnos = "Lemnos",
Sierpinski = "Sierpinski",
TheBox = "The Box",
TwoLakes = "Two Lakes",
StraitOfHormuz = "Strait of Hormuz",
Surrounded = "Surrounded",
@@ -177,6 +178,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Surrounded,
],
arcade: [
GameMapType.TheBox,
GameMapType.Didier,
GameMapType.DidierFrance,
GameMapType.Sierpinski,
+8
View File
@@ -423,6 +423,14 @@ export class PlayerImpl implements Player {
return false;
}
const hasIncoming = this.incomingAllianceRequests().some(
(ar) => ar.requestor() === other,
);
if (hasIncoming) {
return true;
}
const recent = this.pastOutgoingAllianceRequests
.filter((ar) => ar.recipient() === other)
.sort((a, b) => b.createdAt() - a.createdAt());
+54 -6
View File
@@ -17,6 +17,7 @@ import {
PlayerRecord,
ServerDesyncSchema,
ServerErrorMessage,
ServerLobbyInfoMessage,
ServerPrestartMessageSchema,
ServerStartGameMessage,
ServerTurnMessage,
@@ -78,6 +79,8 @@ export class GameServer {
public desyncCount = 0;
private lobbyInfoIntervalId: ReturnType<typeof setInterval> | null = null;
constructor(
public readonly id: string,
readonly log_: Logger,
@@ -232,6 +235,7 @@ export class GameServer {
this.markClientDisconnected(client.clientID, false);
this.allClients.set(client.clientID, client);
this.addListeners(client);
this.startLobbyInfoBroadcast();
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
@@ -280,6 +284,7 @@ export class GameServer {
client.ws = ws;
this.addListeners(client);
this.startLobbyInfoBroadcast();
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, msg.lastTurn);
@@ -293,17 +298,16 @@ export class GameServer {
const parsed = ClientMessageSchema.safeParse(JSON.parse(message));
if (!parsed.success) {
const error = z.prettifyError(parsed.error);
this.log.error("Failed to parse client message", error, {
this.log.warn(`Failed to parse client message ${error}`, {
clientID: client.clientID,
});
client.ws.send(
JSON.stringify({
type: "error",
error,
message,
message: `Server could not parse message from client: ${message}`,
} satisfies ServerErrorMessage),
);
client.ws.close(1002, "ClientMessageSchema");
return;
}
const clientMsg = parsed.data;
@@ -543,6 +547,47 @@ export class GameServer {
});
}
private startLobbyInfoBroadcast() {
if (this._hasStarted || this._hasEnded) {
return;
}
if (this.lobbyInfoIntervalId !== null) {
return;
}
this.broadcastLobbyInfo();
this.lobbyInfoIntervalId = setInterval(() => {
if (
this._hasStarted ||
this._hasEnded ||
this.activeClients.length === 0
) {
this.stopLobbyInfoBroadcast();
return;
}
this.broadcastLobbyInfo();
}, 1000);
}
private stopLobbyInfoBroadcast() {
if (this.lobbyInfoIntervalId === null) {
return;
}
clearInterval(this.lobbyInfoIntervalId);
this.lobbyInfoIntervalId = null;
}
private broadcastLobbyInfo() {
const msg = JSON.stringify({
type: "lobby_info",
lobby: this.gameInfo(),
} satisfies ServerLobbyInfoMessage);
this.activeClients.forEach((c) => {
if (c.ws.readyState === WebSocket.OPEN) {
c.ws.send(msg);
}
});
}
public start() {
if (this._hasStarted || this._hasEnded) {
return;
@@ -772,12 +817,15 @@ export class GameServer {
clientID: c.clientID,
})),
gameConfig: this.gameConfig,
msUntilStart: this.isPublic()
? this.createdAt + this.config.gameCreationRate()
: undefined,
msUntilStart: this.isPublic() ? this.getMsUntilStart() : undefined,
};
}
private getMsUntilStart(): number {
const startTime = this.createdAt + this.config.gameCreationRate();
return Math.max(0, startTime - Date.now());
}
public isPublic(): boolean {
return this.gameConfig.gameType === GameType.Public;
}
+1
View File
@@ -65,6 +65,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
Didier: 1,
AmazonRiver: 3,
Sierpinski: 10,
TheBox: 3,
};
interface MapWithMode {
+1 -1
View File
@@ -267,7 +267,7 @@ async function fetchLobbies(): Promise<number> {
gameID: gi.gameID,
numClients: gi?.clients?.length ?? 0,
gameConfig: gi.gameConfig,
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
msUntilStart: gi.msUntilStart,
} as GameInfo;
});
+383
View File
@@ -0,0 +1,383 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@lit-labs/virtualizer/virtualize.js", async () => {
const { html } = await import("lit");
return {
virtualize: vi.fn(() => html``),
};
});
vi.mock("../../src/client/Utils", () => ({
translateText: vi.fn((key: string) => {
const translations: Record<string, string> = {
"leaderboard_modal.win_score_tooltip":
"Weighted wins based on clan participation and match difficulty",
"leaderboard_modal.loss_score_tooltip":
"Weighted losses based on clan participation and match difficulty",
"leaderboard_modal.title": "Leaderboard",
"leaderboard_modal.ranked_tab": "Ranked",
"leaderboard_modal.clans_tab": "Clans",
"leaderboard_modal.error": "Something went wrong",
"leaderboard_modal.rank": "Rank",
"leaderboard_modal.clan": "Clan",
"leaderboard_modal.games": "Games",
"leaderboard_modal.win_score": "Win Score",
"leaderboard_modal.loss_score": "Loss Score",
"leaderboard_modal.win_loss_ratio": "W/L",
"leaderboard_modal.ratio": "Ratio",
"leaderboard_modal.elo": "Elo",
"leaderboard_modal.player": "Player",
"leaderboard_modal.loading": "Loading",
"leaderboard_modal.try_again": "Try Again",
"leaderboard_modal.no_data_yet": "No data yet",
"leaderboard_modal.no_stats": "No stats",
"leaderboard_modal.your_ranking": "Your ranking",
"common.close": "Close",
};
return translations[key] || key;
}),
}));
vi.mock("../../src/client/Api", () => {
const getApiBase = () => "http://localhost:3000";
return {
getApiBase: vi.fn(getApiBase),
getUserMe: vi.fn(async () => false),
fetchClanLeaderboard: vi.fn(async () => {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return false;
return res.json();
}),
fetchPlayerLeaderboard: vi.fn(async (page: number) => {
const url = new URL(`${getApiBase()}/leaderboard/ranked`);
url.searchParams.set("page", String(page));
const res = await fetch(url.toString(), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
if (res.status === 400) {
const errorJson = await res.json().catch(() => null);
if (errorJson?.message?.includes("Page must be between")) {
return "reached_limit";
}
}
return false;
}
return res.json();
}),
};
});
const jsonRes = (data: any, ok = true, status = 200) => ({
ok,
status,
json: async () => data,
});
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: any) => {
const url =
typeof input === "string" ? input : (input?.url ?? String(input));
if (url.includes("/public/clans/leaderboard")) {
return jsonRes({ start: "...", end: "...", clans: [] });
}
if (url.includes("/leaderboard/ranked")) {
return jsonRes({ "1v1": [] });
}
return jsonRes({}, false, 404);
}),
);
});
import { LeaderboardModal } from "../../src/client/LeaderboardModal";
describe("LeaderboardModal", () => {
let modal: LeaderboardModal;
const awaitChildUpdate = async (selector: string) => {
const el = modal.querySelector(selector) as {
updateComplete?: Promise<unknown>;
} | null;
if (el?.updateComplete) {
await el.updateComplete;
}
};
const getClanTable = () =>
modal.querySelector("leaderboard-clan-table") as {
loadClanLeaderboard: () => Promise<void>;
updateComplete: Promise<unknown>;
} | null;
const getPlayerList = () =>
modal.querySelector("leaderboard-player-list") as {
loadPlayerLeaderboard: (reset?: boolean) => Promise<void>;
updateComplete: Promise<unknown>;
playerData: Array<Record<string, unknown>>;
currentUserEntry?: { playerId: string } | null;
} | null;
beforeEach(async () => {
vi.stubGlobal("fetch", vi.fn());
if (!customElements.get("leaderboard-modal")) {
customElements.define("leaderboard-modal", LeaderboardModal);
}
modal = document.createElement("leaderboard-modal") as LeaderboardModal;
document.body.appendChild(modal);
await modal.updateComplete;
});
afterEach(() => {
document.body.removeChild(modal);
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("Tooltip Implementation - Issue #2508", () => {
it("should render Win Score and Loss Score columns with title attributes", async () => {
// Mock fetch to return sample clan leaderboard data
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
start: "2025-01-01T00:00:00Z",
end: "2025-01-07T23:59:59Z",
clans: [
{
clanTag: "[TEST]",
games: 10,
wins: 8,
losses: 2,
playerSessions: 25,
weightedWins: 8.5,
weightedLosses: 1.5,
weightedWLRatio: 5.67,
},
{
clanTag: "[DEMO]",
games: 8,
wins: 6,
losses: 2,
playerSessions: 20,
weightedWins: 6.0,
weightedLosses: 2.0,
weightedWLRatio: 3.0,
},
],
}),
});
(modal as unknown as { activeTab: string }).activeTab = "clans";
const clanTable = getClanTable();
expect(clanTable).toBeTruthy();
await clanTable!.loadClanLeaderboard();
await clanTable!.updateComplete;
const allHeaders = modal.querySelectorAll("th");
let winScoreHeader: Element | null = null;
let lossScoreHeader: Element | null = null;
// Find the headers by their text content and title attribute
allHeaders.forEach((th) => {
const title = th.getAttribute("title");
if (title?.includes("Weighted wins")) {
winScoreHeader = th;
} else if (title?.includes("Weighted losses")) {
lossScoreHeader = th;
}
});
// Assert that headers exist with correct tooltip text
expect(winScoreHeader).toBeTruthy();
expect(lossScoreHeader).toBeTruthy();
expect(winScoreHeader!.getAttribute("title")).toBe(
"Weighted wins based on clan participation and match difficulty",
);
expect(lossScoreHeader!.getAttribute("title")).toBe(
"Weighted losses based on clan participation and match difficulty",
);
});
it("should use translateText for tooltip internationalization", async () => {
// Verify translation keys are correct
const { translateText } = await import("../../src/client/Utils");
expect(translateText("leaderboard_modal.win_score_tooltip")).toBe(
"Weighted wins based on clan participation and match difficulty",
);
expect(translateText("leaderboard_modal.loss_score_tooltip")).toBe(
"Weighted losses based on clan participation and match difficulty",
);
});
});
describe("Player Data Mapping", () => {
it("should map ranked leaderboard data and set current user entry", async () => {
const { getUserMe } = await import("../../src/client/Api");
(getUserMe as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
player: { publicId: "player-2" },
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
"1v1": [
{
rank: 1,
elo: 1200,
peakElo: 1300,
wins: 6,
losses: 4,
total: 10,
public_id: "player-1",
username: "Alpha",
clanTag: "[AAA]",
},
{
rank: 2,
elo: 1100,
peakElo: 1250,
wins: 4,
losses: 6,
total: 10,
public_id: "player-2",
username: "Bravo",
clanTag: null,
},
],
}),
});
const playerList = getPlayerList();
expect(playerList).toBeTruthy();
await playerList!.loadPlayerLeaderboard(true);
await playerList!.updateComplete;
const playerData = playerList!.playerData;
expect(playerData).toHaveLength(2);
expect(playerData[0]).toEqual(
expect.objectContaining({
playerId: "player-1",
username: "Alpha",
clanTag: "[AAA]",
elo: 1200,
games: 10,
wins: 6,
losses: 4,
winRate: 0.6,
}),
);
expect(playerData[1]).toEqual(
expect.objectContaining({
playerId: "player-2",
username: "Bravo",
clanTag: undefined,
winRate: 0.4,
}),
);
expect(playerList!.currentUserEntry?.playerId).toBe("player-2");
});
});
describe("Modal Functionality", () => {
it("should initialize with default state", () => {
expect(modal).toBeTruthy();
expect((modal as unknown as { activeTab: string }).activeTab).toBe(
"players",
);
});
it("should be a custom element", () => {
expect(modal).toBeInstanceOf(LeaderboardModal);
expect(modal.tagName.toLowerCase()).toBe("leaderboard-modal");
});
it("should close on Escape when open", () => {
const mockModalEl = { open: vi.fn(), close: vi.fn() };
Object.defineProperty(modal, "modalEl", {
get: () => mockModalEl,
configurable: true,
});
(modal as unknown as { onOpen: () => void }).onOpen = vi.fn();
modal.open();
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
true,
);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
false,
);
expect(mockModalEl.close).toHaveBeenCalled();
});
});
describe("Modal Interaction", () => {
it("should switch to clans tab and request clan leaderboard data", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
start: "2025-01-01T00:00:00Z",
end: "2025-01-07T23:59:59Z",
clans: [],
}),
});
const tab = modal.querySelector("#clan-leaderboard-tab");
expect(tab).toBeTruthy();
tab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect((modal as unknown as { activeTab: string }).activeTab).toBe(
"clans",
);
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:3000/public/clans/leaderboard",
{ headers: { Accept: "application/json" } },
);
await Promise.resolve();
await modal.updateComplete;
await awaitChildUpdate("leaderboard-clan-table");
});
it("should render a no data state for empty clan leaderboard", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
start: "2025-01-01T00:00:00Z",
end: "2025-01-07T23:59:59Z",
clans: [],
}),
});
(modal as unknown as { activeTab: string }).activeTab = "clans";
const clanTable = getClanTable();
expect(clanTable).toBeTruthy();
await clanTable!.loadClanLeaderboard();
await clanTable!.updateComplete;
expect(modal.textContent).toContain("No data yet");
expect(modal.textContent).toContain("No stats");
});
it("should render an error state when clan leaderboard fails", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({}),
});
(modal as unknown as { activeTab: string }).activeTab = "clans";
const clanTable = getClanTable();
expect(clanTable).toBeTruthy();
await clanTable!.loadClanLeaderboard();
await clanTable!.updateComplete;
expect(modal.textContent).toContain("Something went wrong");
expect(modal.textContent).toContain("Try Again");
});
});
});
-140
View File
@@ -1,140 +0,0 @@
import { StatsModal } from "../../src/client/StatsModal";
// Mock the translateText function
vi.mock("../../src/client/Utils", () => ({
translateText: vi.fn((key: string) => {
const translations: Record<string, string> = {
"stats_modal.win_score_tooltip":
"Weighted wins based on clan participation and match difficulty",
"stats_modal.loss_score_tooltip":
"Weighted losses based on clan participation and match difficulty",
};
return translations[key] || key;
}),
}));
// Mock the API module
vi.mock("../../src/client/Api", () => ({
getApiBase: vi.fn(() => "http://localhost:3000"),
}));
// Mock fetch
global.fetch = vi.fn();
describe("StatsModal", () => {
let modal: StatsModal;
beforeEach(async () => {
// Define the custom element if not already defined
if (!customElements.get("stats-modal")) {
customElements.define("stats-modal", StatsModal);
}
modal = document.createElement("stats-modal") as StatsModal;
document.body.appendChild(modal);
await modal.updateComplete;
});
afterEach(() => {
document.body.removeChild(modal);
vi.clearAllMocks();
});
describe("Tooltip Implementation - Issue #2508", () => {
it("should render Win Score and Loss Score columns with title attributes", async () => {
// Mock fetch to return sample clan leaderboard data
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
start: "2025-01-01T00:00:00Z",
end: "2025-01-07T23:59:59Z",
clans: [
{
clanTag: "[TEST]",
games: 10,
wins: 8,
losses: 2,
playerSessions: 25,
weightedWins: 8.5,
weightedLosses: 1.5,
weightedWLRatio: 5.67,
},
{
clanTag: "[DEMO]",
games: 8,
wins: 6,
losses: 2,
playerSessions: 20,
weightedWins: 6.0,
weightedLosses: 2.0,
weightedWLRatio: 3.0,
},
],
}),
});
// Mock the modal element's open method
const mockModalEl = { open: vi.fn(), close: vi.fn() };
Object.defineProperty(modal, "modalEl", {
get: () => mockModalEl,
configurable: true,
});
// Trigger modal to load and render data
modal.open();
await modal.updateComplete;
// Wait for async loadLeaderboard to complete
await new Promise((resolve) => setTimeout(resolve, 100));
await modal.updateComplete;
// Query the rendered DOM for table headers (StatsModal uses light DOM via createRenderRoot)
const allHeaders = modal.querySelectorAll("th");
let winScoreHeader: Element | null = null;
let lossScoreHeader: Element | null = null;
// Find the headers by their text content and title attribute
allHeaders.forEach((th) => {
const title = th.getAttribute("title");
if (title?.includes("Weighted wins")) {
winScoreHeader = th;
} else if (title?.includes("Weighted losses")) {
lossScoreHeader = th;
}
});
// Assert that headers exist with correct tooltip text
expect(winScoreHeader).toBeTruthy();
expect(lossScoreHeader).toBeTruthy();
expect(winScoreHeader!.getAttribute("title")).toBe(
"Weighted wins based on clan participation and match difficulty",
);
expect(lossScoreHeader!.getAttribute("title")).toBe(
"Weighted losses based on clan participation and match difficulty",
);
});
it("should use translateText for tooltip internationalization", async () => {
// Verify translation keys are correct
const { translateText } = await import("../../src/client/Utils");
expect(translateText("stats_modal.win_score_tooltip")).toBe(
"Weighted wins based on clan participation and match difficulty",
);
expect(translateText("stats_modal.loss_score_tooltip")).toBe(
"Weighted losses based on clan participation and match difficulty",
);
});
});
describe("Modal Functionality", () => {
it("should initialize with default state", () => {
expect(modal).toBeTruthy();
});
it("should be a custom element", () => {
expect(modal).toBeInstanceOf(StatsModal);
expect(modal.tagName.toLowerCase()).toBe("stats-modal");
});
});
});
@@ -341,6 +341,7 @@ describe("RadialMenuElements", () => {
isPlayer: vi.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = allyPlayer;
mockGame.owner = vi.fn(() => allyPlayer);
const subMenu = rootMenuElement.subMenu!(mockParams);
const allyMenu = subMenu.find((item) => item.id === "ally_break");
+2 -40
View File
@@ -75,12 +75,6 @@ const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
const findAllyBreak = (items: any[]) =>
items.find((i) => i && i.id === "ally_break");
const findAllyBreakConfirm = (items: any[]) =>
items.find((i) => i && i.id === "ally_break_confirm");
const findAllyBreakCancel = (items: any[]) =>
items.find((i) => i && i.id === "ally_break_cancel");
describe("RadialMenuElements ally break", () => {
test("shows break option with correct color when allied", () => {
const params = makeParams();
@@ -91,29 +85,12 @@ describe("RadialMenuElements ally break", () => {
expect(ally.color).toBe(COLORS.breakAlly);
});
test("break option opens confirmation submenu", () => {
test("break action calls handleBreakAlliance and closes menu", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
expect(ally.subMenu).toBeDefined();
const subMenuItems = ally.subMenu!(params);
expect(subMenuItems.length).toBe(2);
const confirmItem = findAllyBreakConfirm(subMenuItems);
const cancelItem = findAllyBreakCancel(subMenuItems);
expect(confirmItem).toBeTruthy();
expect(cancelItem).toBeTruthy();
});
test("confirm action calls handleBreakAlliance and closes menu", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
const subMenuItems = ally.subMenu!(params);
const confirmItem = findAllyBreakConfirm(subMenuItems)!;
confirmItem.action!(params);
ally.action!(params);
expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
params.myPlayer,
@@ -121,19 +98,4 @@ describe("RadialMenuElements ally break", () => {
);
expect(params.closeMenu).toHaveBeenCalled();
});
test("cancel action closes menu without breaking alliance", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
const subMenuItems = ally.subMenu!(params);
const cancelItem = findAllyBreakCancel(subMenuItems)!;
cancelItem.action!(params);
expect(
params.playerActionHandler.handleBreakAlliance,
).not.toHaveBeenCalled();
expect(params.closeMenu).toHaveBeenCalled();
});
});
+20 -7
View File
@@ -13,6 +13,24 @@ const __dirname = path.dirname(__filename);
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const isProduction = mode === "production";
// In dev, redirect visits to /w*/game/* to "/" so Vite serves the index.html.
const devGameHtmlBypass = (req?: {
url?: string;
method?: string;
headers?: { accept?: string | string[] };
}) => {
if (req?.method !== "GET") return undefined;
const accept = req.headers?.accept;
const acceptValue = Array.isArray(accept)
? accept.join(",")
: (accept ?? "");
if (!acceptValue.includes("text/html")) return undefined;
if (!req.url) return undefined;
if (/^\/w\d+\/game\/[^/]+/.test(req.url)) {
return "/";
}
return undefined;
};
return {
test: {
@@ -103,6 +121,7 @@ export default defineConfig(({ mode }) => {
ws: true,
secure: false,
changeOrigin: true,
bypass: (req) => devGameHtmlBypass(req),
rewrite: (path) => path.replace(/^\/w0/, ""),
},
"/w1": {
@@ -110,15 +129,9 @@ export default defineConfig(({ mode }) => {
ws: true,
secure: false,
changeOrigin: true,
bypass: (req) => devGameHtmlBypass(req),
rewrite: (path) => path.replace(/^\/w1/, ""),
},
"/w2": {
target: "ws://localhost:3003",
ws: true,
secure: false,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/w2/, ""),
},
// API proxies
"/api": {
target: "http://localhost:3000",