Merge branch 'main' into local-attack

This commit is contained in:
Aotumuri
2026-02-11 18:32:27 +09:00
committed by GitHub
70 changed files with 3364 additions and 1549 deletions
+5 -1
View File
@@ -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"
}
]
}
+2 -1
View File
@@ -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},
+21
View File
@@ -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
View File
@@ -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",
+85
View File
@@ -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

-20
View File
@@ -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
+32 -33
View File
@@ -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
View File
@@ -13,7 +13,6 @@ export class GutterAds extends LitElement {
private rightAdType: string = "standard_iab_rght1";
private leftContainerId: string = "gutter-ad-container-left";
private rightContainerId: string = "gutter-ad-container-right";
private margin: string = "10px";
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
@@ -50,6 +49,16 @@ export class GutterAds extends LitElement {
});
}
public close(): void {
try {
window.ramp.destroyUnits(this.leftAdType);
window.ramp.destroyUnits(this.rightAdType);
console.log("successfully destroyed gutter ads");
} catch (e) {
console.error("error destroying gutter ads", e);
}
}
private loadAds(): void {
console.log("loading ramp ads");
// Ensure the container elements exist before loading ads
@@ -111,8 +120,8 @@ export class GutterAds extends LitElement {
return html`
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed left-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="margin-left: ${this.margin};"
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="left: calc(50% - 10cm - 230px); top: calc(50% + 10px);"
>
<div
id="${this.leftContainerId}"
@@ -122,8 +131,8 @@ export class GutterAds extends LitElement {
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed right-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="margin-right: ${this.margin};"
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
style="left: calc(50% + 10cm + 70px); top: calc(50% + 10px);"
>
<div
id="${this.rightContainerId}"
+1 -1
View File
@@ -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"
+41 -11
View File
@@ -1,7 +1,8 @@
import { TemplateResult, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { EventBus } from "../core/EventBus";
import {
Difficulty,
Duos,
@@ -17,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
},
);
+4 -11
View File
@@ -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,
+25 -9
View File
@@ -2,13 +2,14 @@ import { z } from "zod";
import { EventBus } from "../core/EventBus";
import {
AllPlayersStats,
ClientID,
ClientMessage,
ClientSendWinnerMessage,
Intent,
PartialGameRecordSchema,
PlayerRecord,
ServerMessage,
ServerStartGameMessage,
StampedIntent,
Turn,
} from "../core/Schemas";
import {
@@ -34,12 +35,13 @@ export class LocalServer {
private turns: Turn[] = [];
private intents: Intent[] = [];
private intents: StampedIntent[] = [];
private startedAt: number;
private paused = false;
private replaySpeedMultiplier = defaultReplaySpeedMultiplier;
private clientID: ClientID | undefined;
private winner: ClientSendWinnerMessage | null = null;
private allPlayersStats: AllPlayersStats = {};
@@ -102,34 +104,48 @@ export class LocalServer {
if (this.lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
this.clientID = this.lobbyConfig.gameStartInfo.players[0]?.clientID;
if (!this.clientID) {
throw new Error("missing clientID");
}
this.clientMessage({
type: "start",
gameStartInfo: this.lobbyConfig.gameStartInfo,
turns: [],
lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt,
myClientID: this.clientID,
} satisfies ServerStartGameMessage);
}
onMessage(clientMsg: ClientMessage) {
if (clientMsg.type === "rejoin") {
if (!this.clientID) {
throw new Error("missing clientID");
}
this.clientMessage({
type: "start",
gameStartInfo: this.lobbyConfig.gameStartInfo!,
turns: this.turns,
lobbyCreatedAt: this.lobbyConfig.gameStartInfo!.lobbyCreatedAt,
myClientID: this.clientID,
} satisfies ServerStartGameMessage);
}
if (clientMsg.type === "intent") {
if (clientMsg.intent.type === "toggle_pause") {
if (clientMsg.intent.paused) {
// Server stamps clientID - client doesn't send it
const stampedIntent = {
...clientMsg.intent,
clientID: this.clientID!,
};
if (stampedIntent.type === "toggle_pause") {
if (stampedIntent.paused) {
// Pausing: add intent and end turn before pause takes effect
this.intents.push(clientMsg.intent);
this.intents.push(stampedIntent);
this.endTurn();
this.paused = true;
} else {
// Unpausing: clear pause flag before adding intent so next turn can execute
this.paused = false;
this.intents.push(clientMsg.intent);
this.intents.push(stampedIntent);
this.endTurn();
}
return;
@@ -139,7 +155,7 @@ export class LocalServer {
return;
}
this.intents.push(clientMsg.intent);
this.intents.push(stampedIntent);
}
if (clientMsg.type === "hash") {
if (!this.lobbyConfig.gameRecord) {
@@ -224,8 +240,8 @@ export class LocalServer {
{
persistentID: getPersistentID(),
username: this.lobbyConfig.playerName,
clientID: this.lobbyConfig.clientID,
stats: this.allPlayersStats[this.lobbyConfig.clientID],
clientID: this.clientID!,
stats: this.allPlayersStats[this.clientID!],
cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics,
clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined,
},
+3 -2
View File
@@ -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;
+1 -2
View File
@@ -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,
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+14 -2
View File
@@ -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(
+1
View File
@@ -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>
`;
}
}
+1 -1
View File
@@ -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()}
>
+3 -292
View File
@@ -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 (
+22 -27
View File
@@ -1,10 +1,8 @@
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import {
ConquestUpdate,
GameUpdateType,
RailroadUpdate,
} from "../../../core/game/GameUpdates";
import { TileRef } from "../../../core/game/GameMap";
import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import SoundManager, { SoundEffect } from "../../sound/SoundManager";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
@@ -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");
+7 -1
View File
@@ -3,7 +3,7 @@ import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes
const AD_SHOW_TICKS = 10 * 60 * 10; // 2 minutes
const HEADER_AD_TYPE = "standard_iab_head1";
const HEADER_AD_CONTAINER_ID = "header-ad-container";
const TWO_XL_BREAKPOINT = 1536;
@@ -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();
}
+15 -8
View File
@@ -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,
]),
+182 -47
View File
@@ -1,33 +1,49 @@
import { colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { PlayerID } from "../../../core/game/Game";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { PlayerID, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import {
GameUpdateType,
RailroadUpdate,
RailTile,
RailType,
RailroadConstructionUpdate,
RailroadDestructionUpdate,
RailroadSnapUpdate,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { AlternateViewEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
import { getBridgeRects, getRailroadRects } from "./RailroadSprites";
import {
computeRailTiles,
RailroadView,
RailTile,
RailType,
} from "./RailroadView";
type RailRef = {
tile: RailTile;
numOccurence: number;
lastOwnerId: PlayerID | null;
};
const SNAPPABLE_STRUCTURES: UnitType[] = [
UnitType.Port,
UnitType.City,
UnitType.Factory,
];
export class RailTileChangedEvent implements GameEvent {
constructor(public tile: TileRef) {}
}
export class RailroadLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private theme: Theme;
private alternativeView = false;
// Save the number of railroads per tiles. Delete when it reaches 0
private existingRailroads = new Map<TileRef, RailRef>();
private railroads = new Map<number, RailroadView>();
// Railroads under construction
private pendingRailroads = new Set<number>();
private nextRailIndexToCheck = 0;
private railTileList: TileRef[] = [];
private railTileIndex = new Map<TileRef, number>();
@@ -38,20 +54,52 @@ export class RailroadLayer implements Layer {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
}
private uiState: UIState,
) {}
shouldTransform(): boolean {
return true;
}
tick() {
this.updatePendingRailroads();
const updates = this.game.updatesSinceLastTick();
const railUpdates =
updates !== null ? updates[GameUpdateType.RailroadEvent] : [];
for (const rail of railUpdates) {
this.handleRailroadRendering(rail);
if (!updates) return;
// The event has to be handled in this specific order: construction / snap / destruction
// Otherwise some ID may not be available yet/anymore
updates[GameUpdateType.RailroadConstructionEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadConstruction(update);
});
updates[GameUpdateType.RailroadSnapEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadSnapEvent(update);
});
updates[GameUpdateType.RailroadDestructionEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadDestruction(update);
});
}
updatePendingRailroads() {
for (const id of this.pendingRailroads) {
const pending = this.railroads.get(id);
if (pending === undefined) {
// Rail deleted or snapped before the end of the animation
this.pendingRailroads.delete(id);
continue;
}
const newTiles = pending.tick();
if (newTiles.length === 0) {
// Animation complete
this.pendingRailroads.delete(id);
continue;
}
for (const railTile of newTiles) {
this.paintRailTile(railTile);
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
}
}
}
@@ -120,6 +168,32 @@ export class RailroadLayer implements Layer {
}
}
private highlightOverlappingRailroads(context: CanvasRenderingContext2D) {
if (
this.uiState.ghostStructure === null ||
!SNAPPABLE_STRUCTURES.includes(this.uiState.ghostStructure)
)
return;
if (
this.uiState.overlappingRailroads === undefined ||
this.uiState.overlappingRailroads.length === 0
)
return;
const offsetX = -this.game.width() / 2;
const offsetY = -this.game.height() / 2;
context.fillStyle = "rgba(0, 255, 0, 0.4)";
for (const id of this.uiState.overlappingRailroads) {
const rail = this.railroads.get(id);
if (rail) {
for (const railTile of rail.drawnTiles()) {
const x = this.game.x(railTile.tile);
const y = this.game.y(railTile.tile);
context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5);
}
}
}
}
renderLayer(context: CanvasRenderingContext2D) {
const scale = this.transformHandler.scale;
if (scale <= 1) {
@@ -154,6 +228,7 @@ export class RailroadLayer implements Layer {
context.save();
context.globalAlpha = alpha;
this.highlightOverlappingRailroads(context);
context.drawImage(
this.canvas,
srcX,
@@ -168,55 +243,115 @@ export class RailroadLayer implements Layer {
context.restore();
}
private handleRailroadRendering(railUpdate: RailroadUpdate) {
for (const railRoad of railUpdate.railTiles) {
if (railUpdate.isActive) {
this.paintRailroad(railRoad);
} else {
this.clearRailroad(railRoad);
}
private onRailroadSnapEvent(update: RailroadSnapUpdate) {
const original = this.railroads.get(update.originalId);
if (!original) {
console.warn("Could not snap railroad: ", update.originalId);
return;
}
if (!original.isComplete()) {
// The animation is not complete but we don't want to compute where the animation should resume
// Just draw every remaining rails at once
this.drawRemainingTiles(original);
}
// No need to compute the directions here, the rails are already painted
const directions1: RailTile[] = update.tiles1.map((tile) => ({
tile,
type: RailType.HORIZONTAL,
}));
const directions2: RailTile[] = update.tiles2.map((tile) => ({
tile,
type: RailType.HORIZONTAL,
}));
// The rails are already painted, consider them complete
this.railroads.set(
update.newId1,
new RailroadView(update.newId1, directions1, true),
);
this.railroads.set(
update.newId2,
new RailroadView(update.newId2, directions2, true),
);
this.railroads.delete(update.originalId);
}
private paintRailroad(railRoad: RailTile) {
const currentOwner = this.game.owner(railRoad.tile)?.id() ?? null;
const railTile = this.existingRailroads.get(railRoad.tile);
private drawRemainingTiles(railroad: RailroadView) {
for (const tile of railroad.remainingTiles()) {
this.paintRail(tile);
}
this.pendingRailroads.delete(railroad.id);
}
if (railTile) {
railTile.numOccurence++;
railTile.tile = railRoad;
railTile.lastOwnerId = currentOwner;
private onRailroadConstruction(railUpdate: RailroadConstructionUpdate) {
const railTiles = computeRailTiles(this.game, railUpdate.tiles);
const rail = new RailroadView(railUpdate.id, railTiles);
this.addRailroad(rail);
}
private onRailroadDestruction(railUpdate: RailroadDestructionUpdate) {
const railroad = this.railroads.get(railUpdate.id);
if (!railroad) {
console.warn("Can't remove unexisting railroad: ", railUpdate.id);
return;
}
this.removeRailroad(railroad);
}
private addRailroad(railroad: RailroadView) {
this.railroads.set(railroad.id, railroad);
this.pendingRailroads.add(railroad.id);
}
private removeRailroad(railroad: RailroadView) {
this.pendingRailroads.delete(railroad.id);
for (const railTile of railroad.drawnTiles()) {
this.clearRailroad(railTile.tile);
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
}
this.railroads.delete(railroad.id);
}
private paintRailTile(railTile: RailTile) {
const currentOwner = this.game.owner(railTile.tile)?.id() ?? null;
const railRef = this.existingRailroads.get(railTile.tile);
if (railRef) {
railRef.numOccurence++;
railRef.tile = railTile;
railRef.lastOwnerId = currentOwner;
} else {
this.existingRailroads.set(railRoad.tile, {
tile: railRoad,
this.existingRailroads.set(railTile.tile, {
tile: railTile,
numOccurence: 1,
lastOwnerId: currentOwner,
});
this.railTileIndex.set(railRoad.tile, this.railTileList.length);
this.railTileList.push(railRoad.tile);
this.paintRail(railRoad);
this.railTileIndex.set(railTile.tile, this.railTileList.length);
this.railTileList.push(railTile.tile);
this.paintRail(railTile);
}
}
private clearRailroad(railRoad: RailTile) {
const ref = this.existingRailroads.get(railRoad.tile);
private clearRailroad(railroad: TileRef) {
const ref = this.existingRailroads.get(railroad);
if (ref) ref.numOccurence--;
if (!ref || ref.numOccurence <= 0) {
this.existingRailroads.delete(railRoad.tile);
this.removeRailTile(railRoad.tile);
this.existingRailroads.delete(railroad);
this.removeRailTile(railroad);
if (this.context === undefined) throw new Error("Not initialized");
if (this.game.isWater(railRoad.tile)) {
if (this.game.isWater(railroad)) {
this.context.clearRect(
this.game.x(railRoad.tile) * 2 - 2,
this.game.y(railRoad.tile) * 2 - 2,
this.game.x(railroad) * 2 - 2,
this.game.y(railroad) * 2 - 2,
5,
6,
);
} else {
this.context.clearRect(
this.game.x(railRoad.tile) * 2 - 1,
this.game.y(railRoad.tile) * 2 - 1,
this.game.x(railroad) * 2 - 1,
this.game.y(railroad) * 2 - 1,
3,
3,
);
@@ -242,15 +377,15 @@ export class RailroadLayer implements Layer {
}
}
paintRail(railRoad: RailTile) {
paintRail(railTile: RailTile) {
if (this.context === undefined) throw new Error("Not initialized");
const { tile } = railRoad;
const { railType } = railRoad;
const { tile } = railTile;
const { type } = railTile;
const x = this.game.x(tile);
const y = this.game.y(tile);
// If rail tile is over water, paint a bridge underlay first
if (this.game.isWater(tile)) {
this.paintBridge(this.context, x, y, railType);
this.paintBridge(this.context, x, y, type);
}
const owner = this.game.owner(tile);
const recipient = owner.isPlayer() ? owner : null;
@@ -263,7 +398,7 @@ export class RailroadLayer implements Layer {
}
this.context.fillStyle = color.toRgbString();
this.paintRailRects(this.context, x, y, railType);
this.paintRailRects(this.context, x, y, type);
}
private paintRailRects(
+14 -14
View File
@@ -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],
];
}
// ⌞
+177
View File
@@ -0,0 +1,177 @@
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
export enum RailType {
VERTICAL,
HORIZONTAL,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT,
}
export type RailTile = {
tile: TileRef;
type: RailType;
};
export function computeRailTiles(game: GameView, tiles: TileRef[]): RailTile[] {
if (tiles.length === 0) return [];
if (tiles.length === 1) {
return [{ tile: tiles[0], type: RailType.VERTICAL }];
}
const railTypes: RailTile[] = [];
// Inverse direction computation for the first tile
railTypes.push({
tile: tiles[0],
type: computeExtremityDirection(game, tiles[0], tiles[1]),
});
for (let i = 1; i < tiles.length - 1; i++) {
const direction = computeDirection(
game,
tiles[i - 1],
tiles[i],
tiles[i + 1],
);
railTypes.push({ tile: tiles[i], type: direction });
}
railTypes.push({
tile: tiles[tiles.length - 1],
type: computeExtremityDirection(
game,
tiles[tiles.length - 1],
tiles[tiles.length - 2],
),
});
return railTypes;
}
function computeExtremityDirection(
game: GameView,
tile: TileRef,
next: TileRef,
): RailType {
const x = game.x(tile);
const y = game.y(tile);
const nextX = game.x(next);
const nextY = game.y(next);
const dx = nextX - x;
const dy = nextY - y;
if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement
if (dx === 0) {
return RailType.VERTICAL;
} else if (dy === 0) {
return RailType.HORIZONTAL;
}
return RailType.VERTICAL;
}
export function computeDirection(
game: GameView,
prev: TileRef,
current: TileRef,
next: TileRef,
): RailType {
const x1 = game.x(prev);
const y1 = game.y(prev);
const x2 = game.x(current);
const y2 = game.y(current);
const x3 = game.x(next);
const y3 = game.y(next);
const dx1 = x2 - x1;
const dy1 = y2 - y1;
const dx2 = x3 - x2;
const dy2 = y3 - y2;
// Straight line
if (dx1 === dx2 && dy1 === dy2) {
if (dx1 !== 0) return RailType.HORIZONTAL;
if (dy1 !== 0) return RailType.VERTICAL;
}
// Turn (corner) cases
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
// Now figure out which type of corner
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
}
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
return RailType.VERTICAL;
}
/**
* A list of tile that can be incrementally painted each tick
*/
export class RailroadView {
private headIndex: number = 0;
private tailIndex: number;
private increment: number = 3;
constructor(
public id: number,
private railTiles: RailTile[],
complete: boolean = false,
) {
// If the railroad is considered complete, no drawing or animation is required
this.tailIndex = complete ? 0 : railTiles.length;
}
isComplete(): boolean {
return this.headIndex >= this.tailIndex;
}
tiles(): RailTile[] {
return this.railTiles;
}
remainingTiles(): RailTile[] {
if (this.isComplete()) {
// Animation complete, no tiles need to be painted
return [];
}
return this.railTiles.slice(this.headIndex, this.tailIndex);
}
drawnTiles(): RailTile[] {
if (this.isComplete()) {
// Animation complete, every tiles have been painted
return this.tiles();
}
let drawnTiles = this.railTiles.slice(0, this.headIndex);
drawnTiles = drawnTiles.concat(this.railTiles.slice(this.tailIndex));
return drawnTiles;
}
tick(): RailTile[] {
if (this.isComplete()) return [];
let updatedRailTiles: RailTile[];
// Check if remaining tiles can be done all at once
if (this.tailIndex - this.headIndex <= 2 * this.increment) {
updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex);
} else {
updatedRailTiles = [
...this.railTiles.slice(
this.headIndex,
this.headIndex + this.increment,
),
...this.railTiles.slice(
this.tailIndex - this.increment,
this.tailIndex,
),
];
}
this.headIndex = Math.min(this.headIndex + this.increment, this.tailIndex);
this.tailIndex = Math.max(this.tailIndex - this.increment, this.headIndex);
return updatedRailTiles;
}
}
@@ -24,8 +24,7 @@ export class SpawnVideoAd extends LitElement implements Layer {
window.innerWidth < 768 ||
crazyGamesSDK.isOnCrazyGames() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
getGamesPlayed() < 3 || // Don't show to new players
getGamesPlayed() % 3 !== 0 // Only show 1 in 3 times
getGamesPlayed() < 3 // Don't show to new players
) {
return;
}
@@ -333,10 +333,15 @@ export class StructureIconsLayer implements Layer {
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
}
// No overlapping when a structure is upgradable
this.uiState.overlappingRailroads = [];
} else if (unit.canBuild === false) {
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.uiState.overlappingRailroads = [];
} else {
this.uiState.overlappingRailroads = unit.overlappingRailroads;
}
const scale = this.transformHandler.scale;
@@ -450,7 +455,13 @@ export class StructureIconsLayer implements Layer {
priceGroup: ghost.priceGroup,
priceBox: ghost.priceBox,
range: null,
buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n },
buildableUnit: {
type,
canBuild: false,
canUpgrade: false,
cost: 0n,
overlappingRailroads: [],
},
};
const showPrice = this.game.config().userSettings().cursorCostLabel();
this.updateGhostPrice(0, showPrice);
+1 -2
View File
@@ -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
View File
@@ -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,
});
+16 -9
View File
@@ -24,20 +24,27 @@ export const greenTeamColors: Colord[] = generateTeamColors(green);
export const botTeamColors: Colord[] = [botColor];
function generateTeamColors(baseColor: Colord): Colord[] {
const hsl = baseColor.toHsl();
const lch = baseColor.toLch();
const colorCount = 64;
const goldenAngle = 137.508;
return Array.from({ length: colorCount }, (_, index) => {
const progression = index / (colorCount - 1);
if (index === 0) return baseColor;
const saturation = hsl.s * (1.0 - 0.3 * progression);
const lightness = Math.min(100, hsl.l + progression * 30);
// Spread hues evenly across ±12° band using golden angle within that range
const hueShift = ((index * goldenAngle) % 24) - 12;
const h = (lch.h + hueShift + 360) % 360;
return colord({
h: hsl.h,
s: saturation,
l: lightness,
});
// Chroma oscillates ±10% around the base to add variety without washing out
const chromaFactor = 1.0 + 0.1 * Math.sin(index * 0.7);
const c = Math.max(10, Math.min(130, lch.c * chromaFactor));
// Lightness alternates above/below the base using golden angle spacing
// Tighter range (±18) keeps teammates recognizable as the same team
const lightOffset = 18 * Math.sin(index * goldenAngle * (Math.PI / 180));
const l = Math.max(25, Math.min(80, lch.l + lightOffset));
return colord({ l, c, h });
});
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { ClientID, GameID, StampedIntent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
@@ -46,7 +46,7 @@ export class Executor {
return turn.intents.map((i) => this.createExec(i));
}
createExec(intent: Intent): Execution {
createExec(intent: StampedIntent): Execution {
const player = this.mg.playerByClientID(intent.clientID);
if (!player) {
console.warn(`player with clientID ${intent.clientID} not found`);
+83 -165
View File
@@ -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;
}
+2 -1
View File
@@ -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);
-170
View File
@@ -1,170 +0,0 @@
import { Execution, Game } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateType, RailTile, RailType } from "../game/GameUpdates";
import { Railroad } from "../game/Railroad";
export class RailroadExecution implements Execution {
private mg: Game;
private active: boolean = true;
private headIndex: number = 0;
private tailIndex: number = 0;
private increment: number = 3;
private railTiles: RailTile[] = [];
constructor(private railRoad: Railroad) {
this.tailIndex = railRoad.tiles.length;
}
isActive(): boolean {
return this.active;
}
init(mg: Game, ticks: number): void {
this.mg = mg;
const tiles = this.railRoad.tiles;
// Inverse direction computation for the first tile
this.railTiles.push({
tile: tiles[0],
railType:
tiles.length > 0
? this.computeExtremityDirection(tiles[0], tiles[1])
: RailType.VERTICAL,
});
for (let i = 1; i < tiles.length - 1; i++) {
const direction = this.computeDirection(
tiles[i - 1],
tiles[i],
tiles[i + 1],
);
this.railTiles.push({ tile: tiles[i], railType: direction });
}
this.railTiles.push({
tile: tiles[tiles.length - 1],
railType:
tiles.length > 0
? this.computeExtremityDirection(
tiles[tiles.length - 1],
tiles[tiles.length - 2],
)
: RailType.VERTICAL,
});
}
private computeExtremityDirection(tile: TileRef, next: TileRef): RailType {
const x = this.mg.x(tile);
const y = this.mg.y(tile);
const nextX = this.mg.x(next);
const nextY = this.mg.y(next);
const dx = nextX - x;
const dy = nextY - y;
if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement
if (dx === 0) {
return RailType.VERTICAL;
} else if (dy === 0) {
return RailType.HORIZONTAL;
}
return RailType.VERTICAL;
}
private computeDirection(
prev: TileRef,
current: TileRef,
next: TileRef,
): RailType {
if (this.mg === null) {
throw new Error("Not initialized");
}
const x1 = this.mg.x(prev);
const y1 = this.mg.y(prev);
const x2 = this.mg.x(current);
const y2 = this.mg.y(current);
const x3 = this.mg.x(next);
const y3 = this.mg.y(next);
const dx1 = x2 - x1;
const dy1 = y2 - y1;
const dx2 = x3 - x2;
const dy2 = y3 - y2;
// Straight line
if (dx1 === dx2 && dy1 === dy2) {
if (dx1 !== 0) return RailType.HORIZONTAL;
if (dy1 !== 0) return RailType.VERTICAL;
}
// Turn (corner) cases
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
// Now figure out which type of corner
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
}
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
return RailType.VERTICAL;
}
tick(ticks: number): void {
if (this.mg === null) {
throw new Error("Not initialized");
}
if (!this.activeSourceOrDestination()) {
this.active = false;
return;
}
if (this.headIndex > this.tailIndex) {
// Construction complete
this.constructionComplete();
return;
}
let updatedRailTiles: RailTile[];
// Check if remaining tiles can be done all at once
if (this.tailIndex - this.headIndex <= 2 * this.increment) {
updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex);
this.constructionComplete();
} else {
updatedRailTiles = this.railTiles.slice(
this.headIndex,
this.headIndex + this.increment,
);
updatedRailTiles = updatedRailTiles.concat(
this.railTiles.slice(this.tailIndex - this.increment, this.tailIndex),
);
this.headIndex += this.increment;
this.tailIndex -= this.increment;
}
if (updatedRailTiles) {
this.mg.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: true,
railTiles: updatedRailTiles,
});
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
private activeSourceOrDestination(): boolean {
return this.railRoad.from.isActive() && this.railRoad.to.isActive();
}
private constructionComplete() {
this.redrawBuildings();
this.active = false;
}
private redrawBuildings() {
if (this.railRoad.from.unit.isActive()) this.railRoad.from.unit.touch();
if (this.railRoad.to.unit.isActive()) this.railRoad.to.unit.touch();
}
}
@@ -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}`);
}
}
+3
View File
@@ -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 {
+20 -16
View File
@@ -44,7 +44,9 @@ export enum GameUpdateType {
Hash,
UnitIncoming,
BonusEvent,
RailroadEvent,
RailroadDestructionEvent,
RailroadConstructionEvent,
RailroadSnapEvent,
ConquestEvent,
EmbargoEvent,
GamePaused,
@@ -67,7 +69,9 @@ export type GameUpdate =
| UnitIncomingUpdate
| AllianceExtensionUpdate
| BonusEventUpdate
| RailroadUpdate
| RailroadConstructionUpdate
| RailroadDestructionUpdate
| RailroadSnapUpdate
| ConquestUpdate
| EmbargoUpdate
| GamePausedUpdate;
@@ -80,24 +84,24 @@ export interface BonusEventUpdate {
troops: number;
}
export enum RailType {
VERTICAL,
HORIZONTAL,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT,
export interface RailroadConstructionUpdate {
type: GameUpdateType.RailroadConstructionEvent;
id: number;
tiles: TileRef[];
}
export interface RailTile {
tile: TileRef;
railType: RailType;
export interface RailroadDestructionUpdate {
type: GameUpdateType.RailroadDestructionEvent;
id: number;
}
export interface RailroadUpdate {
type: GameUpdateType.RailroadEvent;
isActive: boolean;
railTiles: RailTile[];
export interface RailroadSnapUpdate {
type: GameUpdateType.RailroadSnapEvent;
originalId: number;
newId1: number;
newId2: number;
tiles1: TileRef[];
tiles2: TileRef[];
}
export interface ConquestUpdate {
+8
View File
@@ -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 ?? {}]),
);
+10 -5
View File
@@ -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;
});
}
+2
View File
@@ -1,4 +1,5 @@
import { Unit } from "./Game";
import { TileRef } from "./GameMap";
import { StationManager } from "./RailNetworkImpl";
import { TrainStation } from "./TrainStation";
@@ -7,4 +8,5 @@ export interface RailNetwork {
removeStation(unit: Unit): void;
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
stationManager(): StationManager;
overlappingRailroads(tile: TileRef): number[];
}
+28 -6
View File
@@ -1,7 +1,7 @@
import { RailroadExecution } from "../execution/RailroadExecution";
import { PathFinding } from "../pathfinding/PathFinder";
import { Game, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType } from "./GameUpdates";
import { RailNetwork } from "./RailNetwork";
import { Railroad } from "./Railroad";
import { RailSpatialGrid } from "./RailroadSpatialGrid";
@@ -85,6 +85,7 @@ export class RailNetworkImpl implements RailNetwork {
private stationRadius: number = 3;
private gridCellSize: number = 4;
private railGrid: RailSpatialGrid;
private nextId: number = 0;
constructor(
private game: Game,
@@ -141,6 +142,7 @@ export class RailNetworkImpl implements RailNetwork {
for (const rail of rails) {
const from = rail.from;
const to = rail.to;
const originalId = rail.id;
const closestRailIndex = rail.getClosestTileIndex(
this.game,
station.tile(),
@@ -158,11 +160,13 @@ export class RailNetworkImpl implements RailNetwork {
from,
station,
rail.tiles.slice(0, closestRailIndex),
this.nextId++,
);
const newRailTo = new Railroad(
station,
to,
rail.tiles.slice(closestRailIndex),
this.nextId++,
);
// New station is connected to both new rails
@@ -179,6 +183,14 @@ export class RailNetworkImpl implements RailNetwork {
cluster.addStation(station);
editedClusters.add(cluster);
}
this.game.addUpdate({
type: GameUpdateType.RailroadSnapEvent,
originalId,
newId1: newRailFrom.id,
newId2: newRailTo.id,
tiles1: newRailFrom.tiles,
tiles2: newRailTo.tiles,
});
}
// If multiple clusters own the new station, merge them into a single cluster
if (editedClusters.size > 1) {
@@ -187,6 +199,12 @@ export class RailNetworkImpl implements RailNetwork {
return editedClusters.size !== 0;
}
overlappingRailroads(tile: TileRef): number[] {
return [...this.railGrid.query(tile, this.stationRadius)].map(
(railroad: Railroad) => railroad.id,
);
}
private connectToNearbyStations(station: TrainStation) {
const neighbors = this.game.nearbyUnits(
station.tile(),
@@ -256,11 +274,15 @@ export class RailNetworkImpl implements RailNetwork {
private connect(from: TrainStation, to: TrainStation) {
const path = this.pathService.findTilePath(from.tile(), to.tile());
if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) {
const railRoad = new Railroad(from, to, path);
this.game.addExecution(new RailroadExecution(railRoad));
from.addRailroad(railRoad);
to.addRailroad(railRoad);
this.railGrid.register(railRoad);
const railroad = new Railroad(from, to, path, this.nextId++);
this.game.addUpdate({
type: GameUpdateType.RailroadConstructionEvent,
id: railroad.id,
tiles: railroad.tiles,
});
from.addRailroad(railroad);
to.addRailroad(railroad);
this.railGrid.register(railroad);
return true;
}
return false;
+4 -8
View File
@@ -1,6 +1,6 @@
import { Game } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
import { GameUpdateType } from "./GameUpdates";
import { TrainStation } from "./TrainStation";
export class Railroad {
@@ -8,17 +8,13 @@ export class Railroad {
public from: TrainStation,
public to: TrainStation,
public tiles: TileRef[],
public id: number,
) {}
delete(game: Game) {
const railTiles: RailTile[] = this.tiles.map((tile) => ({
tile,
railType: RailType.VERTICAL,
}));
game.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: false,
railTiles,
type: GameUpdateType.RailroadDestructionEvent,
id: this.id,
});
this.from.removeRailroad(this);
this.to.removeRailroad(this);
+3 -8
View File
@@ -2,7 +2,7 @@ import { TrainExecution } from "../execution/TrainExecution";
import { PseudoRandom } from "../PseudoRandom";
import { Game, Player, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
import { GameUpdateType } from "./GameUpdates";
import { Railroad } from "./Railroad";
/**
@@ -92,14 +92,9 @@ export class TrainStation {
(r) => r.from === station || r.to === station,
);
if (toRemove) {
const railTiles: RailTile[] = toRemove.tiles.map((tile) => ({
tile,
railType: RailType.VERTICAL,
}));
this.mg.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: false,
railTiles,
type: GameUpdateType.RailroadDestructionEvent,
id: toRemove.id,
});
this.removeRailroad(toRemove);
}
-83
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -8,7 +8,7 @@ import {
GameMode,
GameType,
} from "../core/game/Game";
import { ClientRejoinMessage, GameConfig, GameID } from "../core/Schemas";
import { GameConfig, GameID } from "../core/Schemas";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
@@ -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
View File
@@ -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
) {
+1
View File
@@ -67,6 +67,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
Sierpinski: 10,
TheBox: 3,
Yenisei: 6,
TradersDream: 4,
};
interface MapWithMode {
+119 -1
View File
@@ -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);
}
}
+45 -8
View File
@@ -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
View File
@@ -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
-69
View File
@@ -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
+1
View File
@@ -39,6 +39,7 @@ describe("InputHandler AutoUpgrade", () => {
ghostStructure: null,
rocketDirectionUp: true,
localAttackHeld: false,
overlappingRailroads: [],
},
mockCanvas,
eventBus,
-62
View File
@@ -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([]);
}
});
});
+147
View File
@@ -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);
});
});
});
+623
View File
@@ -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);
+2 -1
View File
@@ -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);
+26 -2
View File
@@ -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", () => {