mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 15:00:43 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
+5
-1
@@ -204,6 +204,7 @@
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></troubleshooting-modal>
|
||||
|
||||
<account-modal
|
||||
id="page-account"
|
||||
inline
|
||||
@@ -241,7 +242,10 @@
|
||||
<div
|
||||
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">
|
||||
<div
|
||||
class="order-2 sm:order-none w-full sm:w-1/2 min-[1200px]:w-auto lg:max-w-[400px]"
|
||||
>
|
||||
<attacks-display></attacks-display>
|
||||
<control-panel></control-panel>
|
||||
</div>
|
||||
<div
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "Traders Dream",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1010, 120],
|
||||
"flag": "",
|
||||
"name": "Fort Profit"
|
||||
},
|
||||
{
|
||||
"coordinates": [900, 400],
|
||||
"flag": "",
|
||||
"name": "Taxhaven"
|
||||
},
|
||||
{
|
||||
"coordinates": [1350, 370],
|
||||
"flag": "",
|
||||
"name": "Markup Mesa"
|
||||
},
|
||||
{
|
||||
"coordinates": [1100, 1290],
|
||||
"flag": "",
|
||||
"name": "Ledgerland"
|
||||
},
|
||||
{
|
||||
"coordinates": [850, 1550],
|
||||
"flag": "",
|
||||
"name": "Isle of Margins"
|
||||
},
|
||||
{
|
||||
"coordinates": [1350, 1550],
|
||||
"flag": "",
|
||||
"name": "Port Folio"
|
||||
},
|
||||
{
|
||||
"coordinates": [430, 810],
|
||||
"flag": "",
|
||||
"name": "Barter Bluffs"
|
||||
},
|
||||
{
|
||||
"coordinates": [1770, 810],
|
||||
"flag": "",
|
||||
"name": "Dividend Shores"
|
||||
},
|
||||
{
|
||||
"coordinates": [1210, 660],
|
||||
"flag": "",
|
||||
"name": "Market Peaks"
|
||||
},
|
||||
{
|
||||
"coordinates": [1150, 1830],
|
||||
"flag": "",
|
||||
"name": "Cape Commerce"
|
||||
},
|
||||
{
|
||||
"coordinates": [1890, 1390],
|
||||
"flag": "",
|
||||
"name": "Anchorspire"
|
||||
},
|
||||
{
|
||||
"coordinates": [310, 1420],
|
||||
"flag": "",
|
||||
"name": "Inflation Island"
|
||||
},
|
||||
{
|
||||
"coordinates": [1870, 400],
|
||||
"flag": "",
|
||||
"name": "Harborwick"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -63,11 +63,12 @@ var maps = []struct {
|
||||
{Name: "world"},
|
||||
{Name: "lemnos"},
|
||||
{Name: "twolakes"},
|
||||
{Name: "thebox"},
|
||||
{Name: "thebox"},
|
||||
{Name: "didier"},
|
||||
{Name: "didierfrance"},
|
||||
{Name: "amazonriver"},
|
||||
{Name: "yenisei"},
|
||||
{Name: "tradersdream"},
|
||||
{Name: "big_plains", IsTest: true},
|
||||
{Name: "half_land_half_ocean", IsTest: true},
|
||||
{Name: "ocean_and_land", IsTest: true},
|
||||
|
||||
Generated
+21
@@ -1232,6 +1232,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -1275,6 +1276,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2208,6 +2210,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -4601,6 +4604,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz",
|
||||
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4766,6 +4770,7 @@
|
||||
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.34.1",
|
||||
"@typescript-eslint/types": "8.34.1",
|
||||
@@ -5245,6 +5250,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5644,6 +5650,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001718",
|
||||
"electron-to-chromium": "^1.5.160",
|
||||
@@ -5797,6 +5804,7 @@
|
||||
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
@@ -6718,6 +6726,7 @@
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -7287,6 +7296,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8617,6 +8627,7 @@
|
||||
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.28",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -10212,6 +10223,7 @@
|
||||
"integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6",
|
||||
"@types/css-font-loading-module": "^0.0.12",
|
||||
@@ -10256,6 +10268,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -10358,6 +10371,7 @@
|
||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -11180,6 +11194,7 @@
|
||||
"integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sinonjs/commons": "^3.0.1",
|
||||
"@sinonjs/fake-timers": "^15.1.0",
|
||||
@@ -11601,6 +11616,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11843,6 +11859,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -11911,6 +11928,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12057,6 +12075,7 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12806,6 +12825,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12819,6 +12839,7 @@
|
||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.16",
|
||||
"@vitest/mocker": "4.0.16",
|
||||
|
||||
+9
-77
@@ -8,6 +8,7 @@
|
||||
"common": {
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"back": "Back",
|
||||
"available": "Available",
|
||||
"preset_max": "Max",
|
||||
@@ -26,38 +27,29 @@
|
||||
},
|
||||
"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",
|
||||
@@ -65,7 +57,6 @@
|
||||
},
|
||||
"troubleshooting": {
|
||||
"title": "Troubleshooting",
|
||||
"loading": "Loading...",
|
||||
"environment": "Environment",
|
||||
"rendering": "Rendering",
|
||||
"power": "Power",
|
||||
@@ -114,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",
|
||||
@@ -134,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.",
|
||||
@@ -192,25 +180,20 @@
|
||||
"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",
|
||||
@@ -245,9 +228,6 @@
|
||||
},
|
||||
"leaderboard_modal": {
|
||||
"title": "Leaderboard",
|
||||
"title_plural": "Leaderboards",
|
||||
"clan_stats": "Clan Stats",
|
||||
"player_stats": "1v1 Ranked Stats",
|
||||
"ranked_tab": "1v1 Ranked",
|
||||
"clans_tab": "Clans",
|
||||
"loading": "Loading...",
|
||||
@@ -346,7 +326,8 @@
|
||||
"surrounded": "Surrounded",
|
||||
"didier": "Didier",
|
||||
"didierfrance": "Didier (France)",
|
||||
"amazonriver": "Amazon River"
|
||||
"amazonriver": "Amazon River",
|
||||
"tradersdream": "Traders Dream"
|
||||
},
|
||||
"map_categories": {
|
||||
"featured": "Featured",
|
||||
@@ -363,10 +344,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...",
|
||||
@@ -376,7 +354,6 @@
|
||||
"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)",
|
||||
@@ -419,7 +396,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",
|
||||
@@ -446,10 +422,7 @@
|
||||
"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": {
|
||||
@@ -476,8 +449,7 @@
|
||||
},
|
||||
"game_mode": {
|
||||
"ffa": "Free for All",
|
||||
"teams": "Teams",
|
||||
"humans_vs_nations": "Humans vs Nations"
|
||||
"teams": "Teams"
|
||||
},
|
||||
"public_game_modifier": {
|
||||
"random_spawn": "Random Spawn",
|
||||
@@ -722,18 +694,13 @@
|
||||
"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",
|
||||
@@ -748,8 +715,6 @@
|
||||
},
|
||||
"events_display": {
|
||||
"retreating": "retreating",
|
||||
"retaliate": "Retaliate",
|
||||
"boat": "Boat",
|
||||
"alliance_request_status": "{name} {status} your alliance request",
|
||||
"alliance_accepted": "accepted",
|
||||
"alliance_rejected": "rejected",
|
||||
@@ -789,15 +754,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",
|
||||
@@ -810,11 +766,6 @@
|
||||
"friendly": "Friendly",
|
||||
"default": "Default"
|
||||
},
|
||||
"control_panel": {
|
||||
"gold": "Gold",
|
||||
"troops": "Troops",
|
||||
"attack_ratio": "Attack Ratio"
|
||||
},
|
||||
"player_panel": {
|
||||
"gold": "Gold",
|
||||
"troops": "Troops",
|
||||
@@ -824,18 +775,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",
|
||||
@@ -855,16 +802,15 @@
|
||||
"send_troops_modal": {
|
||||
"title_with_name": "Send Troops to {name}",
|
||||
"available_tooltip": "Your current available troops",
|
||||
"min_keep": "Min keep",
|
||||
"slider_tooltip": "{{percent}}% • {{amount}}",
|
||||
"slider_tooltip": "{percent}% • {amount}",
|
||||
"aria_slider": "Troops slider",
|
||||
"capacity_note": "Receiver can accept only {{amount}} right now."
|
||||
"capacity_note": "Receiver can accept only {amount} right now."
|
||||
},
|
||||
"send_gold_modal": {
|
||||
"title_with_name": "Send Gold to {name}",
|
||||
"available_tooltip": "Your current available gold",
|
||||
"aria_slider": "Amount slider",
|
||||
"slider_tooltip": "{{percent}}% • {{amount}}"
|
||||
"slider_tooltip": "{percent}% • {amount}"
|
||||
},
|
||||
"replay_panel": {
|
||||
"replay_speed": "Replay speed",
|
||||
@@ -910,10 +856,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"
|
||||
},
|
||||
@@ -925,15 +867,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"
|
||||
@@ -992,7 +925,6 @@
|
||||
"replay": "Replay",
|
||||
"details": "Details",
|
||||
"ranking": "Ranking",
|
||||
"started": "Started",
|
||||
"map": "Map",
|
||||
"difficulty": "Difficulty",
|
||||
"type": "Type"
|
||||
@@ -1000,7 +932,7 @@
|
||||
"player_stats_tree": {
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"singleplayer": "Solo",
|
||||
"solo": "Solo",
|
||||
"mode": "Mode",
|
||||
"stats_wins": "Wins",
|
||||
"stats_losses": "Losses",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1920,
|
||||
"num_land_tiles": 972041,
|
||||
"width": 2200
|
||||
},
|
||||
"map16x": {
|
||||
"height": 480,
|
||||
"num_land_tiles": 58992,
|
||||
"width": 550
|
||||
},
|
||||
"map4x": {
|
||||
"height": 960,
|
||||
"num_land_tiles": 240669,
|
||||
"width": 1100
|
||||
},
|
||||
"name": "Traders Dream",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1010, 120],
|
||||
"flag": "",
|
||||
"name": "Fort Profit"
|
||||
},
|
||||
{
|
||||
"coordinates": [900, 400],
|
||||
"flag": "",
|
||||
"name": "Taxhaven"
|
||||
},
|
||||
{
|
||||
"coordinates": [1350, 370],
|
||||
"flag": "",
|
||||
"name": "Markup Mesa"
|
||||
},
|
||||
{
|
||||
"coordinates": [1100, 1290],
|
||||
"flag": "",
|
||||
"name": "Ledgerland"
|
||||
},
|
||||
{
|
||||
"coordinates": [850, 1550],
|
||||
"flag": "",
|
||||
"name": "Isle of Margins"
|
||||
},
|
||||
{
|
||||
"coordinates": [1350, 1550],
|
||||
"flag": "",
|
||||
"name": "Port Folio"
|
||||
},
|
||||
{
|
||||
"coordinates": [430, 810],
|
||||
"flag": "",
|
||||
"name": "Barter Bluffs"
|
||||
},
|
||||
{
|
||||
"coordinates": [1770, 810],
|
||||
"flag": "",
|
||||
"name": "Dividend Shores"
|
||||
},
|
||||
{
|
||||
"coordinates": [1210, 660],
|
||||
"flag": "",
|
||||
"name": "Market Peaks"
|
||||
},
|
||||
{
|
||||
"coordinates": [1150, 1830],
|
||||
"flag": "",
|
||||
"name": "Cape Commerce"
|
||||
},
|
||||
{
|
||||
"coordinates": [1890, 1390],
|
||||
"flag": "",
|
||||
"name": "Anchorspire"
|
||||
},
|
||||
{
|
||||
"coordinates": [310, 1420],
|
||||
"flag": "",
|
||||
"name": "Inflation Island"
|
||||
},
|
||||
{
|
||||
"coordinates": [1870, 400],
|
||||
"flag": "",
|
||||
"name": "Harborwick"
|
||||
}
|
||||
]
|
||||
}
|
||||
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: 7.6 KiB |
@@ -2,16 +2,12 @@ import { decodeJwt } from "jose";
|
||||
import { z } from "zod";
|
||||
import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
|
||||
import { base64urlToUuid } from "../core/Base64";
|
||||
import { ID } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getApiBase, getAudience } from "./Api";
|
||||
import { generateCryptoRandomUUID } from "./Utils";
|
||||
|
||||
export type UserAuth = { jwt: string; claims: TokenPayload } | false;
|
||||
|
||||
const PERSISTENT_ID_KEY = "player_persistent_id";
|
||||
const CLIENT_ID_KEY = "client_join_id";
|
||||
const CLIENT_GAME_ID_KEY = "client_join_game_id";
|
||||
|
||||
let __jwt: string | null = null;
|
||||
|
||||
@@ -213,22 +209,6 @@ export function getPersistentID(): string {
|
||||
return base64urlToUuid(sub);
|
||||
}
|
||||
|
||||
export function getClientIDForGame(gameID: string): string {
|
||||
const storedGameID = sessionStorage.getItem(CLIENT_GAME_ID_KEY);
|
||||
const storedClientID = sessionStorage.getItem(CLIENT_ID_KEY);
|
||||
if (
|
||||
storedGameID === gameID &&
|
||||
storedClientID &&
|
||||
ID.safeParse(storedClientID).success
|
||||
) {
|
||||
return storedClientID;
|
||||
}
|
||||
const newID = generateID();
|
||||
sessionStorage.setItem(CLIENT_GAME_ID_KEY, gameID);
|
||||
sessionStorage.setItem(CLIENT_ID_KEY, newID);
|
||||
return newID;
|
||||
}
|
||||
|
||||
// WARNING: DO NOT EXPOSE THIS ID
|
||||
function getPersistentIDFromLocalStorage(): string {
|
||||
// Try to get existing localStorage
|
||||
|
||||
@@ -57,7 +57,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.
|
||||
@@ -72,9 +71,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 ?? {});
|
||||
@@ -83,23 +83,18 @@ 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") {
|
||||
eventBus.emit(new LobbyInfoEvent(message.lobby));
|
||||
// Server tells us our assigned clientID
|
||||
clientID = message.myClientID;
|
||||
eventBus.emit(new LobbyInfoEvent(message.lobby, message.myClientID));
|
||||
return;
|
||||
}
|
||||
if (message.type === "prestart") {
|
||||
@@ -119,11 +114,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,
|
||||
@@ -149,7 +147,7 @@ export function joinLobby(
|
||||
e.message,
|
||||
e.stack,
|
||||
lobbyConfig.gameID,
|
||||
lobbyConfig.clientID,
|
||||
clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.connection_error",
|
||||
@@ -170,7 +168,7 @@ export function joinLobby(
|
||||
message.error,
|
||||
message.message,
|
||||
lobbyConfig.gameID,
|
||||
lobbyConfig.clientID,
|
||||
clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.connection_error",
|
||||
@@ -197,6 +195,7 @@ export function joinLobby(
|
||||
|
||||
async function createClientGame(
|
||||
lobbyConfig: LobbyConfig,
|
||||
clientID: ClientID,
|
||||
eventBus: EventBus,
|
||||
transport: Transport,
|
||||
userSettings: UserSettings,
|
||||
@@ -222,16 +221,14 @@ 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.playerName,
|
||||
lobbyConfig.gameStartInfo.gameID,
|
||||
lobbyConfig.gameStartInfo.players,
|
||||
);
|
||||
@@ -245,6 +242,7 @@ async function createClientGame(
|
||||
|
||||
return new ClientGameRunner(
|
||||
lobbyConfig,
|
||||
clientID,
|
||||
eventBus,
|
||||
gameRenderer,
|
||||
new InputHandler(gameRenderer.uiState, canvas, eventBus),
|
||||
@@ -270,6 +268,7 @@ export class ClientGameRunner {
|
||||
|
||||
constructor(
|
||||
private lobby: LobbyConfig,
|
||||
private clientID: ClientID,
|
||||
private eventBus: EventBus,
|
||||
private renderer: GameRenderer,
|
||||
private input: InputHandler,
|
||||
@@ -303,8 +302,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],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -361,7 +360,7 @@ export class ClientGameRunner {
|
||||
gu.errMsg,
|
||||
gu.stack ?? "missing",
|
||||
this.lobby.gameStartInfo.gameID,
|
||||
this.lobby.clientID,
|
||||
this.clientID,
|
||||
);
|
||||
console.error(gu.stack);
|
||||
this.stop();
|
||||
@@ -423,7 +422,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"),
|
||||
@@ -460,7 +459,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",
|
||||
@@ -471,7 +470,7 @@ export class ClientGameRunner {
|
||||
message.error,
|
||||
message.message,
|
||||
this.lobby.gameID,
|
||||
this.lobby.clientID,
|
||||
this.clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.connection_error",
|
||||
@@ -555,7 +554,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;
|
||||
}
|
||||
@@ -585,7 +584,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;
|
||||
}
|
||||
@@ -646,7 +645,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;
|
||||
}
|
||||
@@ -665,7 +664,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;
|
||||
}
|
||||
@@ -779,7 +778,7 @@ function showErrorModal(
|
||||
error: string,
|
||||
message: string | undefined,
|
||||
gameID: GameID,
|
||||
clientID: ClientID,
|
||||
clientID: ClientID | undefined,
|
||||
closable = false,
|
||||
showDiscord = true,
|
||||
heading = "error_modal.crashed",
|
||||
|
||||
+14
-5
@@ -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}"
|
||||
|
||||
@@ -154,7 +154,7 @@ export class HelpModal extends BaseModal {
|
||||
<iframe
|
||||
id="tutorial-video-iframe"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
src="${TUTORIAL_VIDEO_URL}"
|
||||
src="${this.isModalOpen ? TUTORIAL_VIDEO_URL : ""}"
|
||||
title="${translateText("help_modal.video_tutorial_title")}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
|
||||
@@ -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,11 +18,12 @@ import {
|
||||
ClientInfo,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
LobbyInfoEvent,
|
||||
TeamCountConfig,
|
||||
isValidGameID,
|
||||
} from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getClientIDForGame } from "./Auth";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
@@ -74,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;
|
||||
@@ -81,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,
|
||||
@@ -137,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,
|
||||
@@ -636,10 +663,13 @@ export class HostLobbyModal extends BaseModal {
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.startLobbyUpdates();
|
||||
this.lobbyId = generateID();
|
||||
this.lobbyCreatorClientID = getClientIDForGame(this.lobbyId);
|
||||
// Note: clientID will be assigned by server when we join the lobby
|
||||
// lobbyCreatorClientID stays empty until then
|
||||
|
||||
createLobby(this.lobbyCreatorClientID, this.lobbyId)
|
||||
// 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)) {
|
||||
@@ -654,7 +684,6 @@ export class HostLobbyModal extends BaseModal {
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: this.lobbyId,
|
||||
clientID: this.lobbyCreatorClientID,
|
||||
source: "host",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
@@ -720,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
|
||||
@@ -1083,20 +1113,20 @@ export class HostLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
async function createLobby(
|
||||
creatorClientID: string,
|
||||
gameID: 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 response = await fetch(
|
||||
`/${config.workerPath(gameID)}/api/create_game/${gameID}?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
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
HumansVsNations,
|
||||
} from "../core/game/Game";
|
||||
import { getApiBase } from "./Api";
|
||||
import { getClientIDForGame } from "./Auth";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
@@ -61,9 +60,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
|
||||
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
|
||||
const lobby = event.lobby;
|
||||
if (!this.currentLobbyId || lobby.gameID !== this.currentLobbyId) {
|
||||
return;
|
||||
}
|
||||
this.currentClientID = event.myClientID;
|
||||
// Only stop showing spinner when we have player info
|
||||
if (this.isConnecting && lobby.clients) {
|
||||
this.isConnecting = false;
|
||||
@@ -335,7 +332,6 @@ export class JoinLobbyModal extends BaseModal {
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
source: "public",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
@@ -346,7 +342,8 @@ export class JoinLobbyModal extends BaseModal {
|
||||
|
||||
private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) {
|
||||
this.currentLobbyId = lobbyId;
|
||||
this.currentClientID = getClientIDForGame(lobbyId);
|
||||
// clientID will be assigned by server via lobby_info message
|
||||
this.currentClientID = "";
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
this.nationCount = 0;
|
||||
@@ -545,9 +542,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
this.lobbyCreatorClientID = this.isPrivateLobby()
|
||||
? (lobby.clients?.[0]?.clientID ?? null)
|
||||
: null;
|
||||
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? null;
|
||||
}
|
||||
|
||||
private startLobbyUpdates() {
|
||||
@@ -776,7 +771,6 @@ export class JoinLobbyModal extends BaseModal {
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
source: "private",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
@@ -835,7 +829,6 @@ export class JoinLobbyModal extends BaseModal {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
gameRecord: parsed.data,
|
||||
clientID: this.currentClientID,
|
||||
source: "private",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+3
-2
@@ -210,7 +210,6 @@ declare global {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -504,6 +503,8 @@ 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");
|
||||
@@ -818,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,
|
||||
},
|
||||
@@ -850,6 +850,7 @@ class Client {
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
"lang-selector",
|
||||
"gutter-ads",
|
||||
].forEach((tag) => {
|
||||
const modal = document.querySelector(tag) as HTMLElement & {
|
||||
close?: () => void;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { customElement, query, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { getUserMe, hasLinkedAccount } from "./Api";
|
||||
import { getClientIDForGame, getPlayToken } from "./Auth";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
@@ -230,7 +230,6 @@ export class MatchmakingModal extends BaseModal {
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: this.gameID,
|
||||
clientID: getClientIDForGame(this.gameID),
|
||||
source: "matchmaking",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
|
||||
@@ -912,7 +912,6 @@ export class SinglePlayerModal extends BaseModal {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
clientID: clientID,
|
||||
gameID: gameID,
|
||||
gameStartInfo: {
|
||||
gameID: gameID,
|
||||
|
||||
+2
-25
@@ -403,7 +403,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,
|
||||
@@ -415,7 +415,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);
|
||||
@@ -444,7 +444,6 @@ export class Transport {
|
||||
private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "allianceRequest",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
});
|
||||
}
|
||||
@@ -452,7 +451,6 @@ export class Transport {
|
||||
private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "allianceRequestReply",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
requestor: event.requestor.id(),
|
||||
accept: event.accepted,
|
||||
});
|
||||
@@ -461,7 +459,6 @@ export class Transport {
|
||||
private onBreakAllianceRequestUIEvent(event: SendBreakAllianceIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "breakAlliance",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
});
|
||||
}
|
||||
@@ -471,7 +468,6 @@ export class Transport {
|
||||
) {
|
||||
this.sendIntent({
|
||||
type: "allianceExtension",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
});
|
||||
}
|
||||
@@ -479,7 +475,6 @@ export class Transport {
|
||||
private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "spawn",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
tile: event.tile,
|
||||
});
|
||||
}
|
||||
@@ -487,7 +482,6 @@ export class Transport {
|
||||
private onSendAttackIntent(event: SendAttackIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "attack",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
targetID: event.targetID,
|
||||
troops: event.troops,
|
||||
sourceTile: event.sourceTile,
|
||||
@@ -497,7 +491,6 @@ export class Transport {
|
||||
private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "boat",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
troops: event.troops,
|
||||
dst: event.dst,
|
||||
});
|
||||
@@ -507,7 +500,6 @@ export class Transport {
|
||||
this.sendIntent({
|
||||
type: "upgrade_structure",
|
||||
unit: event.unitType,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unitId: event.unitId,
|
||||
});
|
||||
}
|
||||
@@ -515,7 +507,6 @@ export class Transport {
|
||||
private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "targetPlayer",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
target: event.targetID,
|
||||
});
|
||||
}
|
||||
@@ -523,7 +514,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,
|
||||
@@ -533,7 +523,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,
|
||||
});
|
||||
@@ -542,7 +531,6 @@ export class Transport {
|
||||
private onSendDonateTroopIntent(event: SendDonateTroopsIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "donate_troops",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
troops: event.troops,
|
||||
});
|
||||
@@ -551,7 +539,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,
|
||||
@@ -561,7 +548,6 @@ export class Transport {
|
||||
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "embargo",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
targetID: event.target.id(),
|
||||
action: event.action,
|
||||
});
|
||||
@@ -570,7 +556,6 @@ export class Transport {
|
||||
private onSendEmbargoAllIntent(event: SendEmbargoAllIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "embargo_all",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
action: event.action,
|
||||
});
|
||||
}
|
||||
@@ -578,7 +563,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,
|
||||
@@ -588,7 +572,6 @@ export class Transport {
|
||||
private onPauseGameIntent(event: PauseGameIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "toggle_pause",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
paused: event.paused,
|
||||
});
|
||||
}
|
||||
@@ -628,7 +611,6 @@ export class Transport {
|
||||
private onCancelAttackIntentEvent(event: CancelAttackIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "cancel_attack",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
attackID: event.attackID,
|
||||
});
|
||||
}
|
||||
@@ -636,7 +618,6 @@ export class Transport {
|
||||
private onCancelBoatIntentEvent(event: CancelBoatIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "cancel_boat",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unitID: event.unitID,
|
||||
});
|
||||
}
|
||||
@@ -644,7 +625,6 @@ export class Transport {
|
||||
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "move_warship",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unitId: event.unitId,
|
||||
tile: event.tile,
|
||||
});
|
||||
@@ -653,7 +633,6 @@ export class Transport {
|
||||
private onSendDeleteUnitIntent(event: SendDeleteUnitIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "delete_unit",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unitId: event.unitId,
|
||||
});
|
||||
}
|
||||
@@ -661,7 +640,6 @@ export class Transport {
|
||||
private onSendKickPlayerIntent(event: SendKickPlayerIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "kick_player",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
target: event.target,
|
||||
});
|
||||
}
|
||||
@@ -669,7 +647,6 @@ export class Transport {
|
||||
private onSendUpdateGameConfigIntent(event: SendUpdateGameConfigIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "update_game_config",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
config: event.config,
|
||||
});
|
||||
}
|
||||
|
||||
+26
-2
@@ -44,12 +44,36 @@ export function getGameModeLabel(gameConfig: GameConfig): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric team count
|
||||
// Numeric team count (e.g. "5 teams of 20")
|
||||
const teamCount =
|
||||
typeof playerTeams === "number"
|
||||
? playerTeams
|
||||
: getTeamCount(playerTeams, maxPlayers ?? 0);
|
||||
return translateText("public_lobby.teams", { num: teamCount });
|
||||
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(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FrameProfiler } from "./FrameProfiler";
|
||||
import { TransformHandler } from "./TransformHandler";
|
||||
import { UIState } from "./UIState";
|
||||
import { AlertFrame } from "./layers/AlertFrame";
|
||||
import { AttacksDisplay } from "./layers/AttacksDisplay";
|
||||
import { BuildMenu } from "./layers/BuildMenu";
|
||||
import { ChatDisplay } from "./layers/ChatDisplay";
|
||||
import { ChatModal } from "./layers/ChatModal";
|
||||
@@ -124,6 +125,16 @@ export function createRenderer(
|
||||
eventsDisplay.game = game;
|
||||
eventsDisplay.uiState = uiState;
|
||||
|
||||
const attacksDisplay = document.querySelector(
|
||||
"attacks-display",
|
||||
) as AttacksDisplay;
|
||||
if (!(attacksDisplay instanceof AttacksDisplay)) {
|
||||
console.error("attacks display not found");
|
||||
}
|
||||
attacksDisplay.eventBus = eventBus;
|
||||
attacksDisplay.game = game;
|
||||
attacksDisplay.uiState = uiState;
|
||||
|
||||
const chatDisplay = document.querySelector("chat-display") as ChatDisplay;
|
||||
if (!(chatDisplay instanceof ChatDisplay)) {
|
||||
console.error("chat display not found");
|
||||
@@ -266,17 +277,18 @@ 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, transformHandler),
|
||||
new FxLayer(game, eventBus, transformHandler),
|
||||
new UILayer(game, eventBus, transformHandler),
|
||||
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
|
||||
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
|
||||
new DynamicUILayer(game, transformHandler, eventBus),
|
||||
new NameLayer(game, transformHandler, eventBus),
|
||||
eventsDisplay,
|
||||
attacksDisplay,
|
||||
chatDisplay,
|
||||
buildMenu,
|
||||
new MainRadialMenu(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UnitType } from "../../core/game/Game";
|
||||
export interface UIState {
|
||||
attackRatio: number;
|
||||
ghostStructure: UnitType | null;
|
||||
overlappingRailroads: number[];
|
||||
rocketDirectionUp: boolean;
|
||||
localAttackHeld: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { MessageType, PlayerType, UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
AttackUpdate,
|
||||
GameUpdateType,
|
||||
UnitIncomingUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import {
|
||||
CancelAttackIntentEvent,
|
||||
CancelBoatIntentEvent,
|
||||
SendAttackIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { renderTroops, translateText } from "../../Utils";
|
||||
import { getColoredSprite } from "../SpriteLoader";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
import {
|
||||
GoToPlayerEvent,
|
||||
GoToPositionEvent,
|
||||
GoToUnitEvent,
|
||||
} from "./Leaderboard";
|
||||
import swordIcon from "/images/SwordIcon.svg?url";
|
||||
|
||||
@customElement("attacks-display")
|
||||
export class AttacksDisplay extends LitElement implements Layer {
|
||||
public eventBus: EventBus;
|
||||
public game: GameView;
|
||||
public uiState: UIState;
|
||||
|
||||
private active: boolean = false;
|
||||
private incomingBoatIDs: Set<number> = new Set();
|
||||
private spriteDataURLCache: Map<string, string> = new Map();
|
||||
@state() private _isVisible: boolean = false;
|
||||
@state() private incomingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingLandAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingBoats: UnitView[] = [];
|
||||
@state() private incomingBoats: UnitView[] = [];
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
tick() {
|
||||
this.active = true;
|
||||
|
||||
if (!this._isVisible && !this.game.inSpawnPhase()) {
|
||||
this._isVisible = true;
|
||||
}
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isAlive()) {
|
||||
if (this._isVisible) {
|
||||
this._isVisible = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Track incoming boat unit IDs from UnitIncoming events
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
if (updates) {
|
||||
for (const event of updates[
|
||||
GameUpdateType.UnitIncoming
|
||||
] as UnitIncomingUpdate[]) {
|
||||
if (
|
||||
event.playerID === myPlayer.smallID() &&
|
||||
event.messageType === MessageType.NAVAL_INVASION_INBOUND
|
||||
) {
|
||||
this.incomingBoatIDs.add(event.unitID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve incoming boats from tracked IDs, remove inactive ones
|
||||
const resolvedIncomingBoats: UnitView[] = [];
|
||||
for (const unitID of this.incomingBoatIDs) {
|
||||
const unit = this.game.unit(unitID);
|
||||
if (unit && unit.isActive() && unit.type() === UnitType.TransportShip) {
|
||||
resolvedIncomingBoats.push(unit);
|
||||
} else {
|
||||
this.incomingBoatIDs.delete(unitID);
|
||||
}
|
||||
}
|
||||
this.incomingBoats = resolvedIncomingBoats;
|
||||
|
||||
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
|
||||
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
|
||||
return t !== PlayerType.Bot;
|
||||
});
|
||||
|
||||
this.outgoingAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID !== 0);
|
||||
|
||||
this.outgoingLandAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID === 0);
|
||||
|
||||
this.outgoingBoats = myPlayer
|
||||
.units()
|
||||
.filter((u) => u.type() === UnitType.TransportShip);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
renderLayer(): void {}
|
||||
|
||||
private renderButton(options: {
|
||||
content: any;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
translate?: boolean;
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
const {
|
||||
content,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
translate = true,
|
||||
hidden = false,
|
||||
} = options;
|
||||
|
||||
if (hidden) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="${className}"
|
||||
@click=${onClick}
|
||||
?disabled=${disabled}
|
||||
?translate=${translate}
|
||||
>
|
||||
${content}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private emitCancelAttackIntent(id: string) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelAttackIntentEvent(id));
|
||||
}
|
||||
|
||||
private emitBoatCancelIntent(id: number) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelBoatIntentEvent(id));
|
||||
}
|
||||
|
||||
private emitGoToPlayerEvent(attackerID: number) {
|
||||
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
|
||||
this.eventBus.emit(new GoToPlayerEvent(attacker));
|
||||
}
|
||||
|
||||
private getBoatSpriteDataURL(unit: UnitView): string {
|
||||
const owner = unit.owner();
|
||||
const key = `boat-${owner.id()}`;
|
||||
const cached = this.spriteDataURLCache.get(key);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const canvas = getColoredSprite(unit, this.game.config().theme());
|
||||
const dataURL = canvas.toDataURL();
|
||||
this.spriteDataURLCache.set(key, dataURL);
|
||||
return dataURL;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private async attackWarningOnClick(attack: AttackUpdate) {
|
||||
const playerView = this.game.playerBySmallID(attack.attackerID);
|
||||
if (playerView !== undefined) {
|
||||
if (playerView instanceof PlayerView) {
|
||||
const averagePosition = await playerView.attackAveragePosition(
|
||||
attack.attackerID,
|
||||
attack.id,
|
||||
);
|
||||
|
||||
if (averagePosition === null) {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
} else {
|
||||
this.eventBus.emit(
|
||||
new GoToPositionEvent(averagePosition.x, averagePosition.y),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetaliate(attack: AttackUpdate) {
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
|
||||
const counterTroops = Math.min(
|
||||
attack.troops,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
);
|
||||
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
|
||||
}
|
||||
|
||||
private renderIncomingAttacks() {
|
||||
if (this.incomingAttacks.length === 0) return html``;
|
||||
|
||||
return this.incomingAttacks.map(
|
||||
(attack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(attack.troops)}</span
|
||||
>
|
||||
<span class="truncate"
|
||||
>${(
|
||||
this.game.playerBySmallID(attack.attackerID) as PlayerView
|
||||
)?.name()}</span
|
||||
>
|
||||
${attack.retreating
|
||||
? `(${translateText("events_display.retreating")}...)`
|
||||
: ""} `,
|
||||
onClick: () => this.attackWarningOnClick(attack),
|
||||
className:
|
||||
"text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4"
|
||||
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
|
||||
/>`,
|
||||
onClick: () => this.handleRetaliate(attack),
|
||||
className:
|
||||
"ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 rounded px-1.5 py-1 border border-red-700/50",
|
||||
translate: false,
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderOutgoingAttacks() {
|
||||
if (this.outgoingAttacks.length === 0) return html``;
|
||||
|
||||
return this.outgoingAttacks.map(
|
||||
(attack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: invert(1)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(attack.troops)}</span
|
||||
>
|
||||
<span class="truncate"
|
||||
>${(
|
||||
this.game.playerBySmallID(attack.targetID) as PlayerView
|
||||
)?.name()}</span
|
||||
> `,
|
||||
onClick: async () => this.attackWarningOnClick(attack),
|
||||
className:
|
||||
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitCancelAttackIntent(attack.id),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: attack.retreating,
|
||||
})
|
||||
: html`<span class="ml-auto shrink-0 text-blue-400"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderOutgoingLandAttacks() {
|
||||
if (this.outgoingLandAttacks.length === 0) return html``;
|
||||
|
||||
return this.outgoingLandAttacks.map(
|
||||
(landAttack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: invert(1)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(landAttack.troops)}</span
|
||||
>
|
||||
${translateText("help_modal.ui_wilderness")}`,
|
||||
className:
|
||||
"text-left text-gray-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!landAttack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitCancelAttackIntent(landAttack.id),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: landAttack.retreating,
|
||||
})
|
||||
: html`<span class="ml-auto shrink-0 text-blue-400"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private getBoatTargetName(boat: UnitView): string {
|
||||
const target = boat.targetTile();
|
||||
if (target === undefined) return "";
|
||||
const ownerID = this.game.ownerID(target);
|
||||
if (ownerID === 0) return "";
|
||||
const player = this.game.playerBySmallID(ownerID) as PlayerView;
|
||||
return player?.name() ?? "";
|
||||
}
|
||||
|
||||
private renderBoatIcon(boat: UnitView) {
|
||||
const dataURL = this.getBoatSpriteDataURL(boat);
|
||||
if (!dataURL) return html``;
|
||||
return html`<img
|
||||
src="${dataURL}"
|
||||
class="h-5 w-5 inline-block"
|
||||
style="image-rendering: pixelated"
|
||||
/>`;
|
||||
}
|
||||
|
||||
private renderBoats() {
|
||||
if (this.outgoingBoats.length === 0) return html``;
|
||||
|
||||
return this.outgoingBoats.map(
|
||||
(boat) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`${this.renderBoatIcon(boat)}
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(boat.troops())}</span
|
||||
>
|
||||
<span class="truncate text-xs"
|
||||
>${this.getBoatTargetName(boat)}</span
|
||||
>`,
|
||||
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
|
||||
className:
|
||||
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!boat.retreating()
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitBoatCancelIntent(boat.id()),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: boat.retreating(),
|
||||
})
|
||||
: html`<span class="ml-auto shrink-0 text-blue-400"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderIncomingBoats() {
|
||||
if (this.incomingBoats.length === 0) return html``;
|
||||
|
||||
return this.incomingBoats.map(
|
||||
(boat) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`${this.renderBoatIcon(boat)}
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(boat.troops())}</span
|
||||
>
|
||||
<span class="truncate text-xs">${boat.owner()?.name()}</span>`,
|
||||
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
|
||||
className:
|
||||
"text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.active || !this._isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const hasAnything =
|
||||
this.outgoingAttacks.length > 0 ||
|
||||
this.outgoingLandAttacks.length > 0 ||
|
||||
this.outgoingBoats.length > 0 ||
|
||||
this.incomingAttacks.length > 0 ||
|
||||
this.incomingBoats.length > 0;
|
||||
|
||||
if (!hasAnything) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="w-full mb-1 pointer-events-auto grid grid-cols-2 lg:grid-cols-1 gap-1 text-white text-sm lg:text-base"
|
||||
>
|
||||
${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()}
|
||||
${this.renderBoats()} ${this.renderIncomingAttacks()}
|
||||
${this.renderIncomingBoats()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
return html`
|
||||
<div
|
||||
class="pointer-events-auto ${this._isVisible
|
||||
? "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"
|
||||
? "relative z-[60] w-full 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()}
|
||||
>
|
||||
|
||||
@@ -8,15 +8,12 @@ import {
|
||||
getMessageCategory,
|
||||
MessageCategory,
|
||||
MessageType,
|
||||
PlayerType,
|
||||
Tick,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import {
|
||||
AllianceExpiredUpdate,
|
||||
AllianceRequestReplyUpdate,
|
||||
AllianceRequestUpdate,
|
||||
AttackUpdate,
|
||||
BrokeAllianceUpdate,
|
||||
DisplayChatMessageUpdate,
|
||||
DisplayMessageUpdate,
|
||||
@@ -26,22 +23,15 @@ import {
|
||||
UnitIncomingUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import {
|
||||
CancelAttackIntentEvent,
|
||||
CancelBoatIntentEvent,
|
||||
SendAllianceExtensionIntentEvent,
|
||||
SendAllianceReplyIntentEvent,
|
||||
SendAttackIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import { onlyImages } from "../../../core/Util";
|
||||
import { renderNumber, renderTroops } from "../../Utils";
|
||||
import {
|
||||
GoToPlayerEvent,
|
||||
GoToPositionEvent,
|
||||
GoToUnitEvent,
|
||||
} from "./Leaderboard";
|
||||
import { renderNumber } from "../../Utils";
|
||||
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
|
||||
|
||||
import { getMessageTypeClasses, translateText } from "../../Utils";
|
||||
import { UIState } from "../UIState";
|
||||
@@ -84,10 +74,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
|
||||
// allianceID -> last checked at tick
|
||||
private alliancesCheckedAt = new Map<number, Tick>();
|
||||
@state() private incomingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingLandAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingBoats: UnitView[] = [];
|
||||
@state() private _hidden: boolean = false;
|
||||
@state() private _isVisible: boolean = false;
|
||||
@state() private newEvents: number = 0;
|
||||
@@ -194,9 +180,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
constructor() {
|
||||
super();
|
||||
this.events = [];
|
||||
this.incomingAttacks = [];
|
||||
this.outgoingAttacks = [];
|
||||
this.outgoingBoats = [];
|
||||
}
|
||||
|
||||
init() {}
|
||||
@@ -254,24 +237,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Update attacks
|
||||
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
|
||||
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
|
||||
return t !== PlayerType.Bot;
|
||||
});
|
||||
|
||||
this.outgoingAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID !== 0);
|
||||
|
||||
this.outgoingLandAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID === 0);
|
||||
|
||||
this.outgoingBoats = myPlayer
|
||||
.units()
|
||||
.filter((u) => u.type() === UnitType.TransportShip);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -664,28 +629,12 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
emitCancelAttackIntent(id: string) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelAttackIntentEvent(id));
|
||||
}
|
||||
|
||||
emitBoatCancelIntent(id: number) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelBoatIntentEvent(id));
|
||||
}
|
||||
|
||||
emitGoToPlayerEvent(attackerID: number) {
|
||||
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
this.eventBus.emit(new GoToPlayerEvent(attacker));
|
||||
}
|
||||
|
||||
emitGoToPositionEvent(x: number, y: number) {
|
||||
this.eventBus.emit(new GoToPositionEvent(x, y));
|
||||
}
|
||||
|
||||
emitGoToUnitEvent(unit: UnitView) {
|
||||
this.eventBus.emit(new GoToUnitEvent(unit));
|
||||
}
|
||||
@@ -753,196 +702,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
: event.description;
|
||||
}
|
||||
|
||||
private async attackWarningOnClick(attack: AttackUpdate) {
|
||||
const playerView = this.game.playerBySmallID(attack.attackerID);
|
||||
if (playerView !== undefined) {
|
||||
if (playerView instanceof PlayerView) {
|
||||
const averagePosition = await playerView.attackAveragePosition(
|
||||
attack.attackerID,
|
||||
attack.id,
|
||||
);
|
||||
|
||||
if (averagePosition === null) {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
} else {
|
||||
this.emitGoToPositionEvent(averagePosition.x, averagePosition.y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetaliate(attack: AttackUpdate) {
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
|
||||
const counterTroops = Math.min(
|
||||
attack.troops,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
);
|
||||
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
|
||||
}
|
||||
|
||||
private renderIncomingAttacks() {
|
||||
return html`
|
||||
${this.incomingAttacks.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.incomingAttacks.map(
|
||||
(attack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
this.game.playerBySmallID(
|
||||
attack.attackerID,
|
||||
) as PlayerView
|
||||
)?.name()}
|
||||
${attack.retreating
|
||||
? `(${translateText("events_display.retreating")}...)`
|
||||
: ""}
|
||||
`,
|
||||
onClick: () => this.attackWarningOnClick(attack),
|
||||
className: "text-left text-red-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: translateText("events_display.retaliate"),
|
||||
onClick: () => this.handleRetaliate(attack),
|
||||
className:
|
||||
"inline-block px-3 py-1 text-white rounded-sm text-md md:text-sm cursor-pointer transition-colors duration-300 bg-red-600 hover:bg-red-700",
|
||||
translate: true,
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOutgoingAttacks() {
|
||||
return html`
|
||||
${this.outgoingAttacks.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.outgoingAttacks.map(
|
||||
(attack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
this.game.playerBySmallID(
|
||||
attack.targetID,
|
||||
) as PlayerView
|
||||
)?.name()}
|
||||
`,
|
||||
onClick: async () => this.attackWarningOnClick(attack),
|
||||
className: "text-left text-blue-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitCancelAttackIntent(attack.id),
|
||||
className: "text-left shrink-0",
|
||||
disabled: attack.retreating,
|
||||
})
|
||||
: html`<span class="shrink-0 text-blue-400"
|
||||
>(${translateText(
|
||||
"events_display.retreating",
|
||||
)}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOutgoingLandAttacks() {
|
||||
return html`
|
||||
${this.outgoingLandAttacks.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.outgoingLandAttacks.map(
|
||||
(landAttack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`${renderTroops(landAttack.troops)}
|
||||
${translateText("help_modal.ui_wilderness")}`,
|
||||
className: "text-left text-gray-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!landAttack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () =>
|
||||
this.emitCancelAttackIntent(landAttack.id),
|
||||
className: "text-left shrink-0",
|
||||
disabled: landAttack.retreating,
|
||||
})
|
||||
: html`<span class="shrink-0 text-blue-400"
|
||||
>(${translateText(
|
||||
"events_display.retreating",
|
||||
)}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBoats() {
|
||||
return html`
|
||||
${this.outgoingBoats.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.outgoingBoats.map(
|
||||
(boat) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`${translateText("events_display.boat")}:
|
||||
${renderTroops(boat.troops())}`,
|
||||
onClick: () => this.emitGoToUnitEvent(boat),
|
||||
className: "text-left text-blue-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!boat.retreating()
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitBoatCancelIntent(boat.id()),
|
||||
className: "text-left shrink-0",
|
||||
disabled: boat.retreating(),
|
||||
})
|
||||
: html`<span class="shrink-0 text-blue-400"
|
||||
>(${translateText(
|
||||
"events_display.retreating",
|
||||
)}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBetrayalDebuffTimer() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isTraitor()) {
|
||||
@@ -1161,17 +920,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
<!--- Incoming attacks row -->
|
||||
${this.incomingAttacks.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderIncomingAttacks()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Betrayal debuff timer row -->
|
||||
${(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
@@ -1190,45 +938,8 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Outgoing attacks row -->
|
||||
${this.outgoingAttacks.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderOutgoingAttacks()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Outgoing land attacks row -->
|
||||
${this.outgoingLandAttacks.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderOutgoingLandAttacks()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Boats row -->
|
||||
${this.outgoingBoats.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderBoats()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Empty row when no events or attacks -->
|
||||
<!--- Empty row when no events -->
|
||||
${filteredEvents.length === 0 &&
|
||||
this.incomingAttacks.length === 0 &&
|
||||
this.outgoingAttacks.length === 0 &&
|
||||
this.outgoingLandAttacks.length === 0 &&
|
||||
this.outgoingBoats.length === 0 &&
|
||||
!(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
return (
|
||||
|
||||
@@ -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";
|
||||
@@ -15,6 +13,7 @@ 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;
|
||||
@@ -30,6 +29,7 @@ export class FxLayer implements Layer {
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
) {
|
||||
this.theme = this.game.config().theme();
|
||||
@@ -50,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) => {
|
||||
@@ -129,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,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");
|
||||
|
||||
@@ -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;
|
||||
@@ -72,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ import {
|
||||
} from "./RadialMenuElements";
|
||||
import backIcon from "/images/BackIconWhite.svg?url";
|
||||
|
||||
function resolveColor(
|
||||
item: MenuElement,
|
||||
params: MenuElementParams | null,
|
||||
): string | undefined {
|
||||
if (typeof item.color === "function") {
|
||||
return params ? item.color(params) : undefined;
|
||||
}
|
||||
return item.color;
|
||||
}
|
||||
|
||||
export class CloseRadialMenuEvent implements GameEvent {
|
||||
constructor() {}
|
||||
}
|
||||
@@ -322,7 +332,7 @@ export class RadialMenu implements Layer {
|
||||
const disabled = this.params === null || d.data.disabled(this.params);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (d.data.color ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
|
||||
if (d.data.id === this.selectedItemId && this.currentLevel > level) {
|
||||
@@ -365,7 +375,7 @@ export class RadialMenu implements Layer {
|
||||
const color =
|
||||
this.params === null || d.data.disabled(this.params)
|
||||
? this.config.disabledColor
|
||||
: (d.data.color ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
path.attr("fill", color);
|
||||
}
|
||||
});
|
||||
@@ -431,7 +441,7 @@ export class RadialMenu implements Layer {
|
||||
path.attr("stroke-width", "2");
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (d.data.color ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
path.attr(
|
||||
"fill",
|
||||
@@ -848,10 +858,7 @@ export class RadialMenu implements Layer {
|
||||
|
||||
public disableAllButtons() {
|
||||
this.updateCenterButtonState("default");
|
||||
|
||||
for (const item of this.currentMenuItems) {
|
||||
item.color = this.config.disabledColor;
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
public updateCenterButtonState(state: CenterButtonState) {
|
||||
@@ -1043,7 +1050,7 @@ export class RadialMenu implements Layer {
|
||||
const disabled = this.isItemDisabled(item);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (item.color ?? "#333333");
|
||||
: (resolveColor(item, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
|
||||
// Update path appearance
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface MenuElement {
|
||||
id: string;
|
||||
name: string;
|
||||
displayed?: boolean | ((params: MenuElementParams) => boolean);
|
||||
color?: string;
|
||||
color?: string | ((params: MenuElementParams) => string);
|
||||
icon?: string;
|
||||
text?: string;
|
||||
fontSize?: string;
|
||||
@@ -77,6 +77,7 @@ export const COLORS = {
|
||||
boat: "#3f6ab1",
|
||||
ally: "#53ac75",
|
||||
breakAlly: "#c74848",
|
||||
breakAllyNoDebuff: "#d4882b",
|
||||
delete: "#ff0000",
|
||||
info: "#64748B",
|
||||
target: "#ff0000",
|
||||
@@ -217,7 +218,10 @@ const allyBreakElement: MenuElement = {
|
||||
!params.playerActions?.interaction?.canBreakAlliance,
|
||||
displayed: (params: MenuElementParams) =>
|
||||
!!params.playerActions?.interaction?.canBreakAlliance,
|
||||
color: COLORS.breakAlly,
|
||||
color: (params: MenuElementParams) =>
|
||||
params.selected?.isTraitor() || params.selected?.isDisconnected()
|
||||
? COLORS.breakAllyNoDebuff
|
||||
: COLORS.breakAlly,
|
||||
icon: traitorIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
params.playerActionHandler.handleBreakAlliance(
|
||||
@@ -630,6 +634,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 =
|
||||
@@ -641,9 +646,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,
|
||||
]),
|
||||
|
||||
@@ -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,
|
||||
@@ -40,9 +40,9 @@ function horizontalRailroadRects(): number[][] {
|
||||
function verticalRailroadRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -2, 1, 2],
|
||||
[1, -2, 1, 2],
|
||||
[0, -1, 1, 1],
|
||||
[-1, -1, 1, 2],
|
||||
[1, -1, 1, 2],
|
||||
[0, 0, 1, 1],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -50,9 +50,9 @@ function verticalRailroadRects(): number[][] {
|
||||
function topRightRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -2, 1, 2],
|
||||
[-1, -1, 1, 1],
|
||||
[0, -1, 1, 2],
|
||||
[1, -2, 1, 4],
|
||||
[1, -1, 1, 3],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -60,9 +60,9 @@ function topRightRailroadCornerRects(): number[][] {
|
||||
function topLeftRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -2, 1, 4],
|
||||
[-1, -1, 1, 3],
|
||||
[0, -1, 1, 2],
|
||||
[1, -2, 1, 2],
|
||||
[1, -1, 1, 1],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -70,9 +70,9 @@ function topLeftRailroadCornerRects(): number[][] {
|
||||
function bottomRightRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, 1, 1, 2],
|
||||
[-1, 1, 1, 1],
|
||||
[0, 0, 1, 2],
|
||||
[1, -1, 1, 4],
|
||||
[1, -1, 1, 3],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -80,9 +80,9 @@ function bottomRightRailroadCornerRects(): number[][] {
|
||||
function bottomLeftRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -1, 1, 4],
|
||||
[-1, -1, 1, 3],
|
||||
[0, 0, 1, 2],
|
||||
[1, 1, 1, 2],
|
||||
[1, 1, 1, 1],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -109,8 +109,8 @@ function horizontalBridge(): number[][] {
|
||||
function verticalBridge(): number[][] {
|
||||
// x/y/w/h
|
||||
return [
|
||||
[-2, -2, 1, 3],
|
||||
[2, -2, 1, 3],
|
||||
[-2, -1, 1, 3],
|
||||
[2, -1, 1, 3],
|
||||
];
|
||||
}
|
||||
// ⌞
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -30,7 +30,6 @@ import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
||||
import { PseudoRandom } from "./PseudoRandom";
|
||||
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
||||
import { simpleHash } from "./Util";
|
||||
import { censorNameWithClanTag } from "./validations/username";
|
||||
|
||||
export async function createGameRunner(
|
||||
gameStart: GameStartInfo,
|
||||
@@ -48,7 +47,7 @@ export async function createGameRunner(
|
||||
|
||||
const humans = gameStart.players.map((p) => {
|
||||
return new PlayerInfo(
|
||||
p.clientID === clientID ? p.username : censorNameWithClanTag(p.username),
|
||||
p.username,
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
random.nextID(),
|
||||
|
||||
+42
-33
@@ -148,6 +148,7 @@ const ClientInfoSchema = z.object({
|
||||
export const GameInfoSchema = z.object({
|
||||
gameID: z.string(),
|
||||
clients: z.array(ClientInfoSchema).optional(),
|
||||
lobbyCreatorClientID: z.string().optional(),
|
||||
startsAt: z.number().optional(),
|
||||
serverTime: z.number(),
|
||||
gameConfig: z.lazy(() => GameConfigSchema).optional(),
|
||||
@@ -166,7 +167,10 @@ export const PublicGamesSchema = z.object({
|
||||
});
|
||||
|
||||
export class LobbyInfoEvent implements GameEvent {
|
||||
constructor(public lobby: GameInfo) {}
|
||||
constructor(
|
||||
public lobby: GameInfo,
|
||||
public myClientID: ClientID,
|
||||
) {}
|
||||
}
|
||||
|
||||
export interface ClientInfo {
|
||||
@@ -280,140 +284,137 @@ 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(),
|
||||
sourceTile: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
@@ -445,13 +446,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(),
|
||||
});
|
||||
@@ -540,6 +545,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({
|
||||
@@ -560,6 +567,8 @@ export const ServerErrorSchema = z.object({
|
||||
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", [
|
||||
@@ -604,10 +613,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.
|
||||
@@ -618,7 +627,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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -2,39 +2,36 @@ import {
|
||||
Difficulty,
|
||||
Execution,
|
||||
Game,
|
||||
GameMode,
|
||||
Gold,
|
||||
Nation,
|
||||
Player,
|
||||
PlayerID,
|
||||
Relation,
|
||||
TerrainType,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { assertNever, simpleHash } from "../Util";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { NationAllianceBehavior } from "./nation/NationAllianceBehavior";
|
||||
import { NationEmojiBehavior } from "./nation/NationEmojiBehavior";
|
||||
import { NationMIRVBehavior } from "./nation/NationMIRVBehavior";
|
||||
import { NationNukeBehavior } from "./nation/NationNukeBehavior";
|
||||
import { randTerritoryTileArray } from "./nation/NationUtils";
|
||||
import { NationStructureBehavior } from "./nation/NationStructureBehavior";
|
||||
import { NationWarshipBehavior } from "./nation/NationWarshipBehavior";
|
||||
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
|
||||
|
||||
export class NationExecution implements Execution {
|
||||
private active = true;
|
||||
private random: PseudoRandom;
|
||||
private emojiBehavior: NationEmojiBehavior | null = null;
|
||||
private mirvBehavior: NationMIRVBehavior | null = null;
|
||||
private attackBehavior: AiAttackBehavior | null = null;
|
||||
private allianceBehavior: NationAllianceBehavior | null = null;
|
||||
private warshipBehavior: NationWarshipBehavior | null = null;
|
||||
private nukeBehavior: NationNukeBehavior | null = null;
|
||||
private behaviorsInitialized = false;
|
||||
private emojiBehavior!: NationEmojiBehavior;
|
||||
private mirvBehavior!: NationMIRVBehavior;
|
||||
private attackBehavior!: AiAttackBehavior;
|
||||
private allianceBehavior!: NationAllianceBehavior;
|
||||
private warshipBehavior!: NationWarshipBehavior;
|
||||
private nukeBehavior!: NationNukeBehavior;
|
||||
private structureBehavior!: NationStructureBehavior;
|
||||
private mg: Game;
|
||||
private player: Player | null = null;
|
||||
|
||||
@@ -89,7 +86,7 @@ export class NationExecution implements Execution {
|
||||
tick(ticks: number) {
|
||||
// Ship tracking
|
||||
if (
|
||||
this.warshipBehavior !== null &&
|
||||
this.behaviorsInitialized &&
|
||||
this.player !== null &&
|
||||
this.player.isAlive() &&
|
||||
this.mg.config().gameConfig().difficulty !== Difficulty.Easy
|
||||
@@ -98,6 +95,24 @@ export class NationExecution implements Execution {
|
||||
}
|
||||
|
||||
if (ticks % this.attackRate !== this.attackTick) {
|
||||
// Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval)
|
||||
// Otherwise it is possible that we earn more gold than we can spend
|
||||
// The alternative is placing multiple structures in handleStructures, but that causes problems
|
||||
if (
|
||||
this.behaviorsInitialized &&
|
||||
this.player !== null &&
|
||||
this.player.isAlive()
|
||||
) {
|
||||
const offset = ticks % this.attackRate;
|
||||
const oneThird =
|
||||
(this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate;
|
||||
const twoThirds =
|
||||
(this.attackTick + Math.floor((this.attackRate * 2) / 3)) %
|
||||
this.attackRate;
|
||||
if (offset === oneThird || offset === twoThirds) {
|
||||
this.structureBehavior.handleStructures();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,56 +148,8 @@ export class NationExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.emojiBehavior === null ||
|
||||
this.mirvBehavior === null ||
|
||||
this.attackBehavior === null ||
|
||||
this.allianceBehavior === null ||
|
||||
this.warshipBehavior === null ||
|
||||
this.nukeBehavior === null
|
||||
) {
|
||||
this.emojiBehavior = new NationEmojiBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
this.mirvBehavior = new NationMIRVBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.allianceBehavior = new NationAllianceBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.warshipBehavior = new NationWarshipBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.attackBehavior = new AiAttackBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
this.expandRatio,
|
||||
this.allianceBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.nukeBehavior = new NationNukeBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.attackBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
|
||||
// Send an attack on the first tick
|
||||
if (!this.behaviorsInitialized) {
|
||||
this.initializeBehaviors();
|
||||
this.attackBehavior.forceSendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
@@ -192,13 +159,65 @@ export class NationExecution implements Execution {
|
||||
this.allianceBehavior.handleAllianceRequests();
|
||||
this.allianceBehavior.handleAllianceExtensionRequests();
|
||||
this.mirvBehavior.considerMIRV();
|
||||
this.handleUnits();
|
||||
this.structureBehavior.handleStructures();
|
||||
this.warshipBehavior.maybeSpawnWarship();
|
||||
this.handleEmbargoesToHostileNations();
|
||||
this.attackBehavior.maybeAttack();
|
||||
this.warshipBehavior.counterWarshipInfestation();
|
||||
this.nukeBehavior.maybeSendNuke();
|
||||
}
|
||||
|
||||
private initializeBehaviors(): void {
|
||||
if (this.player === null) throw new Error("Player not initialized");
|
||||
|
||||
this.emojiBehavior = new NationEmojiBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
this.mirvBehavior = new NationMIRVBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.allianceBehavior = new NationAllianceBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.warshipBehavior = new NationWarshipBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.attackBehavior = new AiAttackBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
this.expandRatio,
|
||||
this.allianceBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.nukeBehavior = new NationNukeBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.attackBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.structureBehavior = new NationStructureBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
this.behaviorsInitialized = true;
|
||||
}
|
||||
|
||||
private randomSpawnLand(): TileRef | null {
|
||||
if (this.nation.spawnCell === undefined) throw new Error("not initialized");
|
||||
|
||||
@@ -249,102 +268,6 @@ export class NationExecution implements Execution {
|
||||
});
|
||||
}
|
||||
|
||||
private handleUnits() {
|
||||
if (this.warshipBehavior === null) throw new Error("not initialized");
|
||||
const hasCoastalTiles = this.hasCoastalTiles();
|
||||
const isTeamGame = this.mg.config().gameConfig().gameMode === GameMode.Team;
|
||||
return (
|
||||
this.maybeSpawnStructure(UnitType.City, (num) => num) ||
|
||||
this.maybeSpawnStructure(UnitType.Port, (num) => num) ||
|
||||
this.warshipBehavior.maybeSpawnWarship() ||
|
||||
this.maybeSpawnStructure(UnitType.Factory, (num) =>
|
||||
hasCoastalTiles ? num * 3 : num,
|
||||
) ||
|
||||
this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) ||
|
||||
this.maybeSpawnStructure(UnitType.SAMLauncher, (num) =>
|
||||
isTeamGame ? num : num ** 2,
|
||||
) ||
|
||||
this.maybeSpawnStructure(UnitType.MissileSilo, (num) => num ** 2)
|
||||
);
|
||||
}
|
||||
|
||||
private hasCoastalTiles(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
for (const tile of this.player.borderTiles()) {
|
||||
if (this.mg.isOceanShore(tile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private maybeSpawnStructure(
|
||||
type: UnitType,
|
||||
multiplier: (num: number) => number,
|
||||
) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const owned = this.player.unitsOwned(type);
|
||||
const perceivedCostMultiplier = multiplier(owned + 1);
|
||||
const realCost = this.cost(type);
|
||||
const perceivedCost = realCost * BigInt(perceivedCostMultiplier);
|
||||
if (this.player.gold() < perceivedCost) {
|
||||
return false;
|
||||
}
|
||||
const tile = this.structureSpawnTile(type);
|
||||
if (tile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(type, tile);
|
||||
if (canBuild === false) {
|
||||
return false;
|
||||
}
|
||||
this.mg.addExecution(new ConstructionExecution(this.player, type, tile));
|
||||
return true;
|
||||
}
|
||||
|
||||
private structureSpawnTile(type: UnitType): TileRef | null {
|
||||
if (this.mg === undefined) throw new Error("Not initialized");
|
||||
if (this.player === null) throw new Error("Not initialized");
|
||||
const tiles =
|
||||
type === UnitType.Port
|
||||
? this.randCoastalTileArray(25)
|
||||
: randTerritoryTileArray(this.random, this.mg, this.player, 25);
|
||||
if (tiles.length === 0) return null;
|
||||
const valueFunction = structureSpawnTileValue(this.mg, this.player, type);
|
||||
if (valueFunction === null) return null;
|
||||
let bestTile: TileRef | null = null;
|
||||
let bestValue = 0;
|
||||
for (const t of tiles) {
|
||||
const v = valueFunction(t);
|
||||
if (v <= bestValue && bestTile !== null) continue;
|
||||
if (!this.player.canBuild(type, t)) continue;
|
||||
// Found a better tile
|
||||
bestTile = t;
|
||||
bestValue = v;
|
||||
}
|
||||
return bestTile;
|
||||
}
|
||||
|
||||
private randCoastalTileArray(numTiles: number): TileRef[] {
|
||||
const tiles = Array.from(this.player!.borderTiles()).filter((t) =>
|
||||
this.mg.isOceanShore(t),
|
||||
);
|
||||
return Array.from(this.arraySampler(tiles, numTiles));
|
||||
}
|
||||
|
||||
private *arraySampler<T>(a: T[], sampleSize: number): Generator<T> {
|
||||
if (a.length <= sampleSize) {
|
||||
// Return all elements
|
||||
yield* a;
|
||||
} else {
|
||||
// Sample `sampleSize` elements
|
||||
const remaining = new Set<T>(a);
|
||||
while (sampleSize--) {
|
||||
const t = this.random.randFromSet(remaining);
|
||||
remaining.delete(t);
|
||||
yield t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEmbargoesToHostileNations() {
|
||||
const player = this.player;
|
||||
if (player === null) return;
|
||||
@@ -375,11 +298,6 @@ export class NationExecution implements Execution {
|
||||
});
|
||||
}
|
||||
|
||||
private cost(type: UnitType): Gold {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
return this.mg.unitInfo(type).cost(this.mg, this.player);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
@@ -310,7 +310,8 @@ export class NukeExecution implements Execution {
|
||||
unit.type() !== UnitType.AtomBomb &&
|
||||
unit.type() !== UnitType.HydrogenBomb &&
|
||||
unit.type() !== UnitType.MIRVWarhead &&
|
||||
unit.type() !== UnitType.MIRV
|
||||
unit.type() !== UnitType.MIRV &&
|
||||
unit.type() !== UnitType.SAMMissile
|
||||
) {
|
||||
if (this.mg.euclideanDistSquared(this.dst, unit.tile()) < outer2) {
|
||||
unit.delete(true, this.player);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
Gold,
|
||||
Player,
|
||||
PlayerType,
|
||||
Relation,
|
||||
StructureTypes,
|
||||
Unit,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { assertNever } from "../../Util";
|
||||
import { ConstructionExecution } from "../ConstructionExecution";
|
||||
import { UpgradeStructureExecution } from "../UpgradeStructureExecution";
|
||||
import { closestTile, closestTwoTiles } from "../Util";
|
||||
import { randTerritoryTileArray } from "./NationUtils";
|
||||
|
||||
/**
|
||||
* Configuration for how many structures of each type a nation should build
|
||||
* relative to the number of cities it owns.
|
||||
*/
|
||||
interface StructureRatioConfig {
|
||||
/** How many of this structure per city (e.g., 0.75 means 3 ports for every 4 cities) */
|
||||
ratioPerCity: number;
|
||||
/** Perceived cost increase percentage per owned structure (e.g., 0.1 = 10% more expensive per owned) */
|
||||
perceivedCostIncreasePerOwned: number;
|
||||
}
|
||||
|
||||
/** SAM launcher ratio per city, keyed by difficulty */
|
||||
const SAM_RATIO_BY_DIFFICULTY: Record<Difficulty, number> = {
|
||||
[Difficulty.Easy]: 0.15,
|
||||
[Difficulty.Medium]: 0.2,
|
||||
[Difficulty.Hard]: 0.25,
|
||||
[Difficulty.Impossible]: 0.3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns structure ratios relative to city count, adjusted by difficulty.
|
||||
* Cities are always prioritized and built first.
|
||||
* When cities are disabled, we use TILES_PER_CITY_EQUIVALENT. That's not ideal, nations won't properly upgrade structures, but it's better than nothing. Probably 99.9% of players won't disable cities anyway.
|
||||
*/
|
||||
function getStructureRatios(
|
||||
difficulty: Difficulty,
|
||||
): Partial<Record<UnitType, StructureRatioConfig>> {
|
||||
return {
|
||||
[UnitType.Port]: { ratioPerCity: 0.75, perceivedCostIncreasePerOwned: 1 },
|
||||
[UnitType.Factory]: {
|
||||
ratioPerCity: 0.75,
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
[UnitType.DefensePost]: {
|
||||
ratioPerCity: 0.25,
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
[UnitType.SAMLauncher]: {
|
||||
ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty],
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
[UnitType.MissileSilo]: {
|
||||
ratioPerCity: 0.2,
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Perceived cost increase percentage per city owned */
|
||||
const CITY_PERCEIVED_COST_INCREASE_PER_OWNED = 1;
|
||||
|
||||
/** Factory ratio multiplier when the nation has coastal tiles */
|
||||
const FACTORY_COASTAL_RATIO_MULTIPLIER = 0.33;
|
||||
|
||||
/** Maximum number of missile silos a nation will build */
|
||||
const MAX_MISSILE_SILOS = 3;
|
||||
|
||||
/** If we have more than this many structures per tiles, prefer upgrading over building */
|
||||
const UPGRADE_DENSITY_THRESHOLD = 1 / 1500;
|
||||
|
||||
/** Maximum density of defense posts (per tile owned) before no more can be built */
|
||||
const DEFENSE_POST_DENSITY_THRESHOLD = 1 / 5000;
|
||||
|
||||
/** Estimated number of tiles per city equivalent, used when cities are disabled */
|
||||
const TILES_PER_CITY_EQUIVALENT = 2000;
|
||||
|
||||
export class NationStructureBehavior {
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
private player: Player,
|
||||
) {}
|
||||
|
||||
handleStructures(): boolean {
|
||||
const config = this.game.config();
|
||||
const citiesDisabled = config.isUnitDisabled(UnitType.City);
|
||||
const cityCount = citiesDisabled
|
||||
? Math.max(
|
||||
1,
|
||||
Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT),
|
||||
)
|
||||
: this.player.unitsOwned(UnitType.City);
|
||||
const hasCoastalTiles = this.hasCoastalTiles();
|
||||
|
||||
// Build order for non-city structures (priority order)
|
||||
const buildOrder: UnitType[] = [
|
||||
UnitType.DefensePost,
|
||||
UnitType.Port,
|
||||
UnitType.Factory,
|
||||
UnitType.SAMLauncher,
|
||||
UnitType.MissileSilo,
|
||||
];
|
||||
|
||||
const nukesEnabled =
|
||||
!config.isUnitDisabled(UnitType.AtomBomb) ||
|
||||
!config.isUnitDisabled(UnitType.HydrogenBomb) ||
|
||||
!config.isUnitDisabled(UnitType.MIRV);
|
||||
const missileSilosEnabled = !config.isUnitDisabled(UnitType.MissileSilo);
|
||||
|
||||
for (const structureType of buildOrder) {
|
||||
// Skip disabled structure types
|
||||
if (config.isUnitDisabled(structureType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip ports if no coastal tiles
|
||||
if (structureType === UnitType.Port && !hasCoastalTiles) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip missile silos and SAM launchers if all nukes are disabled
|
||||
if (
|
||||
!nukesEnabled &&
|
||||
(structureType === UnitType.MissileSilo ||
|
||||
structureType === UnitType.SAMLauncher)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SAM launchers if missile silos are disabled
|
||||
if (!missileSilosEnabled && structureType === UnitType.SAMLauncher) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.shouldBuildStructure(structureType, cityCount, hasCoastalTiles)
|
||||
) {
|
||||
if (this.maybeSpawnStructure(structureType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!citiesDisabled && this.maybeSpawnStructure(UnitType.City)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private hasCoastalTiles(): boolean {
|
||||
for (const tile of this.player.borderTiles()) {
|
||||
if (this.game.isOceanShore(tile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if we should build more of this structure type based on
|
||||
* the current city count and the configured ratio.
|
||||
*/
|
||||
private shouldBuildStructure(
|
||||
type: UnitType,
|
||||
cityCount: number,
|
||||
hasCoastalTiles: boolean,
|
||||
): boolean {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
const ratios = getStructureRatios(difficulty);
|
||||
const config = ratios[type];
|
||||
if (config === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ratio = config.ratioPerCity;
|
||||
|
||||
// Heavily reduce factory spawning if we have coastal tiles
|
||||
if (
|
||||
type === UnitType.Factory &&
|
||||
hasCoastalTiles &&
|
||||
!this.game.config().isUnitDisabled(UnitType.Port)
|
||||
) {
|
||||
ratio *= FACTORY_COASTAL_RATIO_MULTIPLIER;
|
||||
}
|
||||
|
||||
const owned = this.player.unitsOwned(type);
|
||||
|
||||
// Hard cap on missile silos
|
||||
if (type === UnitType.MissileSilo && owned >= MAX_MISSILE_SILOS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Density cap on defense posts (can't be upgraded so a new one would be built - problematic if it's a game with high starting gold)
|
||||
if (type === UnitType.DefensePost) {
|
||||
const tilesOwned = this.player.numTilesOwned();
|
||||
if (
|
||||
tilesOwned > 0 &&
|
||||
owned / tilesOwned >= DEFENSE_POST_DENSITY_THRESHOLD
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const targetCount = Math.floor(cityCount * ratio);
|
||||
|
||||
return owned < targetCount;
|
||||
}
|
||||
|
||||
private cost(type: UnitType): Gold {
|
||||
return this.game.unitInfo(type).cost(this.game, this.player);
|
||||
}
|
||||
|
||||
private maybeSpawnStructure(type: UnitType): boolean {
|
||||
const perceivedCost = this.getPerceivedCost(type);
|
||||
if (this.player.gold() < perceivedCost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we should upgrade instead of building new
|
||||
const structures = this.player.units(type);
|
||||
if (
|
||||
this.getTotalStructureDensity() > UPGRADE_DENSITY_THRESHOLD &&
|
||||
type !== UnitType.DefensePost
|
||||
) {
|
||||
if (this.maybeUpgradeStructure(structures)) {
|
||||
return true;
|
||||
}
|
||||
// Density too high but couldn't upgrade (e.g. all under construction) — don't build new, wait for construction (most relevant for SAMs)
|
||||
if (structures.length > 0) {
|
||||
return false;
|
||||
}
|
||||
// No structures of this type exist yet — fall through to build the first one
|
||||
// (even if density is high - the nation is probably on a tiny island and we need to use all building spots we can find)
|
||||
}
|
||||
|
||||
const tile = this.structureSpawnTile(type);
|
||||
if (tile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(type, tile);
|
||||
if (canBuild === false) {
|
||||
return false;
|
||||
}
|
||||
this.game.addExecution(new ConstructionExecution(this.player, type, tile));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the perceived cost for a structure type.
|
||||
* The perceived cost increases by a percentage for each structure of that type already owned.
|
||||
* This makes nations save up gold for nukes.
|
||||
* Once the nation can afford its target stockpile, stop inflating costs.
|
||||
*/
|
||||
private getPerceivedCost(type: UnitType): Gold {
|
||||
const realCost = this.cost(type);
|
||||
|
||||
const saveUpTarget = this.getSaveUpTarget();
|
||||
if (saveUpTarget === 0n || this.player.gold() >= saveUpTarget) {
|
||||
return realCost;
|
||||
}
|
||||
|
||||
const owned = this.player.unitsOwned(type);
|
||||
|
||||
let increasePerOwned: number;
|
||||
if (type === UnitType.City) {
|
||||
increasePerOwned = CITY_PERCEIVED_COST_INCREASE_PER_OWNED;
|
||||
} else {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
const ratios = getStructureRatios(difficulty);
|
||||
const config = ratios[type];
|
||||
increasePerOwned = config?.perceivedCostIncreasePerOwned ?? 0.1;
|
||||
}
|
||||
|
||||
// Each owned structure makes the next one feel more expensive
|
||||
// Formula: realCost * (1 + increasePerOwned * owned)
|
||||
const multiplier = 1 + increasePerOwned * owned;
|
||||
return BigInt(Math.ceil(Number(realCost) * multiplier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the gold target we want to save up for based on which nukes are enabled.
|
||||
* Returns 0 if no saving is needed.
|
||||
*/
|
||||
private getSaveUpTarget(): Gold {
|
||||
const config = this.game.config();
|
||||
|
||||
// No need to save up if missile silos are disabled
|
||||
if (config.isUnitDisabled(UnitType.MissileSilo)) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
const mirvEnabled = !config.isUnitDisabled(UnitType.MIRV);
|
||||
const hydroEnabled = !config.isUnitDisabled(UnitType.HydrogenBomb);
|
||||
const atomEnabled = !config.isUnitDisabled(UnitType.AtomBomb);
|
||||
|
||||
if (mirvEnabled) {
|
||||
// Save up for MIRV + Hydrogen Bomb
|
||||
return this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb);
|
||||
}
|
||||
if (hydroEnabled) {
|
||||
// Save up for 5 hydrogen bombs
|
||||
return this.cost(UnitType.HydrogenBomb) * 5n;
|
||||
}
|
||||
if (atomEnabled) {
|
||||
// Save up for 20 atom bombs
|
||||
return this.cost(UnitType.AtomBomb) * 20n;
|
||||
}
|
||||
// No nukes enabled, no need to save up
|
||||
return 0n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to upgrade an existing structure if density threshold is exceeded.
|
||||
* @param structures The pool of structures to consider for upgrading
|
||||
* @returns true if an upgrade was initiated, false otherwise
|
||||
*/
|
||||
private maybeUpgradeStructure(structures: Unit[]): boolean {
|
||||
if (this.getTotalStructureDensity() <= UPGRADE_DENSITY_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
if (structures.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const structureToUpgrade = this.findBestStructureToUpgrade(structures);
|
||||
if (
|
||||
structureToUpgrade !== null &&
|
||||
this.player.canUpgradeUnit(structureToUpgrade)
|
||||
) {
|
||||
this.game.addExecution(
|
||||
new UpgradeStructureExecution(this.player, structureToUpgrade.id()),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total structure density across player's territory.
|
||||
*/
|
||||
private getTotalStructureDensity(): number {
|
||||
let totalStructures = 0;
|
||||
for (const type of StructureTypes) {
|
||||
totalStructures += this.player.units(type).length; // ignoring levels
|
||||
}
|
||||
const tilesOwned = this.player.numTilesOwned();
|
||||
return tilesOwned > 0 ? totalStructures / tilesOwned : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best structure to upgrade, preferring structures protected by a SAM.
|
||||
* In 50% of cases, picks the second or third best to add variety.
|
||||
*/
|
||||
private findBestStructureToUpgrade(structures: Unit[]): Unit | null {
|
||||
if (structures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter to only upgradable structures
|
||||
const upgradable = structures.filter((s) => this.player.canUpgradeUnit(s));
|
||||
if (upgradable.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Based on difficulty, chance to just pick a random structure
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
let randomChance: number;
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
randomChance = 70;
|
||||
break;
|
||||
case Difficulty.Medium:
|
||||
randomChance = 40;
|
||||
break;
|
||||
case Difficulty.Hard:
|
||||
randomChance = 25;
|
||||
break;
|
||||
case Difficulty.Impossible:
|
||||
randomChance = 10;
|
||||
break;
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
|
||||
if (this.random.nextInt(0, 100) < randomChance) {
|
||||
return this.random.randElement(upgradable);
|
||||
}
|
||||
|
||||
const samLaunchers = this.player.units(UnitType.SAMLauncher);
|
||||
|
||||
// Score each structure based on SAM protection
|
||||
const scored: { structure: Unit; score: number }[] = [];
|
||||
|
||||
for (const structure of upgradable) {
|
||||
let score = 0;
|
||||
|
||||
// Check if protected by any SAM, using per-SAM level-based range
|
||||
for (const sam of samLaunchers) {
|
||||
const samRange = this.game.config().samRange(sam.level());
|
||||
const samRangeSquared = samRange * samRange;
|
||||
const distSquared = this.game.euclideanDistSquared(
|
||||
structure.tile(),
|
||||
sam.tile(),
|
||||
);
|
||||
if (distSquared <= samRangeSquared) {
|
||||
// Protected by this SAM, add score based on SAM level
|
||||
score += 10;
|
||||
if (sam.level() > 1) {
|
||||
score += (sam.level() - 1) * 7.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add small random factor to break ties
|
||||
score += this.random.nextInt(0, 5);
|
||||
|
||||
scored.push({ structure, score });
|
||||
}
|
||||
|
||||
if (scored.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort descending by score
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
// 50% of the time, pick the second or third best for variety
|
||||
if (scored.length >= 2 && this.random.chance(2)) {
|
||||
const pickIndex =
|
||||
scored.length >= 3
|
||||
? this.random.nextInt(1, 3) // pick index 1 or 2
|
||||
: 1; // only index 1 available
|
||||
return scored[pickIndex].structure;
|
||||
}
|
||||
|
||||
return scored[0].structure;
|
||||
}
|
||||
|
||||
private structureSpawnTile(type: UnitType): TileRef | null {
|
||||
const tiles =
|
||||
type === UnitType.Port
|
||||
? this.randCoastalTileArray(25)
|
||||
: randTerritoryTileArray(this.random, this.game, this.player, 25);
|
||||
if (tiles.length === 0) return null;
|
||||
const valueFunction = this.structureSpawnTileValue(type);
|
||||
if (valueFunction === null) return null;
|
||||
let bestTile: TileRef | null = null;
|
||||
let bestValue = 0;
|
||||
for (const t of tiles) {
|
||||
const v = valueFunction(t);
|
||||
if (v <= bestValue && bestTile !== null) continue;
|
||||
if (!this.player.canBuild(type, t)) continue;
|
||||
// Found a better tile
|
||||
bestTile = t;
|
||||
bestValue = v;
|
||||
}
|
||||
return bestTile;
|
||||
}
|
||||
|
||||
private randCoastalTileArray(numTiles: number): TileRef[] {
|
||||
const tiles = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
);
|
||||
return Array.from(this.arraySampler(tiles, numTiles));
|
||||
}
|
||||
|
||||
private *arraySampler<T>(a: T[], sampleSize: number): Generator<T> {
|
||||
if (a.length <= sampleSize) {
|
||||
// Return all elements
|
||||
yield* a;
|
||||
} else {
|
||||
// Sample `sampleSize` elements
|
||||
const remaining = new Set<T>(a);
|
||||
while (sampleSize--) {
|
||||
const t = this.random.randFromSet(remaining);
|
||||
remaining.delete(t);
|
||||
yield t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private structureSpawnTileValue(
|
||||
type: UnitType,
|
||||
): ((tile: TileRef) => number) | null {
|
||||
switch (type) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo:
|
||||
return this.interiorStructureValue(type);
|
||||
case UnitType.Port:
|
||||
return this.portValue();
|
||||
case UnitType.DefensePost:
|
||||
return this.defensePostValue();
|
||||
case UnitType.SAMLauncher:
|
||||
return this.samLauncherValue();
|
||||
default:
|
||||
throw new Error(`Value function not implemented for ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for interior structures (City, Factory, MissileSilo).
|
||||
* Prefers high elevation, distance from border, and spacing from same-type structures.
|
||||
*/
|
||||
private interiorStructureValue(type: UnitType): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const borderTiles = this.player.borderTiles();
|
||||
const otherUnits = this.player.units(type);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const [, closestBorderDist] = closestTile(game, borderTiles, tile);
|
||||
w += Math.min(closestBorderDist, borderSpacing);
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = game.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for ports.
|
||||
* Prefers spacing from other ports.
|
||||
*/
|
||||
private portValue(): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const otherUnits = this.player.units(UnitType.Port);
|
||||
const { structureSpacing } = this.spacingConstants();
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const [, closestOtherDist] = closestTile(game, otherTiles, tile);
|
||||
w += Math.min(closestOtherDist, structureSpacing);
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for defense posts.
|
||||
* Returns null if there are no hostile non-bot neighbors.
|
||||
* Prefers elevation, proximity to border with hostile neighbors, and spacing.
|
||||
*/
|
||||
private defensePostValue(): ((tile: TileRef) => number) | null {
|
||||
const game = this.game;
|
||||
const player = this.player;
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(UnitType.DefensePost);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
// Check if we have any non-friendly non-bot neighbors with more troops
|
||||
const hasHostileNeighbor =
|
||||
player
|
||||
.neighbors()
|
||||
.filter(
|
||||
(n): n is Player =>
|
||||
n.isPlayer() &&
|
||||
player.isFriendly(n) === false &&
|
||||
n.type() !== PlayerType.Bot &&
|
||||
n.troops() > player.troops(),
|
||||
).length > 0;
|
||||
|
||||
// Don't build defense posts if there is no danger
|
||||
if (!hasHostileNeighbor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
const [closest, closestBorderDist] = closestTile(game, borderTiles, tile);
|
||||
if (closest !== null) {
|
||||
// Prefer to be borderSpacing tiles from the border
|
||||
w += Math.max(
|
||||
0,
|
||||
borderSpacing - Math.abs(borderSpacing - closestBorderDist),
|
||||
);
|
||||
|
||||
// Prefer adjacent players who are hostile and have more troops
|
||||
const neighbors: Set<Player> = new Set();
|
||||
for (const neighborTile of game.neighbors(closest)) {
|
||||
if (!game.isLand(neighborTile)) continue;
|
||||
const id = game.ownerID(neighborTile);
|
||||
if (id === player.smallID()) continue;
|
||||
const neighbor = game.playerBySmallID(id);
|
||||
if (!neighbor.isPlayer()) continue;
|
||||
if (neighbor.type() === PlayerType.Bot) continue;
|
||||
if (neighbor.troops() <= player.troops()) continue;
|
||||
neighbors.add(neighbor);
|
||||
}
|
||||
for (const neighbor of neighbors) {
|
||||
w += borderSpacing * (Relation.Friendly - player.relation(neighbor));
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = game.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for SAM launchers.
|
||||
* Prefers elevation, distance from border, spacing, and proximity to protectable structures.
|
||||
* On harder difficulties, weights by structure level and considers existing SAM coverage.
|
||||
*/
|
||||
private samLauncherValue(): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const player = this.player;
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(UnitType.SAMLauncher);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
const { difficulty } = game.config().gameConfig();
|
||||
const weightByLevel =
|
||||
difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible;
|
||||
|
||||
const protectEntries: { tile: TileRef; weight: number }[] = [];
|
||||
for (const unit of player.units()) {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.Port:
|
||||
protectEntries.push({
|
||||
tile: unit.tile(),
|
||||
weight: weightByLevel ? unit.level() : 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
const range = game.config().defaultSamRange();
|
||||
const rangeSquared = range * range;
|
||||
|
||||
const useCoverageWeighting =
|
||||
difficulty !== Difficulty.Easy && this.random.nextInt(0, 100) < 25;
|
||||
|
||||
// Pre-compute existing SAM coverage for each protectable structure
|
||||
let structureCoverage: Map<TileRef, number> | null = null;
|
||||
if (useCoverageWeighting) {
|
||||
structureCoverage = new Map<TileRef, number>();
|
||||
const existingSams = player.units(UnitType.SAMLauncher);
|
||||
for (const entry of protectEntries) {
|
||||
let coverageScore = 0;
|
||||
for (const sam of existingSams) {
|
||||
const samRange = game.config().samRange(sam.level());
|
||||
const dist = game.euclideanDistSquared(entry.tile, sam.tile());
|
||||
if (dist <= samRange * samRange) {
|
||||
coverageScore += sam.level();
|
||||
}
|
||||
}
|
||||
structureCoverage.set(entry.tile, coverageScore);
|
||||
}
|
||||
}
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const closestBorder = closestTwoTiles(game, borderTiles, [tile]);
|
||||
if (closestBorder !== null) {
|
||||
const d = game.manhattanDist(closestBorder.x, tile);
|
||||
w += Math.min(d, borderSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = game.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be in range of other structures (skip on easy difficulty)
|
||||
if (difficulty !== Difficulty.Easy) {
|
||||
for (const entry of protectEntries) {
|
||||
const distanceSquared = game.euclideanDistSquared(tile, entry.tile);
|
||||
if (distanceSquared > rangeSquared) continue;
|
||||
if (useCoverageWeighting && structureCoverage !== null) {
|
||||
const coverage = structureCoverage.get(entry.tile) ?? 0;
|
||||
const coverageWeight = 1 / (1 + coverage);
|
||||
w += structureSpacing * entry.weight * coverageWeight;
|
||||
} else {
|
||||
w += structureSpacing * entry.weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared spacing constants derived from atom bomb range. */
|
||||
private spacingConstants(): {
|
||||
borderSpacing: number;
|
||||
structureSpacing: number;
|
||||
} {
|
||||
const borderSpacing = this.game
|
||||
.config()
|
||||
.nukeMagnitudes(UnitType.AtomBomb).outer;
|
||||
return { borderSpacing, structureSpacing: borderSpacing * 2 };
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { Game, Player, PlayerType, Relation, UnitType } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { closestTile, closestTwoTiles } from "../Util";
|
||||
|
||||
export function structureSpawnTileValue(
|
||||
mg: Game,
|
||||
player: Player,
|
||||
type: UnitType,
|
||||
): ((tile: TileRef) => number) | null {
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(type);
|
||||
// Prefer spacing structures out of atom bomb range
|
||||
const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer;
|
||||
const structureSpacing = borderSpacing * 2;
|
||||
switch (type) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo: {
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += mg.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const [, closestBorderDist] = closestTile(mg, borderTiles, tile);
|
||||
w += Math.min(closestBorderDist, borderSpacing);
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = mg.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
// TODO: Cities and factories should consider train range limits
|
||||
return w;
|
||||
};
|
||||
}
|
||||
case UnitType.Port: {
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const [, closestOtherDist] = closestTile(mg, otherTiles, tile);
|
||||
w += Math.min(closestOtherDist, structureSpacing);
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
case UnitType.DefensePost: {
|
||||
// Check if we have any non-friendly non-bot neighbors
|
||||
const hasHostileNeighbor =
|
||||
player
|
||||
.neighbors()
|
||||
.filter(
|
||||
(n): n is Player =>
|
||||
n.isPlayer() &&
|
||||
player.isFriendly(n) === false &&
|
||||
n.type() !== PlayerType.Bot,
|
||||
).length > 0;
|
||||
|
||||
// Don't build defense posts if there is no danger
|
||||
if (!hasHostileNeighbor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += mg.magnitude(tile);
|
||||
|
||||
const [closest, closestBorderDist] = closestTile(mg, borderTiles, tile);
|
||||
if (closest !== null) {
|
||||
// Prefer to be borderSpacing tiles from the border
|
||||
w += Math.max(
|
||||
0,
|
||||
borderSpacing - Math.abs(borderSpacing - closestBorderDist),
|
||||
);
|
||||
|
||||
// Prefer adjacent players who are hostile
|
||||
const neighbors: Set<Player> = new Set();
|
||||
for (const tile of mg.neighbors(closest)) {
|
||||
if (!mg.isLand(tile)) continue;
|
||||
const id = mg.ownerID(tile);
|
||||
if (id === player.smallID()) continue;
|
||||
const neighbor = mg.playerBySmallID(id);
|
||||
if (!neighbor.isPlayer()) continue;
|
||||
if (neighbor.type() === PlayerType.Bot) continue;
|
||||
neighbors.add(neighbor);
|
||||
}
|
||||
for (const neighbor of neighbors) {
|
||||
w +=
|
||||
borderSpacing * (Relation.Friendly - player.relation(neighbor));
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = mg.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
case UnitType.SAMLauncher: {
|
||||
const protectTiles: Set<TileRef> = new Set();
|
||||
for (const unit of player.units()) {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.Port:
|
||||
protectTiles.add(unit.tile());
|
||||
}
|
||||
}
|
||||
const range = mg.config().defaultSamRange();
|
||||
const rangeSquared = range * range;
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += mg.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const closestBorder = closestTwoTiles(mg, borderTiles, [tile]);
|
||||
if (closestBorder !== null) {
|
||||
const d = mg.manhattanDist(closestBorder.x, tile);
|
||||
w += Math.min(d, borderSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = mg.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be in range of other structures
|
||||
for (const maybeProtected of protectTiles) {
|
||||
const distanceSquared = mg.euclideanDistSquared(tile, maybeProtected);
|
||||
if (distanceSquared > rangeSquared) continue;
|
||||
w += structureSpacing;
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Value function not implemented for ${type}`);
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ export enum GameMapType {
|
||||
DidierFrance = "Didier France",
|
||||
AmazonRiver = "Amazon River",
|
||||
Yenisei = "Yenisei",
|
||||
TradersDream = "Traders Dream",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -178,6 +179,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.FourIslands,
|
||||
GameMapType.Svalmel,
|
||||
GameMapType.Surrounded,
|
||||
GameMapType.TradersDream,
|
||||
],
|
||||
arcade: [
|
||||
GameMapType.TheBox,
|
||||
@@ -845,6 +847,7 @@ export interface BuildableUnit {
|
||||
canUpgrade: number | false;
|
||||
type: UnitType;
|
||||
cost: Gold;
|
||||
overlappingRailroads: number[];
|
||||
}
|
||||
|
||||
export interface PlayerProfile {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -603,12 +603,20 @@ export class GameView implements GameMap {
|
||||
private _config: Config,
|
||||
private _mapData: TerrainMapData,
|
||||
private _myClientID: ClientID,
|
||||
private _myUsername: string,
|
||||
private _gameID: GameID,
|
||||
private humans: Player[],
|
||||
) {
|
||||
this._map = this._mapData.gameMap;
|
||||
this.lastUpdate = null;
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
// Replace the local player's username with their own stored username.
|
||||
// This way the user does not know they are being censored.
|
||||
for (const h of this.humans) {
|
||||
if (h.clientID === this._myClientID) {
|
||||
h.username = this._myUsername;
|
||||
}
|
||||
}
|
||||
this._cosmetics = new Map(
|
||||
this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
|
||||
);
|
||||
|
||||
@@ -960,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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,92 +1,9 @@
|
||||
import {
|
||||
RegExpMatcher,
|
||||
collapseDuplicatesTransformer,
|
||||
englishDataset,
|
||||
englishRecommendedTransformers,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
} from "obscenity";
|
||||
import { translateText } from "../../client/Utils";
|
||||
import { UsernameSchema } from "../Schemas";
|
||||
import { getClanTagOriginalCase, simpleHash } from "../Util";
|
||||
|
||||
const matcher = new RegExpMatcher({
|
||||
...englishDataset.build(),
|
||||
...englishRecommendedTransformers,
|
||||
...resolveConfusablesTransformer(),
|
||||
...skipNonAlphabeticTransformer(),
|
||||
...collapseDuplicatesTransformer(),
|
||||
...resolveLeetSpeakTransformer(),
|
||||
});
|
||||
|
||||
export const MIN_USERNAME_LENGTH = 3;
|
||||
export const MAX_USERNAME_LENGTH = 27;
|
||||
|
||||
const shadowNames = [
|
||||
"NicePeopleOnly",
|
||||
"BeKindPlz",
|
||||
"LearningManners",
|
||||
"StayClassy",
|
||||
"BeNicer",
|
||||
"NeedHugs",
|
||||
"MakeFriends",
|
||||
];
|
||||
|
||||
export function fixProfaneUsername(username: string): string {
|
||||
if (isProfaneUsername(username)) {
|
||||
return shadowNames[simpleHash(username) % shadowNames.length];
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
export function isProfaneUsername(username: string): boolean {
|
||||
return matcher.hasMatch(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and censors profane usernames and clan tags.
|
||||
* Profane username is overwritten, profane clan tag is removed.
|
||||
*
|
||||
* Preserves non-profane clan tag:
|
||||
* prevents desync after clan team assignment because local player's own clan tag and name aren't overwritten
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
||||
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
* - "BadName" -> "Censored"
|
||||
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
|
||||
* - "[CLaN]BadName" -> "[CLaN] Censored"
|
||||
* - "[BAD]GoodName" -> "GoodName"
|
||||
* - "[BAD]BadName" -> "Censored"
|
||||
*/
|
||||
export function censorNameWithClanTag(username: string): string {
|
||||
// Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match
|
||||
const clanTag = getClanTagOriginalCase(username);
|
||||
|
||||
const nameWithoutClan = clanTag
|
||||
? username.replace(`[${clanTag}]`, "").trim()
|
||||
: username;
|
||||
|
||||
const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false;
|
||||
const usernameIsProfane = isProfaneUsername(nameWithoutClan);
|
||||
|
||||
const censoredNameWithoutClan = usernameIsProfane
|
||||
? fixProfaneUsername(nameWithoutClan)
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag if it existed and is not profane
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
return `[${clanTag.toUpperCase()}] ${censoredNameWithoutClan}`;
|
||||
}
|
||||
|
||||
// Don't restore profane or nonexistent clan tag
|
||||
return censoredNameWithoutClan;
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
|
||||
@@ -18,8 +18,8 @@ export class Client {
|
||||
public readonly flares: string[] | undefined,
|
||||
public readonly ip: string,
|
||||
public readonly username: string,
|
||||
public readonly uncensoredUsername: string,
|
||||
public ws: WebSocket,
|
||||
public readonly cosmetics: PlayerCosmetics | undefined,
|
||||
public readonly isRejoin: boolean = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
+14
-16
@@ -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";
|
||||
|
||||
@@ -32,32 +32,30 @@ export class GameManager {
|
||||
);
|
||||
}
|
||||
|
||||
joinClient(client: Client, gameID: GameID): boolean {
|
||||
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(
|
||||
@@ -83,7 +81,7 @@ export class GameManager {
|
||||
disabledUnits: [],
|
||||
...gameConfig,
|
||||
},
|
||||
creatorClientID,
|
||||
creatorPersistentID,
|
||||
startsAt,
|
||||
);
|
||||
this.games.set(id, game);
|
||||
|
||||
+95
-81
@@ -7,13 +7,11 @@ import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientID,
|
||||
ClientMessageSchema,
|
||||
ClientRejoinMessage,
|
||||
ClientSendWinnerMessage,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
GameStartInfo,
|
||||
GameStartInfoSchema,
|
||||
Intent,
|
||||
PlayerRecord,
|
||||
ServerDesyncSchema,
|
||||
ServerErrorMessage,
|
||||
@@ -21,6 +19,7 @@ import {
|
||||
ServerPrestartMessageSchema,
|
||||
ServerStartGameMessage,
|
||||
ServerTurnMessage,
|
||||
StampedIntent,
|
||||
Turn,
|
||||
} from "../core/Schemas";
|
||||
import { createPartialGameRecord, getClanTag } from "../core/Util";
|
||||
@@ -43,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;
|
||||
@@ -63,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;
|
||||
@@ -87,12 +88,18 @@ export class GameServer {
|
||||
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;
|
||||
@@ -150,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 (
|
||||
@@ -180,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,
|
||||
@@ -206,7 +210,7 @@ export class GameServer {
|
||||
clientID: client.clientID,
|
||||
clientIP: ipAnonymize(client.ip),
|
||||
});
|
||||
return;
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
if (this.config.env() === GameEnv.Prod) {
|
||||
@@ -231,6 +235,8 @@ 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);
|
||||
@@ -242,54 +248,47 @@ export class GameServer {
|
||||
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) {
|
||||
@@ -321,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`,
|
||||
@@ -342,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,
|
||||
});
|
||||
@@ -359,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;
|
||||
@@ -400,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,
|
||||
@@ -416,7 +414,7 @@ export class GameServer {
|
||||
},
|
||||
);
|
||||
|
||||
this.updateGameConfig(clientMsg.intent.config);
|
||||
this.updateGameConfig(stampedIntent.config);
|
||||
return;
|
||||
}
|
||||
case "toggle_pause": {
|
||||
@@ -430,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();
|
||||
}
|
||||
|
||||
@@ -451,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;
|
||||
}
|
||||
@@ -501,6 +499,17 @@ 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 {
|
||||
@@ -569,12 +578,14 @@ export class GameServer {
|
||||
}
|
||||
|
||||
private broadcastLobbyInfo() {
|
||||
const msg = JSON.stringify({
|
||||
type: "lobby_info",
|
||||
lobby: this.gameInfo(),
|
||||
} satisfies ServerLobbyInfoMessage);
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -621,7 +632,7 @@ export class GameServer {
|
||||
});
|
||||
}
|
||||
|
||||
private addIntent(intent: Intent) {
|
||||
private addIntent(intent: StampedIntent) {
|
||||
this.intents.push(intent);
|
||||
}
|
||||
|
||||
@@ -646,6 +657,7 @@ export class GameServer {
|
||||
turns: this.turns.slice(lastTurn),
|
||||
gameStartInfo: this.gameStartInfo,
|
||||
lobbyCreatedAt: this.createdAt,
|
||||
myClientID: client.clientID,
|
||||
} satisfies ServerStartGameMessage),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -808,6 +820,7 @@ export class GameServer {
|
||||
username: c.username,
|
||||
clientID: c.clientID,
|
||||
})),
|
||||
lobbyCreatorClientID: this.lobbyCreatorID,
|
||||
gameConfig: this.gameConfig,
|
||||
startsAt: this.startsAt,
|
||||
serverTime: Date.now(),
|
||||
@@ -822,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,
|
||||
@@ -830,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,
|
||||
@@ -838,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) {
|
||||
@@ -1041,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
|
||||
) {
|
||||
|
||||
@@ -67,6 +67,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
Sierpinski: 10,
|
||||
TheBox: 3,
|
||||
Yenisei: 6,
|
||||
TradersDream: 4,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
|
||||
+119
-1
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
DataSet,
|
||||
RegExpMatcher,
|
||||
collapseDuplicatesTransformer,
|
||||
englishDataset,
|
||||
pattern,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
toAsciiLowerCaseTransformer,
|
||||
} from "obscenity";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { decodePatternData } from "../core/PatternDecoder";
|
||||
import {
|
||||
@@ -7,6 +18,95 @@ import {
|
||||
PlayerCosmetics,
|
||||
PlayerPattern,
|
||||
} from "../core/Schemas";
|
||||
import { getClanTagOriginalCase, simpleHash } from "../core/Util";
|
||||
|
||||
export const shadowNames = [
|
||||
"UnhuggedToday",
|
||||
"DaddysLilChamp",
|
||||
"BunnyKisses67",
|
||||
"SnugglePuppy",
|
||||
"CuddleMonster67",
|
||||
"DaddysLilStar",
|
||||
"SnuggleMuffin",
|
||||
"PeesALittle",
|
||||
"PleaseFullSendMe",
|
||||
"NanasLilMan",
|
||||
"NoAlliances",
|
||||
"TryingTooHard67",
|
||||
"MommysLilStinker",
|
||||
"NeedHugs",
|
||||
"MommysLilPeanut",
|
||||
"IWillBetrayU",
|
||||
"DaddysLilTater",
|
||||
"PreciousBubbles",
|
||||
"67 Cringelord",
|
||||
"Peace And Love",
|
||||
"AlmostPottyTrained",
|
||||
];
|
||||
|
||||
export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
const customDataset = new DataSet<{ originalWord: string }>().addAll(
|
||||
englishDataset,
|
||||
);
|
||||
|
||||
for (const word of bannedWords) {
|
||||
customDataset.addPhrase((phrase) =>
|
||||
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
|
||||
);
|
||||
}
|
||||
|
||||
return new RegExpMatcher({
|
||||
...customDataset.build(),
|
||||
blacklistMatcherTransformers: [
|
||||
toAsciiLowerCaseTransformer(),
|
||||
resolveConfusablesTransformer(),
|
||||
resolveLeetSpeakTransformer(),
|
||||
collapseDuplicatesTransformer(),
|
||||
skipNonAlphabeticTransformer(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and censors profane usernames and clan tags.
|
||||
* Profane username is overwritten, profane clan tag is removed.
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
||||
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
* - "BadName" -> "Censored"
|
||||
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
|
||||
* - "[CLaN]BadName" -> "[CLAN] Censored"
|
||||
* - "[BAD]GoodName" -> "GoodName"
|
||||
* - "[BAD]BadName" -> "Censored"
|
||||
*/
|
||||
function censorUsernameWithMatcher(
|
||||
username: string,
|
||||
matcher: RegExpMatcher,
|
||||
): string {
|
||||
const clanTag = getClanTagOriginalCase(username);
|
||||
|
||||
const nameWithoutClan = clanTag
|
||||
? username.replace(`[${clanTag}]`, "").trim()
|
||||
: username;
|
||||
|
||||
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
|
||||
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
|
||||
|
||||
const censoredName = usernameIsProfane
|
||||
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag only if it's clean, otherwise remove it entirely
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
return `[${clanTag.toUpperCase()}] ${censoredName}`;
|
||||
}
|
||||
|
||||
return censoredName;
|
||||
}
|
||||
|
||||
type CosmeticResult =
|
||||
| { type: "allowed"; cosmetics: PlayerCosmetics }
|
||||
@@ -14,13 +114,19 @@ type CosmeticResult =
|
||||
|
||||
export interface PrivilegeChecker {
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
|
||||
censorUsername(username: string): string;
|
||||
}
|
||||
|
||||
export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
private matcher: RegExpMatcher;
|
||||
|
||||
constructor(
|
||||
private cosmetics: Cosmetics,
|
||||
private b64urlDecode: (base64: string) => Uint8Array,
|
||||
) {}
|
||||
bannedWords: string[],
|
||||
) {
|
||||
this.matcher = createMatcher(bannedWords);
|
||||
}
|
||||
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
||||
const cosmetics: PlayerCosmetics = {};
|
||||
@@ -106,10 +212,22 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
}
|
||||
return { color };
|
||||
}
|
||||
|
||||
censorUsername(username: string): string {
|
||||
return censorUsernameWithMatcher(username, this.matcher);
|
||||
}
|
||||
}
|
||||
|
||||
// Default matcher with no custom banned words (just englishDataset)
|
||||
const defaultMatcher = createMatcher([]);
|
||||
|
||||
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
||||
return { type: "allowed", cosmetics: {} };
|
||||
}
|
||||
|
||||
censorUsername(username: string): string {
|
||||
// Fail open: use matcher with just the built-in English profanity dataset
|
||||
return censorUsernameWithMatcher(username, defaultMatcher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
PrivilegeCheckerImpl,
|
||||
} from "./Privilege";
|
||||
|
||||
// Refreshes the privilege checker every 5 minutes.
|
||||
// Refreshes the privilege checker every 3 minutes.
|
||||
// WARNING: This fails open if cosmetics.json is not available.
|
||||
export class PrivilegeRefresher {
|
||||
private privilegeChecker: PrivilegeChecker | null = null;
|
||||
@@ -18,7 +18,9 @@ export class PrivilegeRefresher {
|
||||
private log: Logger;
|
||||
|
||||
constructor(
|
||||
private endpoint: string,
|
||||
private cosmeticsEndpoint: string,
|
||||
private profaneWordsEndpoint: string,
|
||||
private apiKey: string,
|
||||
parentLog: Logger,
|
||||
private refreshInterval: number = 1000 * 60 * 3,
|
||||
) {
|
||||
@@ -37,27 +39,62 @@ export class PrivilegeRefresher {
|
||||
}
|
||||
|
||||
private async loadPrivilegeChecker(): Promise<void> {
|
||||
this.log.info(`Loading privilege checker from ${this.endpoint}`);
|
||||
this.log.info(`Loading privilege checker`);
|
||||
try {
|
||||
const response = await fetch(this.endpoint);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const fetchWithTimeout = async (url: string) => {
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: { "x-api-key": this.apiKey },
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.warn(`Failed to fetch ${url}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [cosmeticsResponse, profaneWordsResponse] = await Promise.all([
|
||||
fetchWithTimeout(this.cosmeticsEndpoint),
|
||||
fetchWithTimeout(this.profaneWordsEndpoint),
|
||||
]);
|
||||
|
||||
if (!cosmeticsResponse || !cosmeticsResponse.ok) {
|
||||
throw new Error(
|
||||
`Cosmetics HTTP error! status: ${cosmeticsResponse?.status ?? "network error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const cosmeticsData = await response.json();
|
||||
const cosmeticsData = await cosmeticsResponse.json();
|
||||
const result = CosmeticsSchema.safeParse(cosmeticsData);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid cosmetics data: ${result.error.message}`);
|
||||
}
|
||||
|
||||
let bannedWords: string[] = [];
|
||||
if (profaneWordsResponse && profaneWordsResponse.ok) {
|
||||
try {
|
||||
bannedWords = await profaneWordsResponse.json();
|
||||
this.log.info(
|
||||
`Loaded ${bannedWords.length} profane words from ${this.profaneWordsEndpoint}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.log.warn(`Failed to parse profane words JSON, using empty list`);
|
||||
}
|
||||
} else {
|
||||
this.log.warn(
|
||||
`Failed to fetch profane words (status ${profaneWordsResponse?.status ?? "network error"}), using empty list`,
|
||||
);
|
||||
}
|
||||
|
||||
this.privilegeChecker = new PrivilegeCheckerImpl(
|
||||
result.data,
|
||||
base64url.decode,
|
||||
bannedWords,
|
||||
);
|
||||
this.log.info(`Privilege checker loaded successfully`);
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to fetch cosmetics from ${this.endpoint}:`, error);
|
||||
this.log.error(`Failed to load privilege checker:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+70
-29
@@ -12,7 +12,6 @@ import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
GameID,
|
||||
ID,
|
||||
PartialGameRecordSchema,
|
||||
ServerErrorMessage,
|
||||
} from "../core/Schemas";
|
||||
@@ -68,6 +67,8 @@ export async function startWorker() {
|
||||
|
||||
const privilegeRefresher = new PrivilegeRefresher(
|
||||
config.jwtIssuer() + "/cosmetics.json",
|
||||
config.jwtIssuer() + "/profane_words_game_server",
|
||||
config.apiKey(),
|
||||
log,
|
||||
);
|
||||
privilegeRefresher.start();
|
||||
@@ -125,12 +126,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`);
|
||||
@@ -164,11 +180,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());
|
||||
});
|
||||
@@ -311,12 +327,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;
|
||||
@@ -324,11 +337,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}`,
|
||||
@@ -338,6 +354,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;
|
||||
|
||||
@@ -353,12 +375,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;
|
||||
@@ -384,7 +404,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;
|
||||
@@ -401,7 +422,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");
|
||||
@@ -409,30 +431,49 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Censor profane usernames server-side (don't reject, just rename)
|
||||
const censoredUsername = privilegeRefresher
|
||||
.get()
|
||||
.censorUsername(clientMsg.username);
|
||||
|
||||
// Create client and add to game
|
||||
const client = new Client(
|
||||
clientMsg.clientID,
|
||||
generateID(),
|
||||
persistentId,
|
||||
claims,
|
||||
roles,
|
||||
flares,
|
||||
ip,
|
||||
censoredUsername,
|
||||
clientMsg.username,
|
||||
ws,
|
||||
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
|
||||
|
||||
@@ -1,30 +1,3 @@
|
||||
// Mocking the obscenity library to control its behavior in tests.
|
||||
vi.mock("obscenity", () => {
|
||||
return {
|
||||
RegExpMatcher: class {
|
||||
private dummy: string[] = ["foo", "bar", "leet", "code"];
|
||||
constructor(_opts: any) {}
|
||||
hasMatch(input: string): boolean {
|
||||
const lower = input.toLowerCase();
|
||||
const decoded = lower
|
||||
.replace(/4/g, "a")
|
||||
.replace(/3/g, "e")
|
||||
.replace(/1/g, "i")
|
||||
.replace(/0/g, "o")
|
||||
.replace(/5/g, "s")
|
||||
.replace(/7/g, "t");
|
||||
return this.dummy.some((token) => decoded.includes(token));
|
||||
}
|
||||
},
|
||||
collapseDuplicatesTransformer: () => ({}),
|
||||
englishRecommendedTransformers: {},
|
||||
englishDataset: { build: () => ({}) },
|
||||
resolveConfusablesTransformer: () => ({}),
|
||||
resolveLeetSpeakTransformer: () => ({}),
|
||||
skipNonAlphabeticTransformer: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mocks the output of translation functions to return predictable values.
|
||||
vi.mock("../src/client/Utils", () => ({
|
||||
translateText: (key: string, vars?: any) =>
|
||||
@@ -32,53 +5,11 @@ vi.mock("../src/client/Utils", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
fixProfaneUsername,
|
||||
isProfaneUsername,
|
||||
MAX_USERNAME_LENGTH,
|
||||
validateUsername,
|
||||
} from "../src/core/validations/username";
|
||||
|
||||
describe("username.ts functions", () => {
|
||||
const shadowNames = [
|
||||
"NicePeopleOnly",
|
||||
"BeKindPlz",
|
||||
"LearningManners",
|
||||
"StayClassy",
|
||||
"BeNicer",
|
||||
"NeedHugs",
|
||||
"MakeFriends",
|
||||
];
|
||||
|
||||
describe("isProfaneUsername & fixProfaneUsername with leet decoding (mocked)", () => {
|
||||
test.each([
|
||||
{ username: "l33t", profane: true }, // decodes to "leet"
|
||||
{ username: "L33T", profane: true },
|
||||
{ username: "l33tc0de", profane: true }, // decodes to "leetcode", contains "leet" and "code"
|
||||
{ username: "L33TC0DE", profane: true },
|
||||
{ username: "foo123", profane: true }, // contains "foo"
|
||||
{ username: "b4r", profane: true }, // decodes to "bar"
|
||||
{ username: "safeName", profane: false },
|
||||
{ username: "s4f3", profane: false }, // decodes to "safe" but "safe" not in dummy list
|
||||
])('isProfaneUsername("%s") → %s', ({ username, profane }) => {
|
||||
expect(isProfaneUsername(username)).toBe(profane);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ username: "safeName" },
|
||||
{ username: "l33t" },
|
||||
{ username: "b4rUser" },
|
||||
])('fixProfaneUsername("%s") behavior', ({ username }) => {
|
||||
const profane = isProfaneUsername(username);
|
||||
const fixed = fixProfaneUsername(username);
|
||||
if (!profane) {
|
||||
expect(fixed).toBe(username);
|
||||
} else {
|
||||
// When profane: result should be one of shadowNames
|
||||
expect(shadowNames).toContain(fixed);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateUsername", () => {
|
||||
test("rejects non-string", () => {
|
||||
// @ts-expect-error: Testing non-string input to validateUsername on purpose
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
localAttackHeld: false,
|
||||
overlappingRailroads: [],
|
||||
},
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
describe("Lang Metadata Check", () => {
|
||||
const langDir = path.join(__dirname, "../resources/lang");
|
||||
const flagDir = path.join(__dirname, "../resources/flags");
|
||||
const metadataFile = path.join(langDir, "metadata.json");
|
||||
|
||||
test("metadata languages point to existing lang json and flag files", () => {
|
||||
if (!fs.existsSync(metadataFile)) {
|
||||
console.log(
|
||||
"No resources/lang/metadata.json file found. Skipping check.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataFile, "utf-8"));
|
||||
if (!Array.isArray(metadata) || metadata.length === 0) {
|
||||
console.log(
|
||||
"No language entries found in metadata.json. Skipping check.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const entry of metadata) {
|
||||
const code = entry?.code;
|
||||
const svg = entry?.svg;
|
||||
if (typeof code !== "string" || code.length === 0) {
|
||||
errors.push(
|
||||
`metadata entry missing valid code: ${JSON.stringify(entry)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof svg !== "string" || svg.length === 0) {
|
||||
errors.push(
|
||||
`[${code}]: metadata svg is missing or not a non-empty string`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const langFilePath = path.join(langDir, `${code}.json`);
|
||||
if (!fs.existsSync(langFilePath)) {
|
||||
errors.push(`[${code}]: lang json file does not exist: ${code}.json`);
|
||||
}
|
||||
|
||||
const svgFile = svg.endsWith(".svg") ? svg : `${svg}.svg`;
|
||||
const flagPath = path.join(flagDir, svgFile);
|
||||
if (!fs.existsSync(flagPath)) {
|
||||
errors.push(`[${code}]: SVG file does not exist: ${svgFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"Metadata lang or SVG file check failed:\n" + errors.join("\n"),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
createMatcher,
|
||||
PrivilegeCheckerImpl,
|
||||
shadowNames,
|
||||
} from "../src/server/Privilege";
|
||||
|
||||
const bannedWords = [
|
||||
"hitler",
|
||||
"adolf",
|
||||
"nazi",
|
||||
"jew",
|
||||
"auschwitz",
|
||||
"whitepower",
|
||||
"heil",
|
||||
"chair", // Test word to verify custom banned words work
|
||||
];
|
||||
|
||||
const matcher = createMatcher(bannedWords);
|
||||
|
||||
// Create a minimal PrivilegeCheckerImpl for testing censorUsername
|
||||
const mockCosmetics = { patterns: {}, colorPalettes: {} };
|
||||
const mockDecoder = () => new Uint8Array();
|
||||
const checker = new PrivilegeCheckerImpl(
|
||||
mockCosmetics,
|
||||
mockDecoder,
|
||||
bannedWords,
|
||||
);
|
||||
const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []);
|
||||
|
||||
describe("UsernameCensor", () => {
|
||||
describe("isProfane (via matcher.hasMatch)", () => {
|
||||
test("detects exact banned words", () => {
|
||||
expect(matcher.hasMatch("hitler")).toBe(true);
|
||||
expect(matcher.hasMatch("nazi")).toBe(true);
|
||||
expect(matcher.hasMatch("auschwitz")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects custom banned words like 'chair'", () => {
|
||||
expect(matcher.hasMatch("chair")).toBe(true);
|
||||
expect(matcher.hasMatch("Chair")).toBe(true);
|
||||
expect(matcher.hasMatch("CHAIR")).toBe(true);
|
||||
expect(matcher.hasMatch("MyChairName")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words case-insensitively", () => {
|
||||
expect(matcher.hasMatch("Hitler")).toBe(true);
|
||||
expect(matcher.hasMatch("NAZI")).toBe(true);
|
||||
expect(matcher.hasMatch("Adolf")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with leet speak", () => {
|
||||
expect(matcher.hasMatch("h1tl3r")).toBe(true);
|
||||
expect(matcher.hasMatch("4d0lf")).toBe(true);
|
||||
expect(matcher.hasMatch("n4z1")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with duplicated characters", () => {
|
||||
expect(matcher.hasMatch("hiiitler")).toBe(true);
|
||||
expect(matcher.hasMatch("naazzii")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with accented characters", () => {
|
||||
expect(matcher.hasMatch("Adölf")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words as substrings", () => {
|
||||
expect(matcher.hasMatch("xhitlerx")).toBe(true);
|
||||
expect(matcher.hasMatch("IloveNazi")).toBe(true);
|
||||
});
|
||||
|
||||
test("allows clean usernames", () => {
|
||||
expect(matcher.hasMatch("CoolPlayer")).toBe(false);
|
||||
expect(matcher.hasMatch("GameMaster")).toBe(false);
|
||||
expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("censorUsername", () => {
|
||||
test("returns clean usernames unchanged", () => {
|
||||
expect(checker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("GameMaster")).toBe("GameMaster");
|
||||
});
|
||||
|
||||
test("replaces profane usernames with a shadow name", () => {
|
||||
const result = checker.censorUsername("hitler");
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("replaces leet speak profane usernames with a shadow name", () => {
|
||||
const result = checker.censorUsername("h1tl3r");
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("preserves clean clan tag when username is profane", () => {
|
||||
const result = checker.censorUsername("[COOL]hitler");
|
||||
expect(result).toMatch(/^\[COOL\] /);
|
||||
const nameAfterTag = result.replace("[COOL] ", "");
|
||||
expect(shadowNames).toContain(nameAfterTag);
|
||||
});
|
||||
|
||||
test("removes profane clan tag but keeps clean username", () => {
|
||||
expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag with leet speak profanity", () => {
|
||||
expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag with uppercased banned word", () => {
|
||||
expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag containing banned word substring", () => {
|
||||
expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes profane clan tag and censors profane username", () => {
|
||||
const result = checker.censorUsername("[NAZI]hitler");
|
||||
// No clan tag prefix, just a shadow name
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("removes leet speak profane clan tag and censors leet speak username", () => {
|
||||
const result = checker.censorUsername("[N4Z1]h1tl3r");
|
||||
// No clan tag prefix, just a shadow name
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("returns deterministic shadow name for same input", () => {
|
||||
const a = checker.censorUsername("hitler");
|
||||
const b = checker.censorUsername("hitler");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test("handles username with no clan tag", () => {
|
||||
expect(checker.censorUsername("NormalPlayer")).toBe("NormalPlayer");
|
||||
});
|
||||
|
||||
test("empty banned words list still catches englishDataset profanity", () => {
|
||||
// The emptyChecker still uses englishDataset, so common profanity is caught
|
||||
expect(emptyChecker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
|
||||
// Verify a known english profanity gets censored even without custom banned words
|
||||
const result = emptyChecker.censorUsername("fuck");
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,623 @@
|
||||
import fs from "fs";
|
||||
import IntlMessageFormat from "intl-messageformat";
|
||||
import path from "path";
|
||||
import ts from "typescript";
|
||||
|
||||
const PROJECT_ROOT = path.join(__dirname, "..");
|
||||
const LANGUAGE_DIR = path.join(PROJECT_ROOT, "resources", "lang");
|
||||
const FLAG_DIR = path.join(PROJECT_ROOT, "resources", "flags");
|
||||
const METADATA_FILE = path.join(LANGUAGE_DIR, "metadata.json");
|
||||
|
||||
/**
|
||||
* 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 NestedTranslations = Record<string, unknown>;
|
||||
|
||||
type ParsedTranslationFile = {
|
||||
file: string;
|
||||
flatMessages: Record<string, string>;
|
||||
};
|
||||
|
||||
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 flattenTranslations(
|
||||
obj: NestedTranslations,
|
||||
file: string,
|
||||
parentKey = "",
|
||||
result: Record<string, string> = {},
|
||||
errors: string[] = [],
|
||||
): Record<string, string> {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = parentKey ? `${parentKey}.${key}` : key;
|
||||
if (typeof value === "string") {
|
||||
result[fullKey] = value;
|
||||
continue;
|
||||
}
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
flattenTranslations(
|
||||
value as NestedTranslations,
|
||||
file,
|
||||
fullKey,
|
||||
result,
|
||||
errors,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
errors.push(
|
||||
`${file}:${fullKey} has invalid type ${Array.isArray(value) ? "array" : typeof value}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function listLanguageJsonFiles(): string[] {
|
||||
return fs
|
||||
.readdirSync(LANGUAGE_DIR)
|
||||
.filter((file) => file.endsWith(".json") && file !== "metadata.json")
|
||||
.sort();
|
||||
}
|
||||
|
||||
function loadTranslationFiles(): {
|
||||
files: ParsedTranslationFile[];
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
const files: ParsedTranslationFile[] = [];
|
||||
|
||||
for (const file of listLanguageJsonFiles()) {
|
||||
const fullPath = path.join(LANGUAGE_DIR, file);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(fullPath, "utf-8");
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${file}: failed to read file (${details})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${file}: invalid JSON (${details})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
errors.push(`${file}: root must be an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push({
|
||||
file,
|
||||
flatMessages: flattenTranslations(
|
||||
parsed as NestedTranslations,
|
||||
file,
|
||||
"",
|
||||
{},
|
||||
errors,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return { files, errors };
|
||||
}
|
||||
|
||||
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("Translation System", () => {
|
||||
test("metadata languages point to existing lang json and flag files", () => {
|
||||
if (!fs.existsSync(METADATA_FILE)) {
|
||||
console.log(
|
||||
"No resources/lang/metadata.json file found. Skipping check.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(METADATA_FILE, "utf-8"));
|
||||
if (!Array.isArray(metadata) || metadata.length === 0) {
|
||||
console.log(
|
||||
"No language entries found in metadata.json. Skipping check.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const knownLanguageFiles = new Set(listLanguageJsonFiles());
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const entry of metadata) {
|
||||
const code = entry?.code;
|
||||
const svg = entry?.svg;
|
||||
|
||||
if (typeof code !== "string" || code.length === 0) {
|
||||
errors.push(
|
||||
`metadata entry missing valid code: ${JSON.stringify(entry)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof svg !== "string" || svg.length === 0) {
|
||||
errors.push(
|
||||
`[${code}]: metadata svg is missing or not a non-empty string`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!knownLanguageFiles.has(`${code}.json`)) {
|
||||
errors.push(`[${code}]: lang json file does not exist: ${code}.json`);
|
||||
}
|
||||
|
||||
const svgFile = svg.endsWith(".svg") ? svg : `${svg}.svg`;
|
||||
const flagPath = path.join(FLAG_DIR, svgFile);
|
||||
if (!fs.existsSync(flagPath)) {
|
||||
errors.push(`[${code}]: SVG file does not exist: ${svgFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"Metadata lang or SVG file check failed:\n" + errors.join("\n"),
|
||||
);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("all translation strings are valid ICU messages", () => {
|
||||
const { files, errors } = loadTranslationFiles();
|
||||
|
||||
for (const { file, flatMessages } of files) {
|
||||
for (const [key, message] of Object.entries(flatMessages)) {
|
||||
try {
|
||||
new IntlMessageFormat(message, "en");
|
||||
} catch (error) {
|
||||
const details =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${file}:${key} has invalid ICU syntax (${details})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("ICU translation validation failed:\n" + errors.join("\n"));
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("en.json keys stay in sync with source usage", () => {
|
||||
const enJsonPath = path.join(LANGUAGE_DIR, "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(PROJECT_ROOT, "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(PROJECT_ROOT, "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([]);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,8 @@ describe("RadialMenuElements", () => {
|
||||
id: () => 1,
|
||||
isAlliedWith: vi.fn(() => false),
|
||||
isPlayer: vi.fn(() => true),
|
||||
isTraitor: vi.fn(() => false),
|
||||
isDisconnected: vi.fn(() => false),
|
||||
} as unknown as PlayerView;
|
||||
|
||||
mockGame = {
|
||||
@@ -339,6 +341,8 @@ describe("RadialMenuElements", () => {
|
||||
id: () => 2,
|
||||
isAlliedWith: vi.fn(() => true),
|
||||
isPlayer: vi.fn(() => true),
|
||||
isTraitor: vi.fn(() => false),
|
||||
isDisconnected: vi.fn(() => false),
|
||||
} as unknown as PlayerView;
|
||||
mockParams.selected = allyPlayer;
|
||||
mockGame.owner = vi.fn(() => allyPlayer);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GameUpdateType } from "src/core/game/GameUpdates";
|
||||
import { vi, type Mocked } from "vitest";
|
||||
import { TrainExecution } from "../../../src/core/execution/TrainExecution";
|
||||
import { Game, Player, Unit, UnitType } from "../../../src/core/game/Game";
|
||||
@@ -112,7 +113,7 @@ describe("TrainStation", () => {
|
||||
|
||||
expect(game.addUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isActive: false,
|
||||
type: GameUpdateType.RailroadDestructionEvent,
|
||||
}),
|
||||
);
|
||||
expect(stationA.getRailroads().size).toBe(0);
|
||||
|
||||
@@ -19,13 +19,18 @@ import {
|
||||
} from "../src/client/graphics/layers/RadialMenuElements";
|
||||
|
||||
// Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions
|
||||
const makePlayer = (id: string) =>
|
||||
const makePlayer = (
|
||||
id: string,
|
||||
opts?: { isTraitor?: boolean; isDisconnected?: boolean },
|
||||
) =>
|
||||
({
|
||||
id: () => id,
|
||||
isAlliedWith: (other: any) =>
|
||||
other && typeof other.id === "function" && other.id() !== id
|
||||
? true
|
||||
: true,
|
||||
isTraitor: () => opts?.isTraitor ?? false,
|
||||
isDisconnected: () => opts?.isDisconnected ?? false,
|
||||
}) as unknown as import("../src/core/game/GameView").PlayerView;
|
||||
|
||||
const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
|
||||
@@ -82,7 +87,26 @@ describe("RadialMenuElements ally break", () => {
|
||||
const ally = findAllyBreak(items)!;
|
||||
expect(ally).toBeTruthy();
|
||||
expect(ally.name).toBe("break");
|
||||
expect(ally.color).toBe(COLORS.breakAlly);
|
||||
expect(typeof ally.color).toBe("function");
|
||||
expect(ally.color(params)).toBe(COLORS.breakAlly);
|
||||
});
|
||||
|
||||
test("shows break option with orange color when allied to traitor", () => {
|
||||
const params = makeParams({
|
||||
selected: makePlayer("p2", { isTraitor: true }),
|
||||
});
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
expect(ally.color(params)).toBe(COLORS.breakAllyNoDebuff);
|
||||
});
|
||||
|
||||
test("shows boat button instead of break when allied to disconnected player", () => {
|
||||
const params = makeParams({
|
||||
selected: makePlayer("p2", { isDisconnected: true }),
|
||||
});
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
expect(findAllyBreak(items)).toBeUndefined();
|
||||
expect(items.find((i) => i.id === "boat")).toBeDefined();
|
||||
});
|
||||
|
||||
test("break action calls handleBreakAlliance and closes menu", () => {
|
||||
|
||||
Reference in New Issue
Block a user