Merge branch 'main' into 2393-show-boat-troops-as-attacking-troops

This commit is contained in:
Rj Manhas
2025-11-19 21:27:22 -07:00
committed by GitHub
56 changed files with 2523 additions and 615 deletions
+118 -4
View File
@@ -1,4 +1,6 @@
## API Usage
# API Usage
## Games
### List Game Metadata
@@ -40,8 +42,7 @@ curl "https://api.openfront.io/public/games?start=2025-10-25T00:00:00Z&end=2025-
"type": "Singleplayer",
"mode": "Free For All",
"difficulty": "Medium"
},
...
}
]
```
@@ -75,7 +76,7 @@ curl "https://api.openfront.io/public/game/ABSgwin6?turns=false"
**Note:** Public player IDs are stripped from game records for privacy.
---
## Players
### Get Player Info
@@ -92,3 +93,116 @@ GET https://api.openfront.io/public/player/:playerId
```bash
curl "https://api.openfront.io/public/player/HabCsQYR"
```
### Get Player Sessions
Retrieve a list of games & client ids (session ids) for a specific player.
**Endpoint:**
```
GET https://api.openfront.io/public/player/:playerId/sessions
```
**Example:**
```bash
curl "https://api.openfront.io/public/player/HabCsQYR/sessions"
```
## Clans
### Clan Leaderboard
Shows the top 100 clans by `weighted wins`.
**Endpoint:**
```
GET https://api.openfront.io/public/clans/leaderboard
```
Weighted wins have a half-life of 30 days to favor recent wins.
Weighted wins are calculated using the following formula:
```
FUNCTION calculateScore(session: ClanSession, decay: NUMBER = 1) → NUMBER
// 1. Calculate average team size
avgTeamSize ← session.totalPlayerCount ÷ session.numTeams
// 2. Determine how much the clan contributed to their team
// (clan players divided by average players per team)
clanMemberRatio ← session.clanPlayerCount ÷ avgTeamSize
// 3. Apply decay factor (e.g., for older sessions)
weightedValue ← clanMemberRatio × decay
// 4. Calculate match difficulty based on number of teams
// More teams → harder to win → higher reward for victory
// Uses square root to avoid extreme scaling
difficulty ← MAX(1, √(session.numTeams - 1))
// 5. Return final score:
// - Win: reward is multiplied by difficulty
// - Loss: penalty is divided by difficulty (less punishment in harder matches)
IF session.hasWon THEN
RETURN weightedValue × difficulty
ELSE
RETURN weightedValue ÷ difficulty
END IF
END FUNCTION
```
### Clan stats
Displays comprehensive clan performance statistics for a specified clan over a chosen time range. If no time range is provided, it shows lifetime stats (starting from early November 2025).
Key metrics include:
- Total games, wins, losses, and win rate
- Win/loss ratio and weighted win/loss ratio\* broken down by:
- Team type (e.g., 2 teams, 3 teams, duos, trios, etc)
- Number of teams in the game (2 teams, 5 teams, 20 teams, etc)
**Note:** No decay is used, so weighted wins will be different from in the leaderboard.
**Endpoint**
```
GET https://openfront.io/public/clan/:clanTag
```
**Query Parameters:**
- `start` (optional): ISO 8601 timestamp
- `end` (optonal): ISO 8601 timestamp
**Example**
```bash
curl https://api.openfront.io/public/clan/UN?start=2025-11-15T00:00:00Z &
end=2025-11-18T23:59:59Z
```
### Clan Sessions
A clan session is created any time a player with that clan tag is in a public team game. If no start or end query parameter is provided, lifetime sessions (starting early November 2025) are shown.
**Endpoint**
```
GET https://api.openfront.io/public/clan/:clanTag/sessions
```
**Query Parameters:**
- `start` (optional): ISO 8601 timestamp
- `end` (optonal): ISO 8601 timestamp
**Example**
```bash
curl https://api.openfront.io/public/clan/UN/sessions?start=2025-11-15T00:00:00Z &
end=2025-11-18T23:59:59Z
```
+3 -1
View File
@@ -25,4 +25,6 @@ Licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/)
## Icons
Icons from [The Noun Project](https://thenounproject.com/)
### [The Noun Project](https://thenounproject.com/)
Stats icon by [Meko](https://thenounproject.com/mekoda/) https://thenounproject.com/icon/stats-4942475/
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,29 @@
{
"name": "Four Islands",
"nations": [
{
"coordinates": [403, 1296],
"flag": "",
"name": "Korinthal",
"strength": 2
},
{
"coordinates": [1152, 1251],
"flag": "",
"name": "Lunareth",
"strength": 2
},
{
"coordinates": [1328, 322],
"flag": "",
"name": "Sylvoria",
"strength": 2
},
{
"coordinates": [114, 121],
"flag": "",
"name": "Myrkwind",
"strength": 2
}
]
}
+1 -1
View File
@@ -51,7 +51,7 @@
},
{
"coordinates": [1409, 372],
"name": "Palestinian Territory",
"name": "Palestine",
"strength": 1,
"flag": "ps"
},
+2 -1
View File
@@ -28,6 +28,7 @@ var maps = []struct {
{Name: "europeclassic"},
{Name: "falklandislands"},
{Name: "faroeislands"},
{Name: "fourislands"},
{Name: "gatewaytotheatlantic"},
{Name: "giantworldmap"},
{Name: "halkidiki"},
@@ -195,4 +196,4 @@ func main() {
}
fmt.Println("Terrain maps generated successfully")
}
}
+7 -7
View File
@@ -29,7 +29,7 @@
"intl-messageformat": "^10.7.16",
"ip-anonymize": "^0.1.0",
"jose": "^6.0.10",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"nanoid": "^3.3.6",
"obscenity": "^0.4.3",
"seedrandom": "^3.0.5",
@@ -12144,9 +12144,9 @@
"license": "MIT"
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -14958,9 +14958,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
+1 -1
View File
@@ -124,7 +124,7 @@
"intl-messageformat": "^10.7.16",
"ip-anonymize": "^0.1.0",
"jose": "^6.0.10",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"nanoid": "^3.3.6",
"obscenity": "^0.4.3",
"seedrandom": "^3.0.5",
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="m479.78 149.95c-4-0.015625-7.8359 1.5664-10.664 4.3906-2.8281 2.8281-4.4102 6.668-4.3945 10.668v870.1c0.042969 3.9609 1.6484 7.7422 4.4727 10.523 2.8203 2.7773 6.625 4.332 10.586 4.3164h240.5c3.9609 0.015625 7.7695-1.5391 10.59-4.3164 2.8203-2.7812 4.4258-6.5625 4.4688-10.523v-870.1c0.015625-4-1.5625-7.8398-4.3906-10.664-2.8281-2.8281-6.668-4.4102-10.668-4.3945zm14.836 30.117h210.61v839.98h-210.61z"/>
<path fill="white" d="m165.09 440.05c-3.9609-0.015626-7.7656 1.5352-10.586 4.3164-2.8203 2.7812-4.4297 6.5625-4.4727 10.523v580.21c0.042969 3.9609 1.6523 7.7422 4.4727 10.523 2.8203 2.7773 6.625 4.332 10.586 4.3164h240.5c3.9609 0.015625 7.7656-1.5391 10.586-4.3164 2.8203-2.7812 4.4297-6.5625 4.4727-10.523v-580.21c-0.042969-3.9609-1.6523-7.7422-4.4727-10.523-2.8203-2.7812-6.625-4.332-10.586-4.3164zm14.84 29.898h210.61v550.1h-210.61z"/>
<path fill="white" d="m794.47 729.94c-4-0.015625-7.8398 1.5664-10.668 4.3945-2.8281 2.8281-4.4102 6.668-4.3945 10.664v290.11c0.042969 3.9609 1.6523 7.7422 4.4727 10.523 2.8203 2.7773 6.6289 4.332 10.59 4.3164h240.5c3.9609 0.015625 7.7656-1.5391 10.586-4.3164 2.8203-2.7812 4.4297-6.5625 4.4727-10.523v-290.11c0.015625-3.9961-1.5664-7.8359-4.3945-10.664-2.8281-2.8281-6.6641-4.4102-10.664-4.3945zm14.836 30.121h210.61v259.99h-210.61z"/>
<path fill="white" d="m479.69 164.99h240.63v870.02h-240.63z"/>
<path fill="white" d="m165 455h240.63v580.01h-240.63z"/>
<path fill="white" d="m794.38 745h240.63v290h-240.63z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="834"
height="834"
shape-rendering="geometricPrecision"
image-rendering="optimizeQuality"
fill-rule="evenodd"
version="1.1"
id="svg6"
sodipodi:docname="AllianceIconFaded.svg"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs6" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.5"
inkscape:cx="417"
inkscape:cy="257"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<path
d="M-.5 397.5v-5l136-239.5c1.667-.667 3.333-.667 5 0 20.752 12.458 41.252 25.291 61.5 38.5.638 1.109 1.138 2.275 1.5 3.5l-135 240.5c-3.0617 2.497-6.0617 2.33-9-.5l-60-37.5zm834-5v5c-21.238 14.207-43.071 27.541-65.5 40-1.285-.45-2.452-1.117-3.5-2L629.5 195c.583-2.499 1.916-4.499 4-6l59-36c1.667-.667 3.333-.667 5 0l136 239.5z"
fill="#278f06"
id="path1"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
d="M432.5 217.5c22.065-.582 44.065.251 66 2.5l85 17c5.667.667 11.333.667 17 0 9.847-1.549 19.514-3.549 29-6L734 417.5c-10.863 11.197-22.029 22.03-33.5 32.5-7.415 5.46-15.248 10.293-23.5 14.5-79.554-66.724-165.721-123.891-258.5-171.5-4.198-2.514-8.698-4.347-13.5-5.5-14.009 3.961-27.842 8.461-41.5 13.5-13.712 20.712-31.045 37.712-52 51-13.542 7.51-28.042 11.677-43.5 12.5-3.583-.558-7.083-1.391-10.5-2.5-2.082-2.409-3.082-5.243-3-8.5.561-6.244 2.061-12.244 4.5-18 21.319-34.477 44.486-67.644 69.5-99.5 11.022-5.673 22.688-9.34 35-11 23.037-3.569 46.037-6.069 69-7.5zm-231 15c10.445 5.724 21.112 11.224 32 16.5 18.501 4.528 37.168 8.195 56 11-17.45 23.055-33.283 47.222-47.5 72.5-11.16 34.914 1.34 50.747 37.5 47.5 17.364-2.453 33.364-8.453 48-18 18.377-12.71 33.71-28.376 46-47 10.749-4.255 21.749-7.255 33-9 66.843 32.897 129.843 71.564 189 116 24.877 18.276 49.21 37.276 73 57 17.316 16.374 17.316 32.708 0 49-10.221 5.794-20.888 6.794-32 3L502 451.5c-9.201 3.025-10.701 8.191-4.5 15.5L622 542.5c3.242 24.405-7.258 37.905-31.5 40.5-4.333.667-8.667.667-13 0l-109-63c-8.528-.156-11.695 4.011-9.5 12.5l107 63c1.107 12.35-2.726 22.85-11.5 31.5-3.448 1.927-7.115 3.261-11 4-8.72.839-17.387.505-26-1l-85-41c-9.548-.747-12.715 3.42-9.5 12.5l2.5 2.5 82 40c-16.901 24.862-40.068 33.362-69.5 25.5-18.921-3.688-37.421-8.855-55.5-15.5 16.937-19.691 18.437-40.524 4.5-62.5-10.469-13.324-23.969-19.657-40.5-19 1.841-25.828-8.825-44.328-32-55.5-5.764-2.214-11.597-3.047-17.5-2.5 2.293-30.025-10.873-49.525-39.5-58.5-15.974-2.677-29.141 2.157-39.5 14.5-2.973 4.474-6.14 8.807-9.5 13-13.651-30.999-36.318-40.832-68-29.5l-11 9c-10.856-14.036-22.19-27.702-34-41l106-189.5z"
fill="#278f04"
id="path2"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
fill="#28900b"
d="M158.5 464.5c17.008.509 28.175 8.842 33.5 25 1.31 7.371.31 14.371-3 21-7.473 10.107-15.306 19.94-23.5 29.5-18.101 6.072-31.601.572-40.5-16.5-3.601-8.846-3.267-17.512 1-26 7.473-10.107 15.306-19.94 23.5-29.5 3.071-1.296 6.071-2.462 9-3.5z"
id="path3"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
fill="#289009"
d="M246.5 471.5c24.731 2.84 35.564 16.507 32.5 41l-58 81c-10.712 9.718-22.212 10.552-34.5 2.5-12.299-9.422-16.465-21.588-12.5-36.5 19.728-28.396 40.228-56.229 61.5-83.5 3.553-2.121 7.22-3.621 11-4.5z"
id="path4"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
fill="#28900b"
d="M293.5 530.5c18.901-.264 30.734 8.736 35.5 27 1.405 7.217.071 13.884-4 20L279.5 639c-19.61 8.459-34.11 2.959-43.5-16.5-3.226-7.013-3.226-14.013 0-21l47-65c3.101-2.852 6.601-4.852 10.5-6z"
id="path5"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
fill="#28900c"
d="M344.5 588.5c23.569 1.069 35.402 13.402 35.5 37-.856 4.044-2.522 7.711-5 11-8.91 11.488-17.577 23.155-26 35-9.872 10.173-21.039 11.673-33.5 4.5-13.736-9.632-18.236-22.465-13.5-38.5 11.116-15.451 22.616-30.617 34.5-45.5 2.809-1.093 5.476-2.26 8-3.5z"
id="path6"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+33 -2
View File
@@ -164,6 +164,18 @@
"logged_in_with_discord": "Logged in with Discord",
"recovery_email_sent": "Recovery email sent to {email}"
},
"stats_modal": {
"title": "Stats",
"clan_stats": "Clan Stats",
"loading": "Loading...",
"error": "Error loading clan stats",
"no_stats": "No clan stats available",
"clan": "Clan",
"games": "Games",
"win_score": "Win Score",
"loss_score": "Loss Score",
"win_loss_ratio": "Win/Loss"
},
"map": {
"map": "Map",
"world": "World",
@@ -198,7 +210,8 @@
"pluto": "Pluto",
"montreal": "Montreal",
"achiran": "Achiran",
"baikalnukewars": "Baikal (Nuke Wars)"
"baikalnukewars": "Baikal (Nuke Wars)",
"fourislands": "Four Islands"
},
"map_categories": {
"continental": "Continental",
@@ -265,7 +278,11 @@
"waiting": "Waiting for players...",
"random_spawn": "Random spawn",
"start": "Start Game",
"host_badge": "Host"
"host_badge": "Host",
"assigned_teams": "Assigned Teams",
"empty_teams": "Empty Teams",
"empty_team": "Empty",
"remove_player": "Remove {{username}}"
},
"team_colors": {
"red": "Red",
@@ -672,6 +689,19 @@
},
"desync_notice": "You are desynced from other players. What you see might differ from other players."
},
"performance_overlay": {
"reset": "Reset",
"copy_json_title": "Copy current performance metrics as JSON",
"copy_clipboard": "Copy JSON",
"copied": "Copied!",
"failed_copy": "Failed to copy",
"fps": "FPS:",
"avg_60s": "Avg (60s):",
"frame": "Frame:",
"tick_exec": "Tick Exec:",
"tick_delay": "Tick Delay:",
"layers_header": "Layers (avg / max, sorted by total time):"
},
"heads_up_message": {
"choose_spawn": "Choose a starting location",
"random_spawn": "Random spawn is enabled. Selecting starting location for you..."
@@ -680,6 +710,7 @@
"title": "Skins",
"colors": "Colors",
"purchase": "Purchase",
"show_only_owned": "My Skins",
"blocked": {
"login": "You must be logged in to access this skin.",
"purchase": "Purchase this skin to unlock it."
+44
View File
@@ -0,0 +1,44 @@
{
"map": {
"height": 1500,
"num_land_tiles": 517044,
"width": 1500
},
"map16x": {
"height": 375,
"num_land_tiles": 31134,
"width": 375
},
"map4x": {
"height": 750,
"num_land_tiles": 127644,
"width": 750
},
"name": "Four Islands",
"nations": [
{
"coordinates": [403, 1296],
"flag": "",
"name": "Korinthal",
"strength": 2
},
{
"coordinates": [1152, 1251],
"flag": "",
"name": "Lunareth",
"strength": 2
},
{
"coordinates": [1328, 322],
"flag": "",
"name": "Sylvoria",
"strength": 2
},
{
"coordinates": [114, 121],
"flag": "",
"name": "Myrkwind",
"strength": 2
}
]
}
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: 4.2 KiB

+1 -1
View File
@@ -67,7 +67,7 @@
{
"coordinates": [1409, 372],
"flag": "ps",
"name": "Palestinian Territory",
"name": "Palestine",
"strength": 1
},
{
+8 -21
View File
@@ -25,6 +25,7 @@ import {
import { generateID } from "../core/Util";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
import "./components/LobbyTeamView";
import "./components/Maps";
import { JoinLobbyEvent } from "./Main";
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
@@ -554,27 +555,13 @@ export class HostLobbyModal extends LitElement {
}
</div>
<div class="players-list">
${this.clients.map(
(client) => html`
<span class="player-tag">
${client.username}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
>(${translateText("host_modal.host_badge")})</span
>`
: html`
<button
class="remove-player-btn"
@click=${() => this.kickPlayer(client.clientID)}
title="Remove ${client.username}"
>
×
</button>
`}
</span>
`,
)}
<lobby-team-view
.gameMode=${this.gameMode}
.clients=${this.clients}
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.teamCount=${this.teamCount}
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
></lobby-team-view>
</div>
<div class="start-game-button-container">
+2
View File
@@ -27,6 +27,7 @@ import { NewsModal } from "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import "./StatsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import { SendKickPlayerIntentEvent } from "./Transport";
@@ -524,6 +525,7 @@ class Client {
"news-modal",
"flag-input-modal",
"account-button",
"stats-button",
"token-login",
"matchmaking-modal",
].forEach((tag) => {
+4 -2
View File
@@ -1,7 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { renderDuration, translateText } from "../client/Utils";
import { GameMapType, GameMode } from "../core/game/Game";
import { GameMapType, GameMode, HumansVsNations } from "../core/game/Game";
import { GameID, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { JoinLobbyEvent } from "./Main";
@@ -161,7 +161,9 @@ export class PublicLobby extends LitElement {
>
${lobby.gameConfig.gameMode === GameMode.Team
? typeof teamCount === "string"
? translateText(`public_lobby.teams_${teamCount}`)
? teamCount === HumansVsNations
? translateText("public_lobby.teams_hvn")
: translateText(`public_lobby.teams_${teamCount}`)
: translateText("public_lobby.teams", {
num: teamCount ?? 0,
})
+237
View File
@@ -0,0 +1,237 @@
import { css, html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
} from "../core/ApiSchemas";
import { getApiBase } from "./jwt";
import { translateText } from "./Utils";
@customElement("stats-modal")
export class StatsModal extends LitElement {
@query("o-modal")
private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
@state() private isLoading: boolean = false;
@state() private error: string | null = null;
@state() private data: ClanLeaderboardResponse | null = null;
private hasLoaded = false;
createRenderRoot() {
return this;
}
public open() {
this.modalEl?.open();
if (!this.hasLoaded && !this.isLoading) {
void this.loadLeaderboard();
}
}
public close() {
this.modalEl?.close();
}
private async loadLeaderboard() {
this.isLoading = true;
this.error = null;
try {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: {
Accept: "application/json",
},
});
if (!res.ok) {
throw new Error(`Unexpected status ${res.status}`);
}
const json = await res.json();
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn(
"ClanLeaderboardModal: invalid response schema",
parsed.error,
);
throw new Error("Invalid response format");
}
this.data = parsed.data;
this.hasLoaded = true;
} catch (err) {
console.warn("ClanLeaderboardModal: failed to load leaderboard", err);
this.error = translateText("stats_modal.error");
} finally {
this.isLoading = false;
this.requestUpdate();
}
}
private renderBody() {
if (this.isLoading) {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-2 text-lg font-semibold">
${translateText("stats_modal.loading")}
</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
`;
}
if (this.error) {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-4 text-center">${this.error}</p>
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium"
@click=${() => this.loadLeaderboard()}
>
Retry
</button>
</div>
`;
}
if (!this.data || this.data.clans.length === 0) {
return html`
<div class="p-6 text-center text-gray-200">
<p class="text-lg font-semibold mb-2">
${translateText("stats_modal.no_stats")}
</p>
</div>
`;
}
const { start, end, clans } = this.data;
const startDate = new Date(start);
const endDate = new Date(end);
return html`
<div class="p-4 md:p-6 text-gray-200">
<div
class="flex flex-col md:flex-row md:items-center md:justify-between mb-4 gap-2"
>
<div>
<h2 class="text-xl font-semibold">
${translateText("stats_modal.clan_stats")}
</h2>
<p class="text-xs text-gray-400 mt-1">
${startDate.toLocaleDateString()} &middot;
${endDate.toLocaleDateString()}
</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs md:text-sm">
<thead>
<tr class="border-b border-gray-700 text-gray-300">
<th class="py-2 pr-3 text-left">
${translateText("stats_modal.clan")}
</th>
<th class="py-2 px-2 text-right">
${translateText("stats_modal.games")}
</th>
<th class="py-2 px-2 text-right">
${translateText("stats_modal.win_score")}
</th>
<th class="py-2 px-2 text-right">
${translateText("stats_modal.loss_score")}
</th>
<th class="py-2 pl-2 text-right">
${translateText("stats_modal.win_loss_ratio")}
</th>
</tr>
</thead>
<tbody>
${clans.map(
(clan) => html`
<tr class="border-b border-gray-800 last:border-b-0">
<td class="py-2 pr-3 font-semibold text-left">
${clan.clanTag}
</td>
<td class="py-2 px-2 text-right">
${clan.games.toLocaleString()}
</td>
<td class="py-2 px-2 text-right">${clan.weightedWins}</td>
<td class="py-2 px-2 text-right">${clan.weightedLosses}</td>
<td class="py-2 pl-2 text-right">
${clan.weightedWLRatio}
</td>
</tr>
`,
)}
</tbody>
</table>
</div>
</div>
`;
}
render() {
return html`
<o-modal id="stats-modal" title="${translateText("stats_modal.title")}">
${this.renderBody()}
</o-modal>
`;
}
}
@customElement("stats-button")
export class StatsButton extends LitElement {
@query("stats-modal") private statsModal: StatsModal;
@state() private isVisible: boolean = true;
static styles = css`
:host {
display: block;
}
`;
constructor() {
super();
}
createRenderRoot() {
return this;
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<div class="fixed top-20 right-4 z-[9999]">
<button
@click="${this.open}"
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
title="${translateText("stats_modal.title")}"
>
<img src="/icons/stats.svg" alt="Stats" class="w-6 h-6" />
</button>
</div>
<stats-modal></stats-modal>
`;
}
private open() {
this.isVisible = true;
this.requestUpdate();
this.statsModal?.open();
}
public close() {
this.statsModal?.close();
this.isVisible = false;
this.requestUpdate();
}
}
+32 -13
View File
@@ -28,6 +28,7 @@ export class TerritoryPatternsModal extends LitElement {
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "colors" = "patterns";
@state() private showOnlyOwned: boolean = false;
private cosmetics: Cosmetics | null = null;
@@ -112,6 +113,9 @@ export class TerritoryPatternsModal extends LitElement {
if (rel === "blocked") {
continue;
}
if (this.showOnlyOwned && rel !== "owned") {
continue;
}
buttons.push(html`
<pattern-button
.pattern=${pattern}
@@ -128,19 +132,34 @@ export class TerritoryPatternsModal extends LitElement {
}
return html`
<div
class="flex flex-wrap gap-4 p-2"
style="justify-content: center; align-items: flex-start;"
>
${this.affiliateCode === null
? html`
<pattern-button
.pattern=${null}
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
></pattern-button>
`
: html``}
${buttons}
<div class="flex flex-col gap-2">
<div class="flex justify-center">
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
.showOnlyOwned
? "bg-blue-500 text-white hover:bg-blue-600"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
@click=${() => {
this.showOnlyOwned = !this.showOnlyOwned;
}}
>
${translateText("territory_patterns.show_only_owned")}
</button>
</div>
<div
class="flex flex-wrap gap-4 p-2"
style="justify-content: center; align-items: flex-start;"
>
${this.affiliateCode === null
? html`
<pattern-button
.pattern=${null}
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
></pattern-button>
`
: html``}
${buttons}
</div>
</div>
`;
}
+288
View File
@@ -0,0 +1,288 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { PastelTheme } from "../../core/configuration/PastelTheme";
import {
ColoredTeams,
Duos,
GameMode,
HumansVsNations,
PlayerInfo,
PlayerType,
Quads,
Team,
Trios,
} from "../../core/game/Game";
import { assignTeams } from "../../core/game/TeamAssignment";
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
import { translateText } from "../Utils";
export interface TeamPreviewData {
team: Team;
players: ClientInfo[];
}
@customElement("lobby-team-view")
export class LobbyTeamView extends LitElement {
@property({ type: String }) gameMode: GameMode = GameMode.FFA;
@property({ type: Array }) clients: ClientInfo[] = [];
@state() private teamPreview: TeamPreviewData[] = [];
@state() private teamMaxSize: number = 0;
@property({ type: String }) lobbyCreatorClientID: string = "";
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
private theme: PastelTheme = new PastelTheme();
@state() private showTeamColors: boolean = false;
willUpdate(changedProperties: Map<string, any>) {
// Recompute team preview when relevant properties change
if (
changedProperties.has("gameMode") ||
changedProperties.has("clients") ||
changedProperties.has("teamCount")
) {
this.computeTeamPreview();
this.showTeamColors = this.getTeamList().length <= 7;
}
}
render() {
return html`<div class="players-list">
${this.gameMode === GameMode.Team
? this.renderTeamMode()
: this.renderFreeForAll()}
</div>`;
}
createRenderRoot() {
return this;
}
private renderTeamMode() {
const active = this.teamPreview.filter((t) => t.players.length > 0);
const empty = this.teamPreview.filter((t) => t.players.length === 0);
return html` <div
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch max-h-[65vh]"
>
<div
class="w-full md:w-60 bg-gray-800 p-2 border border-gray-700 rounded-lg max-h-40 md:max-h-[65vh] overflow-auto"
>
<div class="font-bold mb-1.5 text-gray-300 text-sm">
${translateText("host_modal.players")}
</div>
${repeat(
this.clients,
(c) => c.clientID ?? c.username,
(client) =>
html`<div class="px-2 py-1 rounded bg-gray-700/70 mb-1 text-xs">
${client.username}
</div>`,
)}
</div>
<div
class="flex-1 flex flex-col gap-3 md:gap-4 overflow-auto max-h-[65vh] md:pr-1"
>
<div>
<div class="font-semibold text-gray-200 mb-1 text-sm">
${translateText("host_modal.assigned_teams")}
</div>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
${repeat(
active,
(p) => p.team,
(preview) => this.renderTeamCard(preview, false),
)}
</div>
</div>
<div>
<div class="font-semibold text-gray-200 mb-1 text-sm">
${translateText("host_modal.empty_teams")}
</div>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
${repeat(
empty,
(p) => p.team,
(preview) => this.renderTeamCard(preview, true),
)}
</div>
</div>
</div>
</div>`;
}
private renderFreeForAll() {
return html`${repeat(
this.clients,
(c) => c.clientID ?? c.username,
(client) =>
html`<span class="player-tag">
${client.username}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
>(${translateText("host_modal.host_badge")})</span
>`
: html`<button
class="remove-player-btn"
@click=${() => this.onKickPlayer?.(client.clientID)}
aria-label=${translateText("host_modal.remove_player", {
username: client.username,
})}
>
×
</button>`}
</span>`,
)} `;
}
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
return html`
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
<div
class="px-2 py-1 font-bold flex items-center justify-between text-white rounded-t-xl text-[13px] gap-2 bg-gray-700/70"
>
${this.showTeamColors
? html`<span
class="inline-block w-2.5 h-2.5 rounded-full border-2 border-white/90 shadow-inner"
style="background:${this.teamHeaderColor(preview.team)};"
></span>`
: null}
<span class="truncate">${preview.team}</span>
<span class="text-white/90"
>${preview.players.length}/${this.teamMaxSize}</span
>
</div>
<div class="p-2 ${isEmpty ? "" : "flex flex-col gap-1.5"}">
${isEmpty
? html`<div class="text-[11px] italic text-gray-400">
${translateText("host_modal.empty_team")}
</div>`
: repeat(
preview.players,
(p) => p.clientID ?? p.username,
(p) =>
html` <div
class="bg-gray-700/70 px-2 py-1 rounded text-xs flex items-center justify-between"
>
<span class="truncate">${p.username}</span>
${p.clientID === this.lobbyCreatorClientID
? html`<span class="ml-2 text-[11px] text-green-300"
>(${translateText("host_modal.host_badge")})</span
>`
: html`<button
class="remove-player-btn ml-2"
@click=${() => this.onKickPlayer?.(p.clientID)}
aria-label=${translateText(
"host_modal.remove_player",
{
username: p.username,
},
)}
>
×
</button>`}
</div>`,
)}
</div>
</div>
`;
}
private getTeamList(): Team[] {
if (this.gameMode !== GameMode.Team) return [];
const playerCount = this.clients.length;
const config = this.teamCount;
if (config === HumansVsNations) {
return [ColoredTeams.Humans, ColoredTeams.Nations];
}
let numTeams: number;
if (typeof config === "number") {
numTeams = Math.max(2, config);
} else {
const divisor =
config === Duos ? 2 : config === Trios ? 3 : config === Quads ? 4 : 2;
numTeams = Math.max(2, Math.ceil(playerCount / divisor));
}
if (numTeams < 8) {
const ordered: Team[] = [
ColoredTeams.Red,
ColoredTeams.Blue,
ColoredTeams.Yellow,
ColoredTeams.Green,
ColoredTeams.Purple,
ColoredTeams.Orange,
ColoredTeams.Teal,
];
return ordered.slice(0, numTeams);
}
return Array.from({ length: numTeams }, (_, i) => `Team ${i + 1}`);
}
private teamHeaderColor(team: Team): string {
try {
return this.theme.teamColor(team).toHex();
} catch {
return "#3b3f46"; // Default gray for unknown teams
}
}
private computeTeamPreview() {
if (this.gameMode !== GameMode.Team) {
this.teamPreview = [];
this.teamMaxSize = 0;
return;
}
const teams = this.getTeamList();
// HumansVsNations: show all clients under Humans initially
if (this.teamCount === HumansVsNations) {
this.teamMaxSize = this.clients.length;
this.teamPreview = [
{ team: ColoredTeams.Humans, players: [...this.clients] },
{ team: ColoredTeams.Nations, players: [] },
];
return;
}
const players = this.clients.map(
(c) =>
new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID),
);
const assignment = assignTeams(players, teams);
const buckets = new Map<Team, ClientInfo[]>();
for (const t of teams) buckets.set(t, []);
for (const [p, team] of assignment.entries()) {
if (team === "kicked") continue;
const bucket = buckets.get(team);
if (!bucket) continue;
const client =
this.clients.find((c) => c.clientID === p.clientID) ??
this.clients.find((c) => c.username === p.name);
if (client) bucket.push(client);
}
// Compute per-team capacity safely and align with common team sizes
if (this.teamCount === Duos) {
this.teamMaxSize = 2;
} else if (this.teamCount === Trios) {
this.teamMaxSize = 3;
} else if (this.teamCount === Quads) {
this.teamMaxSize = 4;
} else {
// Fallback: divide players across teams; guard against 0 and empty lobbies
this.teamMaxSize = Math.max(
1,
Math.ceil(this.clients.length / teams.length),
);
}
this.teamPreview = teams.map((t) => ({
team: t,
players: buckets.get(t) ?? [],
}));
}
}
+1
View File
@@ -38,6 +38,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
Montreal: "Montreal",
Achiran: "Achiran",
BaikalNukeWars: "Baikal (Nuke Wars)",
FourIslands: "Four Islands",
};
@customElement("map-display")
+5
View File
@@ -979,6 +979,11 @@
"continent": "Asia",
"name": "Iran"
},
{
"code": "Pahlavi Iran",
"continent": "Asia",
"name": "Pahlavi Iran"
},
{
"code": "ie",
"continent": "Europe",
+62
View File
@@ -0,0 +1,62 @@
export class FrameProfiler {
private static timings: Record<string, number> = {};
private static enabled: boolean = false;
/**
* Enable or disable profiling.
*/
static setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
/**
* Check if profiling is enabled.
*/
static isEnabled(): boolean {
return this.enabled;
}
/**
* Clear all accumulated timings for the current frame.
*/
static clear(): void {
if (!this.enabled) return;
this.timings = {};
}
/**
* Record a duration (in ms) for a named span.
*/
static record(name: string, duration: number): void {
if (!this.enabled || !Number.isFinite(duration)) return;
this.timings[name] = (this.timings[name] ?? 0) + duration;
}
/**
* Convenience helper to start a span.
* Returns a high-resolution timestamp to be passed into end().
*/
static start(): number {
if (!this.enabled) return 0;
return performance.now();
}
/**
* Convenience helper to end a span started with start().
*/
static end(name: string, startTime: number): void {
if (!this.enabled || startTime === 0) return;
const duration = performance.now() - startTime;
this.record(name, duration);
}
/**
* Consume and reset all timings collected so far.
*/
static consume(): Record<string, number> {
if (!this.enabled) return {};
const copy = { ...this.timings };
this.timings = {};
return copy;
}
}
+7 -1
View File
@@ -3,6 +3,7 @@ import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { GameStartingModal } from "../GameStartingModal";
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { AdTimer } from "./layers/AdTimer";
@@ -343,6 +344,7 @@ export class GameRenderer {
}
renderGame() {
FrameProfiler.clear();
const start = performance.now();
// Set background
this.context.fillStyle = this.game
@@ -375,7 +377,10 @@ export class GameRenderer {
needsTransform,
isTransformActive,
);
const layerStart = FrameProfiler.start();
layer.renderLayer?.(this.context);
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
}
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
this.transformHandler.resetChanged();
@@ -383,7 +388,8 @@ export class GameRenderer {
requestAnimationFrame(() => this.renderGame());
const duration = performance.now() - start;
this.performanceOverlay.updateFrameMetrics(duration);
const layerDurations = FrameProfiler.consume();
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
if (duration > 50) {
console.warn(
+222
View File
@@ -0,0 +1,222 @@
import allianceIcon from "../../../resources/images/AllianceIcon.svg";
import allianceIconFaded from "../../../resources/images/AllianceIconFaded.svg";
import allianceRequestBlackIcon from "../../../resources/images/AllianceRequestBlackIcon.svg";
import allianceRequestWhiteIcon from "../../../resources/images/AllianceRequestWhiteIcon.svg";
import crownIcon from "../../../resources/images/CrownIcon.svg";
import disconnectedIcon from "../../../resources/images/DisconnectedIcon.svg";
import embargoBlackIcon from "../../../resources/images/EmbargoBlackIcon.svg";
import embargoWhiteIcon from "../../../resources/images/EmbargoWhiteIcon.svg";
import nukeRedIcon from "../../../resources/images/NukeIconRed.svg";
import nukeWhiteIcon from "../../../resources/images/NukeIconWhite.svg";
import questionMarkIcon from "../../../resources/images/QuestionMarkIcon.svg";
import targetIcon from "../../../resources/images/TargetIcon.svg";
import traitorIcon from "../../../resources/images/TraitorIcon.svg";
import { AllPlayers, nukeTypes } from "../../core/game/Game";
import { GameView, PlayerView } from "../../core/game/GameView";
export type PlayerIconId =
| "crown"
| "traitor"
| "disconnected"
| "alliance"
| "alliance-request"
| "target"
| "emoji"
| "embargo"
| "nuke";
export type PlayerIconKind = "image" | "emoji";
export interface PlayerIconDescriptor {
id: PlayerIconId;
kind: PlayerIconKind;
/** Image URL for image icons */
src?: string;
/** Text content for emoji icons */
text?: string;
/** Whether the icon should be visually centered over the name */
center?: boolean;
}
export interface PlayerIconParams {
game: GameView;
player: PlayerView;
/** Whether the alliance icon (handshake) should be included */
includeAllianceIcon: boolean;
/** Player currently in first place, used for the crown icon */
firstPlace: PlayerView | null;
}
export function getFirstPlacePlayer(game: GameView): PlayerView | null {
const sorted = game
.playerViews()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
return sorted.length > 0 ? sorted[0] : null;
}
export function getPlayerIcons(
params: PlayerIconParams,
): PlayerIconDescriptor[] {
const { game, player, includeAllianceIcon, firstPlace } = params;
const myPlayer = game.myPlayer();
const userSettings = game.config().userSettings();
const isDarkMode = userSettings?.darkMode() ?? false;
const emojisEnabled = userSettings?.emojis() ?? false;
const icons: PlayerIconDescriptor[] = [];
// Crown icon for first place
if (player === firstPlace) {
icons.push({ id: "crown", kind: "image", src: crownIcon });
}
// Traitor icon
if (player.isTraitor()) {
icons.push({ id: "traitor", kind: "image", src: traitorIcon });
}
// Disconnected icon
if (player.isDisconnected()) {
icons.push({ id: "disconnected", kind: "image", src: disconnectedIcon });
}
// Alliance icon
if (
includeAllianceIcon &&
myPlayer !== null &&
myPlayer.isAlliedWith(player)
) {
icons.push({ id: "alliance", kind: "image", src: allianceIcon });
}
// Alliance request icon (theme dependent)
if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) {
const allianceRequestIcon = isDarkMode
? allianceRequestWhiteIcon
: allianceRequestBlackIcon;
icons.push({
id: "alliance-request",
kind: "image",
src: allianceRequestIcon,
});
}
// Target icon (centered on the map, but regular in overlays)
if (myPlayer !== null && new Set(myPlayer.transitiveTargets()).has(player)) {
icons.push({ id: "target", kind: "image", src: targetIcon, center: true });
}
// Emoji handling
if (emojisEnabled) {
const emojis = player
.outgoingEmojis()
.filter(
(emoji) =>
emoji.recipientID === AllPlayers ||
emoji.recipientID === myPlayer?.smallID(),
);
if (emojis.length > 0) {
icons.push({
id: "emoji",
kind: "emoji",
text: emojis[0].message,
});
}
}
// Embargo icon (theme dependent)
if (myPlayer?.hasEmbargo(player)) {
const embargoIcon = isDarkMode ? embargoWhiteIcon : embargoBlackIcon;
icons.push({ id: "embargo", kind: "image", src: embargoIcon });
}
// Nuke icon (different color depending on whether the local player is the target)
const nukesSentByOtherPlayer = game.units(...nukeTypes).filter((unit) => {
const isSendingNuke = player.id() === unit.owner().id();
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
return isSendingNuke && notMyPlayer && unit.isActive();
});
const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => {
const detonationDst = unit.targetTile();
if (!detonationDst || !myPlayer) return false;
const targetId = game.owner(detonationDst).id();
return targetId === myPlayer.id();
});
if (nukesSentByOtherPlayer.length > 0) {
const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon;
icons.push({ id: "nuke", kind: "image", src: icon });
}
return icons;
}
export function createAllianceProgressIcon(
size: number,
fraction: number,
hasExtensionRequest: boolean,
darkMode: boolean,
): HTMLDivElement {
// Wrapper
const wrapper = document.createElement("div");
wrapper.setAttribute("data-icon", "alliance");
wrapper.setAttribute("dark-mode", darkMode.toString());
wrapper.style.position = "relative";
wrapper.style.width = `${size}px`;
wrapper.style.height = `${size}px`;
wrapper.style.display = "inline-block";
// Base faded icon (full)
const base = document.createElement("img");
base.src = allianceIconFaded;
base.style.width = `${size}px`;
base.style.height = `${size}px`;
base.style.display = "block";
base.setAttribute("dark-mode", darkMode.toString());
wrapper.appendChild(base);
// Overlay container for green portion, clipped from the top via clip-path
const overlay = document.createElement("div");
overlay.className = "alliance-progress-overlay";
overlay.style.position = "absolute";
overlay.style.left = "0";
overlay.style.top = "0";
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.clipPath = computeAllianceClipPath(fraction);
const colored = document.createElement("img");
colored.src = allianceIcon; // green icon
colored.style.width = `${size}px`;
colored.style.height = `${size}px`;
colored.style.display = "block";
colored.setAttribute("dark-mode", darkMode.toString());
overlay.appendChild(colored);
wrapper.appendChild(overlay);
// Question mark overlay (shown when there's a pending extension request)
const questionMark = document.createElement("img");
questionMark.className = "alliance-question-mark";
questionMark.src = questionMarkIcon;
questionMark.style.position = "absolute";
questionMark.style.left = "0";
questionMark.style.top = "0";
questionMark.style.width = `${size}px`;
questionMark.style.height = `${size}px`;
questionMark.style.display = hasExtensionRequest ? "block" : "none";
questionMark.style.pointerEvents = "none";
questionMark.setAttribute("dark-mode", darkMode.toString());
wrapper.appendChild(questionMark);
return wrapper;
}
export function computeAllianceClipPath(fraction: number): string {
const topCut = 20 + (1 - fraction) * 80 * 0.78; // min 20%, max 82.40%
return `inset(${topCut.toFixed(2)}% -2px 0 -2px)`;
}
+150 -289
View File
@@ -1,29 +1,25 @@
import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg";
import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg";
import crownIcon from "../../../../resources/images/CrownIcon.svg";
import disconnectedIcon from "../../../../resources/images/DisconnectedIcon.svg";
import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg";
import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg";
import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg";
import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg";
import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg";
import targetIcon from "../../../../resources/images/TargetIcon.svg";
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
import { renderPlayerFlag } from "../../../core/CustomFlag";
import { EventBus } from "../../../core/EventBus";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { Theme } from "../../../core/configuration/Config";
import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game";
import { Cell } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent } from "../../InputHandler";
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
import {
computeAllianceClipPath,
createAllianceProgressIcon,
getFirstPlacePlayer,
getPlayerIcons,
PlayerIconId,
} from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
class RenderInfo {
public icons: Map<string, HTMLImageElement> = new Map(); // Track icon elements
public icons: Map<PlayerIconId, HTMLElement> = new Map(); // Track icon elements
constructor(
public player: PlayerView,
@@ -43,51 +39,20 @@ export class NameLayer implements Layer {
private rand = new PseudoRandom(10);
private renders: RenderInfo[] = [];
private seenPlayers: Set<PlayerView> = new Set();
private traitorIconImage: HTMLImageElement;
private disconnectedIconImage: HTMLImageElement;
private allianceRequestBlackIconImage: HTMLImageElement;
private allianceRequestWhiteIconImage: HTMLImageElement;
private allianceIconImage: HTMLImageElement;
private targetIconImage: HTMLImageElement;
private crownIconImage: HTMLImageElement;
private embargoBlackIconImage: HTMLImageElement;
private embargoWhiteIconImage: HTMLImageElement;
private nukeWhiteIconImage: HTMLImageElement;
private nukeRedIconImage: HTMLImageElement;
private shieldIconImage: HTMLImageElement;
private container: HTMLDivElement;
private firstPlace: PlayerView | null = null;
private theme: Theme = this.game.config().theme();
private userSettings: UserSettings = new UserSettings();
private isVisible: boolean = true;
private firstPlace: PlayerView | null = null;
constructor(
private game: GameView,
private transformHandler: TransformHandler,
private eventBus: EventBus,
) {
this.traitorIconImage = new Image();
this.traitorIconImage.src = traitorIcon;
this.disconnectedIconImage = new Image();
this.disconnectedIconImage.src = disconnectedIcon;
this.allianceIconImage = new Image();
this.allianceIconImage.src = allianceIcon;
this.allianceRequestBlackIconImage = new Image();
this.allianceRequestBlackIconImage.src = allianceRequestBlackIcon;
this.allianceRequestWhiteIconImage = new Image();
this.allianceRequestWhiteIconImage.src = allianceRequestWhiteIcon;
this.crownIconImage = new Image();
this.crownIconImage.src = crownIcon;
this.targetIconImage = new Image();
this.targetIconImage.src = targetIcon;
this.embargoBlackIconImage = new Image();
this.embargoBlackIconImage.src = embargoBlackIcon;
this.embargoWhiteIconImage = new Image();
this.embargoWhiteIconImage.src = embargoWhiteIcon;
this.nukeWhiteIconImage = new Image();
this.nukeWhiteIconImage.src = nukeWhiteIcon;
this.nukeRedIconImage = new Image();
this.nukeRedIconImage.src = nukeRedIcon;
this.shieldIconImage = new Image();
this.shieldIconImage.src = shieldIcon;
this.shieldIconImage = new Image();
this.shieldIconImage.src = shieldIcon;
}
@@ -172,12 +137,9 @@ export class NameLayer implements Layer {
if (this.game.ticks() % 10 !== 0) {
return;
}
const sorted = this.game
.playerViews()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
if (sorted.length > 0) {
this.firstPlace = sorted[0];
}
// Precompute the first-place player for performance
this.firstPlace = getFirstPlacePlayer(this.game);
for (const player of this.game.playerViews()) {
if (player.isAlive()) {
@@ -404,251 +366,152 @@ export class NameLayer implements Layer {
".player-icons",
) as HTMLDivElement;
const iconSize = Math.min(render.fontSize * 1.5, 48);
const myPlayer = this.game.myPlayer();
const isDarkMode = this.userSettings.darkMode();
// Crown icon
const existingCrown = iconsDiv.querySelector('[data-icon="crown"]');
if (render.player === this.firstPlace) {
if (!existingCrown) {
iconsDiv.appendChild(
this.createIconElement(
this.crownIconImage.src,
iconSize,
"crown",
false,
),
);
// Compute which icons should be shown for this player using shared logic
const icons = getPlayerIcons({
game: this.game,
player: render.player,
includeAllianceIcon: true,
firstPlace: this.firstPlace,
});
// Build a set of desired icon IDs
const desiredIconIds = new Set(icons.map((icon) => icon.id));
// Remove any icons that are no longer needed
for (const [id, element] of render.icons) {
if (!desiredIconIds.has(id)) {
element.remove();
render.icons.delete(id);
}
} else if (existingCrown) {
existingCrown.remove();
}
// Traitor icon
let existingTraitor = iconsDiv.querySelector('[data-icon="traitor"]');
if (render.player.isTraitor()) {
const remainingTicks = render.player.getTraitorRemainingTicks();
// Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
// Add or update icons that should be shown
for (const icon of icons) {
if (icon.kind === "emoji" && icon.text) {
let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined;
if (!existingTraitor) {
existingTraitor = this.createIconElement(
this.traitorIconImage.src,
iconSize,
"traitor",
);
iconsDiv.appendChild(existingTraitor);
}
if (!emojiDiv) {
emojiDiv = document.createElement("div");
emojiDiv.style.position = "absolute";
emojiDiv.style.top = "50%";
emojiDiv.style.transform = "translateY(-50%)";
iconsDiv.appendChild(emojiDiv);
render.icons.set(icon.id, emojiDiv);
}
// Apply flashing animation - smooth speed increase starting at 15s
if (existingTraitor instanceof HTMLImageElement) {
if (remainingSeconds <= 15) {
// Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
// Using cubic ease-out for slower, more gradual acceleration
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
emojiDiv.textContent = icon.text;
emojiDiv.style.fontSize = `${iconSize}px`;
} else if (icon.kind === "image" && icon.src) {
// Special handling for alliance icon with progress indicator
if (icon.id === "alliance") {
let allianceWrapper = render.icons.get(icon.id) as
| HTMLDivElement
| undefined;
// Cubic ease-out: slower acceleration, smoother transition
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
const myPlayer = this.game.myPlayer();
const allianceView = myPlayer
?.alliances()
.find((a) => a.other === render.player.id());
const maxDuration = 1.0; // Slow flash at 15 seconds
const minDuration = 0.2; // Fast flash at 0 seconds
const duration =
minDuration + (maxDuration - minDuration) * easedProgress;
const animationDuration = `${duration.toFixed(2)}s`;
let fraction = 0;
let hasExtensionRequest = false;
if (allianceView) {
const remaining = Math.max(
0,
allianceView.expiresAt - this.game.ticks(),
);
const duration = Math.max(1, this.game.config().allianceDuration());
fraction = Math.max(0, Math.min(1, remaining / duration));
hasExtensionRequest = allianceView.hasExtensionRequest;
}
existingTraitor.style.animation = `traitorFlash ${animationDuration} infinite`;
existingTraitor.style.animationTimingFunction = "ease-in-out";
} else {
// Don't flash if more than 15 seconds remaining
existingTraitor.style.animation = "none";
if (!allianceWrapper) {
allianceWrapper = createAllianceProgressIcon(
iconSize,
fraction,
hasExtensionRequest,
this.userSettings.darkMode(),
);
iconsDiv.appendChild(allianceWrapper);
render.icons.set(icon.id, allianceWrapper);
} else {
// Update existing alliance icon
allianceWrapper.style.width = `${iconSize}px`;
allianceWrapper.style.height = `${iconSize}px`;
const overlay = allianceWrapper.querySelector(
".alliance-progress-overlay",
) as HTMLDivElement | null;
if (overlay) {
overlay.style.clipPath = computeAllianceClipPath(fraction);
}
const questionMark = allianceWrapper.querySelector(
".alliance-question-mark",
) as HTMLImageElement | null;
if (questionMark) {
questionMark.style.display = hasExtensionRequest
? "block"
: "none";
}
// Update inner image sizes
const imgs = allianceWrapper.getElementsByTagName("img");
for (const img of imgs) {
img.style.width = `${iconSize}px`;
img.style.height = `${iconSize}px`;
}
}
continue; // Skip regular image handling
}
let imgElement = render.icons.get(icon.id) as
| HTMLImageElement
| undefined;
if (!imgElement) {
imgElement = this.createIconElement(icon.src, iconSize, icon.center);
iconsDiv.appendChild(imgElement);
render.icons.set(icon.id, imgElement);
}
// Update src if it changed (e.g., nuke red/white or dark-mode icons)
if (imgElement.src !== icon.src) {
imgElement.src = icon.src;
}
imgElement.style.width = `${iconSize}px`;
imgElement.style.height = `${iconSize}px`;
// Traitor flashing - smooth speed increase starting at 15s
if (icon.id === "traitor") {
const remainingTicks = render.player.getTraitorRemainingTicks();
// Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
if (remainingSeconds <= 15) {
// Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
// Using cubic ease-out for slower, more gradual acceleration
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
// Cubic ease-out: slower acceleration, smoother transition
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
const maxDuration = 1.0; // Slow flash at 15 seconds
const minDuration = 0.2; // Fast flash at 0 seconds
const duration =
minDuration + (maxDuration - minDuration) * easedProgress;
const animationDuration = `${duration.toFixed(2)}s`;
imgElement.style.animation = `traitorFlash ${animationDuration} infinite`;
imgElement.style.animationTimingFunction = "ease-in-out";
} else {
// Don't flash if more than 15 seconds remaining
imgElement.style.animation = "none";
}
}
}
} else if (existingTraitor) {
existingTraitor.remove();
}
// Disconnected icon
const existingDisconnected = iconsDiv.querySelector(
'[data-icon="disconnected"]',
);
if (render.player.isDisconnected()) {
if (!existingDisconnected) {
iconsDiv.appendChild(
this.createIconElement(
this.disconnectedIconImage.src,
iconSize,
"disconnected",
),
);
}
} else if (existingDisconnected) {
existingDisconnected.remove();
}
// Alliance icon
const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]');
if (myPlayer !== null && myPlayer.isAlliedWith(render.player)) {
if (!existingAlliance) {
iconsDiv.appendChild(
this.createIconElement(
this.allianceIconImage.src,
iconSize,
"alliance",
),
);
}
} else if (existingAlliance) {
existingAlliance.remove();
}
// Alliance request icon
let existingRequestAlliance = iconsDiv.querySelector(
'[data-icon="alliance-request"]',
);
const isThemeAllianceRequestIcon =
existingRequestAlliance?.getAttribute("dark-mode") ===
isDarkMode.toString();
const AllianceRequestIconImageSrc = isDarkMode
? this.allianceRequestWhiteIconImage.src
: this.allianceRequestBlackIconImage.src;
if (myPlayer !== null && render.player.isRequestingAllianceWith(myPlayer)) {
// Create new icon to match theme
if (existingRequestAlliance && !isThemeAllianceRequestIcon) {
existingRequestAlliance.remove();
existingRequestAlliance = null;
}
if (!existingRequestAlliance) {
iconsDiv.appendChild(
this.createIconElement(
AllianceRequestIconImageSrc,
iconSize,
"alliance-request",
),
);
}
} else if (existingRequestAlliance) {
existingRequestAlliance.remove();
}
// Target icon
const existingTarget = iconsDiv.querySelector('[data-icon="target"]');
if (
myPlayer !== null &&
new Set(myPlayer.transitiveTargets()).has(render.player)
) {
if (!existingTarget) {
iconsDiv.appendChild(
this.createIconElement(
this.targetIconImage.src,
iconSize,
"target",
true,
),
);
}
} else if (existingTarget) {
existingTarget.remove();
}
// Emoji handling
const existingEmoji = iconsDiv.querySelector('[data-icon="emoji"]');
const emojis = render.player
.outgoingEmojis()
.filter(
(emoji) =>
emoji.recipientID === AllPlayers ||
emoji.recipientID === myPlayer?.smallID(),
);
if (this.game.config().userSettings()?.emojis() && emojis.length > 0) {
if (!existingEmoji) {
const emojiDiv = document.createElement("div");
emojiDiv.setAttribute("data-icon", "emoji");
emojiDiv.style.fontSize = `${iconSize}px`;
emojiDiv.textContent = emojis[0].message;
emojiDiv.style.position = "absolute";
emojiDiv.style.top = "50%";
emojiDiv.style.transform = "translateY(-50%)";
iconsDiv.appendChild(emojiDiv);
}
} else if (existingEmoji) {
existingEmoji.remove();
}
// Embargo icon
let existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]');
const isThemeEmbargoIcon =
existingEmbargo?.getAttribute("dark-mode") === isDarkMode.toString();
const embargoIconImageSrc = isDarkMode
? this.embargoWhiteIconImage.src
: this.embargoBlackIconImage.src;
if (myPlayer?.hasEmbargo(render.player)) {
// Create new icon to match theme
if (existingEmbargo && !isThemeEmbargoIcon) {
existingEmbargo.remove();
existingEmbargo = null;
}
if (!existingEmbargo) {
iconsDiv.appendChild(
this.createIconElement(embargoIconImageSrc, iconSize, "embargo"),
);
}
} else if (existingEmbargo) {
existingEmbargo.remove();
}
const nukesSentByOtherPlayer = this.game.units().filter((unit) => {
const isSendingNuke = render.player.id() === unit.owner().id();
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
return (
nukeTypes.includes(unit.type()) &&
isSendingNuke &&
notMyPlayer &&
unit.isActive()
);
});
const isMyPlayerTarget = nukesSentByOtherPlayer.find((unit) => {
const detonationDst = unit.targetTile();
if (detonationDst === undefined) return false;
const targetId = this.game.owner(detonationDst).id();
return myPlayer && targetId === myPlayer.id();
});
const existingNuke = iconsDiv.querySelector(
'[data-icon="nuke"]',
) as HTMLImageElement;
if (existingNuke) {
if (nukesSentByOtherPlayer.length === 0) {
existingNuke.remove();
} else if (
isMyPlayerTarget &&
existingNuke.src !== this.nukeRedIconImage.src
) {
existingNuke.src = this.nukeRedIconImage.src;
} else if (
!isMyPlayerTarget &&
existingNuke.src !== this.nukeWhiteIconImage.src
) {
existingNuke.src = this.nukeWhiteIconImage.src;
}
} else if (nukesSentByOtherPlayer.length > 0) {
if (!existingNuke) {
const icon = isMyPlayerTarget
? this.nukeRedIconImage.src
: this.nukeWhiteIconImage.src;
iconsDiv.appendChild(this.createIconElement(icon, iconSize, "nuke"));
}
}
// Update all icon sizes
const icons = iconsDiv.getElementsByTagName("img");
for (const icon of icons) {
icon.style.width = `${iconSize}px`;
icon.style.height = `${iconSize}px`;
}
// Position element with scale
@@ -661,14 +524,12 @@ export class NameLayer implements Layer {
private createIconElement(
src: string,
size: number,
id: string,
center: boolean = false,
): HTMLImageElement {
const icon = document.createElement("img");
icon.src = src;
icon.style.width = `${size}px`;
icon.style.height = `${size}px`;
icon.setAttribute("data-icon", id);
icon.setAttribute("dark-mode", this.userSettings.darkMode().toString());
if (center) {
icon.style.position = "absolute";
@@ -6,6 +6,8 @@ import {
TickMetricsEvent,
TogglePerformanceOverlayEvent,
} from "../../InputHandler";
import { translateText } from "../../Utils";
import { FrameProfiler } from "../FrameProfiler";
import { Layer } from "./Layer";
@customElement("performance-overlay")
@@ -46,6 +48,9 @@ export class PerformanceOverlay extends LitElement implements Layer {
@state()
private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values
@state()
private copyStatus: "idle" | "success" | "error" = "idle";
private frameCount: number = 0;
private lastTime: number = 0;
private frameTimes: number[] = [];
@@ -56,6 +61,22 @@ export class PerformanceOverlay extends LitElement implements Layer {
private tickExecutionTimes: number[] = [];
private tickDelayTimes: number[] = [];
private copyStatusTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Smoothed per-layer render timings (EMA over recent frames)
private layerStats: Map<
string,
{ avg: number; max: number; last: number; total: number }
> = new Map();
@state()
private layerBreakdown: {
name: string;
avg: number;
max: number;
total: number;
}[] = [];
static styles = css`
.performance-overlay {
position: fixed;
@@ -64,7 +85,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
padding: 8px 16px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
@@ -72,6 +93,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
user-select: none;
cursor: move;
transition: none;
min-width: 420px;
}
.performance-overlay.dragging {
@@ -115,6 +137,86 @@ export class PerformanceOverlay extends LitElement implements Layer {
user-select: none;
pointer-events: auto;
}
.reset-button {
position: absolute;
top: 8px;
left: 8px;
height: 20px;
padding: 0 6px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
pointer-events: auto;
}
.copy-json-button {
position: absolute;
top: 8px;
left: 70px;
height: 20px;
padding: 0 6px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
pointer-events: auto;
}
.layers-section {
margin-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 4px;
}
.layer-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
margin-top: 2px;
}
.layer-name {
flex: 0 0 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-bar {
flex: 1;
height: 6px;
background: rgba(148, 163, 184, 0.25);
border-radius: 3px;
overflow: hidden;
}
.layer-bar-fill {
height: 100%;
background: #38bdf8;
border-radius: 3px;
}
.layer-metrics {
flex: 0 0 auto;
white-space: nowrap;
}
`;
constructor() {
@@ -124,6 +226,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
init() {
this.eventBus.on(TogglePerformanceOverlayEvent, () => {
this.userSettings.togglePerformanceOverlay();
this.setVisible(this.userSettings.performanceOverlay());
});
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
@@ -132,6 +235,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
setVisible(visible: boolean) {
this.isVisible = visible;
FrameProfiler.setEnabled(visible);
}
private handleClose() {
@@ -140,7 +244,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
private handleMouseDown = (e: MouseEvent) => {
// Don't start dragging if clicking on close button
if ((e.target as HTMLElement).classList.contains("close-button")) {
const target = e.target as HTMLElement;
if (
target.classList.contains("close-button") ||
target.classList.contains("reset-button") ||
target.classList.contains("copy-json-button")
) {
return;
}
@@ -179,9 +288,45 @@ export class PerformanceOverlay extends LitElement implements Layer {
document.removeEventListener("mouseup", this.handleMouseUp);
};
updateFrameMetrics(frameDuration: number) {
private handleReset = () => {
// reset FPS / frame stats
this.frameCount = 0;
this.lastTime = 0;
this.frameTimes = [];
this.fpsHistory = [];
this.lastSecondTime = 0;
this.framesThisSecond = 0;
this.currentFPS = 0;
this.averageFPS = 0;
this.frameTime = 0;
// reset tick metrics
this.tickExecutionTimes = [];
this.tickDelayTimes = [];
this.tickExecutionAvg = 0;
this.tickExecutionMax = 0;
this.tickDelayAvg = 0;
this.tickDelayMax = 0;
// reset layer breakdown
this.layerStats.clear();
this.layerBreakdown = [];
this.requestUpdate();
};
updateFrameMetrics(
frameDuration: number,
layerDurations?: Record<string, number>,
) {
const wasVisible = this.isVisible;
this.isVisible = this.userSettings.performanceOverlay();
// Update FrameProfiler enabled state when visibility changes
if (wasVisible !== this.isVisible) {
FrameProfiler.setEnabled(this.isVisible);
}
if (!this.isVisible) return;
const now = performance.now();
@@ -233,9 +378,46 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.lastTime = now;
this.frameCount++;
if (layerDurations) {
this.updateLayerStats(layerDurations);
}
this.requestUpdate();
}
private updateLayerStats(layerDurations: Record<string, number>) {
const alpha = 0.2; // smoothing factor for EMA
Object.entries(layerDurations).forEach(([name, duration]) => {
const existing = this.layerStats.get(name);
if (!existing) {
this.layerStats.set(name, {
avg: duration,
max: duration,
last: duration,
total: duration,
});
} else {
const avg = existing.avg + alpha * (duration - existing.avg);
const max = Math.max(existing.max, duration);
const total = existing.total + duration;
this.layerStats.set(name, { avg, max, last: duration, total });
}
});
// Derive contributors sorted by total accumulated time spent
const breakdown = Array.from(this.layerStats.entries())
.map(([name, stats]) => ({
name,
avg: stats.avg,
max: stats.max,
total: stats.total,
}))
.sort((a, b) => b.total - a.total);
this.layerBreakdown = breakdown;
}
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
@@ -286,6 +468,70 @@ export class PerformanceOverlay extends LitElement implements Layer {
return "performance-bad";
}
private buildPerformanceSnapshot() {
return {
timestamp: new Date().toISOString(),
fps: {
current: this.currentFPS,
average60s: this.averageFPS,
frameTimeMs: this.frameTime,
history: [...this.fpsHistory],
},
ticks: {
executionAvgMs: this.tickExecutionAvg,
executionMaxMs: this.tickExecutionMax,
delayAvgMs: this.tickDelayAvg,
delayMaxMs: this.tickDelayMax,
executionSamples: [...this.tickExecutionTimes],
delaySamples: [...this.tickDelayTimes],
},
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
};
}
private clearCopyStatusTimeout() {
if (this.copyStatusTimeoutId !== null) {
clearTimeout(this.copyStatusTimeoutId);
this.copyStatusTimeoutId = null;
}
}
private scheduleCopyStatusReset() {
this.clearCopyStatusTimeout();
this.copyStatusTimeoutId = setTimeout(() => {
this.copyStatus = "idle";
this.copyStatusTimeoutId = null;
this.requestUpdate();
}, 2000);
}
private async handleCopyJson() {
const snapshot = this.buildPerformanceSnapshot();
const json = JSON.stringify(snapshot, null, 2);
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(json);
} else {
const textarea = document.createElement("textarea");
textarea.value = json;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
this.copyStatus = "success";
} catch (err) {
console.warn("Failed to copy performance snapshot", err);
this.copyStatus = "error";
}
this.scheduleCopyStatusReset();
}
render() {
if (!this.isVisible) {
return html``;
@@ -297,41 +543,87 @@ export class PerformanceOverlay extends LitElement implements Layer {
transform: none;
`;
const copyLabel =
this.copyStatus === "success"
? translateText("performance_overlay.copied")
: this.copyStatus === "error"
? translateText("performance_overlay.failed_copy")
: translateText("performance_overlay.copy_clipboard");
const maxLayerAvg =
this.layerBreakdown.length > 0
? Math.max(...this.layerBreakdown.map((l) => l.avg))
: 1;
return html`
<div
class="performance-overlay ${this.isDragging ? "dragging" : ""}"
style="${style}"
@mousedown="${this.handleMouseDown}"
>
<button class="reset-button" @click="${this.handleReset}">
${translateText("performance_overlay.reset")}
</button>
<button
class="copy-json-button"
@click="${this.handleCopyJson}"
title="${translateText("performance_overlay.copy_json_title")}"
>
${copyLabel}
</button>
<button class="close-button" @click="${this.handleClose}">×</button>
<div class="performance-line">
FPS:
${translateText("performance_overlay.fps")}
<span class="${this.getPerformanceColor(this.currentFPS)}"
>${this.currentFPS}</span
>
</div>
<div class="performance-line">
Avg (60s):
${translateText("performance_overlay.avg_60s")}
<span class="${this.getPerformanceColor(this.averageFPS)}"
>${this.averageFPS}</span
>
</div>
<div class="performance-line">
Frame:
${translateText("performance_overlay.frame")}
<span class="${this.getPerformanceColor(1000 / this.frameTime)}"
>${this.frameTime}ms</span
>
</div>
<div class="performance-line">
Tick Exec:
${translateText("performance_overlay.tick_exec")}
<span>${this.tickExecutionAvg.toFixed(2)}ms</span>
(max: <span>${this.tickExecutionMax}ms</span>)
</div>
<div class="performance-line">
Tick Delay:
${translateText("performance_overlay.tick_delay")}
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
(max: <span>${this.tickDelayMax}ms</span>)
</div>
${this.layerBreakdown.length
? html`<div class="layers-section">
<div class="performance-line">
${translateText("performance_overlay.layers_header")}
</div>
${this.layerBreakdown.map((layer) => {
const width = Math.min(
100,
(layer.avg / maxLayerAvg) * 100 || 0,
);
return html`<div class="layer-row">
<span class="layer-name" title=${layer.name}
>${layer.name}</span
>
<div class="layer-bar">
<div class="layer-bar-fill" style="width: ${width}%;"></div>
</div>
<span class="layer-metrics">
${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms
</span>
</div>`;
})}
</div>`
: html``}
</div>
`;
}
@@ -28,6 +28,7 @@ import {
renderTroops,
translateText,
} from "../../Utils";
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { CloseRadialMenuEvent } from "./RadialMenu";
@@ -221,6 +222,33 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return renderDuration(remainingSeconds);
}
private renderPlayerNameIcons(player: PlayerView) {
const firstPlace = getFirstPlacePlayer(this.game);
const icons = getPlayerIcons({
game: this.game,
player,
// Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render
includeAllianceIcon: false,
firstPlace,
});
if (icons.length === 0) {
return html``;
}
return html`<span class="flex items-center gap-1 ml-1 shrink-0">
${icons.map((icon) =>
icon.kind === "emoji" && icon.text
? html`<span class="text-sm shrink-0" translate="no"
>${icon.text}</span
>`
: icon.kind === "image" && icon.src
? html`<img src=${icon.src} alt="" class="w-4 h-4 shrink-0" />`
: html``,
)}
</span>`;
}
private renderPlayerInfo(player: PlayerView) {
const myPlayer = this.game.myPlayer();
const isFriendly = myPlayer?.isFriendly(player);
@@ -306,7 +334,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
/>`
: html``}
${player.name()}
<span>${player.name()}</span>
${this.renderPlayerNameIcons(player)}
</button>
<!-- Collapsible section -->
@@ -253,26 +253,13 @@ export class SpriteFactory {
structureCanvas.height = Math.ceil(iconSize);
const context = structureCanvas.getContext("2d")!;
const tc = owner.territoryColor();
const bc = owner.borderColor();
// Potentially change logic here. Some TC/BC combinations do not provide good color contrast.
const darker = bc.luminance() < tc.luminance() ? bc : tc;
const lighter = bc.luminance() < tc.luminance() ? tc : bc;
let borderColor: string;
if (isConstruction) {
context.fillStyle = "rgb(198, 198, 198)";
borderColor = "rgb(128, 127, 127)";
} else {
context.fillStyle = lighter
.lighten(0.13)
.alpha(renderIcon ? 0.65 : 1)
.toRgbString();
const darken = darker.isLight() ? 0.17 : 0.15;
borderColor = darker.darken(darken).toRgbString();
}
context.strokeStyle = borderColor;
// Use structureColors defined from the PlayerView.
context.fillStyle = isConstruction
? "rgb(198,198,198)"
: owner.structureColors().light.toRgbString();
context.strokeStyle = isConstruction
? "rgb(127,127, 127)"
: owner.structureColors().dark.toRgbString();
context.lineWidth = 1;
const halfIconSize = iconSize / 2;
@@ -400,7 +387,10 @@ export class SpriteFactory {
};
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
context.drawImage(
this.getImageColored(structureInfo.image, borderColor),
this.getImageColored(
structureInfo.image,
owner.structureColors().dark.toRgbString(),
),
offsetX,
offsetY,
);
@@ -19,6 +19,7 @@ import {
DragEvent,
MouseOverEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
@@ -399,7 +400,9 @@ export class TerritoryLayer implements Layer {
now > this.lastRefresh + this.refreshRate
) {
this.lastRefresh = now;
const renderTerritoryStart = FrameProfiler.start();
this.renderTerritory();
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
const vx0 = Math.max(0, topLeft.x);
@@ -411,6 +414,7 @@ export class TerritoryLayer implements Layer {
const h = vy1 - vy0 + 1;
if (w > 0 && h > 0) {
const putImageStart = FrameProfiler.start();
this.context.putImageData(
this.alternativeView ? this.alternativeImageData : this.imageData,
0,
@@ -420,9 +424,11 @@ export class TerritoryLayer implements Layer {
w,
h,
);
FrameProfiler.end("TerritoryLayer:putImageData", putImageStart);
}
}
const drawCanvasStart = FrameProfiler.start();
context.drawImage(
this.canvas,
-this.game.width() / 2,
@@ -430,7 +436,9 @@ export class TerritoryLayer implements Layer {
this.game.width(),
this.game.height(),
);
FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart);
if (this.game.inSpawnPhase()) {
const highlightDrawStart = FrameProfiler.start();
context.drawImage(
this.highlightCanvas,
-this.game.width() / 2,
@@ -438,6 +446,10 @@ export class TerritoryLayer implements Layer {
this.game.width(),
this.game.height(),
);
FrameProfiler.end(
"TerritoryLayer:drawHighlightCanvas",
highlightDrawStart,
);
}
}
+2 -2
View File
@@ -56,7 +56,7 @@
.bg-image {
content: "";
background-image: url("/images/EuropeBackground.webp");
background-image: url("/images/EuropeBackgroundBlurred.webp");
background-position: center;
background-attachment: fixed;
background-size: cover;
@@ -73,7 +73,6 @@
position: absolute;
width: 100%;
height: 100vh;
backdrop-filter: blur(5px);
}
.dark .bg-image {
@@ -406,6 +405,7 @@
<spawn-timer></spawn-timer>
<help-modal></help-modal>
<dark-mode-button></dark-mode-button>
<stats-button></stats-button>
<alert-frame></alert-frame>
<chat-modal></chat-modal>
<user-setting></user-setting>
+4 -29
View File
@@ -32,26 +32,6 @@ export function getApiBase() {
}
function getToken(): string | null {
// Check window hash
const { hash } = window.location;
if (hash.startsWith("#")) {
const params = new URLSearchParams(hash.slice(1));
const token = params.get("token");
if (token) {
localStorage.setItem("token", token);
params.delete("token");
params.toString();
}
// Clean the URL
history.replaceState(
null,
"",
window.location.pathname +
window.location.search +
(params.size > 0 ? "#" + params.toString() : ""),
);
}
// Check cookie
const cookie = document.cookie
.split(";")
@@ -83,21 +63,16 @@ export function discordLogin() {
export async function tokenLogin(token: string): Promise<string | null> {
const response = await fetch(
`${getApiBase()}/login/token?login-token=${token}`,
{
credentials: "include",
},
);
if (response.status !== 200) {
console.error("Token login failed", response);
return null;
}
const json = await response.json();
const { jwt, email } = json;
const payload = decodeJwt(jwt);
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
console.error("Invalid token", result.error, result.error.message);
return null;
}
clearToken();
localStorage.setItem("token", jwt);
const { email } = json;
return email;
}
+21
View File
@@ -90,3 +90,24 @@ export const PlayerProfileSchema = z.object({
stats: PlayerStatsTreeSchema,
});
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
export const ClanLeaderboardEntrySchema = z.object({
clanTag: z.string(),
games: z.number(),
wins: z.number(),
losses: z.number(),
playerSessions: z.number(),
weightedWins: z.number(),
weightedLosses: z.number(),
weightedWLRatio: z.number(),
});
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
export const ClanLeaderboardResponseSchema = z.object({
start: z.iso.datetime(),
end: z.iso.datetime(),
clans: ClanLeaderboardEntrySchema.array(),
});
export type ClanLeaderboardResponse = z.infer<
typeof ClanLeaderboardResponseSchema
>;
+140 -188
View File
@@ -42,222 +42,174 @@ function generateTeamColors(baseColor: Colord): Colord[] {
}
export const nationColors: Colord[] = [
colord("rgb(210,210,100)"), // Lime Yellow
colord("rgb(180,210,120)"), // Light Green
colord("rgb(170,190,100)"), // Yellow Green
colord("rgb(80,200,120)"), // Emerald Green
colord("rgb(130,200,130)"), // Light Sea Green
colord("rgb(140,180,140)"), // Dark Sea Green
colord("rgb(160,190,160)"), // Pale Green
colord("rgb(160,180,140)"), // Dark Olive Green
colord("rgb(100,160,80)"), // Olive Green
colord("rgb(100,140,110)"), // Sea Green
colord("rgb(100,180,160)"), // Aquamarine
colord("rgb(130,180,170)"), // Medium Aquamarine
colord("rgb(170,190,180)"), // Pale Blue Green
colord("rgb(100,130,150)"), // Steel Blue
colord("rgb(120,160,200)"), // Cornflower Blue
colord("rgb(140,150,180)"), // Light Slate Gray
colord("rgb(100,210,210)"), // Turquoise
colord("rgb(140,180,220)"), // Light Blue
colord("rgb(130,170,190)"), // Cadet Blue
colord("rgb(100,180,230)"), // Sky Blue
colord("rgb(80,130,190)"), // Navy Blue
colord("rgb(120,120,190)"), // Periwinkle
colord("rgb(150,110,190)"), // Lavender
colord("rgb(160,120,160)"), // Purple Gray
colord("rgb(170,140,190)"), // Medium Purple
colord("rgb(180,130,180)"), // Plum
colord("rgb(190,140,150)"), // Puce
colord("rgb(180,100,230)"), // Purple
colord("rgb(180,160,180)"), // Mauve
colord("rgb(170,150,170)"), // Dusty Rose
colord("rgb(150,130,150)"), // Thistle
colord("rgb(230,180,180)"), // Light Pink
colord("rgb(210,160,200)"), // Orchid
colord("rgb(230,130,180)"), // Pink
colord("rgb(210,100,160)"), // Hot Pink
colord("rgb(190,100,130)"), // Maroon
colord("rgb(220,120,120)"), // Coral
colord("rgb(200,130,110)"), // Dark Salmon
colord("rgb(230,140,140)"), // Salmon
colord("rgb(230,100,100)"), // Bright Red
colord("rgb(100,180,230)"), // Sky Blue
colord("rgb(230,180,80)"), // Golden Yellow
colord("rgb(180,100,230)"), // Purple
colord("rgb(80,200,120)"), // Emerald Green
colord("rgb(230,130,180)"), // Pink
colord("rgb(100,160,80)"), // Olive Green
colord("rgb(230,150,100)"), // Peach
colord("rgb(80,130,190)"), // Navy Blue
colord("rgb(210,210,100)"), // Lime Yellow
colord("rgb(190,100,130)"), // Maroon
colord("rgb(100,210,210)"), // Turquoise
colord("rgb(210,140,80)"), // Light Orange
colord("rgb(150,110,190)"), // Lavender
colord("rgb(180,210,120)"), // Light Green
colord("rgb(210,100,160)"), // Hot Pink
colord("rgb(100,140,110)"), // Sea Green
colord("rgb(230,180,180)"), // Light Pink
colord("rgb(120,120,190)"), // Periwinkle
colord("rgb(190,170,100)"), // Sand
colord("rgb(100,180,160)"), // Aquamarine
colord("rgb(210,160,200)"), // Orchid
colord("rgb(170,190,100)"), // Yellow Green
colord("rgb(100,130,150)"), // Steel Blue
colord("rgb(230,140,140)"), // Salmon
colord("rgb(140,180,220)"), // Light Blue
colord("rgb(200,160,110)"), // Tan
colord("rgb(180,130,180)"), // Plum
colord("rgb(130,200,130)"), // Light Sea Green
colord("rgb(220,120,120)"), // Coral
colord("rgb(120,160,200)"), // Cornflower Blue
colord("rgb(200,200,140)"), // Khaki
colord("rgb(160,120,160)"), // Purple Gray
colord("rgb(140,180,140)"), // Dark Sea Green
colord("rgb(200,130,110)"), // Dark Salmon
colord("rgb(130,170,190)"), // Cadet Blue
colord("rgb(190,180,160)"), // Tan Gray
colord("rgb(170,140,190)"), // Medium Purple
colord("rgb(160,190,160)"), // Pale Green
colord("rgb(190,150,130)"), // Rosy Brown
colord("rgb(140,150,180)"), // Light Slate Gray
colord("rgb(180,170,140)"), // Dark Khaki
colord("rgb(150,130,150)"), // Thistle
colord("rgb(170,190,180)"), // Pale Blue Green
colord("rgb(190,140,150)"), // Puce
colord("rgb(130,180,170)"), // Medium Aquamarine
colord("rgb(180,160,180)"), // Mauve
colord("rgb(160,180,140)"), // Dark Olive Green
colord("rgb(170,150,170)"), // Dusty Rose
colord("rgb(100,180,230)"), // Sky Blue
colord("rgb(230,180,80)"), // Golden Yellow
colord("rgb(180,100,230)"), // Purple
colord("rgb(80,200,120)"), // Emerald Green
colord("rgb(230,130,180)"), // Pink
colord("rgb(100,160,80)"), // Olive Green
colord("rgb(230,150,100)"), // Peach
colord("rgb(80,130,190)"), // Navy Blue
colord("rgb(210,210,100)"), // Lime Yellow
colord("rgb(190,100,130)"), // Maroon
colord("rgb(100,210,210)"), // Turquoise
colord("rgb(210,140,80)"), // Light Orange
colord("rgb(150,110,190)"), // Lavender
colord("rgb(180,210,120)"), // Light Green
colord("rgb(210,100,160)"), // Hot Pink
colord("rgb(100,140,110)"), // Sea Green
colord("rgb(230,180,180)"), // Light Pink
colord("rgb(120,120,190)"), // Periwinkle
colord("rgb(190,170,100)"), // Sand
colord("rgb(100,180,160)"), // Aquamarine
colord("rgb(210,160,200)"), // Orchid
colord("rgb(170,190,100)"), // Yellow Green
colord("rgb(100,130,150)"), // Steel Blue
colord("rgb(230,140,140)"), // Salmon
colord("rgb(140,180,220)"), // Light Blue
colord("rgb(200,160,110)"), // Tan
colord("rgb(180,130,180)"), // Plum
colord("rgb(130,200,130)"), // Light Sea Green
colord("rgb(220,120,120)"), // Coral
colord("rgb(120,160,200)"), // Cornflower Blue
colord("rgb(200,200,140)"), // Khaki
colord("rgb(160,120,160)"), // Purple Gray
colord("rgb(140,180,140)"), // Dark Sea Green
colord("rgb(200,130,110)"), // Dark Salmon
colord("rgb(130,170,190)"), // Cadet Blue
colord("rgb(190,180,160)"), // Tan Gray
colord("rgb(170,140,190)"), // Medium Purple
colord("rgb(160,190,160)"), // Pale Green
colord("rgb(190,150,130)"), // Rosy Brown
colord("rgb(140,150,180)"), // Light Slate Gray
colord("rgb(190,180,160)"), // Tan Gray
colord("rgb(180,170,140)"), // Dark Khaki
colord("rgb(150,130,150)"), // Thistle
colord("rgb(170,190,180)"), // Pale Blue Green
colord("rgb(190,140,150)"), // Puce
colord("rgb(130,180,170)"), // Medium Aquamarine
colord("rgb(180,160,180)"), // Mauve
colord("rgb(160,180,140)"), // Dark Olive Green
colord("rgb(170,150,170)"), // Dusty Rose
colord("rgb(200,200,140)"), // Khaki
colord("rgb(190,170,100)"), // Sand
];
// Bright pastel theme with 64 colors
export const humanColors: Colord[] = [
colord("rgb(16,185,129)"), // Sea Green
colord("rgb(34,197,94)"), // Emerald
colord("rgb(45,212,191)"), // Turquoise
colord("rgb(48,178,180)"), // Teal
colord("rgb(52,211,153)"), // Spearmint
colord("rgb(56,189,248)"), // Light Blue
colord("rgb(59,130,246)"), // Royal Blue
colord("rgb(67,190,84)"), // Fresh Green
colord("rgb(74,222,128)"), // Mint
colord("rgb(79,70,229)"), // Indigo
colord("rgb(82,183,136)"), // Jade
colord("rgb(96,165,250)"), // Sky Blue
colord("rgb(99,202,253)"), // Azure
colord("rgb(110,231,183)"), // Seafoam
colord("rgb(124,58,237)"), // Royal Purple
colord("rgb(125,211,252)"), // Crystal Blue
colord("rgb(132,204,22)"), // Lime
colord("rgb(133,77,14)"), // Chocolate
colord("rgb(134,239,172)"), // Light Green
colord("rgb(147,51,234)"), // Bright Purple
colord("rgb(147,197,253)"), // Powder Blue
colord("rgb(151,255,187)"), // Fresh Mint
colord("rgb(163,230,53)"), // Yellow Green
colord("rgb(167,139,250)"), // Periwinkle
colord("rgb(168,85,247)"), // Vibrant Purple
colord("rgb(179,136,255)"), // Light Purple
colord("rgb(132,204,22)"), // Lime
colord("rgb(16,185,129)"), // Sea Green
colord("rgb(52,211,153)"), // Spearmint
colord("rgb(45,212,191)"), // Turquoise
colord("rgb(74,222,128)"), // Mint
colord("rgb(110,231,183)"), // Seafoam
colord("rgb(134,239,172)"), // Light Green
colord("rgb(151,255,187)"), // Fresh Mint
colord("rgb(186,255,201)"), // Pale Emerald
colord("rgb(230,250,210)"), // Pastel Lime
colord("rgb(34,197,94)"), // Emerald
colord("rgb(67,190,84)"), // Fresh Green
colord("rgb(82,183,136)"), // Jade
colord("rgb(48,178,180)"), // Teal
colord("rgb(230,255,250)"), // Mint Whisper
colord("rgb(220,240,250)"), // Ice Blue
colord("rgb(233,213,255)"), // Light Lilac
colord("rgb(204,204,255)"), // Soft Lavender Blue
colord("rgb(220,220,255)"), // Meringue Blue
colord("rgb(202,225,255)"), // Baby Blue
colord("rgb(147,197,253)"), // Powder Blue
colord("rgb(125,211,252)"), // Crystal Blue
colord("rgb(99,202,253)"), // Azure
colord("rgb(56,189,248)"), // Light Blue
colord("rgb(96,165,250)"), // Sky Blue
colord("rgb(59,130,246)"), // Royal Blue
colord("rgb(79,70,229)"), // Indigo
colord("rgb(124,58,237)"), // Royal Purple
colord("rgb(147,51,234)"), // Bright Purple
colord("rgb(179,136,255)"), // Light Purple
colord("rgb(167,139,250)"), // Periwinkle
colord("rgb(217,70,239)"), // Fuchsia
colord("rgb(168,85,247)"), // Vibrant Purple
colord("rgb(190,92,251)"), // Amethyst
colord("rgb(192,132,252)"), // Lavender
colord("rgb(202,138,4)"), // Rich Gold
colord("rgb(202,225,255)"), // Baby Blue
colord("rgb(204,204,255)"), // Soft Lavender Blue
colord("rgb(217,70,239)"), // Fuchsia
colord("rgb(220,38,38)"), // Ruby
colord("rgb(220,220,255)"), // Meringue Blue
colord("rgb(220,240,250)"), // Ice Blue
colord("rgb(230,250,210)"), // Pastel Lime
colord("rgb(230,255,250)"), // Mint Whisper
colord("rgb(233,213,255)"), // Light Lilac
colord("rgb(234,88,12)"), // Burnt Orange
colord("rgb(234,179,8)"), // Sunflower
colord("rgb(235,75,75)"), // Bright Red
colord("rgb(236,72,153)"), // Deep Pink
colord("rgb(239,68,68)"), // Crimson
colord("rgb(240,171,252)"), // Orchid
colord("rgb(240,240,200)"), // Light Khaki
colord("rgb(244,114,182)"), // Rose
colord("rgb(236,72,153)"), // Deep Pink
colord("rgb(220,38,38)"), // Ruby
colord("rgb(239,68,68)"), // Crimson
colord("rgb(235,75,75)"), // Bright Red
colord("rgb(245,101,101)"), // Coral
colord("rgb(245,158,11)"), // Amber
colord("rgb(248,113,113)"), // Warm Red
colord("rgb(249,115,22)"), // Tangerine
colord("rgb(250,215,225)"), // Cotton Candy
colord("rgb(250,250,210)"), // Pastel Lemon
colord("rgb(251,113,133)"), // Watermelon
colord("rgb(251,146,60)"), // Light Orange
colord("rgb(251,191,36)"), // Marigold
colord("rgb(251,235,245)"), // Rose Powder
colord("rgb(252,165,165)"), // Peach
colord("rgb(252,211,77)"), // Golden
colord("rgb(253,164,175)"), // Salmon Pink
colord("rgb(252,165,165)"), // Peach
colord("rgb(255,204,229)"), // Blush Pink
colord("rgb(255,223,186)"), // Apricot Cream
colord("rgb(250,215,225)"), // Cotton Candy
colord("rgb(251,235,245)"), // Rose Powder
colord("rgb(240,240,200)"), // Light Khaki
colord("rgb(250,250,210)"), // Pastel Lemon
colord("rgb(255,240,200)"), // Vanilla
colord("rgb(255,223,186)"), // Apricot Cream
colord("rgb(252,211,77)"), // Golden
colord("rgb(251,191,36)"), // Marigold
colord("rgb(234,179,8)"), // Sunflower
colord("rgb(202,138,4)"), // Rich Gold
colord("rgb(245,158,11)"), // Amber
colord("rgb(251,146,60)"), // Light Orange
colord("rgb(249,115,22)"), // Tangerine
colord("rgb(234,88,12)"), // Burnt Orange
colord("rgb(133,77,14)"), // Chocolate
];
export const botColors: Colord[] = [
colord("rgb(190,120,120)"), // Muted Red
colord("rgb(120,160,190)"), // Muted Sky Blue
colord("rgb(190,160,100)"), // Muted Golden Yellow
colord("rgb(160,120,190)"), // Muted Purple
colord("rgb(100,170,130)"), // Muted Emerald Green
colord("rgb(190,130,160)"), // Muted Pink
colord("rgb(120,150,100)"), // Muted Olive Green
colord("rgb(190,140,120)"), // Muted Peach
colord("rgb(100,120,160)"), // Muted Navy Blue
colord("rgb(170,170,120)"), // Muted Lime Yellow
colord("rgb(160,120,130)"), // Muted Maroon
colord("rgb(120,170,170)"), // Muted Turquoise
colord("rgb(170,140,100)"), // Muted Light Orange
colord("rgb(140,120,160)"), // Muted Lavender
colord("rgb(150,170,130)"), // Muted Light Green
colord("rgb(170,120,140)"), // Muted Hot Pink
colord("rgb(120,140,120)"), // Muted Sea Green
colord("rgb(180,160,160)"), // Muted Light Pink
colord("rgb(130,130,160)"), // Muted Periwinkle
colord("rgb(160,150,120)"), // Muted Sand
colord("rgb(120,160,150)"), // Muted Aquamarine
colord("rgb(170,150,170)"), // Muted Orchid
colord("rgb(150,160,120)"), // Muted Yellow Green
colord("rgb(120,130,140)"), // Muted Steel Blue
colord("rgb(180,140,140)"), // Muted Salmon
colord("rgb(140,160,170)"), // Muted Light Blue
colord("rgb(170,150,130)"), // Muted Tan
colord("rgb(160,130,160)"), // Muted Plum
colord("rgb(130,170,130)"), // Muted Light Sea Green
colord("rgb(170,130,130)"), // Muted Coral
colord("rgb(130,150,170)"), // Muted Cornflower Blue
colord("rgb(170,170,140)"), // Muted Khaki
colord("rgb(150,130,150)"), // Muted Purple Gray
colord("rgb(140,160,140)"), // Muted Dark Sea Green
colord("rgb(170,130,120)"), // Muted Dark Salmon
colord("rgb(130,150,160)"), // Muted Cadet Blue
colord("rgb(160,160,150)"), // Muted Tan Gray
colord("rgb(150,140,160)"), // Muted Medium Purple
colord("rgb(150,170,150)"), // Muted Pale Green
colord("rgb(160,140,130)"), // Muted Rosy Brown
colord("rgb(140,150,160)"), // Muted Light Slate Gray
colord("rgb(160,150,140)"), // Muted Dark Khaki
colord("rgb(140,130,140)"), // Muted Thistle
colord("rgb(150,160,160)"), // Muted Pale Blue Green
colord("rgb(160,140,150)"), // Muted Puce
colord("rgb(130,160,150)"), // Muted Medium Aquamarine
colord("rgb(160,150,160)"), // Muted Mauve
colord("rgb(150,160,140)"), // Muted Dark Olive Green
colord("rgb(160,160,150)"), // Muted Tan Gray
colord("rgb(170,170,140)"), // Muted Khaki
colord("rgb(170,170,120)"), // Muted Lime Yellow
colord("rgb(150,160,120)"), // Muted Yellow Green
colord("rgb(150,170,130)"), // Muted Light Green
colord("rgb(150,170,150)"), // Muted Pale Green
colord("rgb(130,170,130)"), // Muted Light Sea Green
colord("rgb(140,160,140)"), // Muted Dark Sea Green
colord("rgb(120,150,100)"), // Muted Olive Green
colord("rgb(120,140,120)"), // Muted Sea Green
colord("rgb(100,170,130)"), // Muted Emerald Green
colord("rgb(120,160,150)"), // Muted Aquamarine
colord("rgb(130,160,150)"), // Muted Medium Aquamarine
colord("rgb(120,170,170)"), // Muted Turquoise
colord("rgb(120,160,190)"), // Muted Sky Blue
colord("rgb(130,150,170)"), // Muted Cornflower Blue
colord("rgb(130,150,160)"), // Muted Cadet Blue
colord("rgb(140,150,160)"), // Muted Light Slate Gray
colord("rgb(140,160,170)"), // Muted Light Blue
colord("rgb(150,160,160)"), // Muted Pale Blue Green
colord("rgb(100,120,160)"), // Muted Navy Blue
colord("rgb(120,130,140)"), // Muted Steel Blue
colord("rgb(130,130,160)"), // Muted Periwinkle
colord("rgb(140,130,140)"), // Muted Thistle
colord("rgb(140,120,160)"), // Muted Lavender
colord("rgb(150,130,150)"), // Muted Purple Gray
colord("rgb(150,140,160)"), // Muted Medium Purple
colord("rgb(160,130,160)"), // Muted Plum
colord("rgb(170,150,170)"), // Muted Orchid
colord("rgb(160,120,190)"), // Muted Purple
colord("rgb(160,120,130)"), // Muted Maroon
colord("rgb(170,120,140)"), // Muted Hot Pink
colord("rgb(170,130,120)"), // Muted Dark Salmon
colord("rgb(170,130,130)"), // Muted Coral
colord("rgb(180,140,140)"), // Muted Salmon
colord("rgb(190,130,160)"), // Muted Pink
colord("rgb(190,120,120)"), // Muted Red
colord("rgb(190,140,120)"), // Muted Peach
colord("rgb(190,160,100)"), // Muted Golden Yellow
colord("rgb(170,140,100)"), // Muted Light Orange
colord("rgb(160,140,130)"), // Muted Rosy Brown
colord("rgb(170,150,130)"), // Muted Tan
colord("rgb(160,150,120)"), // Muted Sand
colord("rgb(160,150,140)"), // Muted Dark Khaki
colord("rgb(160,140,150)"), // Muted Puce
colord("rgb(160,150,160)"), // Muted Mauve
colord("rgb(150,140,150)"), // Muted Dusty Rose
colord("rgb(180,160,160)"), // Muted Light Pink
];
// Fallback colors for when the color palette is exhausted.
+2
View File
@@ -190,6 +190,8 @@ export interface Theme {
// Don't call directly, use PlayerView
territoryColor(playerInfo: PlayerView): Colord;
// Don't call directly, use PlayerView
structureColors(territoryColor: Colord): { light: Colord; dark: Colord };
// Don't call directly, use PlayerView
borderColor(territoryColor: Colord): Colord;
// Don't call directly, use PlayerView
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
+2 -1
View File
@@ -60,6 +60,7 @@ const numPlayersConfig = {
[GameMapType.Europe]: [100, 70, 50],
[GameMapType.EuropeClassic]: [50, 30, 30],
[GameMapType.FalklandIslands]: [50, 30, 20],
[GameMapType.FourIslands]: [20, 15, 10],
[GameMapType.FaroeIslands]: [20, 15, 10],
[GameMapType.GatewayToTheAtlantic]: [100, 70, 50],
[GameMapType.GiantWorldMap]: [100, 70, 50],
@@ -692,7 +693,7 @@ export class DefaultConfig implements Config {
if (attacker.isPlayer() && defender.isPlayer()) {
if (defender.isDisconnected() && attacker.isOnSameTeam(defender)) {
// No troop loss if defender is disconnected.
// No troop loss if defender is disconnected and on same team
mag = 0;
}
if (
+54 -4
View File
@@ -1,4 +1,4 @@
import { Colord, colord } from "colord";
import { Colord, colord, LabaColor } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
@@ -7,10 +7,7 @@ import { ColorAllocator } from "./ColorAllocator";
import { botColors, fallbackColors, humanColors, nationColors } from "./Colors";
import { Theme } from "./Config";
type ColorCache = Map<string, Colord>;
export class PastelTheme implements Theme {
private borderColorCache: ColorCache = new Map<string, Colord>();
private rand = new PseudoRandom(123);
private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors);
private botColorAllocator = new ColorAllocator(botColors, botColors);
@@ -65,6 +62,59 @@ export class PastelTheme implements Theme {
return this.nationColorAllocator.assignColor(player.id());
}
structureColors(territoryColor: Colord): { light: Colord; dark: Colord } {
// Convert territory color to LAB color space. Territory color is rendered in game with alpha = 150/255, use that here.
const lightLAB = territoryColor.alpha(150 / 255).toLab();
// Get "border color" from territory color & convert to LAB color space
const darkLAB = this.borderColor(territoryColor).toLab();
// Calculate the contrast of the two provided colors
let contrast = this.contrast(lightLAB, darkLAB);
// Don't want excessive contrast, so incrementally increase contrast within a loop.
// Define target values, looping limits, and loop counter
const loopLimit = 10; // Switch from darkening border to lightening fill if loopLimit is reached
const maxIterations = 50; // maximum number of loops allowed, throw error above this limit
const contrastTarget = 0.5;
let loopCount = 0;
// Adjust luminance by 5 in each iteration. This is a balance between speed and not overdoing contrast changes.
const luminanceChange = 5;
while (contrast < contrastTarget) {
if (loopCount > maxIterations) {
// Prevent runaway loops
console.warn(`Infinite loop detected during structure color calculation.
Light color: ${colord(lightLAB).toRgbString()},
Dark color: ${colord(darkLAB).toRgbString()},
Contrast: ${contrast}`);
break;
// Increase the light color if the "loop limit" has been reach
// (probably due to the dark color already being as dark as it can be)
} else if (loopCount > loopLimit) {
lightLAB.l = this.clamp(lightLAB.l + luminanceChange);
// Decrease the dark color first to keep the light color as close
// to the territory color as possible
} else {
darkLAB.l = this.clamp(darkLAB.l - luminanceChange);
}
// re-calculate contrast and increment loop counter
contrast = this.contrast(lightLAB, darkLAB);
loopCount++;
}
return { light: colord(lightLAB), dark: colord(darkLAB) };
}
private contrast(first: LabaColor, second: LabaColor): number {
return colord(first).delta(colord(second));
}
private clamp(num: number, low: number = 0, high: number = 100): number {
return Math.min(Math.max(low, num), high);
}
// Don't call directly, use PlayerView
borderColor(territoryColor: Colord): Colord {
return territoryColor.darken(0.125);
+7
View File
@@ -186,6 +186,13 @@ export class AttackExecution implements Execution {
this._owner.id(),
);
}
if (this.removeTroops === false && this.sourceTile === null) {
// startTroops are always added to attack troops at init but not always removed from owner troops
// subtract startTroops from attack troops so we don't give back startTroops to owner that were never removed
// boat attacks (sourceTile !== null) are the exception: troops were removed at departure and must be returned after attack still
this.attack.setTroops(this.attack.troops() - (this.startTroops ?? 0));
}
const survivors = this.attack.troops() - deaths;
this._owner.addTroops(survivors);
this.attack.delete();
+55 -7
View File
@@ -1,3 +1,4 @@
import { renderTroops } from "../../client/Utils";
import {
Execution,
Game,
@@ -14,6 +15,7 @@ import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { AttackExecution } from "./AttackExecution";
const malusForRetreat = 25;
export class TransportShipExecution implements Execution {
private lastMove: number;
@@ -33,13 +35,17 @@ export class TransportShipExecution implements Execution {
private pathFinder: PathFinder;
private originalOwner: Player;
constructor(
private attacker: Player,
private targetID: PlayerID | null,
private ref: TileRef,
private startTroops: number,
private src: TileRef | null,
) {}
) {
this.originalOwner = this.attacker;
}
activeDuringSpawnPhase(): boolean {
return false;
@@ -173,11 +179,44 @@ export class TransportShipExecution implements Execution {
}
this.lastMove = ticks;
if (this.boat.retreating()) {
this.dst = this.src!; // src is guaranteed to be set at this point
// Team mate can conquer disconnected player and get their ships
// captureUnit has changed the owner of the unit, now update attacker
const boatOwner = this.boat.owner();
if (
this.originalOwner.isDisconnected() &&
boatOwner !== this.originalOwner &&
boatOwner.isOnSameTeam(this.originalOwner)
) {
this.attacker = boatOwner;
this.originalOwner = boatOwner; // for when this owner disconnects too
}
if (this.boat.targetTile() !== this.dst) {
this.boat.setTargetTile(this.dst);
if (this.boat.retreating()) {
// Ensure retreat source is still valid for (new) owner
if (this.mg.owner(this.src!) !== this.attacker) {
// Use bestTransportShipSpawn, not canBuild because of its max boats check etc
const newSrc = this.attacker.bestTransportShipSpawn(this.dst);
if (newSrc === false) {
this.src = null;
} else {
this.src = newSrc;
}
}
if (this.src === null) {
console.warn(
`TransportShipExecution: retreating but no src found for new attacker`,
);
this.attacker.addTroops(this.boat.troops());
this.boat.delete(false);
this.active = false;
return;
} else {
this.dst = this.src;
if (this.boat.targetTile() !== this.dst) {
this.boat.setTargetTile(this.dst);
}
}
}
@@ -190,14 +229,23 @@ export class TransportShipExecution implements Execution {
switch (result.type) {
case PathFindResultType.Completed:
if (this.mg.owner(this.dst) === this.attacker) {
this.attacker.addTroops(this.boat.troops());
const deaths = this.boat.troops() * (malusForRetreat / 100);
const survivors = this.boat.troops() - deaths;
this.attacker.addTroops(survivors);
this.boat.delete(false);
this.active = false;
// Record stats
this.mg
.stats()
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
.boatArriveTroops(this.attacker, this.target, survivors);
if (deaths) {
this.mg.displayMessage(
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
MessageType.ATTACK_CANCELLED,
this.attacker.id(),
);
}
return;
}
this.attacker.conquer(this.dst);
+1 -5
View File
@@ -55,10 +55,6 @@ export class WarshipExecution implements Execution {
this.warship.delete();
return;
}
if (this.warship.owner().isDisconnected()) {
this.warship.delete();
return;
}
const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
if (hasPort) {
@@ -93,7 +89,7 @@ export class WarshipExecution implements Execution {
if (
unit.owner() === this.warship.owner() ||
unit === this.warship ||
unit.owner().isFriendly(this.warship.owner()) ||
unit.owner().isFriendly(this.warship.owner(), true) ||
this.alreadySentShell.has(unit)
) {
continue;
+3 -1
View File
@@ -101,6 +101,7 @@ export enum GameMapType {
Montreal = "Montreal",
Achiran = "Achiran",
BaikalNukeWars = "Baikal (Nuke Wars)",
FourIslands = "Four Islands",
}
export type GameMapName = keyof typeof GameMapType;
@@ -143,6 +144,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.DeglaciatedAntarctica,
GameMapType.Achiran,
GameMapType.BaikalNukeWars,
GameMapType.FourIslands,
],
};
@@ -601,7 +603,7 @@ export interface Player {
decayRelations(): void;
isOnSameTeam(other: Player): boolean;
// Either allied or on same team.
isFriendly(other: Player): boolean;
isFriendly(other: Player, treatAFKFriendly?: boolean): boolean;
team(): Team | null;
clan(): string | null;
incomingAllianceRequests(): AllianceRequest[];
+14
View File
@@ -895,6 +895,20 @@ export class GameImpl implements Game {
return this._railNetwork;
}
conquerPlayer(conqueror: Player, conquered: Player) {
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
const ships = conquered
.units()
.filter(
(u) =>
u.type() === UnitType.Warship ||
u.type() === UnitType.TransportShip,
);
for (const ship of ships) {
conqueror.captureUnit(ship);
}
}
const gold = conquered.gold();
this.displayMessage(
`Conquered ${conquered.displayName()} received ${renderNumber(
+1
View File
@@ -181,6 +181,7 @@ export interface AllianceView {
other: PlayerID;
createdAt: Tick;
expiresAt: Tick;
hasExtensionRequest: boolean;
}
export interface AllianceRequestUpdate {
+11
View File
@@ -190,6 +190,8 @@ export class PlayerView {
private _territoryColor: Colord;
private _borderColor: Colord;
// Update here to include structure light and dark colors
private _structureColors: { light: Colord; dark: Colord };
private _defendedBorderColors: { light: Colord; dark: Colord };
constructor(
@@ -235,6 +237,11 @@ export class PlayerView {
this._territoryColor = defaultTerritoryColor;
}
this._structureColors = this.game
.config()
.theme()
.structureColors(this._territoryColor);
const maybeFocusedBorderColor =
this.game.myClientID() === this.data.clientID
? this.game.config().theme().focusedBorderColor()
@@ -268,6 +275,10 @@ export class PlayerView {
return isPrimary ? this._territoryColor : this._borderColor;
}
structureColors(): { light: Colord; dark: Colord } {
return this._structureColors;
}
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
if (tile === undefined || !isDefended) {
return this._borderColor;
+6 -2
View File
@@ -173,6 +173,10 @@ export class PlayerImpl implements Player {
other: a.other(this).id(),
createdAt: a.createdAt(),
expiresAt: a.expiresAt(),
hasExtensionRequest:
a.expiresAt() <=
this.mg.ticks() +
this.mg.config().allianceExtensionPromptOffset(),
}) satisfies AllianceView,
),
hasSpawned: this.hasSpawned(),
@@ -791,8 +795,8 @@ export class PlayerImpl implements Player {
return this._team === other.team();
}
isFriendly(other: Player): boolean {
if (other.isDisconnected()) {
isFriendly(other: Player, treatAFKFriendly: boolean = false): boolean {
if (other.isDisconnected() && !treatAFKFriendly) {
return false;
}
return this.isOnSameTeam(other) || this.isAlliedWith(other);
+2
View File
@@ -148,6 +148,8 @@ export function bestShoreDeploymentSource(
if (t === null) return false;
const candidates = candidateShoreTiles(gm, player, t);
if (candidates.length === 0) return false;
const aStar = new MiniAStar(gm, gm.miniMap(), candidates, t, 1_000_000, 1);
const result = aStar.compute();
if (result !== PathFindResultType.Completed) {
+20
View File
@@ -111,6 +111,26 @@ describe("Attack", () => {
expect(nuke.isActive()).toBe(false);
expect(defender.units(UnitType.TransportShip)[0].troops()).toBeLessThan(90);
});
test("Boat penalty on retreat Transport Ship arrival", async () => {
const player_start_troops = defender.troops();
const boat_troops = player_start_troops * 0.5;
sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops);
game.executeNextTick();
const ship = defender.units(UnitType.TransportShip)[0];
expect(ship.troops()).toBe(boat_troops);
expect(ship.isActive()).toBe(true);
ship.orderBoatRetreat();
game.executeNextTick();
expect(ship.isActive()).toBe(false);
expect(boat_troops).toBeLessThan(defender.troops());
expect(defender.troops()).toBeLessThan(player_start_troops);
});
});
let playerA: Player;
+321 -1
View File
@@ -1,12 +1,25 @@
import { AttackExecution } from "../src/core/execution/AttackExecution";
import { MarkDisconnectedExecution } from "../src/core/execution/MarkDisconnectedExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { TransportShipExecution } from "../src/core/execution/TransportShipExecution";
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
import {
Game,
GameMode,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { toInt } from "../src/core/Util";
import { setup } from "./util/Setup";
import { UseRealAttackLogic } from "./util/TestConfig";
import { executeTicks } from "./util/utils";
let game: Game;
let player1: Player;
let player2: Player;
let enemy: Player;
describe("Disconnected", () => {
beforeEach(async () => {
@@ -158,4 +171,311 @@ describe("Disconnected", () => {
expect(player1.isDisconnected()).toBe(true);
});
});
describe("Disconnected team member interactions", () => {
const coastX = 7;
beforeEach(async () => {
const player1Info = new PlayerInfo(
"[CLAN]Player1",
PlayerType.Human,
null,
"player_1_id",
);
const player2Info = new PlayerInfo(
"[CLAN]Player2",
PlayerType.Human,
null,
"player_2_id",
);
game = await setup(
"half_land_half_ocean",
{
infiniteGold: true,
instantBuild: true,
gameMode: GameMode.Team,
playerTeams: 2, // ignore player2 "kicked" console warn
},
[player1Info, player2Info],
undefined,
UseRealAttackLogic, // don't use TestConfig's mock attackLogic
);
game.addExecution(
new SpawnExecution(player1Info, game.map().ref(coastX - 2, 1)),
new SpawnExecution(player2Info, game.map().ref(coastX - 2, 4)),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player(player1Info.id);
player2 = game.player(player2Info.id);
player2.markDisconnected(false);
expect(player1.team()).not.toBeNull();
expect(player2.team()).not.toBeNull();
expect(player1.isOnSameTeam(player2)).toBe(true);
});
test("Team Warships should not attack disconnected team mate ships", () => {
const warship = player1.buildUnit(
UnitType.Warship,
game.map().ref(coastX + 1, 10),
{
patrolTile: game.map().ref(coastX + 1, 10),
},
);
game.addExecution(new WarshipExecution(warship));
const transportShip = player2.buildUnit(
UnitType.TransportShip,
game.map().ref(coastX + 1, 11),
{
troops: 100,
},
);
player2.markDisconnected(true);
executeTicks(game, 10);
expect(warship.targetUnit()).toBe(undefined);
expect(transportShip.isActive()).toBe(true);
expect(transportShip.owner()).toBe(player2);
});
test("Disconnected player Warship should not attack team members' ships", () => {
const warship = player2.buildUnit(
UnitType.Warship,
game.map().ref(coastX + 1, 5),
{
patrolTile: game.map().ref(coastX + 1, 10),
},
);
game.addExecution(new WarshipExecution(warship));
const transportShip = player1.buildUnit(
UnitType.TransportShip,
game.map().ref(coastX + 1, 6),
{
troops: 100,
},
);
player2.markDisconnected(true);
executeTicks(game, 10);
expect(warship.targetUnit()).toBe(undefined);
expect(transportShip.isActive()).toBe(true);
expect(transportShip.owner()).toBe(player1);
});
test("Player can attack disconnected team mate without troop loss", () => {
player2.conquer(game.map().ref(coastX - 2, 2));
player2.conquer(game.map().ref(coastX - 2, 3));
player2.markDisconnected(true);
const troopsBeforeAttack = player1.troops();
const startTroops = troopsBeforeAttack * 0.25;
game.addExecution(
new AttackExecution(startTroops, player1, player2.id(), null),
);
let expectedTotalGrowth = 0n;
let afterTickZero = false;
while (player2.isAlive()) {
if (afterTickZero) {
// No growth on tick 0, troop additions start from tick 1
const troopIncThisTick = game.config().troopIncreaseRate(player1);
expectedTotalGrowth += toInt(troopIncThisTick);
}
game.executeNextTick();
afterTickZero = true;
}
// Tick for retreat() in AttackExecution to add back startTtoops to owner troops
const troopIncThisTick1 = game.config().troopIncreaseRate(player1);
expectedTotalGrowth += toInt(troopIncThisTick1);
game.executeNextTick();
const expectedFinalTroops = Number(
toInt(troopsBeforeAttack) + expectedTotalGrowth,
);
// Verify no troop loss
expect(player1.troops()).toBe(expectedFinalTroops);
});
test("Conqueror gets conquered disconnected team member's transport- and warships", () => {
const warship = player2.buildUnit(
UnitType.Warship,
game.map().ref(coastX + 1, 1),
{
patrolTile: game.map().ref(coastX + 1, 1),
},
);
const transportShip = player2.buildUnit(
UnitType.TransportShip,
game.map().ref(coastX + 1, 3),
{
troops: 100,
},
);
player2.conquer(game.map().ref(coastX - 2, 1));
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(warship.owner()).toBe(player1);
expect(transportShip.owner()).toBe(player1);
});
test("Captured transport ship landing attack should be in name of new owner", () => {
player2.conquer(game.map().ref(coastX, 1));
player2.conquer(game.map().ref(coastX - 1, 1));
player2.conquer(game.map().ref(coastX, 2));
const enemyShoreTile = game.map().ref(coastX, 15);
game.addExecution(
new TransportShipExecution(
player2,
null,
enemyShoreTile,
100,
game.map().ref(coastX, 1),
),
);
executeTicks(game, 1);
expect(player2.isAlive()).toBe(true);
const transportShip = player2.units(UnitType.TransportShip)[0];
expect(player2.units(UnitType.TransportShip).length).toBe(1);
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
executeTicks(game, 30);
// Verify ship landed and tile ownership transferred to new ship owner
expect(game.owner(enemyShoreTile)).toBe(player1);
});
test("Captured transport ship should retreat to owner's shore tile", () => {
player1.conquer(game.map().ref(coastX, 4));
player2.conquer(game.map().ref(coastX, 1));
const enemyShoreTile = game.map().ref(coastX, 8);
game.addExecution(
new TransportShipExecution(
player2,
null,
enemyShoreTile,
100,
game.map().ref(coastX, 1),
),
);
executeTicks(game, 1);
const transportShip = player2.units(UnitType.TransportShip)[0];
expect(player2.units(UnitType.TransportShip).length).toBe(1);
expect(transportShip.targetTile()).toBe(enemyShoreTile);
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
transportShip.orderBoatRetreat();
executeTicks(game, 2);
expect(transportShip.targetTile()).not.toBe(enemyShoreTile);
expect(game.owner(transportShip.targetTile()!)).toBe(player1);
});
test("Retreating transport ship is deleted if new owner has no shore tiles", () => {
player2.conquer(game.map().ref(coastX, 1));
player2.conquer(game.map().ref(coastX - 6, 2));
player1.conquer(game.map().ref(coastX - 6, 3));
const enemyShoreTile = game.map().ref(coastX, 15);
const boatTroops = 100;
game.addExecution(
new TransportShipExecution(
player2,
null,
enemyShoreTile,
boatTroops,
game.map().ref(coastX, 1),
),
);
executeTicks(game, 1);
const transportShip = player2.units(UnitType.TransportShip)[0];
expect(player2.units(UnitType.TransportShip).length).toBe(1);
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
// Make sure player1 has no shore tiles for the ship to retreat to anymore
const enemyInfo = new PlayerInfo(
"Enemy",
PlayerType.Human,
null,
"enemy_id",
);
enemy = game.addPlayer(enemyInfo);
const shoreTiles = Array.from(player1.borderTiles()).filter((t) =>
game.isShore(t),
);
shoreTiles.forEach((tile) => {
enemy.conquer(tile);
});
expect(
Array.from(player1.borderTiles()).filter((t) => game.isShore(t)).length,
).toBe(0);
executeTicks(game, 1);
const troopIncPerTick = game.config().troopIncreaseRate(player1);
const expectedTroopGrowth = toInt(troopIncPerTick * 1);
const expectedFinalTroops = Number(
toInt(player1.troops()) + expectedTroopGrowth,
);
transportShip.orderBoatRetreat();
executeTicks(game, 1);
expect(transportShip.isActive()).toBe(false);
// Also test if boat troops were returned to player1 as new ship owner
expect(player1.troops()).toBe(expectedFinalTroops + boatTroops);
});
});
});
+41
View File
@@ -0,0 +1,41 @@
import { computeAllianceClipPath } from "../src/client/graphics/PlayerIcons";
describe("PlayerIcons", () => {
describe("computeAllianceClipPath", () => {
test("returns full visibility (20% top cut) when alliance time is at 100%", () => {
const result = computeAllianceClipPath(1.0);
// topCut = 20 + (1 - 1.0) * 80 * 0.78 = 20 + 0 = 20.00
expect(result).toBe("inset(20.00% -2px 0 -2px)");
});
test("returns maximum cut (82.40% top cut) when alliance time is at 0%", () => {
const result = computeAllianceClipPath(0.0);
// topCut = 20 + (1 - 0.0) * 80 * 0.78 = 20 + 62.4 = 82.40
expect(result).toBe("inset(82.40% -2px 0 -2px)");
});
test("returns 51.20% top cut when alliance time is at 50%", () => {
const result = computeAllianceClipPath(0.5);
// topCut = 20 + (1 - 0.5) * 80 * 0.78 = 20 + 31.2 = 51.20
expect(result).toBe("inset(51.20% -2px 0 -2px)");
});
test("returns 27.80% top cut when alliance time is at 87.5%", () => {
const result = computeAllianceClipPath(0.875);
// topCut = 20 + (1 - 0.875) * 80 * 0.78 = 20 + 7.8 = 27.80
expect(result).toBe("inset(27.80% -2px 0 -2px)");
});
test("returns 74.60% top cut when alliance time is at 12.5%", () => {
const result = computeAllianceClipPath(0.125);
// topCut = 20 + (1 - 0.125) * 80 * 0.78 = 20 + 54.6 = 74.60
expect(result).toBe("inset(74.60% -2px 0 -2px)");
});
test("includes -2px horizontal overscan to prevent subpixel gaps", () => {
const result = computeAllianceClipPath(0.5);
expect(result).toContain("-2px");
expect(result.match(/-2px/g)).toHaveLength(2); // Should appear twice (left and right)
});
});
});
+103
View File
@@ -0,0 +1,103 @@
// Mock BuildMenu to avoid importing lit and other ESM-heavy deps in this unit test
jest.mock(
"../src/client/graphics/layers/BuildMenu",
() => ({
BuildMenu: class {},
flattenedBuildTable: [],
}),
{ virtual: true },
);
// Mock Utils to avoid touching DOM (document) during tests
jest.mock("../src/client/Utils", () => ({
translateText: (k: string) => k,
getSvgAspectRatio: async () => 1,
}));
import {
COLORS,
rootMenuElement,
type MenuElementParams,
} from "../src/client/graphics/layers/RadialMenuElements";
// Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions
const makePlayer = (id: string) =>
({
id: () => id,
isAlliedWith: (other: any) =>
other && typeof other.id === "function" && other.id() !== id
? true
: true,
}) as unknown as import("../src/core/game/GameView").PlayerView;
const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
const myPlayer = (opts?.myPlayer as any) ?? makePlayer("p1");
const selected = (opts?.selected as any) ?? makePlayer("p2");
return {
myPlayer,
selected,
tile: {} as any,
playerActions: {
canAttack: true,
interaction: {
canBreakAlliance: true,
canSendAllianceRequest: false,
canEmbargo: false,
},
} as any,
game: {
inSpawnPhase: () => false,
owner: () => ({ isPlayer: () => false }),
} as any,
buildMenu: {
canBuildOrUpgrade: () => false,
cost: () => 0,
count: () => 0,
sendBuildOrUpgrade: () => {},
} as any,
emojiTable: {} as any,
playerActionHandler: {
handleBreakAlliance: jest.fn(),
handleEmbargo: jest.fn(),
handleDonateGold: jest.fn(),
handleDonateTroops: jest.fn(),
handleTargetPlayer: jest.fn(),
} as any,
playerPanel: {
show: jest.fn(),
} as any,
chatIntegration: {
createQuickChatMenu: jest.fn(() => []),
} as any,
eventBus: {} as any,
closeMenu: jest.fn(),
};
};
const findAllyBreak = (items: any[]) =>
items.find((i) => i && i.id === "ally_break");
describe("RadialMenuElements ally break", () => {
test("shows break option with correct color when allied", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
expect(ally).toBeTruthy();
expect(ally.name).toBe("break");
expect(ally.color).toBe(COLORS.breakAlly);
});
test("action calls handleBreakAlliance and closes menu", () => {
const params = makeParams();
const items = rootMenuElement.subMenu!(params);
const ally = findAllyBreak(items)!;
ally.action!(params);
expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
params.myPlayer,
params.selected,
);
expect(params.closeMenu).toHaveBeenCalled();
});
});
+2 -1
View File
@@ -25,6 +25,7 @@ export async function setup(
_gameConfig: Partial<GameConfig> = {},
humans: PlayerInfo[] = [],
currentDir: string = __dirname,
ConfigClass: typeof TestConfig = TestConfig,
): Promise<Game> {
// Suppress console.debug for tests.
console.debug = () => {};
@@ -70,7 +71,7 @@ export async function setup(
randomSpawn: false,
..._gameConfig,
};
const config = new TestConfig(
const config = new ConfigClass(
serverConfig,
gameConfig,
new UserSettings(),
+23
View File
@@ -85,3 +85,26 @@ export class TestConfig extends DefaultConfig {
return 1;
}
}
export class UseRealAttackLogic extends TestConfig {
// Override to use DefaultConfig's real attackLogic
attackLogic(
gm: Game,
attackTroops: number,
attacker: Player,
defender: Player | TerraNullius,
tileToConquer: TileRef,
): {
attackerTroopLoss: number;
defenderTroopLoss: number;
tilesPerTickUsed: number;
} {
return DefaultConfig.prototype.attackLogic.call(
this,
gm,
attackTroops,
attacker,
defender,
tileToConquer,
);
}
}