Merge branch 'main' into embeddedurlfix

This commit is contained in:
Ryan
2026-02-09 22:10:57 +00:00
committed by GitHub
104 changed files with 6420 additions and 3683 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,17 @@
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 +240,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 -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 983 KiB

@@ -0,0 +1,35 @@
{
"name": "Yenisei",
"nations": [
{
"coordinates": [1050, 535],
"flag": "ru",
"name": "Baikalovsk"
},
{
"coordinates": [1120, 1315],
"flag": "ru",
"name": "Mungui"
},
{
"coordinates": [345, 1190],
"flag": "ru",
"name": "Polykarpovsk"
},
{
"coordinates": [335, 800],
"flag": "ru",
"name": "Central Island"
},
{
"coordinates": [75, 390],
"flag": "ru",
"name": "West Coast"
},
{
"coordinates": [420, 440],
"flag": "ru",
"name": "Northern Island"
}
]
}
+1
View File
@@ -67,6 +67,7 @@ var maps = []struct {
{Name: "didier"},
{Name: "didierfrance"},
{Name: "amazonriver"},
{Name: "yenisei"},
{Name: "big_plains", IsTest: true},
{Name: "half_land_half_ocean", IsTest: true},
{Name: "ocean_and_land", IsTest: true},
-19
View File
@@ -102,25 +102,6 @@ server {
add_header Cache-Control "public, max-age=86400"; # 24 hours
}
# /api/public_lobbies endpoint - Cache for 1 second to handle high request volume
location = /api/public_lobbies {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# Cache configuration
proxy_cache API_CACHE;
proxy_cache_valid 200 1s;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503 http_504;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# /api/env endpoint - Cache for 1 hour
location = /api/env {
proxy_pass http://127.0.0.1:3000;
+750 -686
View File
File diff suppressed because it is too large Load Diff
+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",
+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

+30 -98
View File
@@ -8,6 +8,7 @@
"common": {
"close": "Close",
"copy": "Copy",
"paste": "Paste",
"back": "Back",
"available": "Available",
"preset_max": "Max",
@@ -21,41 +22,34 @@
"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)",
"join_discord": "Discord",
"login_discord": "Login with Discord",
"sign_in": "Sign in",
"discord_avatar_alt": "Discord profile avatar",
"user_avatar_alt": "{username}'s avatar",
"checking_login": "Checking login...",
"logged_in": "Logged in!",
"log_out": "Log out",
"create": "Create Lobby",
"join": "Join Lobby",
"solo": "Solo",
"instructions": "Instructions",
"game_info": "Game info",
"wiki": "Wiki",
"privacy_policy": "Privacy Policy",
"terms_of_service": "Terms of Service",
"copyright": "© OpenFront™ and Contributors",
"reddit": "Reddit",
"play": "Play",
"news": "News",
"store": "Store",
"store_new_badge": "NEW",
"settings": "Settings",
"keys": "Keys",
"stats": "Stats",
"leaderboard": "Leaderboard",
"account": "Account",
"help": "Help",
"menu": "Menu",
"troubleshooting": "Troubleshooting",
"go_to_troubleshooting": "Go to our troubleshooting page",
"pick_pattern": "Pick a pattern!"
"go_to_troubleshooting": "Go to our troubleshooting page"
},
"news": {
"github_link": "on GitHub",
@@ -63,7 +57,6 @@
},
"troubleshooting": {
"title": "Troubleshooting",
"loading": "Loading...",
"environment": "Environment",
"rendering": "Rendering",
"power": "Power",
@@ -112,7 +105,6 @@
"ui_leaderboard_desc": "Shows the top players of the game and their names, % owned land, gold and troops. Using Show All shows all players in the game. If you don't want to see the leaderboard, click Hide.",
"ui_control": "Control panel",
"ui_control_desc": "The control panel contains the following elements:",
"ui_pop": "Pop - The amount of units you have, your max population and the rate at which you gain them.",
"ui_gold": "Gold - The amount of gold you have and the rate at which you gain it.",
"ui_attack_ratio": "Attack ratio - The amount of troops that will be used when you attack. You can adjust the attack ratio using the slider. Having more attacking troops than defending troops will make you lose fewer troops in the attack, while having less will increase the damage dealt to your attacking troops. The effect doesn't go beyond ratios of 2:1.",
"ui_events": "Event panel",
@@ -132,12 +124,10 @@
"radial_title": "Radial menu",
"radial_desc": "Right clicking (or touch on mobile) opens the Radial menu. Right click outside it to close it. From the menu you can:",
"radial_build": "Open the Build menu.",
"radial_attack": "Open the Attack menu.",
"radial_info": "Open the Info menu.",
"radial_boat": "Send a Boat (transport ship) to attack at the selected location. Only available if you have access to water.",
"radial_donate_troops": "Donate troops equivalent to your attack ratio slider percentage to the ally you opened the radial menu on.",
"radial_donate_gold": "Opens the gold donation slider menu so you can quickly send allies gold.",
"radial_close": "Close the menu.",
"info_title": "Info menu",
"info_enemy_desc": "Contains information such as the selected player's name, gold, troops, stopped trading with you, nukes sent to you, and if the player is a traitor. Stopped trading means you won't receive gold from them and they won't sent you gold via trade ships. Manually (if the player clicked \"Stop trading\", which lasts until you both click \"Start trading\") or automatically (if you betrayed your alliance, which lasts until you become allies again or after 5 minutes). Traitor displays Yes for 30 seconds when the player betrayed and attacked a player who was in an alliance with them. The icons below represent the following interactions:",
"info_chat": "Send a quick chat message to the player. Select a Category, a Phrase, and if the phrase contains [P1] select a Player name to replace it with. Hit Send.",
@@ -185,30 +175,28 @@
"icon_request": "Envelope - Alliance request. This player has sent you an alliance request.",
"info_enemy_panel": "Enemy info panel",
"exit_confirmation": "Are you sure you want to exit the game?",
"bomb_direction": "Atom / Hydrogen bomb arc direction"
"bomb_direction": "Atom / Hydrogen bomb arc direction",
"icon_alt_player_leaderboard": "Player Leaderboard Icon",
"icon_alt_team_leaderboard": "Team Leaderboard Icon"
},
"single_modal": {
"title": "Solo",
"random_spawn": "Random spawn",
"allow_alliances": "Allow alliances",
"toggle_achievements": "Toggle achievements",
"sign_in_for_achievements": "Sign in for achievements",
"options_title": "Options",
"bots": "Bots: ",
"bots_disabled": "Disabled",
"nations": "Nations: ",
"disable_nations": "Disable Nations",
"instant_build": "Instant build",
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
"compact_map": "Compact Map",
"crowded": "Crowded",
"max_timer": "Game length (minutes)",
"max_timer_placeholder": "Mins",
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
"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",
@@ -238,15 +226,18 @@
"enter_email_address": "Please enter an email address",
"personal_player_id": "Personal Player ID:"
},
"stats_modal": {
"title": "Stats",
"clan_stats": "Clan Stats",
"leaderboard_modal": {
"title": "Leaderboard",
"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",
@@ -254,7 +245,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",
@@ -351,10 +343,7 @@
"private_lobby": {
"title": "Join Private Lobby",
"enter_id": "Enter Lobby ID",
"player": "Player",
"players": "Players",
"join_lobby": "Join Lobby",
"checking": "Checking lobby...",
"not_found": "Lobby not found. Please check the ID and try again.",
"error": "An error occurred. Please try again or contact support.",
"joined_waiting": "Lobby joined! Waiting for host to start...",
@@ -362,18 +351,22 @@
"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)",
@@ -402,7 +395,6 @@
"bots": "Bots: ",
"bots_disabled": "Disabled",
"player_immunity_duration": "PVP immunity duration (minutes)",
"nations": "Nations: ",
"disable_nations": "Disable Nations",
"max_timer": "Game length (minutes)",
"mins_placeholder": "Mins",
@@ -429,10 +421,8 @@
"teams_Trios": "Trios (teams of 3)",
"teams_Quads": "Quads (teams of 4)",
"teams_Humans Vs Nations": "Humans vs Nations",
"gold_multiplier": "Gold multiplier",
"gold_multiplier_placeholder": "2.0x",
"starting_gold": "Starting gold",
"starting_gold_placeholder": "5000000"
"crowded": "Crowded modifier"
},
"team_colors": {
"red": "Red",
@@ -699,19 +689,15 @@
"exit": "Exit Game",
"keep": "Keep Playing",
"spectate": "Spectate",
"requeue": "Play Again",
"wishlist": "Wishlist on Steam!",
"ofm_winter": "OpenFront Masters Winter Tournament!",
"ofm_winter_description": "Join the competitive tournament and compete against the best players",
"join_tournament": "Join Tournament",
"join_discord": "Join Our Discord Community!",
"discord_description": "Connect with players, discover new features, and win prizes!",
"join_server": "Join Server",
"youtube_tutorial": "Need some help?"
},
"leaderboard": {
"title": "Leaderboard",
"hide": "Hide",
"rank": "Rank",
"player": "Player",
"team": "Team",
"owned": "Owned",
@@ -724,29 +710,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",
@@ -777,6 +740,8 @@
"attack_cancelled_retreat": "Attack cancelled, {troops} soldiers killed during retreat",
"received_gold_from_captured_ship": "Received {gold} gold from ship captured from {name}",
"received_gold_from_trade": "Received {gold} gold from trade with {name}",
"received_gold_from_conquest": "Conquered {name}, received {gold} gold",
"conquered_no_gold": "Conquered {name} (didn't play, no gold awarded)",
"missile_intercepted": "Missile intercepted {unit}",
"mirv_warheads_intercepted": "{count, plural, one {{count} MIRV warhead intercepted} other {{count} MIRV warheads intercepted}}",
"sent_troops_to_player": "Sent {troops} troops to {name}",
@@ -788,15 +753,6 @@
"unit_destroyed": "Your {unit} was destroyed",
"no_boats_available": "No boats available, max {max}"
},
"unit_info_modal": {
"structure_info": "Structure Info",
"unit_type_unknown": "Unknown",
"close": "Close",
"cooldown": "Cooldown",
"type": "Type",
"upgrade": "Upgrade",
"level": "Level"
},
"player_type": {
"player": "Player",
"nation": "Nation",
@@ -809,11 +765,6 @@
"friendly": "Friendly",
"default": "Default"
},
"control_panel": {
"gold": "Gold",
"troops": "Troops",
"attack_ratio": "Attack Ratio"
},
"player_panel": {
"gold": "Gold",
"troops": "Troops",
@@ -823,18 +774,14 @@
"active": "Active",
"stopped": "Stopped",
"alliance_time_remaining": "Alliance Expires In",
"embargo": "Stopped trading with you",
"nuke": "Nukes sent by them to you",
"start_trade": "Start Trading",
"stop_trade": "Stop Trading",
"stop_trade_all": "Stop Trading with All",
"start_trade_all": "Start Trading with All",
"alliances": "Alliances",
"flag": "Flag",
"chat": "Chat",
"target": "Target",
"break_alliance": "Break Alliance",
"alliance": "Alliance",
"send_alliance": "Send Alliance",
"send_troops": "Send Troops",
"send_gold": "Send Gold",
@@ -854,7 +801,6 @@
"send_troops_modal": {
"title_with_name": "Send Troops to {name}",
"available_tooltip": "Your current available troops",
"min_keep": "Min keep",
"slider_tooltip": "{{percent}}% • {{amount}}",
"aria_slider": "Troops slider",
"capacity_note": "Receiver can accept only {{amount}} right now."
@@ -909,10 +855,6 @@
"show_only_owned": "My Skins",
"all_owned": "All skins owned! Check back later for new items.",
"not_logged_in": "Not logged in",
"blocked": {
"login": "You must be logged in to access this skin.",
"purchase": "Purchase this skin to unlock it."
},
"pattern": {
"default": "Default"
},
@@ -924,15 +866,6 @@
"button_title": "Pick a flag!",
"search_flag": "Search..."
},
"spawn_ad": {
"loading": "Loading advertisement..."
},
"auth": {
"login_required": "Login is required to access this website.",
"redirecting": "You are being redirected...",
"not_authorized": "You are not authorized to access this website.",
"contact_admin": "If you believe you are seeing this message in error, please contact the website administrator."
},
"radial_menu": {
"delete_unit_title": "Delete Unit",
"delete_unit_description": "Click to delete the nearest unit"
@@ -991,7 +924,6 @@
"replay": "Replay",
"details": "Details",
"ranking": "Ranking",
"started": "Started",
"map": "Map",
"difficulty": "Difficulty",
"type": "Type"
@@ -999,7 +931,7 @@
"player_stats_tree": {
"public": "Public",
"private": "Private",
"singleplayer": "Solo",
"solo": "Solo",
"mode": "Mode",
"stats_wins": "Wins",
"stats_losses": "Losses",
+50
View File
@@ -0,0 +1,50 @@
{
"map": {
"height": 1500,
"num_land_tiles": 1207315,
"width": 1200
},
"map16x": {
"height": 375,
"num_land_tiles": 70747,
"width": 300
},
"map4x": {
"height": 750,
"num_land_tiles": 295140,
"width": 600
},
"name": "Yenisei",
"nations": [
{
"coordinates": [1050, 535],
"flag": "ru",
"name": "Baikalovsk"
},
{
"coordinates": [1120, 1315],
"flag": "ru",
"name": "Mungui"
},
{
"coordinates": [345, 1190],
"flag": "ru",
"name": "Polykarpovsk"
},
{
"coordinates": [335, 800],
"flag": "ru",
"name": "Central Island"
},
{
"coordinates": [75, 390],
"flag": "ru",
"name": "West Coast"
},
{
"coordinates": [420, 440],
"flag": "ru",
"name": "Northern Island"
}
]
}
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: 12 KiB

+78
View File
@@ -1,7 +1,11 @@
import { z } from "zod";
import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
PlayerProfile,
PlayerProfileSchema,
RankedLeaderboardResponse,
RankedLeaderboardResponseSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
@@ -185,3 +189,77 @@ 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) {
console.warn(
"fetchPlayerLeaderboard: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = RankedLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
// Handle "Page must be between X and Y" error as end of list
if (json?.message?.includes?.("Page must be between")) {
return "reached_limit";
}
console.warn(
"fetchPlayerLeaderboard: Zod validation failed",
parsed.error.toString(),
);
return false;
}
return parsed.data;
} catch (err) {
console.error("fetchPlayerLeaderboard: request failed", err);
return false;
}
}
+35 -32
View File
@@ -5,6 +5,7 @@ import {
GameID,
GameRecord,
GameStartInfo,
LobbyInfoEvent,
PlayerCosmeticRefs,
PlayerRecord,
ServerMessage,
@@ -55,7 +56,6 @@ export interface LobbyConfig {
serverConfig: ServerConfig;
cosmetics: PlayerCosmeticRefs;
playerName: string;
clientID: ClientID;
gameID: GameID;
turnstileToken: string | null;
// GameStartInfo only exists when playing a singleplayer game.
@@ -70,9 +70,10 @@ export function joinLobby(
onPrestart: () => void,
onJoin: () => void,
): (force?: boolean) => boolean {
console.log(
`joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
);
// Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer)
let clientID: ClientID | undefined;
console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`);
const userSettings: UserSettings = new UserSettings();
startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config ?? {});
@@ -81,21 +82,20 @@ export function joinLobby(
let currentGameRunner: ClientGameRunner | null = null;
let hasJoined = false;
const onconnect = () => {
if (hasJoined) {
console.log("rejoining game");
transport.rejoinGame(0);
} else {
hasJoined = true;
console.log(`Joining game lobby ${lobbyConfig.gameID}`);
transport.joinGame();
}
// Always send join - server will detect reconnection via persistentID
console.log(`Joining game lobby ${lobbyConfig.gameID}`);
transport.joinGame();
};
let terrainLoad: Promise<TerrainMapData> | null = null;
const onmessage = (message: ServerMessage) => {
if (message.type === "lobby_info") {
// Server tells us our assigned clientID
clientID = message.myClientID;
eventBus.emit(new LobbyInfoEvent(message.lobby, message.myClientID));
return;
}
if (message.type === "prestart") {
console.log(
`lobby: game prestarting: ${JSON.stringify(message, replacer)}`,
@@ -113,11 +113,14 @@ export function joinLobby(
console.log(
`lobby: game started: ${JSON.stringify(message, replacer, 2)}`,
);
// Server tells us our assigned clientID (also sent on start for late joins)
clientID = message.myClientID;
onJoin();
// For multiplayer games, GameStartInfo is not known until game starts.
lobbyConfig.gameStartInfo = message.gameStartInfo;
createClientGame(
lobbyConfig,
clientID,
eventBus,
transport,
userSettings,
@@ -143,7 +146,7 @@ export function joinLobby(
e.message,
e.stack,
lobbyConfig.gameID,
lobbyConfig.clientID,
clientID,
true,
false,
"error_modal.connection_error",
@@ -164,7 +167,7 @@ export function joinLobby(
message.error,
message.message,
lobbyConfig.gameID,
lobbyConfig.clientID,
clientID,
true,
false,
"error_modal.connection_error",
@@ -191,6 +194,7 @@ export function joinLobby(
async function createClientGame(
lobbyConfig: LobbyConfig,
clientID: ClientID,
eventBus: EventBus,
transport: Transport,
userSettings: UserSettings,
@@ -216,16 +220,13 @@ async function createClientGame(
mapLoader,
);
}
const worker = new WorkerClient(
lobbyConfig.gameStartInfo,
lobbyConfig.clientID,
);
const worker = new WorkerClient(lobbyConfig.gameStartInfo, clientID);
await worker.initialize();
const gameView = new GameView(
worker,
config,
gameMap,
lobbyConfig.clientID,
clientID,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
);
@@ -239,6 +240,7 @@ async function createClientGame(
return new ClientGameRunner(
lobbyConfig,
clientID,
eventBus,
gameRenderer,
new InputHandler(gameRenderer.uiState, canvas, eventBus),
@@ -264,6 +266,7 @@ export class ClientGameRunner {
constructor(
private lobby: LobbyConfig,
private clientID: ClientID,
private eventBus: EventBus,
private renderer: GameRenderer,
private input: InputHandler,
@@ -297,8 +300,8 @@ export class ClientGameRunner {
{
persistentID: getPersistentID(),
username: this.lobby.playerName,
clientID: this.lobby.clientID,
stats: update.allPlayersStats[this.lobby.clientID],
clientID: this.clientID,
stats: update.allPlayersStats[this.clientID],
},
];
@@ -355,7 +358,7 @@ export class ClientGameRunner {
gu.errMsg,
gu.stack ?? "missing",
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
this.clientID,
);
console.error(gu.stack);
this.stop();
@@ -417,7 +420,7 @@ export class ClientGameRunner {
"spawn_failed",
translateText("error_modal.spawn_failed.description"),
this.lobby.gameID,
this.lobby.clientID,
this.clientID,
true,
false,
translateText("error_modal.spawn_failed.title"),
@@ -454,7 +457,7 @@ export class ClientGameRunner {
`desync from server: ${JSON.stringify(message)}`,
"",
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
this.clientID,
true,
false,
"error_modal.desync_notice",
@@ -465,7 +468,7 @@ export class ClientGameRunner {
message.error,
message.message,
this.lobby.gameID,
this.lobby.clientID,
this.clientID,
true,
false,
"error_modal.connection_error",
@@ -549,7 +552,7 @@ export class ClientGameRunner {
return;
}
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
@@ -584,7 +587,7 @@ export class ClientGameRunner {
const tile = this.gameView.ref(cell.x, cell.y);
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
@@ -645,7 +648,7 @@ export class ClientGameRunner {
}
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
@@ -664,7 +667,7 @@ export class ClientGameRunner {
}
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
@@ -761,7 +764,7 @@ function showErrorModal(
error: string,
message: string | undefined,
gameID: GameID,
clientID: ClientID,
clientID: ClientID | undefined,
closable = false,
showDiscord = true,
heading = "error_modal.crashed",
+1 -1
View File
@@ -73,7 +73,7 @@ export class FlagInputModal extends BaseModal {
}}
/>
<span
class="text-xs font-bold text-gray-300 group-hover:text-white text-center leading-tight w-full truncate"
class="text-xs font-bold text-gray-300 group-hover:text-white text-center leading-tight w-full whitespace-normal break-words"
>${country.name}</span
>
</button>
+14 -5
View File
@@ -13,7 +13,6 @@ export class GutterAds extends LitElement {
private rightAdType: string = "standard_iab_rght1";
private leftContainerId: string = "gutter-ad-container-left";
private rightContainerId: string = "gutter-ad-container-right";
private margin: string = "10px";
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
@@ -50,6 +49,16 @@ export class GutterAds extends LitElement {
});
}
public close(): void {
try {
window.ramp.destroyUnits(this.leftAdType);
window.ramp.destroyUnits(this.rightAdType);
console.log("successfully destroyed gutter ads");
} catch (e) {
console.error("error destroying gutter ads", e);
}
}
private loadAds(): void {
console.log("loading ramp ads");
// Ensure the container elements exist before loading ads
@@ -111,8 +120,8 @@ export class GutterAds extends LitElement {
return html`
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed left-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="margin-left: ${this.margin};"
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="left: calc(50% - 10cm - 230px); top: calc(50% + 10px);"
>
<div
id="${this.leftContainerId}"
@@ -122,8 +131,8 @@ export class GutterAds extends LitElement {
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed right-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="margin-right: ${this.margin};"
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="left: calc(50% + 10cm + 70px); top: calc(50% + 10px);"
>
<div
id="${this.rightContainerId}"
+43 -8
View File
@@ -1,7 +1,8 @@
import { TemplateResult, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { EventBus } from "../core/EventBus";
import {
Difficulty,
Duos,
@@ -17,10 +18,12 @@ import {
ClientInfo,
GameConfig,
GameInfo,
LobbyInfoEvent,
TeamCountConfig,
isValidGameID,
} from "../core/Schemas";
import { generateID } from "../core/Util";
import { getPlayToken } from "./Auth";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
@@ -73,6 +76,8 @@ export class HostLobbyModal extends BaseModal {
@state() private lobbyCreatorClientID: string = "";
@state() private nationCount: number = 0;
@property({ attribute: false }) eventBus: EventBus | null = null;
private playersInterval: NodeJS.Timeout | null = null;
// Add a new timer for debouncing bot changes
private botsUpdateTimer: number | null = null;
@@ -80,6 +85,14 @@ export class HostLobbyModal extends BaseModal {
private leaveLobbyOnClose = true;
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
const lobby = event.lobby;
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? "";
if (lobby.clients) {
this.clients = lobby.clients;
}
};
private renderOptionToggle(
labelKey: string,
checked: boolean,
@@ -136,6 +149,21 @@ export class HostLobbyModal extends BaseModal {
}
}
private startLobbyUpdates() {
this.stopLobbyUpdates();
if (!this.eventBus) {
console.warn(
"HostLobbyModal: eventBus not set, cannot subscribe to lobby updates",
);
return;
}
this.eventBus.on(LobbyInfoEvent, this.handleLobbyInfo);
}
private stopLobbyUpdates() {
this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo);
}
render() {
const maxTimerHandlers = this.createToggleHandlers(
() => this.maxTimer,
@@ -635,9 +663,13 @@ export class HostLobbyModal extends BaseModal {
}
protected onOpen(): void {
this.lobbyCreatorClientID = generateID();
this.startLobbyUpdates();
this.lobbyId = generateID();
// Note: clientID will be assigned by server when we join the lobby
// lobbyCreatorClientID stays empty until then
createLobby(this.lobbyCreatorClientID)
// Pass auth token for creator identification (server extracts persistentID from it)
createLobby(this.lobbyId)
.then(async (lobby) => {
this.lobbyId = lobby.gameID;
if (!isValidGameID(this.lobbyId)) {
@@ -652,7 +684,7 @@ export class HostLobbyModal extends BaseModal {
new CustomEvent("join-lobby", {
detail: {
gameID: this.lobbyId,
clientID: this.lobbyCreatorClientID,
source: "host",
} as JoinLobbyEvent,
bubbles: true,
composed: true,
@@ -717,6 +749,7 @@ export class HostLobbyModal extends BaseModal {
protected onClose(): void {
console.log("Closing host lobby modal");
this.stopLobbyUpdates();
if (this.leaveLobbyOnClose) {
this.leaveLobby();
this.updateHistory("/"); // Reset URL to base
@@ -1080,18 +1113,20 @@ export class HostLobbyModal extends BaseModal {
}
}
async function createLobby(creatorClientID: string): Promise<GameInfo> {
async function createLobby(gameID: string): Promise<GameInfo> {
const config = await getServerConfigFromClient();
// Send JWT token for creator identification - server extracts persistentID from it
// persistentID should never be exposed to other clients
const token = await getPlayToken();
try {
const id = generateID();
const response = await fetch(
`/${config.workerPath(id)}/api/create_game/${id}?creatorClientID=${encodeURIComponent(creatorClientID)}`,
`/${config.workerPath(gameID)}/api/create_game/${gameID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
// body: JSON.stringify(data), // Include this if you need to send data
},
);
+840
View File
@@ -0,0 +1,840 @@
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 { 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 isPrivateLobby(): boolean {
return this.gameConfig?.gameType === GameType.Private;
}
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
const lobby = event.lobby;
this.currentClientID = event.myClientID;
// Only stop showing spinner when we have player info
if (this.isConnecting && lobby.clients) {
this.isConnecting = false;
}
this.updateFromLobby({
...lobby,
startsAt: lobby.startsAt ?? 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.players?.length ?? 0;
const hostClientID = this.isPrivateLobby()
? (this.lobbyCreatorClientID ?? "")
: "";
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.isPrivateLobby()
? 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=${hostClientID}
.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.isPrivateLobby()
? 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 = "", isPublic: boolean = false) {
super.open();
if (lobbyId) {
this.startTrackingLobby(lobbyId);
// If opened with lobbyInfo (public lobby case), auto-join the lobby
if (isPublic) {
this.joinPublicLobby(lobbyId);
} 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) {
// Dispatch join-lobby event to actually connect to the lobby
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: lobbyId,
source: "public",
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) {
this.currentLobbyId = lobbyId;
// clientID will be assigned by server via lobby_info message
this.currentClientID = "";
this.gameConfig = null;
this.players = [];
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.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) {
this.players = lobby.clients ?? [];
this.lobbyStartAt = lobby.startsAt ?? null;
this.syncCountdownTimer();
if (lobby.gameConfig) {
const mapChanged = this.gameConfig?.gameMap !== lobby.gameConfig.gameMap;
this.gameConfig = lobby.gameConfig;
if (mapChanged) {
this.loadNationCount();
}
}
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? null;
}
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,
source: "private",
} 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,
source: "private",
} 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",
+135
View File
@@ -0,0 +1,135 @@
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 overflow-hidden ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
${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>
`;
}
}
+20 -51
View File
@@ -1,6 +1,5 @@
import { GameInfo } from "../core/Schemas";
type LobbyUpdateHandler = (lobbies: GameInfo[]) => void;
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { PublicGames, PublicGamesSchema } from "../core/Schemas";
interface LobbySocketOptions {
reconnectDelay?: number;
@@ -8,36 +7,39 @@ interface LobbySocketOptions {
pollIntervalMs?: number;
}
function getRandomWorkerPath(numWorkers: number): string {
const workerIndex = Math.floor(Math.random() * numWorkers);
return `/w${workerIndex}`;
}
export class PublicLobbySocket {
private ws: WebSocket | null = null;
private wsReconnectTimeout: number | null = null;
private fallbackPollInterval: number | null = null;
private wsConnectionAttempts = 0;
private wsAttemptCounted = false;
private workerPath: string = "";
private readonly reconnectDelay: number;
private readonly maxWsAttempts: number;
private readonly pollIntervalMs: number;
private readonly onLobbiesUpdate: LobbyUpdateHandler;
constructor(
onLobbiesUpdate: LobbyUpdateHandler,
private onLobbiesUpdate: (data: PublicGames) => void,
options?: LobbySocketOptions,
) {
this.onLobbiesUpdate = onLobbiesUpdate;
this.reconnectDelay = options?.reconnectDelay ?? 3000;
this.maxWsAttempts = options?.maxWsAttempts ?? 3;
this.pollIntervalMs = options?.pollIntervalMs ?? 1000;
}
start() {
async start() {
this.wsConnectionAttempts = 0;
// Get config to determine number of workers, then pick a random one
const config = await getServerConfigFromClient();
this.workerPath = getRandomWorkerPath(config.numWorkers());
this.connectWebSocket();
}
stop() {
this.disconnectWebSocket();
this.stopFallbackPolling();
}
private connectWebSocket() {
@@ -49,7 +51,7 @@ export class PublicLobbySocket {
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/lobbies`;
const wsUrl = `${protocol}//${window.location.host}${this.workerPath}/lobbies`;
this.ws = new WebSocket(wsUrl);
this.wsAttemptCounted = false;
@@ -70,15 +72,14 @@ export class PublicLobbySocket {
clearTimeout(this.wsReconnectTimeout);
this.wsReconnectTimeout = null;
}
this.stopFallbackPolling();
}
private handleMessage(event: MessageEvent) {
try {
const message = JSON.parse(event.data as string);
if (message.type === "lobbies_update") {
this.onLobbiesUpdate(message.data?.lobbies ?? []);
}
const publicGames = PublicGamesSchema.parse(
JSON.parse(event.data as string),
);
this.onLobbiesUpdate(publicGames);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
@@ -101,10 +102,7 @@ export class PublicLobbySocket {
this.wsConnectionAttempts++;
}
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
console.log(
"Max WebSocket attempts reached, falling back to HTTP polling",
);
this.startFallbackPolling();
console.error("Max WebSocket attempts reached");
} else {
this.scheduleReconnect();
}
@@ -121,7 +119,7 @@ export class PublicLobbySocket {
this.wsConnectionAttempts++;
}
if (this.wsConnectionAttempts >= this.maxWsAttempts) {
this.startFallbackPolling();
alert("error connecting to game service");
} else {
this.scheduleReconnect();
}
@@ -145,33 +143,4 @@ export class PublicLobbySocket {
this.wsReconnectTimeout = null;
}
}
private startFallbackPolling() {
if (this.fallbackPollInterval !== null) return;
console.log("Starting HTTP fallback polling");
this.fetchLobbiesHTTP();
this.fallbackPollInterval = window.setInterval(() => {
this.fetchLobbiesHTTP();
}, this.pollIntervalMs);
}
private stopFallbackPolling() {
if (this.fallbackPollInterval !== null) {
clearInterval(this.fallbackPollInterval);
this.fallbackPollInterval = null;
}
}
private async fetchLobbiesHTTP() {
try {
const response = await fetch(`/api/public_lobbies`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.onLobbiesUpdate(data.lobbies as GameInfo[]);
} catch (error) {
console.error("Error fetching lobbies via HTTP:", error);
}
}
}
+25 -9
View File
@@ -2,13 +2,14 @@ import { z } from "zod";
import { EventBus } from "../core/EventBus";
import {
AllPlayersStats,
ClientID,
ClientMessage,
ClientSendWinnerMessage,
Intent,
PartialGameRecordSchema,
PlayerRecord,
ServerMessage,
ServerStartGameMessage,
StampedIntent,
Turn,
} from "../core/Schemas";
import {
@@ -34,12 +35,13 @@ export class LocalServer {
private turns: Turn[] = [];
private intents: Intent[] = [];
private intents: StampedIntent[] = [];
private startedAt: number;
private paused = false;
private replaySpeedMultiplier = defaultReplaySpeedMultiplier;
private clientID: ClientID | undefined;
private winner: ClientSendWinnerMessage | null = null;
private allPlayersStats: AllPlayersStats = {};
@@ -102,34 +104,48 @@ export class LocalServer {
if (this.lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
this.clientID = this.lobbyConfig.gameStartInfo.players[0]?.clientID;
if (!this.clientID) {
throw new Error("missing clientID");
}
this.clientMessage({
type: "start",
gameStartInfo: this.lobbyConfig.gameStartInfo,
turns: [],
lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt,
myClientID: this.clientID,
} satisfies ServerStartGameMessage);
}
onMessage(clientMsg: ClientMessage) {
if (clientMsg.type === "rejoin") {
if (!this.clientID) {
throw new Error("missing clientID");
}
this.clientMessage({
type: "start",
gameStartInfo: this.lobbyConfig.gameStartInfo!,
turns: this.turns,
lobbyCreatedAt: this.lobbyConfig.gameStartInfo!.lobbyCreatedAt,
myClientID: this.clientID,
} satisfies ServerStartGameMessage);
}
if (clientMsg.type === "intent") {
if (clientMsg.intent.type === "toggle_pause") {
if (clientMsg.intent.paused) {
// Server stamps clientID - client doesn't send it
const stampedIntent = {
...clientMsg.intent,
clientID: this.clientID!,
};
if (stampedIntent.type === "toggle_pause") {
if (stampedIntent.paused) {
// Pausing: add intent and end turn before pause takes effect
this.intents.push(clientMsg.intent);
this.intents.push(stampedIntent);
this.endTurn();
this.paused = true;
} else {
// Unpausing: clear pause flag before adding intent so next turn can execute
this.paused = false;
this.intents.push(clientMsg.intent);
this.intents.push(stampedIntent);
this.endTurn();
}
return;
@@ -139,7 +155,7 @@ export class LocalServer {
return;
}
this.intents.push(clientMsg.intent);
this.intents.push(stampedIntent);
}
if (clientMsg.type === "hash") {
if (!this.lobbyConfig.gameRecord) {
@@ -224,8 +240,8 @@ export class LocalServer {
{
persistentID: getPersistentID(),
username: this.lobbyConfig.playerName,
clientID: this.lobbyConfig.clientID,
stats: this.allPlayersStats[this.lobbyConfig.clientID],
clientID: this.clientID!,
stats: this.allPlayersStats[this.clientID!],
cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics,
clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined,
},
+66 -19
View File
@@ -22,19 +22,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 {
@@ -203,19 +203,20 @@ 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;
}
}
export interface JoinLobbyEvent {
clientID: string;
// Multiplayer games only have gameID, gameConfig is not known until game starts.
gameID: string;
// GameConfig only exists when playing a singleplayer game.
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
}
class Client {
@@ -228,7 +229,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;
@@ -302,6 +303,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(
@@ -498,13 +503,14 @@ class Client {
) as HostPrivateLobbyModal;
if (!this.hostModal || !(this.hostModal instanceof HostPrivateLobbyModal)) {
console.warn("Host private lobby modal element not found");
} else {
this.hostModal.eventBus = this.eventBus;
}
const hostLobbyButton = document.getElementById("host-lobby-button");
if (hostLobbyButton === null) throw new Error("Missing host-lobby-button");
hostLobbyButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
window.showPage?.("page-host-lobby");
this.publicLobby.leaveLobby();
} else {
window.dispatchEvent(
new CustomEvent("show-message", {
@@ -519,10 +525,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",
@@ -531,7 +539,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", {
@@ -631,7 +639,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"),
]);
@@ -644,7 +652,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;
@@ -733,7 +741,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;
@@ -748,6 +756,32 @@ class Client {
if (decodedHash.startsWith("#refresh")) {
window.location.href = "/";
}
// Handle requeue parameter for ranked matchmaking
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.has("requeue")) {
// Remove only the requeue parameter, preserving other params and hash
searchParams.delete("requeue");
const newUrl =
window.location.pathname +
(searchParams.toString() ? "?" + searchParams.toString() : "") +
window.location.hash;
history.replaceState(null, "", newUrl);
// Wait for matchmaking button to be defined, then trigger its click handler
// This goes through username validation instead of bypassing it
customElements.whenDefined("matchmaking-button").then(() => {
const matchmakingButton = document.querySelector(
"matchmaking-button button",
) as HTMLButtonElement | null;
if (matchmakingButton) {
matchmakingButton.click();
} else {
console.warn(
"Requeue requested, but matchmaking button not found in DOM.",
);
}
});
}
}
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
@@ -759,7 +793,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.source !== "public") {
this.updateJoinUrlForShare(lobby.gameID, config);
}
const pattern = this.userSettings.getSelectedPatternName(
await fetchCosmetics(),
@@ -782,7 +819,6 @@ class Client {
turnstileToken: await this.getTurnstileToken(lobby),
playerName:
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
clientID: lobby.clientID,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
},
@@ -796,10 +832,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",
@@ -810,10 +846,11 @@ class Client {
"news-modal",
"flag-input-modal",
"account-button",
"stats-button",
"leaderboard-button",
"token-login",
"matchmaking-modal",
"lang-selector",
"gutter-ads",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
@@ -884,6 +921,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, true);
}
private async handleLeaveLobby(/* event: CustomEvent */) {
if (this.gameStop === null) {
return;
@@ -902,7 +950,6 @@ class Client {
document.body.classList.remove("in-game");
crazyGamesSDK.gameplayStop();
this.publicLobby.leaveLobby();
}
private handleKickPlayer(event: CustomEvent) {
+2 -3
View File
@@ -2,7 +2,6 @@ import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { generateID } from "../core/Util";
import { getUserMe, hasLinkedAccount } from "./Api";
import { getPlayToken } from "./Auth";
import { BaseModal } from "./components/BaseModal";
@@ -231,7 +230,7 @@ export class MatchmakingModal extends BaseModal {
new CustomEvent("join-lobby", {
detail: {
gameID: this.gameID,
clientID: generateID(),
source: "matchmaking",
} as JoinLobbyEvent,
bubbles: true,
composed: true,
@@ -320,7 +319,7 @@ export class MatchmakingButton extends LitElement {
window.showPage?.("page-account");
}
private open() {
public open() {
this.matchmakingModal?.open();
}
+65 -263
View File
@@ -1,35 +1,31 @@
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 { GameID, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { GameMapType } from "../core/game/Game";
import { GameID, PublicGameInfo, PublicGames } from "../core/Schemas";
import { PublicLobbySocket } from "./LobbySocket";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
getGameModeLabel,
getModifierLabels,
normaliseMapKey,
renderDuration,
translateText,
} from "./Utils";
export interface ShowPublicLobbyModalEvent {
lobby: PublicGameInfo;
}
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private lobbies: GameInfo[] = [];
@state() private publicGames: PublicGames | null = null;
@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),
private serverTimeOffset = 0;
private lobbySocket = new PublicLobbySocket((data) =>
this.handleLobbiesUpdate(data),
);
createRenderRoot() {
@@ -44,15 +40,20 @@ export class PublicLobby extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
this.lobbySocket.stop();
this.stopJoiningAnimation();
}
private handleLobbiesUpdate(lobbies: GameInfo[]) {
this.lobbies = lobbies;
this.lobbies.forEach((l) => {
private handleLobbiesUpdate(publicGames: PublicGames) {
this.publicGames = publicGames;
// Calculate offset between server time and client time
if (this.publicGames) {
this.serverTimeOffset = this.publicGames.serverTime - Date.now();
}
this.publicGames.games.forEach((l) => {
if (!this.lobbyIDToStart.has(l.gameID)) {
const msUntilStart = l.msUntilStart ?? 0;
this.lobbyIDToStart.set(l.gameID, msUntilStart + Date.now());
// Convert server's startsAt to client time by subtracting offset
const startsAt = l.startsAt ?? Date.now();
this.lobbyIDToStart.set(l.gameID, startsAt - this.serverTimeOffset);
}
if (l.gameConfig && !this.mapImages.has(l.gameID)) {
@@ -74,9 +75,9 @@ export class PublicLobby extends LitElement {
}
render() {
if (this.lobbies.length === 0) return html``;
if (!this.publicGames) return html``;
const lobby = this.lobbies[0];
const lobby = this.publicGames.games[0];
if (!lobby?.gameConfig) return html``;
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
@@ -84,52 +85,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 +114,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 +140,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 +165,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 +193,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 +205,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", {
private lobbyClicked(lobby: PublicGameInfo) {
// 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,
}),
);
}
}
+93 -40
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() {
@@ -859,7 +912,6 @@ export class SinglePlayerModal extends BaseModal {
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
clientID: clientID,
gameID: gameID,
gameStartInfo: {
gameID: gameID,
@@ -914,6 +966,7 @@ export class SinglePlayerModal extends BaseModal {
},
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
},
source: "singleplayer",
} satisfies JoinLobbyEvent,
bubbles: true,
composed: true,
-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>
`;
}
}
+2 -25
View File
@@ -402,7 +402,7 @@ export class Transport {
this.sendMsg({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
// Note: clientID is not sent - server assigns it based on persistentID
username: this.lobbyConfig.playerName,
cosmetics: this.lobbyConfig.cosmetics,
turnstileToken: this.lobbyConfig.turnstileToken,
@@ -414,7 +414,7 @@ export class Transport {
this.sendMsg({
type: "rejoin",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
// Note: clientID is not sent - server looks it up from persistentID in token
lastTurn: lastTurn,
token: await getPlayToken(),
} satisfies ClientRejoinMessage);
@@ -443,7 +443,6 @@ export class Transport {
private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) {
this.sendIntent({
type: "allianceRequest",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
});
}
@@ -451,7 +450,6 @@ export class Transport {
private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) {
this.sendIntent({
type: "allianceRequestReply",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
accept: event.accepted,
});
@@ -460,7 +458,6 @@ export class Transport {
private onBreakAllianceRequestUIEvent(event: SendBreakAllianceIntentEvent) {
this.sendIntent({
type: "breakAlliance",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
});
}
@@ -470,7 +467,6 @@ export class Transport {
) {
this.sendIntent({
type: "allianceExtension",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
});
}
@@ -478,7 +474,6 @@ export class Transport {
private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) {
this.sendIntent({
type: "spawn",
clientID: this.lobbyConfig.clientID,
tile: event.tile,
});
}
@@ -486,7 +481,6 @@ export class Transport {
private onSendAttackIntent(event: SendAttackIntentEvent) {
this.sendIntent({
type: "attack",
clientID: this.lobbyConfig.clientID,
targetID: event.targetID,
troops: event.troops,
});
@@ -495,7 +489,6 @@ export class Transport {
private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) {
this.sendIntent({
type: "boat",
clientID: this.lobbyConfig.clientID,
troops: event.troops,
dst: event.dst,
});
@@ -505,7 +498,6 @@ export class Transport {
this.sendIntent({
type: "upgrade_structure",
unit: event.unitType,
clientID: this.lobbyConfig.clientID,
unitId: event.unitId,
});
}
@@ -513,7 +505,6 @@ export class Transport {
private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) {
this.sendIntent({
type: "targetPlayer",
clientID: this.lobbyConfig.clientID,
target: event.targetID,
});
}
@@ -521,7 +512,6 @@ export class Transport {
private onSendEmojiIntent(event: SendEmojiIntentEvent) {
this.sendIntent({
type: "emoji",
clientID: this.lobbyConfig.clientID,
recipient:
event.recipient === AllPlayers ? AllPlayers : event.recipient.id(),
emoji: event.emoji,
@@ -531,7 +521,6 @@ export class Transport {
private onSendDonateGoldIntent(event: SendDonateGoldIntentEvent) {
this.sendIntent({
type: "donate_gold",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
gold: event.gold ? Number(event.gold) : null,
});
@@ -540,7 +529,6 @@ export class Transport {
private onSendDonateTroopIntent(event: SendDonateTroopsIntentEvent) {
this.sendIntent({
type: "donate_troops",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
troops: event.troops,
});
@@ -549,7 +537,6 @@ export class Transport {
private onSendQuickChatIntent(event: SendQuickChatEvent) {
this.sendIntent({
type: "quick_chat",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
quickChatKey: event.quickChatKey,
target: event.target,
@@ -559,7 +546,6 @@ export class Transport {
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
this.sendIntent({
type: "embargo",
clientID: this.lobbyConfig.clientID,
targetID: event.target.id(),
action: event.action,
});
@@ -568,7 +554,6 @@ export class Transport {
private onSendEmbargoAllIntent(event: SendEmbargoAllIntentEvent) {
this.sendIntent({
type: "embargo_all",
clientID: this.lobbyConfig.clientID,
action: event.action,
});
}
@@ -576,7 +561,6 @@ export class Transport {
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
this.sendIntent({
type: "build_unit",
clientID: this.lobbyConfig.clientID,
unit: event.unit,
tile: event.tile,
rocketDirectionUp: event.rocketDirectionUp,
@@ -586,7 +570,6 @@ export class Transport {
private onPauseGameIntent(event: PauseGameIntentEvent) {
this.sendIntent({
type: "toggle_pause",
clientID: this.lobbyConfig.clientID,
paused: event.paused,
});
}
@@ -626,7 +609,6 @@ export class Transport {
private onCancelAttackIntentEvent(event: CancelAttackIntentEvent) {
this.sendIntent({
type: "cancel_attack",
clientID: this.lobbyConfig.clientID,
attackID: event.attackID,
});
}
@@ -634,7 +616,6 @@ export class Transport {
private onCancelBoatIntentEvent(event: CancelBoatIntentEvent) {
this.sendIntent({
type: "cancel_boat",
clientID: this.lobbyConfig.clientID,
unitID: event.unitID,
});
}
@@ -642,7 +623,6 @@ export class Transport {
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
this.sendIntent({
type: "move_warship",
clientID: this.lobbyConfig.clientID,
unitId: event.unitId,
tile: event.tile,
});
@@ -651,7 +631,6 @@ export class Transport {
private onSendDeleteUnitIntent(event: SendDeleteUnitIntentEvent) {
this.sendIntent({
type: "delete_unit",
clientID: this.lobbyConfig.clientID,
unitId: event.unitId,
});
}
@@ -659,7 +638,6 @@ export class Transport {
private onSendKickPlayerIntent(event: SendKickPlayerIntentEvent) {
this.sendIntent({
type: "kick_player",
clientID: this.lobbyConfig.clientID,
target: event.target,
});
}
@@ -667,7 +645,6 @@ export class Transport {
private onSendUpdateGameConfigIntent(event: SendUpdateGameConfigIntentEvent) {
this.sendIntent({
type: "update_game_config",
clientID: this.lobbyConfig.clientID,
config: event.config,
});
}
+2 -2
View File
@@ -147,8 +147,8 @@ export class UsernameInput extends LitElement {
}
private validateAndStore() {
// Prevent empty username even if clan tag is present
if (!this.baseUsername.trim()) {
// Validate base username meets minimum length (clan tag doesn't count)
if (this.baseUsername.trim().length < MIN_USERNAME_LENGTH) {
this._isValid = false;
this.validationError = translateText("username.too_short", {
min: MIN_USERNAME_LENGTH,
+150 -1
View File
@@ -1,9 +1,158 @@
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 (e.g. "5 teams of 20")
const teamCount =
typeof playerTeams === "number"
? playerTeams
: getTeamCount(playerTeams, maxPlayers ?? 0);
const teamSize =
teamCount > 0 ? Math.floor((maxPlayers ?? 0) / teamCount) : 0;
// If the computed team size matches a named format, use that label instead
const namedTeamType =
teamSize === 2
? Duos
: teamSize === 3
? Trios
: teamSize === 4
? Quads
: null;
if (namedTeamType) {
const teamKey = `public_lobby.teams_${namedTeamType}`;
const translated = translateText(teamKey, { team_count: teamCount });
if (translated !== teamKey) {
return translated;
}
}
const teamsLabel = translateText("public_lobby.teams", { num: teamCount });
if (teamSize > 0) {
return `${teamsLabel} ${translateText("public_lobby.players_per_team", { num: teamSize })}`;
}
return teamsLabel;
}
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);
+2 -2
View File
@@ -167,8 +167,8 @@ 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"
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<div class="relative">
<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
@@ -13,7 +13,7 @@ export class OButton extends LitElement {
@property({ type: Boolean }) disable = false;
@property({ type: Boolean }) fill = false;
private static readonly BASE_CLASS =
"bg-blue-600 hover:bg-blue-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg";
"bg-blue-600 hover:bg-blue-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg whitespace-normal break-words leading-tight overflow-hidden relative";
createRenderRoot() {
return this;
@@ -39,9 +39,11 @@ export class OButton extends LitElement {
class=${classMap(this.getButtonClasses())}
?disabled=${this.disable}
>
${this.translationKey === ""
? this.title
: translateText(this.translationKey)}
<span class="block min-w-0">
${this.translationKey === ""
? this.title
: translateText(this.translationKey)}
</span>
</button>
`;
}
@@ -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>`
+3 -3
View File
@@ -34,7 +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/SpawnVideoAd";
import { SpawnVideoAd } from "./layers/SpawnVideoReward";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
@@ -265,11 +265,11 @@ export function createRenderer(
const layers: Layer[] = [
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game, eventBus, transformHandler),
new RailroadLayer(game, eventBus, transformHandler, uiState),
structureLayer,
samRadiusLayer,
new UnitLayer(game, eventBus, transformHandler),
new FxLayer(game),
new FxLayer(game, eventBus, transformHandler),
new UILayer(game, eventBus, transformHandler),
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
+1
View File
@@ -3,5 +3,6 @@ import { UnitType } from "../../core/game/Game";
export interface UIState {
attackRatio: number;
ghostStructure: UnitType | null;
overlappingRailroads: number[];
rocketDirectionUp: boolean;
}
+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-tr-xl min-[1200px]: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>
`;
}
+8 -4
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,10 +1035,12 @@ 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">
<div
class="w-full p-2 lg:p-3 bg-gray-800/70 min-[1200px]:rounded-t-lg lg:rounded-tl-lg"
>
<div class="flex justify-between items-center">
<div class="flex gap-4">
${this.renderToggleButton(
@@ -1079,7 +1083,7 @@ export class EventsDisplay extends LitElement implements Layer {
<!-- Content Area -->
<div
class="bg-gray-800/70 max-h-[30vh] overflow-y-auto w-full h-full sm:rounded-b-lg events-container"
class="bg-gray-800/70 max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
>
<div>
<table
+83 -47
View File
@@ -1,10 +1,8 @@
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import {
ConquestUpdate,
GameUpdateType,
RailroadUpdate,
} from "../../../core/game/GameUpdates";
import { TileRef } from "../../../core/game/GameMap";
import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import SoundManager, { SoundEffect } from "../../sound/SoundManager";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
@@ -13,20 +11,27 @@ import { Fx, FxType } from "../fx/Fx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SpriteFx } from "../fx/SpriteFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { RailTileChangedEvent } from "./RailroadLayer";
export class FxLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private lastRefresh: number = 0;
private lastRefreshMs: number = 0;
private refreshRate: number = 10;
private theme: Theme;
private animatedSpriteLoader: AnimatedSpriteLoader =
new AnimatedSpriteLoader();
private allFx: Fx[] = [];
private hasBufferedFrame = false;
constructor(private game: GameView) {
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
) {
this.theme = this.game.config().theme();
}
@@ -45,12 +50,6 @@ export class FxLayer implements Layer {
if (unitView === undefined) return;
this.onUnitEvent(unitView);
});
this.game
.updatesSinceLastTick()
?.[GameUpdateType.RailroadEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadEvent(update);
});
this.game
.updatesSinceLastTick()
?.[GameUpdateType.ConquestEvent]?.forEach((update) => {
@@ -124,22 +123,19 @@ export class FxLayer implements Layer {
}
}
onRailroadEvent(railroad: RailroadUpdate) {
const railTiles = railroad.railTiles;
for (const rail of railTiles) {
// No need for pseudorandom, this is fx
const chanceFx = Math.floor(Math.random() * 3);
if (chanceFx === 0) {
const x = this.game.x(rail.tile);
const y = this.game.y(rail.tile);
const animation = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.Dust,
);
this.allFx.push(animation);
}
onRailroadEvent(tile: TileRef) {
// No need for pseudorandom, this is fx
const chanceFx = Math.floor(Math.random() * 3);
if (chanceFx === 0) {
const x = this.game.x(tile);
const y = this.game.y(tile);
const animation = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.Dust,
);
this.allFx.push(animation);
}
}
@@ -235,6 +231,10 @@ export class FxLayer implements Layer {
async init() {
this.redraw();
this.eventBus.on(RailTileChangedEvent, (e) => {
this.onRailroadEvent(e.tile);
});
try {
this.animatedSpriteLoader.loadAllAnimatedSpriteImages();
console.log("FX sprites loaded successfully");
@@ -254,28 +254,64 @@ export class FxLayer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
const now = Date.now();
if (this.game.config().userSettings()?.fxLayer()) {
if (now > this.lastRefresh + this.refreshRate) {
const delta = now - this.lastRefresh;
this.renderAllFx(context, delta);
this.lastRefresh = now;
const nowMs = performance.now();
const hasFx = this.allFx.length > 0;
if (!this.game.config().userSettings()?.fxLayer() || !hasFx) {
if (this.hasBufferedFrame) {
// Clear stale pixels once when fx ends/disabled so re-enabling doesn't
// flash old frames.
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.hasBufferedFrame = false;
}
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
this.lastRefreshMs = nowMs;
return;
}
const needsRefresh =
!this.hasBufferedFrame || nowMs > this.lastRefreshMs + this.refreshRate;
if (needsRefresh) {
const delta = this.hasBufferedFrame ? nowMs - this.lastRefreshMs : 0;
this.renderAllFx(delta);
this.lastRefreshMs = nowMs;
this.hasBufferedFrame = true;
}
this.drawVisibleFx(context);
}
renderAllFx(context: CanvasRenderingContext2D, delta: number) {
if (this.allFx.length > 0) {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.renderContextFx(delta);
}
private drawVisibleFx(context: CanvasRenderingContext2D) {
const mapW = this.game.width();
const mapH = this.game.height();
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
const pad = 2;
const left = Math.max(0, Math.floor(topLeft.x - pad));
const top = Math.max(0, Math.floor(topLeft.y - pad));
const right = Math.min(mapW, Math.ceil(bottomRight.x + pad));
const bottom = Math.min(mapH, Math.ceil(bottomRight.y + pad));
const width = Math.max(0, right - left);
const height = Math.max(0, bottom - top);
if (width === 0 || height === 0) return;
context.drawImage(
this.canvas,
left,
top,
width,
height,
-mapW / 2 + left,
-mapH / 2 + top,
width,
height,
);
}
private renderAllFx(delta: number) {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.renderContextFx(delta);
}
renderContextFx(duration: number) {
+61 -34
View File
@@ -16,8 +16,11 @@ export class GameLeftSidebar extends LitElement implements Layer {
private isLeaderboardShow = false;
@state()
private isTeamLeaderboardShow = false;
@state()
private isVisible = false;
@state()
private isPlayerTeamLabelVisible = false;
@state()
private playerTeam: string | null = null;
private playerColor: Colord = new Colord("#FFFFFF");
@@ -59,7 +62,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
this.requestUpdate();
}
if (!this.game.inSpawnPhase()) {
if (!this.game.inSpawnPhase() && this.isPlayerTeamLabelVisible) {
this.isPlayerTeamLabelVisible = false;
this.requestUpdate();
}
@@ -91,10 +94,65 @@ export class GameLeftSidebar extends LitElement implements Layer {
this.isVisible ? "translate-x-0" : "hidden"
}`}
>
<div class="flex items-center gap-4 xl:gap-6 text-white">
<div
class="cursor-pointer p-0.5 bg-gray-700/50 hover:bg-gray-600 border rounded-md border-slate-500 transition-colors"
@click=${this.toggleLeaderboard}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.code === "Space") {
e.preventDefault();
this.toggleLeaderboard();
}
}}
>
<img
src=${this.isLeaderboardShow
? leaderboardSolidIcon
: leaderboardRegularIcon}
alt=${translateText("help_modal.icon_alt_player_leaderboard") ||
"Player Leaderboard Icon"}
width="20"
height="20"
/>
</div>
${this.isTeamGame
? html`
<div
class="cursor-pointer p-0.5 bg-gray-700/50 hover:bg-gray-600 border rounded-md border-slate-500 transition-colors"
@click=${this.toggleTeamLeaderboard}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (
e.key === "Enter" ||
e.key === " " ||
e.code === "Space"
) {
e.preventDefault();
this.toggleTeamLeaderboard();
}
}}
>
<img
src=${this.isTeamLeaderboardShow
? teamSolidIcon
: teamRegularIcon}
alt=${translateText(
"help_modal.icon_alt_team_leaderboard",
) || "Team Leaderboard Icon"}
width="20"
height="20"
/>
</div>
`
: null}
</div>
${this.isPlayerTeamLabelVisible
? html`
<div
class="flex items-center w-full h-8 lg:h-10 text-white py-1 lg:p-2"
class="flex items-center w-full text-white"
@contextmenu=${(e: Event) => e.preventDefault()}
>
${translateText("help_modal.ui_your_team")}
@@ -108,39 +166,8 @@ export class GameLeftSidebar extends LitElement implements Layer {
`
: null}
<div
class=${`flex items-center gap-2 text-white ${
this.isLeaderboardShow || this.isTeamLeaderboardShow ? "mb-2" : ""
}`}
class=${`block lg:flex flex-wrap ${this.isLeaderboardShow && this.isTeamLeaderboardShow ? "gap-2" : ""}`}
>
<div class="cursor-pointer" @click=${this.toggleLeaderboard}>
<img
src=${this.isLeaderboardShow
? leaderboardSolidIcon
: leaderboardRegularIcon}
alt="treeIcon"
width="20"
height="20"
/>
</div>
${this.isTeamGame
? html`
<div
class="cursor-pointer"
@click=${this.toggleTeamLeaderboard}
>
<img
src=${this.isTeamLeaderboardShow
? teamSolidIcon
: teamRegularIcon}
alt="treeIcon"
width="20"
height="20"
/>
</div>
`
: null}
</div>
<div class="block lg:flex flex-wrap gap-2">
<leader-board .visible=${this.isLeaderboardShow}></leader-board>
<team-stats
class="flex-1"
+1 -1
View File
@@ -126,7 +126,7 @@ export class HeadsUpMessage extends LitElement implements Layer {
${this.isVisible
? html`
<div
class="fixed top-[10%] left-1/2 -translate-x-1/2 z-[11000]
class="fixed top-[15%] left-1/2 -translate-x-1/2 z-[11000]
inline-flex items-center justify-center h-8 lg:h-10
w-fit max-w-[90vw]
bg-gray-900/60 rounded-md lg:rounded-lg
+9 -2
View File
@@ -3,7 +3,7 @@ import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes
const AD_SHOW_TICKS = 10 * 60 * 10; // 2 minutes
const HEADER_AD_TYPE = "standard_iab_head1";
const HEADER_AD_CONTAINER_ID = "header-ad-container";
const TWO_XL_BREAKPOINT = 1536;
@@ -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 {
@@ -71,6 +72,12 @@ export class InGameHeaderAd extends LitElement implements Layer {
private hideHeaderAd(): void {
this.shouldShow = false;
this.adLoaded = false;
try {
window.ramp.destroyUnits(HEADER_AD_TYPE);
console.log("successfully destroyed in game header ad");
} catch (e) {
console.error("error destroying in game header ad", e);
}
this.requestUpdate();
}
+4 -2
View File
@@ -177,7 +177,7 @@ export class Leaderboard extends LitElement implements Layer {
}
return html`
<div
class="max-h-[35vh] overflow-y-auto text-white text-xs md:text-xs lg:text-sm md:max-h-[50vh] ${this
class="max-h-[35vh] overflow-y-auto text-white text-xs md:text-xs lg:text-sm md:max-h-[50vh] mt-2 ${this
.visible
? ""
: "hidden"}"
@@ -265,7 +265,9 @@ export class Leaderboard extends LitElement implements Layer {
</div>
<button
class="mt-1 px-1.5 py-0.5 md:px-2 md:py-0.5 text-xs md:text-xs lg:text-sm border border-white/20 hover:bg-white/10 text-white mx-auto block"
class="mt-1 px-1.5 pb-0.5 md:px-2 text-xs md:text-xs lg:text-sm
border rounded-md border-slate-500 transition-colors
text-white mx-auto block hover:bg-white/10"
@click=${() => {
this.showTopFive = !this.showTopFive;
this.updateLeaderboard();
+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 lg:gap-1"
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-4 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>
@@ -618,6 +618,7 @@ export const rootMenuElement: MenuElement = {
color: COLORS.info,
subMenu: (params: MenuElementParams) => {
const isAllied = params.selected?.isAlliedWith(params.myPlayer);
const isDisconnected = isDisconnectedTarget(params);
const tileOwner = params.game.owner(params.tile);
const isOwnTerritory =
@@ -629,9 +630,9 @@ export const rootMenuElement: MenuElement = {
...(isOwnTerritory
? [deleteUnitElement, allyRequestElement, buildMenuElement]
: [
isAllied ? allyBreakElement : boatMenuElement,
isAllied && !isDisconnected ? allyBreakElement : boatMenuElement,
allyRequestElement,
isFriendlyTarget(params) && !isDisconnectedTarget(params)
isFriendlyTarget(params) && !isDisconnected
? donateGoldRadialElement
: attackMenuElement,
]),
+182 -47
View File
@@ -1,33 +1,49 @@
import { colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { PlayerID } from "../../../core/game/Game";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { PlayerID, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import {
GameUpdateType,
RailroadUpdate,
RailTile,
RailType,
RailroadConstructionUpdate,
RailroadDestructionUpdate,
RailroadSnapUpdate,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { AlternateViewEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
import { getBridgeRects, getRailroadRects } from "./RailroadSprites";
import {
computeRailTiles,
RailroadView,
RailTile,
RailType,
} from "./RailroadView";
type RailRef = {
tile: RailTile;
numOccurence: number;
lastOwnerId: PlayerID | null;
};
const SNAPPABLE_STRUCTURES: UnitType[] = [
UnitType.Port,
UnitType.City,
UnitType.Factory,
];
export class RailTileChangedEvent implements GameEvent {
constructor(public tile: TileRef) {}
}
export class RailroadLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private theme: Theme;
private alternativeView = false;
// Save the number of railroads per tiles. Delete when it reaches 0
private existingRailroads = new Map<TileRef, RailRef>();
private railroads = new Map<number, RailroadView>();
// Railroads under construction
private pendingRailroads = new Set<number>();
private nextRailIndexToCheck = 0;
private railTileList: TileRef[] = [];
private railTileIndex = new Map<TileRef, number>();
@@ -38,20 +54,52 @@ export class RailroadLayer implements Layer {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
}
private uiState: UIState,
) {}
shouldTransform(): boolean {
return true;
}
tick() {
this.updatePendingRailroads();
const updates = this.game.updatesSinceLastTick();
const railUpdates =
updates !== null ? updates[GameUpdateType.RailroadEvent] : [];
for (const rail of railUpdates) {
this.handleRailroadRendering(rail);
if (!updates) return;
// The event has to be handled in this specific order: construction / snap / destruction
// Otherwise some ID may not be available yet/anymore
updates[GameUpdateType.RailroadConstructionEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadConstruction(update);
});
updates[GameUpdateType.RailroadSnapEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadSnapEvent(update);
});
updates[GameUpdateType.RailroadDestructionEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadDestruction(update);
});
}
updatePendingRailroads() {
for (const id of this.pendingRailroads) {
const pending = this.railroads.get(id);
if (pending === undefined) {
// Rail deleted or snapped before the end of the animation
this.pendingRailroads.delete(id);
continue;
}
const newTiles = pending.tick();
if (newTiles.length === 0) {
// Animation complete
this.pendingRailroads.delete(id);
continue;
}
for (const railTile of newTiles) {
this.paintRailTile(railTile);
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
}
}
}
@@ -120,6 +168,32 @@ export class RailroadLayer implements Layer {
}
}
private highlightOverlappingRailroads(context: CanvasRenderingContext2D) {
if (
this.uiState.ghostStructure === null ||
!SNAPPABLE_STRUCTURES.includes(this.uiState.ghostStructure)
)
return;
if (
this.uiState.overlappingRailroads === undefined ||
this.uiState.overlappingRailroads.length === 0
)
return;
const offsetX = -this.game.width() / 2;
const offsetY = -this.game.height() / 2;
context.fillStyle = "rgba(0, 255, 0, 0.4)";
for (const id of this.uiState.overlappingRailroads) {
const rail = this.railroads.get(id);
if (rail) {
for (const railTile of rail.drawnTiles()) {
const x = this.game.x(railTile.tile);
const y = this.game.y(railTile.tile);
context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5);
}
}
}
}
renderLayer(context: CanvasRenderingContext2D) {
const scale = this.transformHandler.scale;
if (scale <= 1) {
@@ -154,6 +228,7 @@ export class RailroadLayer implements Layer {
context.save();
context.globalAlpha = alpha;
this.highlightOverlappingRailroads(context);
context.drawImage(
this.canvas,
srcX,
@@ -168,55 +243,115 @@ export class RailroadLayer implements Layer {
context.restore();
}
private handleRailroadRendering(railUpdate: RailroadUpdate) {
for (const railRoad of railUpdate.railTiles) {
if (railUpdate.isActive) {
this.paintRailroad(railRoad);
} else {
this.clearRailroad(railRoad);
}
private onRailroadSnapEvent(update: RailroadSnapUpdate) {
const original = this.railroads.get(update.originalId);
if (!original) {
console.warn("Could not snap railroad: ", update.originalId);
return;
}
if (!original.isComplete()) {
// The animation is not complete but we don't want to compute where the animation should resume
// Just draw every remaining rails at once
this.drawRemainingTiles(original);
}
// No need to compute the directions here, the rails are already painted
const directions1: RailTile[] = update.tiles1.map((tile) => ({
tile,
type: RailType.HORIZONTAL,
}));
const directions2: RailTile[] = update.tiles2.map((tile) => ({
tile,
type: RailType.HORIZONTAL,
}));
// The rails are already painted, consider them complete
this.railroads.set(
update.newId1,
new RailroadView(update.newId1, directions1, true),
);
this.railroads.set(
update.newId2,
new RailroadView(update.newId2, directions2, true),
);
this.railroads.delete(update.originalId);
}
private paintRailroad(railRoad: RailTile) {
const currentOwner = this.game.owner(railRoad.tile)?.id() ?? null;
const railTile = this.existingRailroads.get(railRoad.tile);
private drawRemainingTiles(railroad: RailroadView) {
for (const tile of railroad.remainingTiles()) {
this.paintRail(tile);
}
this.pendingRailroads.delete(railroad.id);
}
if (railTile) {
railTile.numOccurence++;
railTile.tile = railRoad;
railTile.lastOwnerId = currentOwner;
private onRailroadConstruction(railUpdate: RailroadConstructionUpdate) {
const railTiles = computeRailTiles(this.game, railUpdate.tiles);
const rail = new RailroadView(railUpdate.id, railTiles);
this.addRailroad(rail);
}
private onRailroadDestruction(railUpdate: RailroadDestructionUpdate) {
const railroad = this.railroads.get(railUpdate.id);
if (!railroad) {
console.warn("Can't remove unexisting railroad: ", railUpdate.id);
return;
}
this.removeRailroad(railroad);
}
private addRailroad(railroad: RailroadView) {
this.railroads.set(railroad.id, railroad);
this.pendingRailroads.add(railroad.id);
}
private removeRailroad(railroad: RailroadView) {
this.pendingRailroads.delete(railroad.id);
for (const railTile of railroad.drawnTiles()) {
this.clearRailroad(railTile.tile);
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
}
this.railroads.delete(railroad.id);
}
private paintRailTile(railTile: RailTile) {
const currentOwner = this.game.owner(railTile.tile)?.id() ?? null;
const railRef = this.existingRailroads.get(railTile.tile);
if (railRef) {
railRef.numOccurence++;
railRef.tile = railTile;
railRef.lastOwnerId = currentOwner;
} else {
this.existingRailroads.set(railRoad.tile, {
tile: railRoad,
this.existingRailroads.set(railTile.tile, {
tile: railTile,
numOccurence: 1,
lastOwnerId: currentOwner,
});
this.railTileIndex.set(railRoad.tile, this.railTileList.length);
this.railTileList.push(railRoad.tile);
this.paintRail(railRoad);
this.railTileIndex.set(railTile.tile, this.railTileList.length);
this.railTileList.push(railTile.tile);
this.paintRail(railTile);
}
}
private clearRailroad(railRoad: RailTile) {
const ref = this.existingRailroads.get(railRoad.tile);
private clearRailroad(railroad: TileRef) {
const ref = this.existingRailroads.get(railroad);
if (ref) ref.numOccurence--;
if (!ref || ref.numOccurence <= 0) {
this.existingRailroads.delete(railRoad.tile);
this.removeRailTile(railRoad.tile);
this.existingRailroads.delete(railroad);
this.removeRailTile(railroad);
if (this.context === undefined) throw new Error("Not initialized");
if (this.game.isWater(railRoad.tile)) {
if (this.game.isWater(railroad)) {
this.context.clearRect(
this.game.x(railRoad.tile) * 2 - 2,
this.game.y(railRoad.tile) * 2 - 2,
this.game.x(railroad) * 2 - 2,
this.game.y(railroad) * 2 - 2,
5,
6,
);
} else {
this.context.clearRect(
this.game.x(railRoad.tile) * 2 - 1,
this.game.y(railRoad.tile) * 2 - 1,
this.game.x(railroad) * 2 - 1,
this.game.y(railroad) * 2 - 1,
3,
3,
);
@@ -242,15 +377,15 @@ export class RailroadLayer implements Layer {
}
}
paintRail(railRoad: RailTile) {
paintRail(railTile: RailTile) {
if (this.context === undefined) throw new Error("Not initialized");
const { tile } = railRoad;
const { railType } = railRoad;
const { tile } = railTile;
const { type } = railTile;
const x = this.game.x(tile);
const y = this.game.y(tile);
// If rail tile is over water, paint a bridge underlay first
if (this.game.isWater(tile)) {
this.paintBridge(this.context, x, y, railType);
this.paintBridge(this.context, x, y, type);
}
const owner = this.game.owner(tile);
const recipient = owner.isPlayer() ? owner : null;
@@ -263,7 +398,7 @@ export class RailroadLayer implements Layer {
}
this.context.fillStyle = color.toRgbString();
this.paintRailRects(this.context, x, y, railType);
this.paintRailRects(this.context, x, y, type);
}
private paintRailRects(
@@ -1,4 +1,4 @@
import { RailType } from "../../../core/game/GameUpdates";
import { RailType } from "./RailroadView";
const railTypeToFunctionMap: Record<RailType, () => number[][]> = {
[RailType.TOP_RIGHT]: topRightRailroadCornerRects,
+177
View File
@@ -0,0 +1,177 @@
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
export enum RailType {
VERTICAL,
HORIZONTAL,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT,
}
export type RailTile = {
tile: TileRef;
type: RailType;
};
export function computeRailTiles(game: GameView, tiles: TileRef[]): RailTile[] {
if (tiles.length === 0) return [];
if (tiles.length === 1) {
return [{ tile: tiles[0], type: RailType.VERTICAL }];
}
const railTypes: RailTile[] = [];
// Inverse direction computation for the first tile
railTypes.push({
tile: tiles[0],
type: computeExtremityDirection(game, tiles[0], tiles[1]),
});
for (let i = 1; i < tiles.length - 1; i++) {
const direction = computeDirection(
game,
tiles[i - 1],
tiles[i],
tiles[i + 1],
);
railTypes.push({ tile: tiles[i], type: direction });
}
railTypes.push({
tile: tiles[tiles.length - 1],
type: computeExtremityDirection(
game,
tiles[tiles.length - 1],
tiles[tiles.length - 2],
),
});
return railTypes;
}
function computeExtremityDirection(
game: GameView,
tile: TileRef,
next: TileRef,
): RailType {
const x = game.x(tile);
const y = game.y(tile);
const nextX = game.x(next);
const nextY = game.y(next);
const dx = nextX - x;
const dy = nextY - y;
if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement
if (dx === 0) {
return RailType.VERTICAL;
} else if (dy === 0) {
return RailType.HORIZONTAL;
}
return RailType.VERTICAL;
}
export function computeDirection(
game: GameView,
prev: TileRef,
current: TileRef,
next: TileRef,
): RailType {
const x1 = game.x(prev);
const y1 = game.y(prev);
const x2 = game.x(current);
const y2 = game.y(current);
const x3 = game.x(next);
const y3 = game.y(next);
const dx1 = x2 - x1;
const dy1 = y2 - y1;
const dx2 = x3 - x2;
const dy2 = y3 - y2;
// Straight line
if (dx1 === dx2 && dy1 === dy2) {
if (dx1 !== 0) return RailType.HORIZONTAL;
if (dy1 !== 0) return RailType.VERTICAL;
}
// Turn (corner) cases
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
// Now figure out which type of corner
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
}
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
return RailType.VERTICAL;
}
/**
* A list of tile that can be incrementally painted each tick
*/
export class RailroadView {
private headIndex: number = 0;
private tailIndex: number;
private increment: number = 3;
constructor(
public id: number,
private railTiles: RailTile[],
complete: boolean = false,
) {
// If the railroad is considered complete, no drawing or animation is required
this.tailIndex = complete ? 0 : railTiles.length;
}
isComplete(): boolean {
return this.headIndex >= this.tailIndex;
}
tiles(): RailTile[] {
return this.railTiles;
}
remainingTiles(): RailTile[] {
if (this.isComplete()) {
// Animation complete, no tiles need to be painted
return [];
}
return this.railTiles.slice(this.headIndex, this.tailIndex);
}
drawnTiles(): RailTile[] {
if (this.isComplete()) {
// Animation complete, every tiles have been painted
return this.tiles();
}
let drawnTiles = this.railTiles.slice(0, this.headIndex);
drawnTiles = drawnTiles.concat(this.railTiles.slice(this.tailIndex));
return drawnTiles;
}
tick(): RailTile[] {
if (this.isComplete()) return [];
let updatedRailTiles: RailTile[];
// Check if remaining tiles can be done all at once
if (this.tailIndex - this.headIndex <= 2 * this.increment) {
updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex);
} else {
updatedRailTiles = [
...this.railTiles.slice(
this.headIndex,
this.headIndex + this.increment,
),
...this.railTiles.slice(
this.tailIndex - this.increment,
this.tailIndex,
),
];
}
this.headIndex = Math.min(this.headIndex + this.increment, this.tailIndex);
this.tailIndex = Math.max(this.tailIndex - this.increment, this.headIndex);
return updatedRailTiles;
}
}
@@ -4,7 +4,7 @@ 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/VideoAd";
import "../../components/VideoReward";
import { Layer } from "./Layer";
@customElement("spawn-video-ad")
@@ -24,8 +24,7 @@ export class SpawnVideoAd extends LitElement implements Layer {
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
getGamesPlayed() < 3 // Don't show to new players
) {
return;
}
@@ -333,10 +333,15 @@ export class StructureIconsLayer implements Layer {
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
}
// No overlapping when a structure is upgradable
this.uiState.overlappingRailroads = [];
} else if (unit.canBuild === false) {
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.uiState.overlappingRailroads = [];
} else {
this.uiState.overlappingRailroads = unit.overlappingRailroads;
}
const scale = this.transformHandler.scale;
@@ -450,7 +455,13 @@ export class StructureIconsLayer implements Layer {
priceGroup: ghost.priceGroup,
priceBox: ghost.priceBox,
range: null,
buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n },
buildableUnit: {
type,
canBuild: false,
canUpgrade: false,
cost: 0n,
overlappingRailroads: [],
},
};
const showPrice = this.game.config().userSettings().cursorCostLabel();
this.updateGhostPrice(0, showPrice);
+1 -1
View File
@@ -132,7 +132,7 @@ export class TeamStats extends LitElement implements Layer {
return html`
<div
class="max-h-[30vh] overflow-y-auto grid bg-slate-800/70 w-full text-white text-xs md:text-sm"
class="max-h-[30vh] overflow-y-auto grid bg-slate-800/70 w-full text-white text-xs md:text-sm mt-2"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<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>
`
+24 -1
View File
@@ -8,6 +8,7 @@ import {
} from "../../../client/Utils";
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import { RankedType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { getUserMe } from "../../Api";
@@ -37,6 +38,9 @@ export class WinModal extends LitElement implements Layer {
@state()
private isWin = false;
@state()
private isRankedGame = false;
@state()
private patternContent: TemplateResult | null = null;
@@ -75,11 +79,21 @@ export class WinModal extends LitElement implements Layer {
>
${translateText("win_modal.exit")}
</button>
${this.isRankedGame
? html`
<button
@click=${this._handleRequeue}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-purple-600 text-white border-0 rounded-sm transition-all duration-200 hover:bg-purple-500 hover:-translate-y-px active:translate-y-px"
>
${translateText("win_modal.requeue")}
</button>
`
: null}
<button
@click=${this.hide}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded-sm transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${this.isWin
${this.game.myPlayer()?.isAlive()
? translateText("win_modal.keep")
: translateText("win_modal.spectate")}
</button>
@@ -251,6 +265,9 @@ export class WinModal extends LitElement implements Layer {
async show() {
crazyGamesSDK.gameplayStop();
await this.loadPatternContent();
// Check if this is a ranked game
this.isRankedGame =
this.game.config().gameConfig().rankedType === RankedType.OneVOne;
this.isVisible = true;
this.requestUpdate();
setTimeout(() => {
@@ -270,6 +287,12 @@ export class WinModal extends LitElement implements Layer {
window.location.href = "/";
}
private _handleRequeue() {
this.hide();
// Navigate to homepage and open matchmaking modal
window.location.href = "/?requeue";
}
init() {}
tick() {
+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
>;
+74 -42
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>;
@@ -131,6 +136,9 @@ export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
export type PublicGameInfo = z.infer<typeof PublicGameInfoSchema>;
const ClientInfoSchema = z.object({
clientID: z.string(),
@@ -140,18 +148,31 @@ const ClientInfoSchema = z.object({
export const GameInfoSchema = z.object({
gameID: z.string(),
clients: z.array(ClientInfoSchema).optional(),
numClients: z.number().optional(),
msUntilStart: z.number().optional(),
lobbyCreatorClientID: z.string().optional(),
startsAt: z.number().optional(),
serverTime: z.number(),
gameConfig: z.lazy(() => GameConfigSchema).optional(),
});
export interface GameInfo {
gameID: GameID;
clients?: ClientInfo[];
numClients?: number;
msUntilStart?: number;
gameConfig?: GameConfig;
export const PublicGameInfoSchema = z.object({
gameID: z.string(),
numClients: z.number(),
startsAt: z.number(),
gameConfig: z.lazy(() => GameConfigSchema).optional(),
});
export const PublicGamesSchema = z.object({
serverTime: z.number(),
games: PublicGameInfoSchema.array(),
});
export class LobbyInfoEvent implements GameEvent {
constructor(
public lobby: GameInfo,
public myClientID: ClientID,
) {}
}
export interface ClientInfo {
clientID: ClientID;
username: string;
@@ -212,7 +233,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,
@@ -263,139 +284,136 @@ export const QuickChatKeySchema = z.enum(
// Intents
//
const BaseIntentSchema = z.object({
clientID: ID,
});
export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({
export const AllianceExtensionIntentSchema = z.object({
type: z.literal("allianceExtension"),
recipient: ID,
});
export const AttackIntentSchema = BaseIntentSchema.extend({
export const AttackIntentSchema = z.object({
type: z.literal("attack"),
targetID: ID.nullable(),
troops: z.number().nonnegative().nullable(),
});
export const SpawnIntentSchema = BaseIntentSchema.extend({
export const SpawnIntentSchema = z.object({
type: z.literal("spawn"),
tile: z.number(),
});
export const BoatAttackIntentSchema = BaseIntentSchema.extend({
export const BoatAttackIntentSchema = z.object({
type: z.literal("boat"),
troops: z.number().nonnegative(),
dst: z.number(),
});
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
export const AllianceRequestIntentSchema = z.object({
type: z.literal("allianceRequest"),
recipient: ID,
});
export const AllianceRequestReplyIntentSchema = BaseIntentSchema.extend({
export const AllianceRequestReplyIntentSchema = z.object({
type: z.literal("allianceRequestReply"),
requestor: ID, // The one who made the original alliance request
accept: z.boolean(),
});
export const BreakAllianceIntentSchema = BaseIntentSchema.extend({
export const BreakAllianceIntentSchema = z.object({
type: z.literal("breakAlliance"),
recipient: ID,
});
export const TargetPlayerIntentSchema = BaseIntentSchema.extend({
export const TargetPlayerIntentSchema = z.object({
type: z.literal("targetPlayer"),
target: ID,
});
export const EmojiIntentSchema = BaseIntentSchema.extend({
export const EmojiIntentSchema = z.object({
type: z.literal("emoji"),
recipient: z.union([ID, z.literal(AllPlayers)]),
emoji: EmojiSchema,
});
export const EmbargoIntentSchema = BaseIntentSchema.extend({
export const EmbargoIntentSchema = z.object({
type: z.literal("embargo"),
targetID: ID,
action: z.union([z.literal("start"), z.literal("stop")]),
});
export const EmbargoAllIntentSchema = BaseIntentSchema.extend({
export const EmbargoAllIntentSchema = z.object({
type: z.literal("embargo_all"),
action: z.union([z.literal("start"), z.literal("stop")]),
});
export const DonateGoldIntentSchema = BaseIntentSchema.extend({
export const DonateGoldIntentSchema = z.object({
type: z.literal("donate_gold"),
recipient: ID,
gold: z.number().nonnegative().nullable(),
});
export const DonateTroopIntentSchema = BaseIntentSchema.extend({
export const DonateTroopIntentSchema = z.object({
type: z.literal("donate_troops"),
recipient: ID,
troops: z.number().nonnegative().nullable(),
});
export const BuildUnitIntentSchema = BaseIntentSchema.extend({
export const BuildUnitIntentSchema = z.object({
type: z.literal("build_unit"),
unit: z.enum(UnitType),
tile: z.number(),
rocketDirectionUp: z.boolean().optional(),
});
export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
export const UpgradeStructureIntentSchema = z.object({
type: z.literal("upgrade_structure"),
unit: z.enum(UnitType),
unitId: z.number(),
});
export const CancelAttackIntentSchema = BaseIntentSchema.extend({
export const CancelAttackIntentSchema = z.object({
type: z.literal("cancel_attack"),
attackID: z.string(),
});
export const CancelBoatIntentSchema = BaseIntentSchema.extend({
export const CancelBoatIntentSchema = z.object({
type: z.literal("cancel_boat"),
unitID: z.number(),
});
export const MoveWarshipIntentSchema = BaseIntentSchema.extend({
export const MoveWarshipIntentSchema = z.object({
type: z.literal("move_warship"),
unitId: z.number(),
tile: z.number(),
});
export const DeleteUnitIntentSchema = BaseIntentSchema.extend({
export const DeleteUnitIntentSchema = z.object({
type: z.literal("delete_unit"),
unitId: z.number(),
});
export const QuickChatIntentSchema = BaseIntentSchema.extend({
export const QuickChatIntentSchema = z.object({
type: z.literal("quick_chat"),
recipient: ID,
quickChatKey: QuickChatKeySchema,
target: ID.optional(),
});
export const MarkDisconnectedIntentSchema = BaseIntentSchema.extend({
export const MarkDisconnectedIntentSchema = z.object({
type: z.literal("mark_disconnected"),
clientID: ID,
isDisconnected: z.boolean(),
});
export const KickPlayerIntentSchema = BaseIntentSchema.extend({
export const KickPlayerIntentSchema = z.object({
type: z.literal("kick_player"),
target: ID,
});
export const TogglePauseIntentSchema = BaseIntentSchema.extend({
export const TogglePauseIntentSchema = z.object({
type: z.literal("toggle_pause"),
paused: z.boolean().default(false),
});
export const UpdateGameConfigIntentSchema = BaseIntentSchema.extend({
export const UpdateGameConfigIntentSchema = z.object({
type: z.literal("update_game_config"),
config: GameConfigSchema.partial(),
});
@@ -427,13 +445,17 @@ const IntentSchema = z.discriminatedUnion("type", [
UpdateGameConfigIntentSchema,
]);
// StampedIntent = Intent with server-stamped clientID (used in turns and execution)
export const StampedIntentSchema = IntentSchema.and(z.object({ clientID: ID }));
export type StampedIntent = Intent & { clientID: ClientID };
//
// Server utility types
//
export const TurnSchema = z.object({
turnNumber: z.number(),
intents: IntentSchema.array(),
intents: StampedIntentSchema.array(),
// The hash of the game state at the end of the turn.
hash: z.number().nullable().optional(),
});
@@ -522,6 +544,8 @@ export const ServerStartGameMessageSchema = z.object({
turns: TurnSchema.array(),
gameStartInfo: GameStartInfoSchema,
lobbyCreatedAt: z.number(),
// The clientID assigned to this connection by the server
myClientID: ID,
});
export const ServerDesyncSchema = z.object({
@@ -539,6 +563,13 @@ export const ServerErrorSchema = z.object({
message: z.string().optional(),
});
export const ServerLobbyInfoMessageSchema = z.object({
type: z.literal("lobby_info"),
lobby: GameInfoSchema,
// The clientID assigned to this connection by the server
myClientID: ID,
});
export const ServerMessageSchema = z.discriminatedUnion("type", [
ServerTurnMessageSchema,
ServerPrestartMessageSchema,
@@ -546,6 +577,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
ServerPingMessageSchema,
ServerDesyncSchema,
ServerErrorSchema,
ServerLobbyInfoMessageSchema,
]);
//
@@ -580,10 +612,10 @@ export const ClientIntentMessageSchema = z.object({
});
// WARNING: never send this message to clients.
// Note: clientID is NOT included - server assigns it based on persistentID from token
export const ClientJoinMessageSchema = z.object({
type: z.literal("join"),
clientID: ID,
token: TokenSchema, // WARNING: PII
token: TokenSchema, // WARNING: PII - server extracts persistentID from this
gameID: ID,
username: UsernameSchema,
// Server replaces the refs with the actual cosmetic data.
@@ -594,7 +626,7 @@ export const ClientJoinMessageSchema = z.object({
export const ClientRejoinMessageSchema = z.object({
type: z.literal("rejoin"),
gameID: ID,
clientID: ID,
// Note: clientID is NOT sent - server looks it up from persistentID in token
lastTurn: z.number(),
token: TokenSchema,
});
+8 -7
View File
@@ -80,13 +80,14 @@ export function calculateBoundingBox(
maxX = -Infinity,
maxY = -Infinity;
borderTiles.forEach((tile: TileRef) => {
const cell = gm.cell(tile);
minX = Math.min(minX, cell.x);
minY = Math.min(minY, cell.y);
maxX = Math.max(maxX, cell.x);
maxY = Math.max(maxY, cell.y);
});
for (const tile of borderTiles) {
const x = gm.x(tile);
const y = gm.y(tile);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
}
+16 -9
View File
@@ -24,20 +24,27 @@ export const greenTeamColors: Colord[] = generateTeamColors(green);
export const botTeamColors: Colord[] = [botColor];
function generateTeamColors(baseColor: Colord): Colord[] {
const hsl = baseColor.toHsl();
const lch = baseColor.toLch();
const colorCount = 64;
const goldenAngle = 137.508;
return Array.from({ length: colorCount }, (_, index) => {
const progression = index / (colorCount - 1);
if (index === 0) return baseColor;
const saturation = hsl.s * (1.0 - 0.3 * progression);
const lightness = Math.min(100, hsl.l + progression * 30);
// Spread hues evenly across ±12° band using golden angle within that range
const hueShift = ((index * goldenAngle) % 24) - 12;
const h = (lch.h + hueShift + 360) % 360;
return colord({
h: hsl.h,
s: saturation,
l: lightness,
});
// Chroma oscillates ±10% around the base to add variety without washing out
const chromaFactor = 1.0 + 0.1 * Math.sin(index * 0.7);
const c = Math.max(10, Math.min(130, lch.c * chromaFactor));
// Lightness alternates above/below the base using golden angle spacing
// Tighter range (±18) keeps teammates recognizable as the same team
const lightOffset = 18 * Math.sin(index * goldenAngle * (Math.PI / 180));
const l = Math.max(25, Math.min(80, lch.l + lightOffset));
return colord({ l, c, h });
});
}
+11 -1
View File
@@ -1,7 +1,8 @@
import { Execution, Game, Player } from "../game/Game";
import { Execution, Game, isStructureType, Player } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
import { DeleteUnitExecution } from "./DeleteUnitExecution";
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
export class BotExecution implements Execution {
@@ -58,6 +59,7 @@ export class BotExecution implements Execution {
}
this.acceptAllAllianceRequests();
this.deleteAllStructures();
this.maybeAttack();
}
@@ -80,6 +82,14 @@ export class BotExecution implements Execution {
}
}
private deleteAllStructures() {
for (const unit of this.bot.units()) {
if (isStructureType(unit.type()) && this.bot.canDeleteUnit()) {
this.mg.addExecution(new DeleteUnitExecution(this.bot, unit.id()));
}
}
}
private maybeAttack() {
if (this.attackBehavior === null) {
throw new Error("not initialized");
+2 -2
View File
@@ -1,6 +1,6 @@
import { Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { ClientID, GameID, StampedIntent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
@@ -46,7 +46,7 @@ export class Executor {
return turn.intents.map((i) => this.createExec(i));
}
createExec(intent: Intent): Execution {
createExec(intent: StampedIntent): Execution {
const player = this.mg.playerByClientID(intent.clientID);
if (!player) {
console.warn(`player with clientID ${intent.clientID} not found`);
+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);
}
+29 -18
View File
@@ -1,5 +1,5 @@
import { Config } from "../configuration/Config";
import { Execution, Game, Player, UnitType } from "../game/Game";
import { Cell, Execution, Game, Player, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
@@ -139,11 +139,12 @@ export class PlayerExecution implements Execution {
const largestCluster = clusters[largestIndex];
if (largestCluster === undefined) throw new Error("No clusters");
this.player.largestClusterBoundingBox = calculateBoundingBox(
this.mg,
const largestClusterBox = calculateBoundingBox(this.mg, largestCluster);
this.player.largestClusterBoundingBox = largestClusterBox;
const surroundedBy = this.surroundedBySamePlayer(
largestCluster,
largestClusterBox,
);
const surroundedBy = this.surroundedBySamePlayer(largestCluster);
if (surroundedBy && !surroundedBy.isFriendly(this.player)) {
this.removeCluster(largestCluster);
}
@@ -158,7 +159,10 @@ export class PlayerExecution implements Execution {
}
}
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
private surroundedBySamePlayer(
cluster: Set<TileRef>,
clusterBox: { min: Cell; max: Cell },
): false | Player {
const enemies = new Set<number>();
for (const tile of cluster) {
let hasUnownedNeighbor = false;
@@ -187,7 +191,6 @@ export class PlayerExecution implements Execution {
}
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
const clusterBox = calculateBoundingBox(this.mg, cluster);
if (inscribed(enemyBox, clusterBox)) {
return enemy;
}
@@ -195,7 +198,11 @@ export class PlayerExecution implements Execution {
}
private isSurrounded(cluster: Set<TileRef>): boolean {
const enemyTiles = new Set<TileRef>();
let hasEnemy = false;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const tr of cluster) {
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
return false;
@@ -203,27 +210,31 @@ export class PlayerExecution implements Execution {
this.mg.forEachNeighbor(tr, (n) => {
const owner = this.mg.owner(n);
if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) {
enemyTiles.add(n);
hasEnemy = true;
const x = this.mg.x(n);
const y = this.mg.y(n);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
});
}
if (enemyTiles.size === 0) {
if (!hasEnemy) {
return false;
}
const enemyBox = calculateBoundingBox(this.mg, enemyTiles);
const clusterBox = calculateBoundingBox(this.mg, cluster);
const enemyBox = { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
return inscribed(enemyBox, clusterBox);
}
private removeCluster(cluster: Set<TileRef>) {
if (
Array.from(cluster).some(
(t) => this.mg?.ownerID(t) !== this.player?.smallID(),
)
) {
// Other removeCluster operations could change tile owners,
// so double check.
return;
for (const t of cluster) {
if (this.mg?.ownerID(t) !== this.player?.smallID()) {
// Other removeCluster operations could change tile owners,
// so double check.
return;
}
}
const capturing = this.getCapturingPlayer(cluster);
-170
View File
@@ -1,170 +0,0 @@
import { Execution, Game } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateType, RailTile, RailType } from "../game/GameUpdates";
import { Railroad } from "../game/Railroad";
export class RailroadExecution implements Execution {
private mg: Game;
private active: boolean = true;
private headIndex: number = 0;
private tailIndex: number = 0;
private increment: number = 3;
private railTiles: RailTile[] = [];
constructor(private railRoad: Railroad) {
this.tailIndex = railRoad.tiles.length;
}
isActive(): boolean {
return this.active;
}
init(mg: Game, ticks: number): void {
this.mg = mg;
const tiles = this.railRoad.tiles;
// Inverse direction computation for the first tile
this.railTiles.push({
tile: tiles[0],
railType:
tiles.length > 0
? this.computeExtremityDirection(tiles[0], tiles[1])
: RailType.VERTICAL,
});
for (let i = 1; i < tiles.length - 1; i++) {
const direction = this.computeDirection(
tiles[i - 1],
tiles[i],
tiles[i + 1],
);
this.railTiles.push({ tile: tiles[i], railType: direction });
}
this.railTiles.push({
tile: tiles[tiles.length - 1],
railType:
tiles.length > 0
? this.computeExtremityDirection(
tiles[tiles.length - 1],
tiles[tiles.length - 2],
)
: RailType.VERTICAL,
});
}
private computeExtremityDirection(tile: TileRef, next: TileRef): RailType {
const x = this.mg.x(tile);
const y = this.mg.y(tile);
const nextX = this.mg.x(next);
const nextY = this.mg.y(next);
const dx = nextX - x;
const dy = nextY - y;
if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement
if (dx === 0) {
return RailType.VERTICAL;
} else if (dy === 0) {
return RailType.HORIZONTAL;
}
return RailType.VERTICAL;
}
private computeDirection(
prev: TileRef,
current: TileRef,
next: TileRef,
): RailType {
if (this.mg === null) {
throw new Error("Not initialized");
}
const x1 = this.mg.x(prev);
const y1 = this.mg.y(prev);
const x2 = this.mg.x(current);
const y2 = this.mg.y(current);
const x3 = this.mg.x(next);
const y3 = this.mg.y(next);
const dx1 = x2 - x1;
const dy1 = y2 - y1;
const dx2 = x3 - x2;
const dy2 = y3 - y2;
// Straight line
if (dx1 === dx2 && dy1 === dy2) {
if (dx1 !== 0) return RailType.HORIZONTAL;
if (dy1 !== 0) return RailType.VERTICAL;
}
// Turn (corner) cases
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
// Now figure out which type of corner
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
}
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
return RailType.VERTICAL;
}
tick(ticks: number): void {
if (this.mg === null) {
throw new Error("Not initialized");
}
if (!this.activeSourceOrDestination()) {
this.active = false;
return;
}
if (this.headIndex > this.tailIndex) {
// Construction complete
this.constructionComplete();
return;
}
let updatedRailTiles: RailTile[];
// Check if remaining tiles can be done all at once
if (this.tailIndex - this.headIndex <= 2 * this.increment) {
updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex);
this.constructionComplete();
} else {
updatedRailTiles = this.railTiles.slice(
this.headIndex,
this.headIndex + this.increment,
);
updatedRailTiles = updatedRailTiles.concat(
this.railTiles.slice(this.tailIndex - this.increment, this.tailIndex),
);
this.headIndex += this.increment;
this.tailIndex -= this.increment;
}
if (updatedRailTiles) {
this.mg.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: true,
railTiles: updatedRailTiles,
});
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
private activeSourceOrDestination(): boolean {
return this.railRoad.from.isActive() && this.railRoad.to.isActive();
}
private constructionComplete() {
this.redrawBuildings();
this.active = false;
}
private redrawBuildings() {
if (this.railRoad.from.unit.isActive()) this.railRoad.from.unit.touch();
if (this.railRoad.to.unit.isActive()) this.railRoad.to.unit.touch();
}
}
+19 -17
View File
@@ -45,7 +45,9 @@ export class TrainStationExecution implements Execution {
this.active = false;
return;
}
this.spawnTrain(this.station, ticks);
if (this.spawnTrains) {
this.spawnTrain(this.station, ticks);
}
}
private shouldSpawnTrain(): boolean {
@@ -69,8 +71,8 @@ export class TrainStationExecution implements Execution {
if (cluster === null) {
return;
}
const availableForTrade = cluster.availableForTrade(this.unit.owner());
if (availableForTrade.size === 0) {
const owner = this.unit.owner();
if (!cluster.hasAnyTradeDestination(owner)) {
return;
}
if (!this.shouldSpawnTrain()) {
@@ -79,20 +81,20 @@ export class TrainStationExecution implements Execution {
// Pick a destination randomly.
// Could be improved to pick a lucrative trip
const destination: TrainStation =
this.random.randFromSet(availableForTrade);
if (destination !== station) {
this.mg.addExecution(
new TrainExecution(
this.mg.railNetwork(),
this.unit.owner(),
station,
destination,
this.numCars,
),
);
this.lastSpawnTick = currentTick;
}
const destination = cluster.randomTradeDestination(owner, this.random);
if (destination === null) return;
if (destination === station) return;
this.mg.addExecution(
new TrainExecution(
this.mg.railNetwork(),
owner,
station,
destination,
this.numCars,
),
);
this.lastSpawnTick = currentTick;
}
activeDuringSpawnPhase(): boolean {
@@ -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);
}
}
+3
View File
@@ -118,6 +118,7 @@ export enum GameMapType {
Didier = "Didier",
DidierFrance = "Didier France",
AmazonRiver = "Amazon River",
Yenisei = "Yenisei",
}
export type GameMapName = keyof typeof GameMapType;
@@ -165,6 +166,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.TwoLakes,
GameMapType.StraitOfHormuz,
GameMapType.AmazonRiver,
GameMapType.Yenisei,
],
fantasy: [
GameMapType.Pangaea,
@@ -843,6 +845,7 @@ export interface BuildableUnit {
canUpgrade: number | false;
type: UnitType;
cost: Gold;
overlappingRailroads: number[];
}
export interface PlayerProfile {
+65 -27
View File
@@ -7,6 +7,7 @@ import {
import { AStarWaterHierarchical } from "../pathfinding/algorithms/AStar.WaterHierarchical";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { ATTACK_INDEX_SENT } from "../StatsSchemas";
import { simpleHash } from "../Util";
import { AllianceImpl } from "./AllianceImpl";
import { AllianceRequestImpl } from "./AllianceRequestImpl";
@@ -621,31 +622,46 @@ export class GameImpl implements Game {
}
private updateBorders(tile: TileRef) {
const tiles: TileRef[] = [];
tiles.push(tile);
this.neighbors(tile).forEach((t) => tiles.push(t));
for (const t of tiles) {
const updateBorderStatus = (t: TileRef) => {
if (!this.hasOwner(t)) {
continue;
return;
}
const owner = this.owner(t) as PlayerImpl;
if (this.calcIsBorder(t)) {
(this.owner(t) as PlayerImpl)._borderTiles.add(t);
owner._borderTiles.add(t);
} else {
(this.owner(t) as PlayerImpl)._borderTiles.delete(t);
owner._borderTiles.delete(t);
}
}
};
updateBorderStatus(tile);
this.forEachNeighbor(tile, updateBorderStatus);
}
private calcIsBorder(tile: TileRef): boolean {
if (!this.hasOwner(tile)) {
return false;
}
for (const neighbor of this.neighbors(tile)) {
const bordersEnemy = this.owner(tile) !== this.owner(neighbor);
if (bordersEnemy) {
return true;
}
const ownerId = this.ownerID(tile);
const x = this.x(tile);
const y = this.y(tile);
if (x > 0 && this.ownerID(this._map.ref(x - 1, y)) !== ownerId) {
return true;
}
if (
x + 1 < this._width &&
this.ownerID(this._map.ref(x + 1, y)) !== ownerId
) {
return true;
}
if (y > 0 && this.ownerID(this._map.ref(x, y - 1)) !== ownerId) {
return true;
}
if (
y + 1 < this._height &&
this.ownerID(this._map.ref(x, y + 1)) !== ownerId
) {
return true;
}
return false;
}
@@ -1097,26 +1113,48 @@ export class GameImpl implements Game {
}
}
const gold = conquered.gold();
this.displayMessage(
`Conquered ${conquered.displayName()} received ${renderNumber(
// Don't transfer gold when the conquered player didn't play (never attacked anyone)
// This is especially important when starting gold is enabled
const stats = this._stats.getPlayerStats(conquered);
const attacksSent = stats?.attacks?.[ATTACK_INDEX_SENT] ?? 0n;
const skipGoldTransfer =
attacksSent === 0n && conquered.type() === PlayerType.Human;
const gold = skipGoldTransfer ? 0n : conquered.gold();
if (skipGoldTransfer) {
this.displayMessage(
"events_display.conquered_no_gold",
MessageType.CONQUERED_PLAYER,
conqueror.id(),
undefined,
{
name: conquered.displayName(),
},
);
} else {
this.displayMessage(
"events_display.received_gold_from_conquest",
MessageType.CONQUERED_PLAYER,
conqueror.id(),
gold,
)} gold`,
MessageType.CONQUERED_PLAYER,
conqueror.id(),
gold,
);
conqueror.addGold(gold);
conquered.removeGold(gold);
{
gold: renderNumber(gold),
name: conquered.displayName(),
},
);
conqueror.addGold(gold);
conquered.removeGold(gold);
// Record stats
this.stats().goldWar(conqueror, conquered, gold);
}
this.addUpdate({
type: GameUpdateType.ConquestEvent,
conquerorId: conqueror.id(),
conqueredId: conquered.id(),
gold,
});
// Record stats
this.stats().goldWar(conqueror, conquered, gold);
}
}
+20 -16
View File
@@ -44,7 +44,9 @@ export enum GameUpdateType {
Hash,
UnitIncoming,
BonusEvent,
RailroadEvent,
RailroadDestructionEvent,
RailroadConstructionEvent,
RailroadSnapEvent,
ConquestEvent,
EmbargoEvent,
GamePaused,
@@ -67,7 +69,9 @@ export type GameUpdate =
| UnitIncomingUpdate
| AllianceExtensionUpdate
| BonusEventUpdate
| RailroadUpdate
| RailroadConstructionUpdate
| RailroadDestructionUpdate
| RailroadSnapUpdate
| ConquestUpdate
| EmbargoUpdate
| GamePausedUpdate;
@@ -80,24 +84,24 @@ export interface BonusEventUpdate {
troops: number;
}
export enum RailType {
VERTICAL,
HORIZONTAL,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT,
export interface RailroadConstructionUpdate {
type: GameUpdateType.RailroadConstructionEvent;
id: number;
tiles: TileRef[];
}
export interface RailTile {
tile: TileRef;
railType: RailType;
export interface RailroadDestructionUpdate {
type: GameUpdateType.RailroadDestructionEvent;
id: number;
}
export interface RailroadUpdate {
type: GameUpdateType.RailroadEvent;
isActive: boolean;
railTiles: RailTile[];
export interface RailroadSnapUpdate {
type: GameUpdateType.RailroadSnapEvent;
originalId: number;
newId1: number;
newId2: number;
tiles1: TileRef[];
tiles2: TileRef[];
}
export interface ConquestUpdate {
+45 -9
View File
@@ -20,6 +20,7 @@ import {
ColoredTeams,
Embargo,
EmojiMessage,
GameMode,
Gold,
MessageType,
MutableAlliance,
@@ -29,6 +30,7 @@ import {
PlayerProfile,
PlayerType,
Relation,
StructureTypes,
Team,
TerraNullius,
Tick,
@@ -423,6 +425,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());
@@ -950,20 +960,25 @@ export class PlayerImpl implements Player {
const validTiles = tile !== null ? this.validStructureSpawnTiles(tile) : [];
return Object.values(UnitType).map((u) => {
let canUpgrade: number | false = false;
let canBuild: TileRef | false = false;
if (!this.mg.inSpawnPhase()) {
const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile);
if (existingUnit !== false) {
canUpgrade = existingUnit.id();
}
if (tile !== null) {
canBuild = this.canBuild(u, tile, validTiles);
}
}
return {
type: u,
canBuild:
this.mg.inSpawnPhase() || tile === null
? false
: this.canBuild(u, tile, validTiles),
canUpgrade: canUpgrade,
canBuild,
canUpgrade,
cost: this.mg.config().unitInfo(u).cost(this.mg, this),
overlappingRailroads:
canBuild !== false
? this.mg.railNetwork().overlappingRailroads(canBuild)
: [],
} as BuildableUnit;
});
}
@@ -978,7 +993,10 @@ export class PlayerImpl implements Player {
}
const cost = this.mg.unitInfo(unitType).cost(this.mg, this);
if (!this.isAlive() || this.gold() < cost) {
if (
unitType !== UnitType.MIRVWarhead &&
(!this.isAlive() || this.gold() < cost)
) {
return false;
}
switch (unitType) {
@@ -986,10 +1004,10 @@ export class PlayerImpl implements Player {
if (!this.mg.hasOwner(targetTile)) {
return false;
}
return this.nukeSpawn(targetTile);
return this.nukeSpawn(targetTile, unitType);
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
return this.nukeSpawn(targetTile);
return this.nukeSpawn(targetTile, unitType);
case UnitType.MIRVWarhead:
return targetTile;
case UnitType.Port:
@@ -1016,7 +1034,7 @@ export class PlayerImpl implements Player {
}
}
nukeSpawn(tile: TileRef): TileRef | false {
nukeSpawn(tile: TileRef, nukeType: UnitType): TileRef | false {
if (this.mg.isSpawnImmunityActive()) {
return false;
}
@@ -1026,6 +1044,24 @@ export class PlayerImpl implements Player {
return false;
}
}
// Prevent launching nukes that would hit teammate structures (only in team games)
if (
this.mg.config().gameConfig().gameMode === GameMode.Team &&
nukeType !== UnitType.MIRV
) {
const magnitude = this.mg.config().nukeMagnitudes(nukeType);
const wouldHitTeammate = this.mg.anyUnitNearby(
tile,
magnitude.outer,
StructureTypes,
(unit) => unit.owner().isPlayer() && this.isOnSameTeam(unit.owner()),
);
if (wouldHitTeammate) {
return false;
}
}
// only get missilesilos that are not on cooldown and not under construction
const spawns = this.units(UnitType.MissileSilo)
.filter((silo) => {
+2
View File
@@ -1,4 +1,5 @@
import { Unit } from "./Game";
import { TileRef } from "./GameMap";
import { StationManager } from "./RailNetworkImpl";
import { TrainStation } from "./TrainStation";
@@ -7,4 +8,5 @@ export interface RailNetwork {
removeStation(unit: Unit): void;
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
stationManager(): StationManager;
overlappingRailroads(tile: TileRef): number[];
}
+28 -6
View File
@@ -1,7 +1,7 @@
import { RailroadExecution } from "../execution/RailroadExecution";
import { PathFinding } from "../pathfinding/PathFinder";
import { Game, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType } from "./GameUpdates";
import { RailNetwork } from "./RailNetwork";
import { Railroad } from "./Railroad";
import { RailSpatialGrid } from "./RailroadSpatialGrid";
@@ -85,6 +85,7 @@ export class RailNetworkImpl implements RailNetwork {
private stationRadius: number = 3;
private gridCellSize: number = 4;
private railGrid: RailSpatialGrid;
private nextId: number = 0;
constructor(
private game: Game,
@@ -141,6 +142,7 @@ export class RailNetworkImpl implements RailNetwork {
for (const rail of rails) {
const from = rail.from;
const to = rail.to;
const originalId = rail.id;
const closestRailIndex = rail.getClosestTileIndex(
this.game,
station.tile(),
@@ -158,11 +160,13 @@ export class RailNetworkImpl implements RailNetwork {
from,
station,
rail.tiles.slice(0, closestRailIndex),
this.nextId++,
);
const newRailTo = new Railroad(
station,
to,
rail.tiles.slice(closestRailIndex),
this.nextId++,
);
// New station is connected to both new rails
@@ -179,6 +183,14 @@ export class RailNetworkImpl implements RailNetwork {
cluster.addStation(station);
editedClusters.add(cluster);
}
this.game.addUpdate({
type: GameUpdateType.RailroadSnapEvent,
originalId,
newId1: newRailFrom.id,
newId2: newRailTo.id,
tiles1: newRailFrom.tiles,
tiles2: newRailTo.tiles,
});
}
// If multiple clusters own the new station, merge them into a single cluster
if (editedClusters.size > 1) {
@@ -187,6 +199,12 @@ export class RailNetworkImpl implements RailNetwork {
return editedClusters.size !== 0;
}
overlappingRailroads(tile: TileRef): number[] {
return [...this.railGrid.query(tile, this.stationRadius)].map(
(railroad: Railroad) => railroad.id,
);
}
private connectToNearbyStations(station: TrainStation) {
const neighbors = this.game.nearbyUnits(
station.tile(),
@@ -256,11 +274,15 @@ export class RailNetworkImpl implements RailNetwork {
private connect(from: TrainStation, to: TrainStation) {
const path = this.pathService.findTilePath(from.tile(), to.tile());
if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) {
const railRoad = new Railroad(from, to, path);
this.game.addExecution(new RailroadExecution(railRoad));
from.addRailroad(railRoad);
to.addRailroad(railRoad);
this.railGrid.register(railRoad);
const railroad = new Railroad(from, to, path, this.nextId++);
this.game.addUpdate({
type: GameUpdateType.RailroadConstructionEvent,
id: railroad.id,
tiles: railroad.tiles,
});
from.addRailroad(railroad);
to.addRailroad(railroad);
this.railGrid.register(railroad);
return true;
}
return false;
+4 -8
View File
@@ -1,6 +1,6 @@
import { Game } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
import { GameUpdateType } from "./GameUpdates";
import { TrainStation } from "./TrainStation";
export class Railroad {
@@ -8,17 +8,13 @@ export class Railroad {
public from: TrainStation,
public to: TrainStation,
public tiles: TileRef[],
public id: number,
) {}
delete(game: Game) {
const railTiles: RailTile[] = this.tiles.map((tile) => ({
tile,
railType: RailType.VERTICAL,
}));
game.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: false,
railTiles,
type: GameUpdateType.RailroadDestructionEvent,
id: this.id,
});
this.from.removeRailroad(this);
this.to.removeRailroad(this);
+45 -14
View File
@@ -2,7 +2,7 @@ import { TrainExecution } from "../execution/TrainExecution";
import { PseudoRandom } from "../PseudoRandom";
import { Game, Player, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
import { GameUpdateType } from "./GameUpdates";
import { Railroad } from "./Railroad";
/**
@@ -92,14 +92,9 @@ export class TrainStation {
(r) => r.from === station || r.to === station,
);
if (toRemove) {
const railTiles: RailTile[] = toRemove.tiles.map((tile) => ({
tile,
railType: RailType.VERTICAL,
}));
this.mg.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: false,
railTiles,
type: GameUpdateType.RailroadDestructionEvent,
id: toRemove.id,
});
this.removeRailroad(toRemove);
}
@@ -155,6 +150,12 @@ export class TrainStation {
*/
export class Cluster {
public stations: Set<TrainStation> = new Set();
private tradeStations: Set<TrainStation> = new Set();
private isTradeStation(station: TrainStation): boolean {
const type = station.unit.type();
return type === UnitType.City || type === UnitType.Port;
}
has(station: TrainStation) {
return this.stations.has(station);
@@ -162,11 +163,15 @@ export class Cluster {
addStation(station: TrainStation) {
this.stations.add(station);
if (this.isTradeStation(station)) {
this.tradeStations.add(station);
}
station.setCluster(this);
}
removeStation(station: TrainStation) {
this.stations.delete(station);
this.tradeStations.delete(station);
}
addStations(stations: Set<TrainStation>) {
@@ -181,14 +186,39 @@ export class Cluster {
}
}
hasAnyTradeDestination(player: Player): boolean {
for (const station of this.tradeStations) {
if (station.tradeAvailable(player)) {
return true;
}
}
return false;
}
randomTradeDestination(
player: Player,
random: PseudoRandom,
): TrainStation | null {
let selected: TrainStation | null = null;
let eligibleSeen = 0;
for (const station of this.tradeStations) {
if (!station.tradeAvailable(player)) continue;
eligibleSeen++;
// Reservoir sampling: keep each eligible station with probability 1/eligibleSeen.
if (random.nextInt(0, eligibleSeen) === 0) {
selected = station;
}
}
return selected;
}
availableForTrade(player: Player): Set<TrainStation> {
const tradingStations = new Set<TrainStation>();
for (const station of this.stations) {
if (
(station.unit.type() === UnitType.City ||
station.unit.type() === UnitType.Port) &&
station.tradeAvailable(player)
) {
for (const station of this.tradeStations) {
if (station.tradeAvailable(player)) {
tradingStations.add(station);
}
}
@@ -201,6 +231,7 @@ export class Cluster {
clear() {
this.stations.clear();
this.tradeStations.clear();
}
}
-1
View File
@@ -20,6 +20,5 @@ export class Client {
public readonly username: string,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
public readonly isRejoin: boolean = false,
) {}
}
+22 -16
View File
@@ -8,7 +8,7 @@ import {
GameMode,
GameType,
} from "../core/game/Game";
import { ClientRejoinMessage, GameConfig, GameID } from "../core/Schemas";
import { GameConfig, GameID } from "../core/Schemas";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
@@ -26,32 +26,37 @@ export class GameManager {
return this.games.get(id) ?? null;
}
joinClient(client: Client, gameID: GameID): boolean {
public publicLobbies(): GameServer[] {
return Array.from(this.games.values()).filter(
(g) => g.phase() === GamePhase.Lobby && g.isPublic(),
);
}
joinClient(
client: Client,
gameID: GameID,
): "joined" | "kicked" | "rejected" | "not_found" {
const game = this.games.get(gameID);
if (game) {
game.joinClient(client);
return true;
}
return false;
if (!game) return "not_found";
return game.joinClient(client);
}
rejoinClient(
ws: WebSocket,
persistentID: string,
msg: ClientRejoinMessage,
gameID: GameID,
lastTurn: number = 0,
): boolean {
const game = this.games.get(msg.gameID);
if (game) {
game.rejoinClient(ws, persistentID, msg);
return true;
}
return false;
const game = this.games.get(gameID);
if (!game) return false;
return game.rejoinClient(ws, persistentID, lastTurn);
}
createGame(
id: GameID,
gameConfig: GameConfig | undefined,
creatorClientID?: string,
creatorPersistentID?: string,
startsAt?: number,
) {
const game = new GameServer(
id,
@@ -76,7 +81,8 @@ export class GameManager {
disabledUnits: [],
...gameConfig,
},
creatorClientID,
creatorPersistentID,
startsAt,
);
this.games.set(id, game);
return game;
+1 -2
View File
@@ -149,8 +149,7 @@ export function buildPreview(
activePlayers = countActivePlayers(players);
} else {
activePlayers =
countActivePlayers(players) ||
(lobby?.numClients ?? lobby?.clients?.length ?? 0);
countActivePlayers(players) || (lobby?.clients?.length ?? 0);
}
const map = lobby?.gameConfig?.gameMap ?? config.gameMap;
let mode = lobby?.gameConfig?.gameMode ?? config.gameMode ?? GameMode.FFA;
+144 -93
View File
@@ -7,19 +7,19 @@ import { GameType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
ClientRejoinMessage,
ClientSendWinnerMessage,
GameConfig,
GameInfo,
GameStartInfo,
GameStartInfoSchema,
Intent,
PlayerRecord,
ServerDesyncSchema,
ServerErrorMessage,
ServerLobbyInfoMessage,
ServerPrestartMessageSchema,
ServerStartGameMessage,
ServerTurnMessage,
StampedIntent,
Turn,
} from "../core/Schemas";
import { createPartialGameRecord, getClanTag } from "../core/Util";
@@ -42,9 +42,11 @@ export class GameServer {
private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds
private turns: Turn[] = [];
private intents: Intent[] = [];
private intents: StampedIntent[] = [];
public activeClients: Client[] = [];
private allClients: Map<ClientID, Client> = new Map();
// Map persistentID to clientID for reconnection lookup
private persistentIdToClientId: Map<string, ClientID> = new Map();
private clientsDisconnectedStatus: Map<ClientID, boolean> = new Map();
private _hasStarted = false;
private _startTime: number | null = null;
@@ -62,7 +64,7 @@ export class GameServer {
private _hasPrestarted = false;
private kickedClients: Set<ClientID> = new Set();
private kickedPersistentIds: Set<string> = new Set();
private outOfSyncClients: Set<ClientID> = new Set();
private isPaused = false;
@@ -78,17 +80,26 @@ export class GameServer {
public desyncCount = 0;
private lobbyInfoIntervalId: ReturnType<typeof setInterval> | null = null;
constructor(
public readonly id: string,
readonly log_: Logger,
public readonly createdAt: number,
private config: ServerConfig,
public gameConfig: GameConfig,
private lobbyCreatorID?: string,
private creatorPersistentID?: string,
private startsAt?: number,
) {
this.log = log_.child({ gameID: id });
}
private get lobbyCreatorID(): ClientID | undefined {
return this.creatorPersistentID
? this.persistentIdToClientId.get(this.creatorPersistentID)
: undefined;
}
public updateGameConfig(gameConfig: Partial<GameConfig>): void {
if (gameConfig.gameMap !== undefined) {
this.gameConfig.gameMap = gameConfig.gameMap;
@@ -146,20 +157,24 @@ export class GameServer {
}
}
public joinClient(client: Client) {
this.websockets.add(client.ws);
if (this.kickedClients.has(client.clientID)) {
this.log.warn(`cannot add client, already kicked`, {
clientID: client.clientID,
});
return;
}
private isKicked(clientID: ClientID): boolean {
const persistentID = this.allClients.get(clientID)?.persistentID;
return (
persistentID !== undefined && this.kickedPersistentIds.has(persistentID)
);
}
if (this.allClients.has(client.clientID)) {
this.log.warn("cannot add client, already in game", {
clientID: client.clientID,
});
return;
// Get existing clientID for this persistentID, or null if new player
public getClientIdForPersistentId(persistentID: string): ClientID | null {
const clientID = this.persistentIdToClientId.get(persistentID);
if (!clientID) return null;
if (this.kickedPersistentIds.has(persistentID)) return null;
return clientID;
}
public joinClient(client: Client): "joined" | "kicked" | "rejected" {
if (this.kickedPersistentIds.has(client.persistentID)) {
return "kicked";
}
if (
@@ -176,16 +191,9 @@ export class GameServer {
error: "full-lobby",
} satisfies ServerErrorMessage),
);
return;
return "rejected";
}
// Log when lobby creator joins private game
if (client.clientID === this.lobbyCreatorID) {
this.log.info("Lobby creator joined", {
gameID: this.id,
creatorID: this.lobbyCreatorID,
});
}
this.log.info("client joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
@@ -202,7 +210,7 @@ export class GameServer {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
});
return;
return "rejected";
}
if (this.config.env() === GameEnv.Prod) {
@@ -227,63 +235,60 @@ export class GameServer {
}
// Client connection accepted
this.websockets.add(client.ws);
this.persistentIdToClientId.set(client.persistentID, client.clientID);
this.activeClients.push(client);
client.lastPing = Date.now();
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) {
this.sendStartGameMsg(client.ws, 0);
}
return "joined";
}
// Attempt to reconnect a client by persistentID. Returns true if successful.
// Only the WebSocket is updated — username, cosmetics, etc. are preserved
// from the original join to maintain consistency throughout the game session.
public rejoinClient(
ws: WebSocket,
persistentID: string,
msg: ClientRejoinMessage,
): void {
lastTurn: number = 0,
): boolean {
const clientID = this.getClientIdForPersistentId(persistentID);
if (!clientID) return false;
const client = this.allClients.get(clientID);
if (!client) return false;
this.websockets.add(ws);
this.log.info("client rejoining", { clientID, lastTurn });
if (this.kickedClients.has(msg.clientID)) {
this.log.warn("cannot rejoin client, client has been kicked", {
clientID: msg.clientID,
});
return;
}
const client = this.allClients.get(msg.clientID);
if (!client) {
this.log.warn("cannot rejoin client, existing client not found", {
clientID: msg.clientID,
});
return;
}
if (client.persistentID !== persistentID) {
this.log.error("persistent ids do not match", {
clientID: msg.clientID,
clientPersistentID: persistentID,
existingIP: ipAnonymize(client.ip),
existingPersistentID: client.persistentID,
});
return;
// Close old WebSocket to prevent resource leaks
if (client.ws !== ws) {
client.ws.removeAllListeners();
client.ws.close();
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== msg.clientID,
(c) => c.clientID !== client.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
this.markClientDisconnected(msg.clientID, false);
this.markClientDisconnected(client.clientID, false);
client.ws = ws;
this.addListeners(client);
this.startLobbyInfoBroadcast();
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, msg.lastTurn);
this.sendStartGameMsg(client.ws, lastTurn);
}
return true;
}
private addListeners(client: Client) {
@@ -315,13 +320,12 @@ export class GameServer {
break;
}
case "intent": {
if (clientMsg.intent.clientID !== client.clientID) {
this.log.warn(
`client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`,
);
return;
}
switch (clientMsg.intent.type) {
// Server stamps clientID from the authenticated connection
const stampedIntent = {
...clientMsg.intent,
clientID: client.clientID,
};
switch (stampedIntent.type) {
case "mark_disconnected": {
this.log.warn(
`Should not receive mark_disconnected intent from client`,
@@ -336,14 +340,14 @@ export class GameServer {
this.log.warn(`Only lobby creator can kick players`, {
clientID: client.clientID,
creatorID: this.lobbyCreatorID,
target: clientMsg.intent.target,
target: stampedIntent.target,
gameID: this.id,
});
return;
}
// Don't allow lobby creator to kick themselves
if (client.clientID === clientMsg.intent.target) {
if (client.clientID === stampedIntent.target) {
this.log.warn(`Cannot kick yourself`, {
clientID: client.clientID,
});
@@ -353,13 +357,13 @@ export class GameServer {
// Log and execute the kick
this.log.info(`Lobby creator initiated kick of player`, {
creatorID: client.clientID,
target: clientMsg.intent.target,
target: stampedIntent.target,
gameID: this.id,
kickMethod: "websocket",
});
this.kickClient(
clientMsg.intent.target,
stampedIntent.target,
KICK_REASON_LOBBY_CREATOR,
);
return;
@@ -394,7 +398,7 @@ export class GameServer {
return;
}
if (clientMsg.intent.config.gameType === GameType.Public) {
if (stampedIntent.config.gameType === GameType.Public) {
this.log.warn(`Cannot update game to public via WebSocket`, {
gameID: this.id,
clientID: client.clientID,
@@ -410,7 +414,7 @@ export class GameServer {
},
);
this.updateGameConfig(clientMsg.intent.config);
this.updateGameConfig(stampedIntent.config);
return;
}
case "toggle_pause": {
@@ -424,15 +428,15 @@ export class GameServer {
return;
}
if (clientMsg.intent.paused) {
if (stampedIntent.paused) {
// Pausing: send intent and complete current turn before pause takes effect
this.addIntent(clientMsg.intent);
this.addIntent(stampedIntent);
this.endTurn();
this.isPaused = true;
} else {
// Unpausing: clear pause flag before sending intent so next turn can execute
this.isPaused = false;
this.addIntent(clientMsg.intent);
this.addIntent(stampedIntent);
this.endTurn();
}
@@ -445,7 +449,7 @@ export class GameServer {
default: {
// Don't process intents while game is paused
if (!this.isPaused) {
this.addIntent(clientMsg.intent);
this.addIntent(stampedIntent);
}
break;
}
@@ -495,21 +499,23 @@ export class GameServer {
client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
}
});
// Check if WebSocket already closed before we added the listener (race condition)
if (client.ws.readyState >= 2) {
this.log.info("client WebSocket already closing/closed, removing", {
clientID: client.clientID,
readyState: client.ws.readyState,
});
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== client.clientID,
);
}
}
public numClients(): number {
return this.activeClients.length;
}
public startTime(): number {
if (this._startTime !== null && this._startTime > 0) {
return this._startTime;
} else {
//game hasn't started yet, only works for public games
return this.createdAt + this.config.gameCreationRate();
}
}
public prestart() {
if (this.hasStarted()) {
return;
@@ -542,6 +548,49 @@ 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 lobbyInfo = this.gameInfo();
this.activeClients.forEach((c) => {
if (c.ws.readyState === WebSocket.OPEN) {
const msg = JSON.stringify({
type: "lobby_info",
lobby: lobbyInfo,
myClientID: c.clientID,
} satisfies ServerLobbyInfoMessage);
c.ws.send(msg);
}
});
}
public start() {
if (this._hasStarted || this._hasEnded) {
return;
@@ -583,7 +632,7 @@ export class GameServer {
});
}
private addIntent(intent: Intent) {
private addIntent(intent: StampedIntent) {
this.intents.push(intent);
}
@@ -608,6 +657,7 @@ export class GameServer {
turns: this.turns.slice(lastTurn),
gameStartInfo: this.gameStartInfo,
lobbyCreatedAt: this.createdAt,
myClientID: client.clientID,
} satisfies ServerStartGameMessage),
);
} catch (error) {
@@ -741,8 +791,9 @@ export class GameServer {
}
}
const msSinceCreation = now - this.createdAt;
const lessThanLifetime = msSinceCreation < this.config.gameCreationRate();
// Public Games
const lessThanLifetime = Date.now() < this.startsAt!;
const notEnoughPlayers =
this.gameConfig.gameType === GameType.Public &&
this.gameConfig.maxPlayers &&
@@ -750,8 +801,7 @@ export class GameServer {
if (lessThanLifetime && notEnoughPlayers) {
return GamePhase.Lobby;
}
const warmupOver =
now > this.createdAt + this.config.gameCreationRate() + 30 * 1000;
const warmupOver = now > this.startsAt! + 30 * 1000;
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished;
}
@@ -770,10 +820,10 @@ export class GameServer {
username: c.username,
clientID: c.clientID,
})),
lobbyCreatorClientID: this.lobbyCreatorID,
gameConfig: this.gameConfig,
msUntilStart: this.isPublic()
? this.createdAt + this.config.gameCreationRate()
: undefined,
startsAt: this.startsAt,
serverTime: Date.now(),
};
}
@@ -785,7 +835,7 @@ export class GameServer {
clientID: ClientID,
reasonKey: string = KICK_REASON_DUPLICATE_SESSION,
): void {
if (this.kickedClients.has(clientID)) {
if (this.isKicked(clientID)) {
this.log.warn(`cannot kick client, already kicked`, {
clientID,
reasonKey,
@@ -793,7 +843,8 @@ export class GameServer {
return;
}
if (!this.allClients.has(clientID)) {
const clientToKick = this.allClients.get(clientID);
if (!clientToKick) {
this.log.warn(`cannot kick client, not found in game`, {
clientID,
reasonKey,
@@ -801,7 +852,7 @@ export class GameServer {
return;
}
this.kickedClients.add(clientID);
this.kickedPersistentIds.add(clientToKick.persistentID);
const client = this.activeClients.find((c) => c.clientID === clientID);
if (client) {
@@ -1004,7 +1055,7 @@ export class GameServer {
private handleWinner(client: Client, clientMsg: ClientSendWinnerMessage) {
if (
this.outOfSyncClients.has(client.clientID) ||
this.kickedClients.has(client.clientID) ||
this.isKicked(client.clientID) ||
this.winner !== null ||
client.reportedWinner !== null
) {
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod";
import {
GameConfigSchema,
PublicGameInfoSchema,
PublicGamesSchema,
} from "../core/Schemas";
export type WorkerLobbyList = z.infer<typeof WorkerLobbyListSchema>;
export type WorkerReady = z.infer<typeof WorkerReadySchema>;
export type MasterLobbiesBroadcast = z.infer<
typeof MasterLobbiesBroadcastSchema
>;
export type MasterCreateGame = z.infer<typeof MasterCreateGameSchema>;
export type WorkerMessage = z.infer<typeof WorkerMessageSchema>;
export type MasterMessage = z.infer<typeof MasterMessageSchema>;
// --- Worker Messages ---
// Worker tells the master about its lobbies.
const WorkerLobbyListSchema = z.object({
type: z.literal("lobbyList"),
lobbies: z.array(PublicGameInfoSchema),
});
const WorkerReadySchema = z.object({
type: z.literal("workerReady"),
workerId: z.number(),
});
export const WorkerMessageSchema = z.discriminatedUnion("type", [
WorkerLobbyListSchema,
WorkerReadySchema,
]);
// --- Master Messages ---
// Broadcasts all public game info to all workers.
// Workers need information on all public lobbies so
// it can send it to the client.
const MasterLobbiesBroadcastSchema = z.object({
type: z.literal("lobbiesBroadcast"),
publicGames: PublicGamesSchema,
});
// Master sends a message to worker to schedule a new public game/lobby.
const MasterCreateGameSchema = z.object({
type: z.literal("createGame"),
gameID: z.string(),
gameConfig: GameConfigSchema,
startsAt: z.number(),
});
export const MasterMessageSchema = z.discriminatedUnion("type", [
MasterLobbiesBroadcastSchema,
MasterCreateGameSchema,
]);
+1
View File
@@ -66,6 +66,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
AmazonRiver: 3,
Sierpinski: 10,
TheBox: 3,
Yenisei: 6,
};
interface MapWithMode {
+9 -199
View File
@@ -5,19 +5,16 @@ import rateLimit from "express-rate-limit";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { startPolling } from "./PollingLoop";
import { MasterLobbyService } from "./MasterLobbyService";
import { renderHtml } from "./RenderHtml";
const config = getServerConfigFromServer();
const playlist = new MapPlaylist();
const readyWorkers = new Set();
let lobbyService: MasterLobbyService;
const app = express();
const server = http.createServer(app);
@@ -68,33 +65,6 @@ app.use(
}),
);
let publicLobbiesData: { lobbies: GameInfo[] } = { lobbies: [] };
const publicLobbyIDs: Set<string> = new Set();
const connectedClients: Set<WebSocket> = new Set();
// Broadcast lobbies to all connected clients
function broadcastLobbies() {
const message = JSON.stringify({
type: "lobbies_update",
data: publicLobbiesData,
});
const clientsToRemove: WebSocket[] = [];
connectedClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
} else {
clientsToRemove.push(client);
}
});
clientsToRemove.forEach((client) => {
connectedClients.delete(client);
});
}
// Start the master process
export async function startMaster() {
if (!cluster.isPrimary) {
@@ -106,36 +76,7 @@ export async function startMaster() {
log.info(`Primary ${process.pid} is running`);
log.info(`Setting up ${config.numWorkers()} workers...`);
// Setup WebSocket server for clients
const wss = new WebSocketServer({ server, path: "/lobbies" });
wss.on("connection", (ws: WebSocket) => {
connectedClients.add(ws);
// Send current lobbies immediately (always send, even if empty)
ws.send(
JSON.stringify({ type: "lobbies_update", data: publicLobbiesData }),
);
ws.on("close", () => {
connectedClients.delete(ws);
});
ws.on("error", (error) => {
log.error(`WebSocket error:`, error);
connectedClients.delete(ws);
try {
if (
ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING
) {
ws.close(1011, "WebSocket internal error");
}
} catch (closeError) {
log.error("Error while closing WebSocket after error:", closeError);
}
});
});
lobbyService = new MasterLobbyService(config, playlist, log);
// Generate admin token for worker authentication
const ADMIN_TOKEN = crypto.randomBytes(16).toString("hex");
@@ -157,44 +98,21 @@ export async function startMaster() {
INSTANCE_ID,
});
lobbyService.registerWorker(i, worker);
log.info(`Started worker ${i} (PID: ${worker.process.pid})`);
}
cluster.on("message", (worker, message) => {
if (message.type === "WORKER_READY") {
const workerId = message.workerId;
readyWorkers.add(workerId);
log.info(
`Worker ${workerId} is ready. (${readyWorkers.size}/${config.numWorkers()} ready)`,
);
// Start scheduling when all workers are ready
if (readyWorkers.size === config.numWorkers()) {
log.info("All workers ready, starting game scheduling");
const scheduleLobbies = () => {
schedulePublicGame(playlist).catch((error) => {
log.error("Error scheduling public game:", error);
});
};
startPolling(async () => {
const lobbies = await fetchLobbies();
if (lobbies === 0) {
scheduleLobbies();
}
}, 100);
}
}
});
// Handle worker crashes
cluster.on("exit", (worker, code, signal) => {
const workerId = (worker as any).process?.env?.WORKER_ID;
if (!workerId) {
if (workerId === undefined) {
log.error(`worker crashed could not find id`);
return;
}
const workerIdNum = parseInt(workerId);
lobbyService.removeWorker(workerIdNum);
log.warn(
`Worker ${workerId} (PID: ${worker.process.pid}) died with code: ${code} and signal: ${signal}`,
);
@@ -207,6 +125,7 @@ export async function startMaster() {
INSTANCE_ID,
});
lobbyService.registerWorker(workerIdNum, newWorker);
log.info(
`Restarted worker ${workerId} (New PID: ${newWorker.process.pid})`,
);
@@ -226,115 +145,6 @@ app.get("/api/env", async (req, res) => {
res.json(envConfig);
});
// Add lobbies endpoint to list public games for this worker
app.get("/api/public_lobbies", async (req, res) => {
res.json(publicLobbiesData);
});
async function fetchLobbies(): Promise<number> {
const fetchPromises: Promise<GameInfo | null>[] = [];
for (const gameID of new Set(publicLobbyIDs)) {
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // 5 second timeout
const port = config.workerPort(gameID);
const promise = fetch(`http://localhost:${port}/api/game/${gameID}`, {
headers: { [config.adminHeader()]: config.adminToken() },
signal: controller.signal,
})
.then((resp) => resp.json())
.then((json) => {
return json as GameInfo;
})
.catch((error) => {
log.error(`Error fetching game ${gameID}:`, error);
// Return null or a placeholder if fetch fails
publicLobbyIDs.delete(gameID);
return null;
});
fetchPromises.push(promise);
}
// Wait for all promises to resolve
const results = await Promise.all(fetchPromises);
// Filter out any null results from failed fetches
const lobbyInfos: GameInfo[] = results
.filter((result) => result !== null)
.map((gi: GameInfo) => {
return {
gameID: gi.gameID,
numClients: gi?.clients?.length ?? 0,
gameConfig: gi.gameConfig,
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
} as GameInfo;
});
lobbyInfos.forEach((l) => {
if (
"msUntilStart" in l &&
l.msUntilStart !== undefined &&
l.msUntilStart <= 250
) {
publicLobbyIDs.delete(l.gameID);
return;
}
if (
"gameConfig" in l &&
l.gameConfig !== undefined &&
"maxPlayers" in l.gameConfig &&
l.gameConfig.maxPlayers !== undefined &&
"numClients" in l &&
l.numClients !== undefined &&
l.gameConfig.maxPlayers <= l.numClients
) {
publicLobbyIDs.delete(l.gameID);
return;
}
});
// Update the lobbies data
publicLobbiesData = {
lobbies: lobbyInfos,
};
broadcastLobbies();
return publicLobbyIDs.size;
}
// Function to schedule a new public game
async function schedulePublicGame(playlist: MapPlaylist) {
const gameID = generateID();
publicLobbyIDs.add(gameID);
const workerPath = config.workerPath(gameID);
// Send request to the worker to start the game
try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/api/create_game/${gameID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[config.adminHeader()]: config.adminToken(),
},
body: JSON.stringify(await playlist.gameConfig()),
},
);
if (!response.ok) {
throw new Error(`Failed to schedule public game: ${response.statusText}`);
}
} catch (error) {
log.error(`Failed to schedule public game on worker ${workerPath}:`, error);
throw error;
}
}
// SPA fallback route
app.get("*", async function (_req, res) {
try {
+135
View File
@@ -0,0 +1,135 @@
import { Worker } from "cluster";
import winston from "winston";
import { ServerConfig } from "../core/configuration/Config";
import { PublicGameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import {
MasterCreateGame,
MasterLobbiesBroadcast,
WorkerMessageSchema,
} from "./IPCBridgeSchema";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { startPolling } from "./PollingLoop";
export interface MasterLobbyServiceOptions {
config: ServerConfig;
playlist: MapPlaylist;
log: typeof logger;
}
export class MasterLobbyService {
private readonly workers = new Map<number, Worker>();
// Worker id => the lobbies it owns.
private readonly workerLobbies = new Map<number, PublicGameInfo[]>();
private readonly readyWorkers = new Set<number>();
private started = false;
constructor(
private config: ServerConfig,
private playlist: MapPlaylist,
private log: winston.Logger,
) {}
registerWorker(workerId: number, worker: Worker) {
this.workers.set(workerId, worker);
worker.on("message", (raw: unknown) => {
const result = WorkerMessageSchema.safeParse(raw);
if (!result.success) {
this.log.error("Invalid IPC message from worker:", raw);
return;
}
const msg = result.data;
switch (msg.type) {
case "workerReady":
this.handleWorkerReady(msg.workerId);
break;
case "lobbyList":
this.workerLobbies.set(workerId, msg.lobbies);
break;
}
});
}
removeWorker(workerId: number) {
this.workers.delete(workerId);
this.workerLobbies.delete(workerId);
this.readyWorkers.delete(workerId);
}
private handleWorkerReady(workerId: number) {
this.readyWorkers.add(workerId);
this.log.info(
`Worker ${workerId} is ready. (${this.readyWorkers.size}/${this.config.numWorkers()} ready)`,
);
if (this.readyWorkers.size === this.config.numWorkers() && !this.started) {
this.started = true;
this.log.info("All workers ready, starting game scheduling");
startPolling(async () => this.broadcastLobbies(), 250);
startPolling(async () => await this.maybeScheduleLobby(), 1000);
}
}
private getAllLobbies(): PublicGameInfo[] {
const lobbies = Array.from(this.workerLobbies.values())
.flat()
.sort((a, b) => a.startsAt! - b.startsAt);
return lobbies;
}
private broadcastLobbies() {
const msg = {
type: "lobbiesBroadcast",
publicGames: {
serverTime: Date.now(),
games: this.getAllLobbies(),
},
} satisfies MasterLobbiesBroadcast;
for (const worker of this.workers.values()) {
worker.send(msg, (e) => {
if (e) {
this.log.error("Failed to send lobbies broadcast to worker:", e);
}
});
}
}
private async maybeScheduleLobby() {
const lobbies = this.getAllLobbies();
if (lobbies.length >= 2) {
return;
}
const lastStart = lobbies.reduce(
(max, pb) => Math.max(max, pb.startsAt),
Date.now(),
);
const gameID = generateID();
const workerId = this.config.workerIndex(gameID);
const gameConfig = await this.playlist.gameConfig();
const worker = this.workers.get(workerId);
if (!worker) {
this.log.error(`Worker ${workerId} not found`);
return;
}
worker.send(
{
type: "createGame",
gameID,
gameConfig,
startsAt: lastStart + this.config.gameCreationRate(),
} satisfies MasterCreateGame,
(e) => {
if (e) {
this.log.error("Failed to schedule lobby on worker:", e);
}
},
);
this.log.info(`Scheduled public game ${gameID} on worker ${workerId}`);
}
}
+77 -45
View File
@@ -12,7 +12,6 @@ import { GameType } from "../core/game/Game";
import {
ClientMessageSchema,
GameID,
ID,
PartialGameRecordSchema,
ServerErrorMessage,
} from "../core/Schemas";
@@ -30,6 +29,7 @@ import { MapPlaylist } from "./MapPlaylist";
import { startPolling } from "./PollingLoop";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { verifyTurnstileToken } from "./Turnstile";
import { WorkerLobbyService } from "./WorkerLobbyService";
import { initWorkerMetrics } from "./WorkerMetrics";
const config = getServerConfigFromServer();
@@ -42,6 +42,18 @@ const playlist = new MapPlaylist(true);
export async function startWorker() {
log.info(`Worker starting...`);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });
const gm = new GameManager(config, log);
// Initialize lobby service (handles WebSocket upgrade routing)
const lobbyService = new WorkerLobbyService(server, wss, gm, log);
setTimeout(
() => {
startMatchmakingPolling(gm);
@@ -49,15 +61,6 @@ export async function startWorker() {
1000 + Math.random() * 2000,
);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const gm = new GameManager(config, log);
if (config.otelEnabled()) {
initWorkerMetrics(gm);
}
@@ -121,12 +124,27 @@ export async function startWorker() {
app.post("/api/create_game/:id", async (req, res) => {
const id = req.params.id;
const creatorClientID = (() => {
if (typeof req.query.creatorClientID !== "string") return undefined;
const trimmed = req.query.creatorClientID.trim();
return ID.safeParse(trimmed).success ? trimmed : undefined;
})();
// Extract persistentID from Authorization header token
// Never accept persistentID directly from client
let creatorPersistentID: string | undefined;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring("Bearer ".length);
const result = await verifyClientToken(token, config);
if (result.type === "success") {
creatorPersistentID = result.persistentId;
} else {
log.warn(`Invalid creator token: ${result.message}`);
return res.status(401).json({ error: "Invalid creator token" });
}
} else if (
!req.headers[config.adminHeader()] // Public games use admin token instead
) {
return res
.status(400)
.json({ error: "Authorization header required to create a game" });
}
if (!id) {
log.warn(`cannot create game, id not found`);
@@ -160,11 +178,11 @@ export async function startWorker() {
return res.status(400).json({ error: "Worker, game id mismatch" });
}
// Pass creatorClientID to createGame
const game = gm.createGame(id, gc, creatorClientID);
// Pass creatorPersistentID to createGame
const game = gm.createGame(id, gc, creatorPersistentID);
log.info(
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`,
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorPersistentID ? `, creator: ${creatorPersistentID.substring(0, 8)}...` : ""}`,
);
res.json(game.gameInfo());
});
@@ -307,12 +325,9 @@ export async function startWorker() {
const result = await verifyClientToken(clientMsg.token, config);
if (result.type === "error") {
log.warn(`Invalid token: ${result.message}`, {
clientID: clientMsg.clientID,
gameID: clientMsg.gameID,
});
ws.close(
1002,
`Unauthorized: invalid token for client ${clientMsg.clientID}`,
);
ws.close(1002, `Unauthorized: invalid token`);
return;
}
const { persistentId, claims } = result;
@@ -320,11 +335,14 @@ export async function startWorker() {
if (clientMsg.type === "rejoin") {
log.info("rejoining game", {
gameID: clientMsg.gameID,
clientID: clientMsg.clientID,
persistentID: persistentId,
});
const wasFound = gm.rejoinClient(ws, persistentId, clientMsg);
const wasFound = gm.rejoinClient(
ws,
persistentId,
clientMsg.gameID,
clientMsg.lastTurn,
);
if (!wasFound) {
log.warn(
`game ${clientMsg.gameID} not found on worker ${workerId}`,
@@ -334,6 +352,12 @@ export async function startWorker() {
return;
}
// Try to reconnect an existing client (e.g., page refresh)
// If successful, skip all authorization
if (gm.rejoinClient(ws, persistentId, clientMsg.gameID)) {
return;
}
let roles: string[] | undefined;
let flares: string[] | undefined;
@@ -349,12 +373,10 @@ export async function startWorker() {
const result = await getUserMe(clientMsg.token, config);
if (result.type === "error") {
log.warn(`Unauthorized: ${result.message}`, {
clientID: clientMsg.clientID,
persistentID: persistentId,
gameID: clientMsg.gameID,
});
ws.close(
1002,
`Unauthorized: user me fetch failed for client ${clientMsg.clientID}`,
);
ws.close(1002, "Unauthorized: user me fetch failed");
return;
}
roles = result.response.player.roles;
@@ -380,7 +402,8 @@ export async function startWorker() {
if (cosmeticResult.type === "forbidden") {
log.warn(`Forbidden: ${cosmeticResult.reason}`, {
clientID: clientMsg.clientID,
persistentID: persistentId,
gameID: clientMsg.gameID,
});
ws.close(1002, cosmeticResult.reason);
return;
@@ -397,7 +420,8 @@ export async function startWorker() {
break;
case "rejected":
log.warn("Unauthorized: Turnstile token rejected", {
clientID: clientMsg.clientID,
persistentID: persistentId,
gameID: clientMsg.gameID,
reason: turnstileResult.reason,
});
ws.close(1002, "Unauthorized: Turnstile token rejected");
@@ -405,7 +429,8 @@ export async function startWorker() {
case "error":
// Fail open, allow the client to join.
log.error("Turnstile token error", {
clientID: clientMsg.clientID,
persistentID: persistentId,
gameID: clientMsg.gameID,
reason: turnstileResult.reason,
});
}
@@ -413,7 +438,7 @@ export async function startWorker() {
// Create client and add to game
const client = new Client(
clientMsg.clientID,
generateID(),
persistentId,
claims,
roles,
@@ -424,11 +449,23 @@ export async function startWorker() {
cosmeticResult.cosmetics,
);
const wasFound = gm.joinClient(client, clientMsg.gameID);
const joinResult = gm.joinClient(client, clientMsg.gameID);
if (!wasFound) {
if (joinResult === "not_found") {
log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`);
// Handle game not found case
ws.close(1002, "Game not found");
} else if (joinResult === "kicked") {
log.warn(`kicked client tried to join game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
workerId,
});
ws.close(1002, "Cannot join game");
} else if (joinResult === "rejected") {
log.info(`client rejected from game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
workerId,
});
ws.close(1002, "Lobby full");
}
// Handle other message types
@@ -459,13 +496,8 @@ export async function startWorker() {
log.info(`running on http://localhost:${PORT}`);
log.info(`Handling requests with path prefix /w${workerId}/`);
// Signal to the master process that this worker is ready
if (process.send) {
process.send({
type: "WORKER_READY",
workerId: workerId,
});
log.info(`signaled ready state to master`);
}
lobbyService.sendReady(workerId);
log.info(`signaled ready state to master`);
});
// Global error handler
+136
View File
@@ -0,0 +1,136 @@
import http from "http";
import { WebSocket, WebSocketServer } from "ws";
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import { GameManager } from "./GameManager";
import {
MasterMessageSchema,
WorkerLobbyList,
WorkerReady,
} from "./IPCBridgeSchema";
import { logger } from "./Logger";
export class WorkerLobbyService {
private readonly lobbiesWss: WebSocketServer;
private readonly lobbyClients: Set<WebSocket> = new Set();
constructor(
private readonly server: http.Server,
private readonly gameWss: WebSocketServer,
private readonly gm: GameManager,
private readonly log: typeof logger,
) {
this.lobbiesWss = new WebSocketServer({ noServer: true });
this.setupUpgradeHandler();
this.setupLobbiesWebSocket();
this.setupIPCListener();
}
private setupIPCListener() {
process.on("message", (raw: unknown) => {
const result = MasterMessageSchema.safeParse(raw);
if (!result.success) {
this.log.error("Invalid IPC message from master:", raw);
return;
}
const msg = result.data;
switch (msg.type) {
case "lobbiesBroadcast":
// Forward message to all clients
this.broadcastLobbiesToClients(msg.publicGames);
// Update master with my lobby info
this.sendMyLobbiesToMaster();
break;
case "createGame":
if (this.gm.game(msg.gameID) !== null) {
this.log.warn(`Game ${msg.gameID} already exists, skipping create`);
return;
}
this.log.info(`Creating public game ${msg.gameID} from master`);
this.gm.createGame(
msg.gameID,
msg.gameConfig,
undefined,
msg.startsAt,
);
break;
}
});
}
sendReady(workerId: number) {
const msg: WorkerReady = { type: "workerReady", workerId };
process.send?.(msg);
}
private sendMyLobbiesToMaster() {
const lobbies = this.gm
.publicLobbies()
.map((g) => g.gameInfo())
.map((gi) => {
return {
gameID: gi.gameID,
numClients: gi.clients?.length ?? 0,
startsAt: gi.startsAt!,
gameConfig: gi.gameConfig,
} satisfies PublicGameInfo;
});
process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);
}
private setupUpgradeHandler() {
this.server.on("upgrade", (request, socket, head) => {
const pathname = request.url ?? "";
if (pathname === "/lobbies" || pathname.endsWith("/lobbies")) {
this.lobbiesWss.handleUpgrade(request, socket, head, (ws) => {
this.lobbiesWss.emit("connection", ws, request);
});
} else {
this.gameWss.handleUpgrade(request, socket, head, (ws) => {
this.gameWss.emit("connection", ws, request);
});
}
});
}
private setupLobbiesWebSocket() {
this.lobbiesWss.on("connection", (ws: WebSocket) => {
this.lobbyClients.add(ws);
ws.on("close", () => {
this.lobbyClients.delete(ws);
});
ws.on("error", (error) => {
this.log.error(`Lobbies WebSocket error:`, error);
this.lobbyClients.delete(ws);
try {
if (
ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING
) {
ws.close(1011, "WebSocket internal error");
}
} catch (closeError) {
this.log.error("Error closing lobbies WebSocket:", closeError);
}
});
});
}
private broadcastLobbiesToClients(publicGames: PublicGames) {
const message = JSON.stringify(publicGames);
const clientsToRemove: WebSocket[] = [];
this.lobbyClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
} else {
clientsToRemove.push(client);
}
});
clientsToRemove.forEach((client) => {
this.lobbyClients.delete(client);
});
}
}
+25
View File
@@ -34,11 +34,36 @@ describe("AttackStats", () => {
test("should increase war gold stat when a player is eliminated", () => {
expect(player1.sharesBorderWith(player2)).toBeTruthy();
// Player2 must attack to be considered active (otherwise gold won't transfer)
game.addExecution(
new AttackExecution(1, player2, game.terraNullius().id()),
);
game.executeNextTick();
performAttack(game, player1, player2);
expectWarGoldStatIsIncreasedAfterKill(game, player1, player2);
});
test("should NOT increase war gold stat when a inactive player is eliminated", () => {
expect(player1.sharesBorderWith(player2)).toBeTruthy();
const attackerStatsBefore = game.stats().stats()[player1.clientID()!];
const warGoldBefore = attackerStatsBefore?.gold?.[GOLD_INDEX_WAR] ?? 0n;
performAttack(game, player1, player2);
const attackerStatsAfter = game.stats().stats()[player1.clientID()!];
const warGoldAfter = attackerStatsAfter?.gold?.[GOLD_INDEX_WAR] ?? 0n;
expect(warGoldAfter).toBe(warGoldBefore);
});
test("should increase war gold stat when elimination occurs via territory annexation", () => {
// Player2 must attack to be considered active (otherwise gold won't transfer)
game.addExecution(
new AttackExecution(1, player2, game.terraNullius().id()),
);
game.executeNextTick();
// Mark every tile on the map as owned by player1
for (let x = 0; x < game.map().width(); x++) {
for (let y = 0; y < game.map().height(); y++) {
+6 -1
View File
@@ -34,7 +34,12 @@ describe("InputHandler AutoUpgrade", () => {
eventBus = new EventBus();
inputHandler = new InputHandler(
{ attackRatio: 20, ghostStructure: null, rocketDirectionUp: true },
{
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
},
mockCanvas,
eventBus,
);
+449
View File
@@ -0,0 +1,449 @@
import fs from "fs";
import path from "path";
import ts from "typescript";
/**
* Regex patterns for keys that are intentionally generated dynamically.
* This keeps dynamic handling explicit and reviewable.
*/
const DYNAMIC_KEY_PATTERNS: RegExp[] = [
/^difficulty\.[^.]+$/,
/^map\.[^.]+$/,
/^map_categories\.[^.]+$/,
/^chat\.[^.]+\.[^.]+$/,
/^player_stats_table\.unit\.[^.]+$/,
/^host_modal\.teams_.+$/,
/^public_lobby\.teams_.+$/,
/^team_colors\.[^.]+$/,
/^territory_patterns\.pattern\.[^.]+$/,
/^territory_patterns\.pattern_owned\.[^.]+$/,
/^territory_patterns\.color_palette\.[^.]+$/,
/^build_menu\.desc\.[^.]+$/,
/^unit_type\.[^.]+$/,
];
/**
* Keys that are intentionally not expected to be used via translateText.
*/
const IGNORED_UNUSED_KEY_PATTERNS: RegExp[] = [
/^lang\./, // language metadata, not a UI translation key
];
type ScanResult = {
usedKeys: Set<string>;
referencedStaticKeys: Set<string>;
dynamicPrefixes: Set<string>;
};
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
const keys: string[] = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
keys.push(...flattenKeys(value as Record<string, unknown>, fullKey));
} else {
keys.push(fullKey);
}
}
return keys;
}
function getAllFiles(
dir: string,
extensions: string[],
/** Tracks visited real paths to guard against symlink cycles. */
seen: Set<string> = new Set(),
): string[] {
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return []; // cycle via directory symlink
seen.add(realDir);
const results: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...getAllFiles(fullPath, extensions, seen));
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
results.push(fullPath);
}
}
return results;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function prefixToRegex(prefix: string): RegExp {
return new RegExp(`^${escapeRegex(prefix)}.+$`);
}
function isTranslateTextCall(node: ts.CallExpression): boolean {
if (ts.isIdentifier(node.expression)) {
return node.expression.text === "translateText";
}
if (ts.isPropertyAccessExpression(node.expression)) {
return node.expression.name.text === "translateText";
}
return false;
}
function isStringLiteralLike(
node: ts.Node,
): node is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral {
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
}
function getPlusOperands(expr: ts.Expression): ts.Expression[] {
if (
ts.isBinaryExpression(expr) &&
expr.operatorToken.kind === ts.SyntaxKind.PlusToken
) {
return [...getPlusOperands(expr.left), ...getPlusOperands(expr.right)];
}
return [expr];
}
function getStaticStringFromPlus(expr: ts.Expression): string | null {
const parts = getPlusOperands(expr);
let value = "";
for (const part of parts) {
if (!isStringLiteralLike(part)) return null;
value += part.text;
}
return value;
}
function getDynamicPrefixFromPlus(expr: ts.Expression): string | null {
const parts = getPlusOperands(expr);
if (parts.length === 0) return null;
const first = parts[0];
if (!isStringLiteralLike(first)) return null;
if (parts.every((part) => isStringLiteralLike(part))) return null;
return first.text || null;
}
function extractDataI18nKeys(content: string): Set<string> {
const keys = new Set<string>();
const attrRegex =
/data-i18n(?:-title|-alt|-aria-label|-placeholder)?\s*=\s*["']([^"']+)["']/g;
let match: RegExpExecArray | null;
while ((match = attrRegex.exec(content)) !== null) {
keys.add(match[1]);
}
return keys;
}
function extractTranslationKeyLikeAttrs(content: string): Set<string> {
const keys = new Set<string>();
const keyLikeAttrRegex =
/\b(?:translationKey|labelKey|disabledKey|titleKey|ariaLabelKey|placeholderKey)\s*=\s*["']([^"']+)["']/g;
let match: RegExpExecArray | null;
while ((match = keyLikeAttrRegex.exec(content)) !== null) {
keys.add(match[1]);
}
return keys;
}
function isPotentialTranslationKey(
key: string,
rootKeys: Set<string>,
enKeySet: Set<string>,
allowBareRoot = false,
): boolean {
if (enKeySet.has(key)) return true;
if (allowBareRoot && rootKeys.has(key)) return true;
if (!key.includes(".")) return false;
const root = key.split(".")[0];
return rootKeys.has(root);
}
function isKeyNamedProperty(name: ts.PropertyName): boolean {
if (ts.isIdentifier(name)) return /key$/i.test(name.text);
if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) {
return /key$/i.test(name.text);
}
return false;
}
function collectFromExpression(
expression: ts.Expression,
result: ScanResult,
rootKeys: Set<string>,
enKeySet: Set<string>,
allowBareRoot = false,
): void {
if (isStringLiteralLike(expression)) {
if (
isPotentialTranslationKey(
expression.text,
rootKeys,
enKeySet,
allowBareRoot,
)
) {
result.referencedStaticKeys.add(expression.text);
}
return;
}
if (ts.isTemplateExpression(expression)) {
const prefix = expression.head.text;
if (
prefix.length > 0 &&
/[._]$/.test(prefix) &&
isPotentialTranslationKey(prefix, rootKeys, enKeySet)
) {
result.dynamicPrefixes.add(prefix);
}
return;
}
if (
ts.isBinaryExpression(expression) &&
expression.operatorToken.kind === ts.SyntaxKind.PlusToken
) {
const staticValue = getStaticStringFromPlus(expression);
if (staticValue !== null) {
if (isPotentialTranslationKey(staticValue, rootKeys, enKeySet)) {
result.referencedStaticKeys.add(staticValue);
}
return;
}
const prefix = getDynamicPrefixFromPlus(expression);
if (
prefix !== null &&
/[._]$/.test(prefix) &&
isPotentialTranslationKey(prefix, rootKeys, enKeySet)
) {
result.dynamicPrefixes.add(prefix);
}
return;
}
if (ts.isParenthesizedExpression(expression)) {
collectFromExpression(
expression.expression,
result,
rootKeys,
enKeySet,
allowBareRoot,
);
return;
}
if (ts.isConditionalExpression(expression)) {
collectFromExpression(
expression.whenTrue,
result,
rootKeys,
enKeySet,
allowBareRoot,
);
collectFromExpression(
expression.whenFalse,
result,
rootKeys,
enKeySet,
allowBareRoot,
);
return;
}
}
function scanTsFile(
filePath: string,
rootKeys: Set<string>,
enKeySet: Set<string>,
): ScanResult {
const content = fs.readFileSync(filePath, "utf-8");
const sourceFile = ts.createSourceFile(
filePath,
content,
ts.ScriptTarget.Latest,
true,
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const result: ScanResult = {
usedKeys: new Set<string>(),
referencedStaticKeys: new Set<string>(),
dynamicPrefixes: new Set<string>(),
};
const dataI18nKeys = extractDataI18nKeys(content);
for (const key of dataI18nKeys) {
result.referencedStaticKeys.add(key);
}
const keyLikeAttrKeys = extractTranslationKeyLikeAttrs(content);
for (const key of keyLikeAttrKeys) {
result.referencedStaticKeys.add(key);
}
const visit = (node: ts.Node) => {
// Broad match: any string literal in any .ts/.tsx file that exactly
// matches an en.json key is counted as "used". This is intentionally
// permissive to avoid false-positive "unused" reports, but it means a
// key appearing in an unrelated context (e.g. a log message or object
// key that happens to share the same name) will mask a genuinely
// unused translation key.
if (isStringLiteralLike(node) && enKeySet.has(node.text)) {
result.usedKeys.add(node.text);
}
if (ts.isCallExpression(node) && isTranslateTextCall(node)) {
const firstArg = node.arguments[0];
if (firstArg !== undefined) {
collectFromExpression(firstArg, result, rootKeys, enKeySet, true);
}
}
if (
ts.isPropertyAssignment(node) &&
isKeyNamedProperty(node.name) &&
ts.isExpression(node.initializer)
) {
collectFromExpression(node.initializer, result, rootKeys, enKeySet);
}
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
/key$/i.test(node.name.text) &&
node.initializer !== undefined
) {
collectFromExpression(node.initializer, result, rootKeys, enKeySet);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
for (const key of dataI18nKeys) {
if (enKeySet.has(key)) {
result.usedKeys.add(key);
}
}
for (const key of keyLikeAttrKeys) {
if (enKeySet.has(key)) {
result.usedKeys.add(key);
}
}
return result;
}
describe("Unused Translation Keys", () => {
test("en.json keys stay in sync with source usage", () => {
const enJsonPath = path.join(__dirname, "../resources/lang/en.json");
const enJson = JSON.parse(fs.readFileSync(enJsonPath, "utf-8"));
const allKeys = flattenKeys(enJson);
const enKeySet = new Set(allKeys);
const rootKeys = new Set(Object.keys(enJson as Record<string, unknown>));
const srcDir = path.join(__dirname, "../src");
const sourceFiles = getAllFiles(srcDir, [".ts", ".tsx", ".js", ".jsx"]);
const usedKeys = new Set<string>();
const referencedStaticKeys = new Set<string>();
const dynamicPrefixes = new Set<string>();
for (const file of sourceFiles) {
const scan = scanTsFile(file, rootKeys, enKeySet);
for (const key of scan.usedKeys) usedKeys.add(key);
for (const key of scan.referencedStaticKeys)
referencedStaticKeys.add(key);
for (const prefix of scan.dynamicPrefixes) dynamicPrefixes.add(prefix);
}
const indexHtmlPath = path.join(__dirname, "../index.html");
if (fs.existsSync(indexHtmlPath)) {
const htmlContent = fs.readFileSync(indexHtmlPath, "utf-8");
const htmlDataI18nKeys = extractDataI18nKeys(htmlContent);
for (const key of htmlDataI18nKeys) {
referencedStaticKeys.add(key);
if (enKeySet.has(key)) {
usedKeys.add(key);
}
}
}
const derivedDynamicPatterns = Array.from(dynamicPrefixes)
.sort()
.map((prefix) => prefixToRegex(prefix));
const dynamicKeyPatterns = [
...DYNAMIC_KEY_PATTERNS,
...derivedDynamicPatterns,
];
const unusedKeys: string[] = [];
const dynamicKeys: string[] = [];
const missingKeys: string[] = [];
// NOTE: The isPotentialTranslationKey check below intentionally skips any
// referenced key whose root namespace (the part before the first ".") is
// not already present in en.json's rootKeys. This means keys under entirely
// new namespaces (e.g. "brand_new_namespace.some_key") will NOT be reported
// as missing. This trade-off was chosen to reduce false-positive noise from
// string literals that look like translation keys but aren't (config keys,
// CSS classes, etc.). It is a known limitation: if a real translation key
// is added under a brand-new namespace and no en.json entry exists yet,
// this test will not catch it.
for (const key of Array.from(referencedStaticKeys).sort()) {
if (enKeySet.has(key)) continue;
if (!isPotentialTranslationKey(key, rootKeys, enKeySet)) continue;
missingKeys.push(key);
}
for (const key of allKeys) {
if (usedKeys.has(key)) continue;
if (IGNORED_UNUSED_KEY_PATTERNS.some((pattern) => pattern.test(key))) {
continue;
}
const isDynamic = dynamicKeyPatterns.some((pattern) => pattern.test(key));
if (isDynamic) {
dynamicKeys.push(key);
} else {
unusedKeys.push(key);
}
}
const hasFailing = missingKeys.length > 0 || unusedKeys.length > 0;
if (hasFailing) {
if (derivedDynamicPatterns.length > 0) {
console.log(
`\nDerived dynamic patterns (${derivedDynamicPatterns.length}):\n` +
derivedDynamicPatterns.map((p) => ` ${p.source}`).join("\n"),
);
}
if (dynamicKeys.length > 0) {
console.log(
`\nDynamically referenced keys (${dynamicKeys.length}) - verify manually:\n` +
dynamicKeys.map((k) => ` ${k}`).join("\n"),
);
}
if (missingKeys.length > 0) {
console.error(
`\nMissing translation keys in en.json (${missingKeys.length}):\n` +
missingKeys.map((k) => ` ${k}`).join("\n"),
);
}
if (unusedKeys.length > 0) {
console.error(
`\nUnused translation keys (${unusedKeys.length}):\n` +
unusedKeys.map((k) => ` ${k}`).join("\n"),
);
}
}
expect(missingKeys).toEqual([]);
expect(unusedKeys).toEqual([]);
});
});
+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");
});
});
});
-113
View File
@@ -1,113 +0,0 @@
import { PublicLobbySocket } from "../../src/client/LobbySocket";
class MockWebSocket extends EventTarget {
static instances: MockWebSocket[] = [];
static readonly OPEN = 1;
static readonly CLOSED = 3;
readonly url: string;
readyState = MockWebSocket.OPEN;
constructor(url: string) {
super();
this.url = url;
MockWebSocket.instances.push(this);
}
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, listener, options);
}
close(code?: number, reason?: string) {
this.readyState = MockWebSocket.CLOSED;
this.dispatchEvent(new CloseEvent("close", { code, reason }));
}
send(_data: unknown) {}
}
describe("PublicLobbySocket", () => {
const originalWebSocket = globalThis.WebSocket;
const originalFetch = globalThis.fetch;
beforeEach(() => {
MockWebSocket.instances = [];
// @ts-expect-error assign test mock
globalThis.WebSocket = MockWebSocket;
});
afterEach(() => {
globalThis.WebSocket = originalWebSocket;
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
it("delivers lobby updates from websocket messages", () => {
const updates: unknown[][] = [];
const socket = new PublicLobbySocket((lobbies) => updates.push(lobbies));
socket.start();
const ws = MockWebSocket.instances.at(-1);
expect(ws?.url).toContain("/lobbies");
ws?.dispatchEvent(
new MessageEvent("message", {
data: JSON.stringify({
type: "lobbies_update",
data: {
lobbies: [
{
gameID: "g1",
numClients: 1,
gameConfig: {
maxPlayers: 2,
gameMode: 0,
gameMap: "Earth",
},
},
],
},
}),
}),
);
expect(updates).toHaveLength(1);
expect((updates[0][0] as { gameID: string }).gameID).toBe("g1");
socket.stop();
});
it("falls back to HTTP polling after max websocket attempts", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ lobbies: [] }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const socket = new PublicLobbySocket(() => {}, {
maxWsAttempts: 1,
reconnectDelay: 0,
pollIntervalMs: 50,
});
socket.start();
const ws = MockWebSocket.instances.at(-1);
ws?.dispatchEvent(new CloseEvent("close"));
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(60);
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledTimes(2);
socket.stop();
});
});
-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");
});
});
});
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RankedType } from "../../../../src/core/game/Game";
vi.mock("../../../../src/client/Utils", () => ({
translateText: vi.fn((key: string) => {
const translations: Record<string, string> = {
"win_modal.exit": "Exit",
"win_modal.requeue": "Play Again",
"win_modal.keep": "Keep Playing",
"win_modal.spectate": "Spectate",
};
return translations[key] || key;
}),
getGamesPlayed: vi.fn(() => 10),
isInIframe: vi.fn(() => false),
TUTORIAL_VIDEO_URL: "https://example.com/tutorial",
}));
vi.mock("../../../../src/client/Api", () => ({
getUserMe: vi.fn(async () => null),
}));
vi.mock("../../../../src/client/Cosmetics", () => ({
fetchCosmetics: vi.fn(async () => []),
handlePurchase: vi.fn(),
patternRelationship: vi.fn(() => ({})),
}));
vi.mock("../../../../src/client/CrazyGamesSDK", () => ({
crazyGamesSDK: {
happytime: vi.fn(),
requestAd: vi.fn(),
gameplayStop: vi.fn(),
},
}));
describe("WinModal Requeue", () => {
let mockLocationHref = "";
beforeEach(() => {
mockLocationHref = "";
// Mock window.location.href using Object.defineProperty
const locationMock = {
get href() {
return mockLocationHref;
},
set href(value: string) {
mockLocationHref = value;
},
};
Object.defineProperty(window, "location", {
value: locationMock,
writable: true,
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("isRankedGame detection", () => {
it("should detect ranked 1v1 game", () => {
const gameConfig = {
rankedType: RankedType.OneVOne,
};
const isRankedGame = gameConfig.rankedType === RankedType.OneVOne;
expect(isRankedGame).toBe(true);
});
it("should not detect non-ranked game", () => {
const gameConfig = {
rankedType: undefined,
};
const isRankedGame = gameConfig.rankedType === RankedType.OneVOne;
expect(isRankedGame).toBe(false);
});
});
describe("requeue navigation", () => {
it("should navigate to /?requeue when requeue is triggered", () => {
// Simulate the _handleRequeue behavior
const handleRequeue = () => {
window.location.href = "/?requeue";
};
handleRequeue();
expect(window.location.href).toBe("/?requeue");
});
it("should navigate to / when exit is triggered", () => {
// Simulate the _handleExit behavior
const handleExit = () => {
window.location.href = "/";
};
handleExit();
expect(window.location.href).toBe("/");
});
});
describe("requeue URL parameter handling", () => {
it("should parse requeue parameter from URL", () => {
const url = new URL("http://localhost:9000/?requeue");
const hasRequeue = url.searchParams.has("requeue");
expect(hasRequeue).toBe(true);
});
it("should not find requeue parameter when absent", () => {
const url = new URL("http://localhost:9000/");
const hasRequeue = url.searchParams.has("requeue");
expect(hasRequeue).toBe(false);
});
});
});

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