Merge branch 'main' into canbuildtransport-perf
@@ -0,0 +1,28 @@
|
||||
# Credits
|
||||
|
||||
## Code
|
||||
|
||||
OpenFront is licensed under AGPL-3.0.
|
||||
See [Contributors](https://github.com/openfrontio/OpenFrontIO/graphs/contributors) for code contributors.
|
||||
|
||||
## Map Data
|
||||
|
||||
### OpenStreetMap
|
||||
|
||||
© [OpenStreetMap contributors](https://www.openstreetmap.org/copyright)
|
||||
Licensed under ODbL
|
||||
|
||||
### Natural Earth
|
||||
|
||||
[Natural Earth](https://www.naturalearthdata.com/)
|
||||
Public Domain
|
||||
|
||||
### Bedmap3 Antarctica Dataset
|
||||
|
||||
Pritchard, H.D., Fretwell, P.T., Fremand, A.C. et al. Bedmap3 updated ice bed, surface and thickness gridded datasets for Antarctica. _Sci Data_ 12, 109 (2025).
|
||||
[https://doi.org/10.1038/s41597-025-04672-y](https://doi.org/10.1038/s41597-025-04672-y)
|
||||
Licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
## Icons
|
||||
|
||||
Icons from [The Noun Project](https://thenounproject.com/)
|
||||
@@ -11,12 +11,16 @@ export default {
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.tsx?$": ["@swc/jest"],
|
||||
"^.+\\.mjs$": ["@swc/jest"],
|
||||
"^.+\\.js$": ["@swc/jest"],
|
||||
},
|
||||
transformIgnorePatterns: ["node_modules/(?!(node:)/)"],
|
||||
transformIgnorePatterns: [
|
||||
"node_modules/(?!(nanoid|@jsep|fastpriorityqueue|@datastructures-js)/)",
|
||||
],
|
||||
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 21.5,
|
||||
statements: 21,
|
||||
branches: 16,
|
||||
lines: 21.0,
|
||||
functions: 20.5,
|
||||
|
||||
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Baikal (Nuke Wars)",
|
||||
"nations": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 654 KiB After Width: | Height: | Size: 655 KiB |
@@ -3,21 +3,75 @@
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [800, 430],
|
||||
"flag": "ca",
|
||||
"flag": "quebec",
|
||||
"name": "Laval",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1110, 930],
|
||||
"flag": "ca",
|
||||
"name": "Montreal",
|
||||
"strength": 2
|
||||
"flag": "quebec",
|
||||
"name": "Royal Mount park",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1220, 1360],
|
||||
"flag": "ca",
|
||||
"flag": "quebec",
|
||||
"name": "Hochelaga Archipelago",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1080, 980],
|
||||
"flag": "ca",
|
||||
"name": "Westmount",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1400, 1000],
|
||||
"flag": "quebec",
|
||||
"name": "Saint-Lambert",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [500, 130],
|
||||
"flag": "quebec",
|
||||
"name": "Blainville",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [350, 650],
|
||||
"flag": "quebec",
|
||||
"name": "Saint-Eustache",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [200, 1350],
|
||||
"flag": "quebec",
|
||||
"name": "Perrot Island",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [25, 950],
|
||||
"flag": "quebec",
|
||||
"name": "Kanesatake Lands",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [50, 450],
|
||||
"flag": "quebec",
|
||||
"name": "Mirabel",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [650, 1450],
|
||||
"flag": "quebec",
|
||||
"name": "Chateauguay",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1330, 300],
|
||||
"flag": "quebec",
|
||||
"name": "Pointe-aux-Trembles",
|
||||
"strength": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ var maps = []struct {
|
||||
{Name: "australia"},
|
||||
{Name: "achiran"},
|
||||
{Name: "baikal"},
|
||||
{Name: "baikalnukewars"},
|
||||
{Name: "betweentwoseas"},
|
||||
{Name: "blacksea"},
|
||||
{Name: "britannia"},
|
||||
|
||||
|
Before Width: | Height: | Size: 303 B After Width: | Height: | Size: 739 B |
|
Before Width: | Height: | Size: 305 B After Width: | Height: | Size: 585 B |
|
Before Width: | Height: | Size: 262 B After Width: | Height: | Size: 592 B |
|
Before Width: | Height: | Size: 325 B After Width: | Height: | Size: 801 B |
|
Before Width: | Height: | Size: 227 B After Width: | Height: | Size: 537 B |
|
Before Width: | Height: | Size: 259 B After Width: | Height: | Size: 726 B |
@@ -139,6 +139,7 @@
|
||||
"options_title": "Options",
|
||||
"bots": "Bots: ",
|
||||
"bots_disabled": "Disabled",
|
||||
"nations": "Nations: ",
|
||||
"disable_nations": "Disable Nations",
|
||||
"instant_build": "Instant build",
|
||||
"infinite_gold": "Infinite gold",
|
||||
@@ -146,6 +147,7 @@
|
||||
"compact_map": "Mini Map",
|
||||
"max_timer": "Game length (minutes)",
|
||||
"disable_nukes": "Disable Nukes",
|
||||
"automatic_difficulty": "Automatic Difficulty",
|
||||
"enables_title": "Enable Settings",
|
||||
"start": "Start Game"
|
||||
},
|
||||
@@ -194,7 +196,8 @@
|
||||
"yenisei": "Yenisei",
|
||||
"pluto": "Pluto",
|
||||
"montreal": "Montreal",
|
||||
"achiran": "Achiran"
|
||||
"achiran": "Achiran",
|
||||
"baikalnukewars": "Baikal (Nuke Wars)"
|
||||
},
|
||||
"map_categories": {
|
||||
"continental": "Continental",
|
||||
@@ -222,6 +225,7 @@
|
||||
"teams_Duos": "Duos (teams of 2)",
|
||||
"teams_Trios": "Trios (teams of 3)",
|
||||
"teams_Quads": "Quads (teams of 4)",
|
||||
"teams_hvn": "Humans Vs Nations",
|
||||
"teams": "{num} teams"
|
||||
},
|
||||
"matchmaking_modal": {
|
||||
@@ -244,6 +248,7 @@
|
||||
"options_title": "Options",
|
||||
"bots": "Bots: ",
|
||||
"bots_disabled": "Disabled",
|
||||
"nations": "Nations: ",
|
||||
"disable_nations": "Disable Nations",
|
||||
"max_timer": "Game length (minutes)",
|
||||
"instant_build": "Instant build",
|
||||
@@ -252,6 +257,7 @@
|
||||
"infinite_troops": "Infinite troops",
|
||||
"donate_troops": "Donate troops",
|
||||
"compact_map": "Mini Map",
|
||||
"automatic_difficulty": "Automatic Difficulty",
|
||||
"enables_title": "Enable Settings",
|
||||
"player": "Player",
|
||||
"players": "Players",
|
||||
@@ -271,8 +277,8 @@
|
||||
},
|
||||
"game_starting_modal": {
|
||||
"title": "Game is Starting...",
|
||||
"code_license": "Code licensed under AGPL-3.0",
|
||||
"desc": "Preparing for the lobby to start. Please wait."
|
||||
"credits": "Credits",
|
||||
"code_license": "Code licensed under AGPL-3.0 (no warranty)"
|
||||
},
|
||||
"difficulty": {
|
||||
"difficulty": "Difficulty",
|
||||
@@ -324,8 +330,8 @@
|
||||
"attack_ratio_label": "⚔️ Attack Ratio",
|
||||
"attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)",
|
||||
"troop_ratio_desc": "Adjust the balance between troops (for combat) and workers (for gold production) (1–100%)",
|
||||
"territory_patterns_label": "🏳️ Territory Patterns",
|
||||
"territory_patterns_desc": "Choose whether to display territory pattern designs in game",
|
||||
"territory_patterns_label": "🏳️ Territory Skins",
|
||||
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
|
||||
"performance_overlay_label": "Performance Overlay",
|
||||
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
|
||||
"easter_writing_speed_label": "Writing Speed Multiplier",
|
||||
@@ -659,8 +665,8 @@
|
||||
"colors": "Colors",
|
||||
"purchase": "Purchase",
|
||||
"blocked": {
|
||||
"login": "You must be logged in to access this pattern.",
|
||||
"purchase": "Purchase this pattern to unlock it."
|
||||
"login": "You must be logged in to access this skin.",
|
||||
"purchase": "Purchase this skin to unlock it."
|
||||
},
|
||||
"pattern": {
|
||||
"default": "Default",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1564,
|
||||
"num_land_tiles": 1968430,
|
||||
"width": 2500
|
||||
},
|
||||
"map16x": {
|
||||
"height": 391,
|
||||
"num_land_tiles": 120323,
|
||||
"width": 625
|
||||
},
|
||||
"map4x": {
|
||||
"height": 782,
|
||||
"num_land_tiles": 488353,
|
||||
"width": 1250
|
||||
},
|
||||
"name": "Baikal (Nuke Wars)",
|
||||
"nations": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1396,
|
||||
"num_land_tiles": 938800,
|
||||
"num_land_tiles": 933860,
|
||||
"width": 2000
|
||||
},
|
||||
"map16x": {
|
||||
"height": 349,
|
||||
"num_land_tiles": 56046,
|
||||
"num_land_tiles": 55046,
|
||||
"width": 500
|
||||
},
|
||||
"map4x": {
|
||||
"height": 698,
|
||||
"num_land_tiles": 231151,
|
||||
"num_land_tiles": 228988,
|
||||
"width": 1000
|
||||
},
|
||||
"name": "Britannia",
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -18,21 +18,75 @@
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [800, 430],
|
||||
"flag": "ca",
|
||||
"flag": "quebec",
|
||||
"name": "Laval",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1110, 930],
|
||||
"flag": "ca",
|
||||
"name": "Montreal",
|
||||
"strength": 2
|
||||
"flag": "quebec",
|
||||
"name": "Royal Mount park",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1220, 1360],
|
||||
"flag": "ca",
|
||||
"flag": "quebec",
|
||||
"name": "Hochelaga Archipelago",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1080, 980],
|
||||
"flag": "ca",
|
||||
"name": "Westmount",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1400, 1000],
|
||||
"flag": "quebec",
|
||||
"name": "Saint-Lambert",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [500, 130],
|
||||
"flag": "quebec",
|
||||
"name": "Blainville",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [350, 650],
|
||||
"flag": "quebec",
|
||||
"name": "Saint-Eustache",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [200, 1350],
|
||||
"flag": "quebec",
|
||||
"name": "Perrot Island",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [25, 950],
|
||||
"flag": "quebec",
|
||||
"name": "Kanesatake Lands",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [50, 450],
|
||||
"flag": "quebec",
|
||||
"name": "Mirabel",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [650, 1450],
|
||||
"flag": "quebec",
|
||||
"name": "Chateauguay",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1330, 300],
|
||||
"flag": "quebec",
|
||||
"name": "Pointe-aux-Trembles",
|
||||
"strength": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 316 B |
|
After Width: | Height: | Size: 438 B |
|
After Width: | Height: | Size: 246 B |
|
After Width: | Height: | Size: 228 B |
|
After Width: | Height: | Size: 204 B |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 553 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 409 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -51,6 +51,13 @@ export class GameStartingModal extends LitElement {
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin: 2px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal .loading {
|
||||
font-size: 16px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
padding: 10px;
|
||||
@@ -88,16 +95,38 @@ export class GameStartingModal extends LitElement {
|
||||
.copyright {
|
||||
font-size: 32px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal a {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 20px;
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal a:hover {
|
||||
color: #6bb0ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="modal ${this.isVisible ? "visible" : ""}">
|
||||
<div class="copyright">© OpenFront</div>
|
||||
<h5>${translateText("game_starting_modal.code_license")}</h5>
|
||||
<p>${translateText("game_starting_modal.title")}</p>
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO/blob/main/CREDITS.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>${translateText("game_starting_modal.credits")}</a
|
||||
>
|
||||
<p>${translateText("game_starting_modal.code_license")}</p>
|
||||
<p class="loading">${translateText("game_starting_modal.title")}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
Quads,
|
||||
Trios,
|
||||
UnitType,
|
||||
@@ -284,7 +285,18 @@ export class HostLobbyModal extends LitElement {
|
||||
${translateText("host_modal.team_count")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
${[2, 3, 4, 5, 6, 7, Quads, Trios, Duos].map(
|
||||
${[
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
Quads,
|
||||
Trios,
|
||||
Duos,
|
||||
HumansVsNations,
|
||||
].map(
|
||||
(o) => html`
|
||||
<div
|
||||
class="option-card ${this.teamCount === o
|
||||
@@ -294,7 +306,9 @@ export class HostLobbyModal extends LitElement {
|
||||
>
|
||||
<div class="option-card-title">
|
||||
${typeof o === "string"
|
||||
? translateText(`public_lobby.teams_${o}`)
|
||||
? o === HumansVsNations
|
||||
? translateText("public_lobby.teams_hvn")
|
||||
: translateText(`public_lobby.teams_${o}`)
|
||||
: translateText("public_lobby.teams", {
|
||||
num: o,
|
||||
})}
|
||||
@@ -313,42 +327,53 @@ export class HostLobbyModal extends LitElement {
|
||||
${translateText("host_modal.options_title")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
<label for="bots-count" class="option-card">
|
||||
<input
|
||||
type="range"
|
||||
id="bots-count"
|
||||
min="0"
|
||||
max="400"
|
||||
step="1"
|
||||
@input=${this.handleBotsChange}
|
||||
@change=${this.handleBotsChange}
|
||||
.value="${String(this.bots)}"
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
<span>${translateText("host_modal.bots")}</span>${
|
||||
this.bots === 0
|
||||
? translateText("host_modal.bots_disabled")
|
||||
: this.bots
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
for="disable-npcs"
|
||||
class="option-card ${this.disableNPCs ? "selected" : ""}"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<label for="bots-count" class="option-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="disable-npcs"
|
||||
@change=${this.handleDisableNPCsChange}
|
||||
.checked=${this.disableNPCs}
|
||||
type="range"
|
||||
id="bots-count"
|
||||
min="0"
|
||||
max="400"
|
||||
step="1"
|
||||
@input=${this.handleBotsChange}
|
||||
@change=${this.handleBotsChange}
|
||||
.value="${String(this.bots)}"
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
${translateText("host_modal.disable_nations")}
|
||||
<span>${translateText("host_modal.bots")}</span>${
|
||||
this.bots === 0
|
||||
? translateText("host_modal.bots_disabled")
|
||||
: this.bots
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
${
|
||||
!(
|
||||
this.gameMode === GameMode.Team &&
|
||||
this.teamCount === HumansVsNations
|
||||
)
|
||||
? html`
|
||||
<label
|
||||
for="disable-npcs"
|
||||
class="option-card ${this.disableNPCs
|
||||
? "selected"
|
||||
: ""}"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="disable-npcs"
|
||||
@change=${this.handleDisableNPCsChange}
|
||||
.checked=${this.disableNPCs}
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
${translateText("host_modal.disable_nations")}
|
||||
</div>
|
||||
</label>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<label
|
||||
for="instant-build"
|
||||
class="option-card ${this.instantBuild ? "selected" : ""}"
|
||||
@@ -718,7 +743,6 @@ export class HostLobbyModal extends LitElement {
|
||||
? GameMapSize.Compact
|
||||
: GameMapSize.Normal,
|
||||
difficulty: this.selectedDifficulty,
|
||||
disableNPCs: this.disableNPCs,
|
||||
bots: this.bots,
|
||||
infiniteGold: this.infiniteGold,
|
||||
donateGold: this.donateGold,
|
||||
@@ -728,6 +752,14 @@ export class HostLobbyModal extends LitElement {
|
||||
gameMode: this.gameMode,
|
||||
disabledUnits: this.disabledUnits,
|
||||
playerTeams: this.teamCount,
|
||||
...(this.gameMode === GameMode.Team &&
|
||||
this.teamCount === HumansVsNations
|
||||
? {
|
||||
disableNPCs: false,
|
||||
}
|
||||
: {
|
||||
disableNPCs: this.disableNPCs,
|
||||
}),
|
||||
maxTimerValue:
|
||||
this.maxTimer === true ? this.maxTimerValue : undefined,
|
||||
} satisfies Partial<GameConfig>),
|
||||
|
||||
@@ -167,6 +167,9 @@ export class InputHandler {
|
||||
console.warn("Invalid keybinds JSON:", e);
|
||||
}
|
||||
|
||||
// Mac users might have different keybinds
|
||||
const isMac = /Mac/.test(navigator.userAgent);
|
||||
|
||||
this.keybinds = {
|
||||
toggleView: "Space",
|
||||
centerCamera: "KeyC",
|
||||
@@ -180,7 +183,7 @@ export class InputHandler {
|
||||
attackRatioUp: "KeyY",
|
||||
boatAttack: "KeyB",
|
||||
groundAttack: "KeyG",
|
||||
modifierKey: "ControlLeft",
|
||||
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
|
||||
altKey: "AltLeft",
|
||||
buildCity: "Digit1",
|
||||
buildFactory: "Digit2",
|
||||
@@ -195,12 +198,6 @@ export class InputHandler {
|
||||
...saved,
|
||||
};
|
||||
|
||||
// Mac users might have different keybinds
|
||||
const isMac = /Mac/.test(navigator.userAgent);
|
||||
if (isMac) {
|
||||
this.keybinds.modifierKey = "MetaLeft"; // Use Command key on Mac
|
||||
}
|
||||
|
||||
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
|
||||
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
|
||||
this.canvas.addEventListener(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
createPartialGameRecord,
|
||||
decompressGameRecord,
|
||||
getClanTag,
|
||||
replacer,
|
||||
} from "../core/Util";
|
||||
import { LobbyConfig } from "./ClientGameRunner";
|
||||
@@ -188,6 +189,7 @@ export class LocalServer {
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
stats: this.allPlayersStats[this.lobbyConfig.clientID],
|
||||
cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics,
|
||||
clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined,
|
||||
},
|
||||
];
|
||||
if (this.lobbyConfig.gameStartInfo === undefined) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
HumansVsNations,
|
||||
Quads,
|
||||
Trios,
|
||||
UnitType,
|
||||
@@ -195,7 +196,18 @@ export class SinglePlayerModal extends LitElement {
|
||||
${translateText("host_modal.team_count")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
${[2, 3, 4, 5, 6, 7, Quads, Trios, Duos].map(
|
||||
${[
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
Quads,
|
||||
Trios,
|
||||
Duos,
|
||||
HumansVsNations,
|
||||
].map(
|
||||
(o) => html`
|
||||
<div
|
||||
class="option-card ${this.teamCount === o
|
||||
@@ -205,7 +217,9 @@ export class SinglePlayerModal extends LitElement {
|
||||
>
|
||||
<div class="option-card-title">
|
||||
${typeof o === "string"
|
||||
? translateText(`public_lobby.teams_${o}`)
|
||||
? o === HumansVsNations
|
||||
? translateText("public_lobby.teams_hvn")
|
||||
: translateText(`public_lobby.teams_${o}`)
|
||||
: translateText(`public_lobby.teams`, { num: o })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,21 +254,29 @@ export class SinglePlayerModal extends LitElement {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
for="singleplayer-modal-disable-npcs"
|
||||
class="option-card ${this.disableNPCs ? "selected" : ""}"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="singleplayer-modal-disable-npcs"
|
||||
@change=${this.handleDisableNPCsChange}
|
||||
.checked=${this.disableNPCs}
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
${translateText("single_modal.disable_nations")}
|
||||
</div>
|
||||
</label>
|
||||
${!(
|
||||
this.gameMode === GameMode.Team &&
|
||||
this.teamCount === HumansVsNations
|
||||
)
|
||||
? html`
|
||||
<label
|
||||
for="singleplayer-modal-disable-npcs"
|
||||
class="option-card ${this.disableNPCs ? "selected" : ""}"
|
||||
>
|
||||
<div class="checkbox-icon"></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="singleplayer-modal-disable-npcs"
|
||||
@change=${this.handleDisableNPCsChange}
|
||||
.checked=${this.disableNPCs}
|
||||
/>
|
||||
<div class="option-card-title">
|
||||
${translateText("single_modal.disable_nations")}
|
||||
</div>
|
||||
</label>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<label
|
||||
for="singleplayer-modal-instant-build"
|
||||
class="option-card ${this.instantBuild ? "selected" : ""}"
|
||||
@@ -534,7 +556,6 @@ export class SinglePlayerModal extends LitElement {
|
||||
gameMode: this.gameMode,
|
||||
playerTeams: this.teamCount,
|
||||
difficulty: this.selectedDifficulty,
|
||||
disableNPCs: this.disableNPCs,
|
||||
maxTimerValue: this.maxTimer ? this.maxTimerValue : undefined,
|
||||
bots: this.bots,
|
||||
infiniteGold: this.infiniteGold,
|
||||
@@ -545,6 +566,14 @@ export class SinglePlayerModal extends LitElement {
|
||||
disabledUnits: this.disabledUnits
|
||||
.map((u) => Object.values(UnitType).find((ut) => ut === u))
|
||||
.filter((ut): ut is UnitType => ut !== undefined),
|
||||
...(this.gameMode === GameMode.Team &&
|
||||
this.teamCount === HumansVsNations
|
||||
? {
|
||||
disableNPCs: false,
|
||||
}
|
||||
: {
|
||||
disableNPCs: this.disableNPCs,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} satisfies JoinLobbyEvent,
|
||||
|
||||
@@ -245,3 +245,56 @@ export function isInIframe(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSvgAspectRatio(src: string): Promise<number | null> {
|
||||
const self = getSvgAspectRatio as any;
|
||||
self.svgAspectRatioCache ??= new Map();
|
||||
|
||||
const cached = self.svgAspectRatioCache.get(src);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
const resp = await fetch(src, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
if (!resp.ok) throw new Error(`Fetch failed: ${resp.status}`);
|
||||
const text = await resp.text();
|
||||
|
||||
// Try parse viewBox
|
||||
const vbMatch = text.match(/viewBox="([^"]+)"/i);
|
||||
if (vbMatch) {
|
||||
const parts = vbMatch[1]
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
if (parts.length === 4 && parts.every((n) => !Number.isNaN(n))) {
|
||||
const [, , vbW, vbH] = parts;
|
||||
if (vbW > 0 && vbH > 0) {
|
||||
const ratio = vbW / vbH;
|
||||
self.svgAspectRatioCache.set(src, ratio);
|
||||
return ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to width/height attributes (may be with units; strip px)
|
||||
const widthMatch = text.match(/<svg[^>]*\swidth="([^"]+)"/i);
|
||||
const heightMatch = text.match(/<svg[^>]*\sheight="([^"]+)"/i);
|
||||
if (widthMatch && heightMatch) {
|
||||
const parseNum = (s: string) => Number(s.replace(/[^0-9.]/g, ""));
|
||||
const w = parseNum(widthMatch[1]);
|
||||
const h = parseNum(heightMatch[1]);
|
||||
if (w > 0 && h > 0) {
|
||||
const ratio = w / h;
|
||||
self.svgAspectRatioCache.set(src, ratio);
|
||||
return ratio;
|
||||
}
|
||||
}
|
||||
// Not an SVG or no usable metadata
|
||||
} catch (e) {
|
||||
// fetch may fail due to CORS or non-SVG..
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
Pluto: "Pluto",
|
||||
Montreal: "Montreal",
|
||||
Achiran: "Achiran",
|
||||
BaikalNukeWars: "Baikal (Nuke Wars)",
|
||||
};
|
||||
|
||||
@customElement("map-display")
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
|
||||
import buildingExplosion from "../../../resources/sprites/buildingExplosion.png";
|
||||
import conquestSword from "../../../resources/sprites/conquestSword.png";
|
||||
import dust from "../../../resources/sprites/dust.png";
|
||||
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
|
||||
import miniFire from "../../../resources/sprites/minifire.png";
|
||||
import nuke from "../../../resources/sprites/nukeExplosion.png";
|
||||
import SAMExplosion from "../../../resources/sprites/samExplosion.png";
|
||||
import sinkingShip from "../../../resources/sprites/sinkingShip.png";
|
||||
import miniSmoke from "../../../resources/sprites/smoke.png";
|
||||
import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png";
|
||||
import unitExplosion from "../../../resources/sprites/unitExplosion.png";
|
||||
|
||||
import bats from "../../../resources/sprites/halloween/bats.png";
|
||||
import bubble from "../../../resources/sprites/halloween/bubble.png";
|
||||
import ghost from "../../../resources/sprites/halloween/ghost.png";
|
||||
import minifireGreen from "../../../resources/sprites/halloween/minifireGreen.png";
|
||||
import shark from "../../../resources/sprites/halloween/shark.png";
|
||||
import skull from "../../../resources/sprites/halloween/skull.png";
|
||||
import skullNuke from "../../../resources/sprites/halloween/skullNuke.png";
|
||||
import miniSmokeAndFireGreen from "../../../resources/sprites/halloween/smokeAndFireGreen.png";
|
||||
import tentacle from "../../../resources/sprites/halloween/tentacle.png";
|
||||
import tornado from "../../../resources/sprites/halloween/tornado.png";
|
||||
|
||||
import { Theme } from "../../core/configuration/Config";
|
||||
import { PlayerView } from "../../core/game/GameView";
|
||||
import { AnimatedSprite } from "./AnimatedSprite";
|
||||
@@ -28,7 +34,7 @@ type AnimatedSpriteConfig = {
|
||||
|
||||
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
[FxType.MiniFire]: {
|
||||
url: miniFire,
|
||||
url: minifireGreen,
|
||||
frameWidth: 7,
|
||||
frameCount: 6,
|
||||
frameDuration: 100,
|
||||
@@ -37,28 +43,28 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originY: 11,
|
||||
},
|
||||
[FxType.MiniSmoke]: {
|
||||
url: miniSmoke,
|
||||
frameWidth: 11,
|
||||
frameCount: 4,
|
||||
frameDuration: 120,
|
||||
url: ghost,
|
||||
frameWidth: 10,
|
||||
frameCount: 5,
|
||||
frameDuration: 100,
|
||||
looping: true,
|
||||
originX: 2,
|
||||
originX: 4,
|
||||
originY: 10,
|
||||
},
|
||||
[FxType.MiniBigSmoke]: {
|
||||
url: miniBigSmoke,
|
||||
frameWidth: 24,
|
||||
frameCount: 5,
|
||||
url: bats,
|
||||
frameWidth: 21,
|
||||
frameCount: 6,
|
||||
frameDuration: 120,
|
||||
looping: true,
|
||||
originX: 9,
|
||||
originY: 14,
|
||||
},
|
||||
[FxType.MiniSmokeAndFire]: {
|
||||
url: miniSmokeAndFire,
|
||||
url: miniSmokeAndFireGreen,
|
||||
frameWidth: 24,
|
||||
frameCount: 5,
|
||||
frameDuration: 120,
|
||||
frameDuration: 90,
|
||||
looping: true,
|
||||
originX: 9,
|
||||
originY: 14,
|
||||
@@ -90,15 +96,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originX: 9,
|
||||
originY: 9,
|
||||
},
|
||||
[FxType.BuildingExplosion]: {
|
||||
url: buildingExplosion,
|
||||
frameWidth: 17,
|
||||
frameCount: 10,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
originX: 8,
|
||||
originY: 8,
|
||||
},
|
||||
[FxType.SinkingShip]: {
|
||||
url: sinkingShip,
|
||||
frameWidth: 16,
|
||||
@@ -108,14 +105,23 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originX: 7,
|
||||
originY: 7,
|
||||
},
|
||||
[FxType.Nuke]: {
|
||||
url: nuke,
|
||||
frameWidth: 60,
|
||||
frameCount: 9,
|
||||
[FxType.BuildingExplosion]: {
|
||||
url: buildingExplosion,
|
||||
frameWidth: 17,
|
||||
frameCount: 10,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
originX: 30,
|
||||
originY: 30,
|
||||
originX: 8,
|
||||
originY: 8,
|
||||
},
|
||||
[FxType.Nuke]: {
|
||||
url: skullNuke,
|
||||
frameWidth: 42,
|
||||
frameCount: 19,
|
||||
frameDuration: 50,
|
||||
looping: false,
|
||||
originX: 20,
|
||||
originY: 21,
|
||||
},
|
||||
[FxType.SAMExplosion]: {
|
||||
url: SAMExplosion,
|
||||
@@ -127,16 +133,51 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originY: 19,
|
||||
},
|
||||
[FxType.Conquest]: {
|
||||
url: conquestSword,
|
||||
frameWidth: 21,
|
||||
frameCount: 10,
|
||||
url: skull,
|
||||
frameWidth: 14,
|
||||
frameCount: 14,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 10,
|
||||
originY: 16,
|
||||
originX: 7,
|
||||
originY: 23,
|
||||
},
|
||||
[FxType.Tentacle]: {
|
||||
url: tentacle,
|
||||
frameWidth: 22,
|
||||
frameCount: 26,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 13,
|
||||
originY: 28,
|
||||
},
|
||||
[FxType.Shark]: {
|
||||
url: shark,
|
||||
frameWidth: 25,
|
||||
frameCount: 14,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 13,
|
||||
originY: 8,
|
||||
},
|
||||
[FxType.Bubble]: {
|
||||
url: bubble,
|
||||
frameWidth: 22,
|
||||
frameCount: 13,
|
||||
frameDuration: 80,
|
||||
looping: false,
|
||||
originX: 13,
|
||||
originY: 8,
|
||||
},
|
||||
[FxType.Tornado]: {
|
||||
url: tornado,
|
||||
frameWidth: 30,
|
||||
frameCount: 10,
|
||||
frameDuration: 80,
|
||||
looping: true,
|
||||
originX: 11,
|
||||
originY: 22,
|
||||
},
|
||||
};
|
||||
|
||||
export class AnimatedSpriteLoader {
|
||||
private animatedSpriteImageMap: Map<FxType, HTMLCanvasElement> = new Map();
|
||||
// Do not color the same sprite twice
|
||||
|
||||
@@ -27,6 +27,7 @@ import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
|
||||
import { PlayerPanel } from "./layers/PlayerPanel";
|
||||
import { RailroadLayer } from "./layers/RailroadLayer";
|
||||
import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
|
||||
import { SettingsModal } from "./layers/SettingsModal";
|
||||
import { SpawnTimer } from "./layers/SpawnTimer";
|
||||
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
|
||||
@@ -201,6 +202,12 @@ export function createRenderer(
|
||||
headsUpMessage.game = game;
|
||||
|
||||
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
|
||||
const samRadiusLayer = new SAMRadiusLayer(
|
||||
game,
|
||||
eventBus,
|
||||
transformHandler,
|
||||
uiState,
|
||||
);
|
||||
|
||||
const fpsDisplay = document.querySelector("fps-display") as FPSDisplay;
|
||||
if (!(fpsDisplay instanceof FPSDisplay)) {
|
||||
@@ -230,6 +237,7 @@ export function createRenderer(
|
||||
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
|
||||
new RailroadLayer(game, transformHandler),
|
||||
structureLayer,
|
||||
samRadiusLayer,
|
||||
new UnitLayer(game, eventBus, transformHandler),
|
||||
new FxLayer(game),
|
||||
new UILayer(game, eventBus, transformHandler),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Colord } from "colord";
|
||||
import atomBombSprite from "../../../resources/sprites/atombomb.png";
|
||||
import hydrogenBombSprite from "../../../resources/sprites/hydrogenbomb.png";
|
||||
import miniPumpkin from "../../../resources/sprites/halloween/miniPumpkin.png";
|
||||
import pumpkin from "../../../resources/sprites/halloween/pumpkin.png";
|
||||
import mirvSprite from "../../../resources/sprites/mirv2.png";
|
||||
import samMissileSprite from "../../../resources/sprites/samMissile.png";
|
||||
import tradeShipSprite from "../../../resources/sprites/tradeship.png";
|
||||
@@ -26,8 +26,8 @@ const SPRITE_CONFIG: Partial<Record<UnitType | TrainTypeSprite, string>> = {
|
||||
[UnitType.TransportShip]: transportShipSprite,
|
||||
[UnitType.Warship]: warshipSprite,
|
||||
[UnitType.SAMMissile]: samMissileSprite,
|
||||
[UnitType.AtomBomb]: atomBombSprite,
|
||||
[UnitType.HydrogenBomb]: hydrogenBombSprite,
|
||||
[UnitType.AtomBomb]: miniPumpkin,
|
||||
[UnitType.HydrogenBomb]: pumpkin,
|
||||
[UnitType.TradeShip]: tradeShipSprite,
|
||||
[UnitType.MIRV]: mirvSprite,
|
||||
[TrainTypeSprite.Engine]: trainEngineSprite,
|
||||
|
||||
@@ -248,13 +248,51 @@ export class TransformHandler {
|
||||
// Adjust the offset
|
||||
this.offsetX = zoomPointX - (canvasX - this.game.width() / 2) / this.scale;
|
||||
this.offsetY = zoomPointY - (canvasY - this.game.height() / 2) / this.scale;
|
||||
this.clampOffsets();
|
||||
this.changed = true;
|
||||
}
|
||||
|
||||
private clampOffsets() {
|
||||
const canvasRect = this.boundingRect();
|
||||
const canvasWidth = canvasRect.width;
|
||||
const canvasHeight = canvasRect.height;
|
||||
const gameWidth = this.game.width();
|
||||
const gameH = this.game.height();
|
||||
const scale = this.scale;
|
||||
|
||||
// Allow panning so that up to half of the viewport can be outside the map on each side.
|
||||
// This lets a map corner be placed at the screen center, but no further.
|
||||
// Derivation (X axis):
|
||||
// gameLeftX = -gameWidth/(2*scale) + offsetX + gameWidth/2 >= -vw/2
|
||||
// gameRightX = (canvasWidth - gameWidth/2)/scale + offsetX + gameWidth/2 <= gameWidth + vw/2
|
||||
// Solving gives:
|
||||
// minOffsetX = -gameWidth/2 + (gameWidth - canvasWidth) / (2*scale)
|
||||
// maxOffsetX = gameWidth/2 + (gameWidth - canvasWidth) / (2*scale)
|
||||
const minOffsetX = -gameWidth / 2 + (gameWidth - canvasWidth) / (2 * scale);
|
||||
const maxOffsetX = gameWidth / 2 + (gameWidth - canvasWidth) / (2 * scale);
|
||||
|
||||
const minOffsetY = -gameH / 2 + (gameH - canvasHeight) / (2 * scale);
|
||||
const maxOffsetY = gameH / 2 + (gameH - canvasHeight) / (2 * scale);
|
||||
|
||||
// Clamp offsets within computed bounds on each axis
|
||||
if (this.offsetX < minOffsetX) {
|
||||
this.offsetX = minOffsetX;
|
||||
} else if (this.offsetX > maxOffsetX) {
|
||||
this.offsetX = maxOffsetX;
|
||||
}
|
||||
|
||||
if (this.offsetY < minOffsetY) {
|
||||
this.offsetY = minOffsetY;
|
||||
} else if (this.offsetY > maxOffsetY) {
|
||||
this.offsetY = maxOffsetY;
|
||||
}
|
||||
}
|
||||
|
||||
onMove(event: DragEvent) {
|
||||
this.clearTarget();
|
||||
this.offsetX -= event.deltaX / this.scale;
|
||||
this.offsetY -= event.deltaY / this.scale;
|
||||
this.clampOffsets();
|
||||
this.changed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ export function conquestFxFactory(
|
||||
x,
|
||||
y,
|
||||
FxType.Conquest,
|
||||
2500,
|
||||
);
|
||||
const fadeAnimation = new FadeFx(swordAnimation, 0.1, 0.6);
|
||||
conquestFx.push(fadeAnimation);
|
||||
|
||||
@@ -16,4 +16,8 @@ export enum FxType {
|
||||
UnderConstruction = "UnderConstruction",
|
||||
Dust = "Dust",
|
||||
Conquest = "Conquest",
|
||||
Tentacle = "Tentacle",
|
||||
Shark = "Shark",
|
||||
Bubble = "Bubble",
|
||||
Tornado = "Tornado",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NukeMagnitude } from "../../../core/configuration/Config";
|
||||
import { Fx } from "./Fx";
|
||||
|
||||
export class NukeAreaFx implements Fx {
|
||||
private lifeTime = 0;
|
||||
private ended = false;
|
||||
private readonly endAnimationDuration = 300; // in ms
|
||||
private readonly startAnimationDuration = 200; // in ms
|
||||
|
||||
private readonly innerDiameter: number;
|
||||
private readonly outerDiameter: number;
|
||||
|
||||
private offset = 0;
|
||||
private readonly dashSize: number;
|
||||
private readonly rotationSpeed = 20; // px per seconds
|
||||
private readonly baseAlpha = 0.9;
|
||||
|
||||
constructor(
|
||||
private x: number,
|
||||
private y: number,
|
||||
magnitude: NukeMagnitude,
|
||||
) {
|
||||
this.innerDiameter = magnitude.inner;
|
||||
this.outerDiameter = magnitude.outer;
|
||||
const numDash = Math.max(1, Math.floor(this.outerDiameter / 3));
|
||||
this.dashSize = (Math.PI / numDash) * this.outerDiameter;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
this.lifeTime = 0; // reset for fade-out timing
|
||||
}
|
||||
|
||||
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
this.lifeTime += frameTime;
|
||||
|
||||
if (this.ended && this.lifeTime >= this.endAnimationDuration) return false;
|
||||
let t: number;
|
||||
if (this.ended) {
|
||||
t = Math.max(0, 1 - this.lifeTime / this.endAnimationDuration);
|
||||
} else {
|
||||
t = Math.min(1, this.lifeTime / this.startAnimationDuration);
|
||||
}
|
||||
const alpha = Math.max(0, Math.min(1, this.baseAlpha * t));
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
|
||||
ctx.fillStyle = `rgba(255,0,0,${Math.max(0, alpha - 0.6)})`;
|
||||
|
||||
// Inner circle
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 1;
|
||||
const innerDiameter =
|
||||
(this.innerDiameter / 2) * (1 - t) + this.innerDiameter * t;
|
||||
ctx.arc(this.x, this.y, innerDiameter, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fill();
|
||||
|
||||
// Outer circle
|
||||
this.offset += this.rotationSpeed * (frameTime / 1000);
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.lineDashOffset = this.offset;
|
||||
ctx.setLineDash([this.dashSize]);
|
||||
const outerDiameter =
|
||||
(this.outerDiameter + 20) * (1 - t) + this.outerDiameter * t;
|
||||
ctx.arc(this.x, this.y, outerDiameter, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,35 @@ function fadeInOut(
|
||||
return 1 - f * f;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Move a sprite around
|
||||
*/
|
||||
export class MoveSpriteFx implements Fx {
|
||||
private originX: number;
|
||||
private originY: number;
|
||||
constructor(
|
||||
private fxToMove: SpriteFx,
|
||||
private toX: number,
|
||||
private toY: number,
|
||||
private fadeIn: number = 0.1,
|
||||
private fadeOut: number = 0.9,
|
||||
) {
|
||||
this.originX = fxToMove.x;
|
||||
this.originY = fxToMove.y;
|
||||
}
|
||||
|
||||
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
const t = this.fxToMove.getElapsedTime() / this.fxToMove.getDuration();
|
||||
this.fxToMove.x = Math.floor(this.originX * (1 - t) + this.toX * t);
|
||||
this.fxToMove.y = Math.floor(this.originY * (1 - t) + this.toY * t);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = fadeInOut(t, this.fadeIn, this.fadeOut);
|
||||
const result = this.fxToMove.renderTick(duration, ctx);
|
||||
ctx.restore();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade in/out another FX
|
||||
*/
|
||||
@@ -49,8 +78,8 @@ export class SpriteFx implements Fx {
|
||||
protected waitToTheEnd = false;
|
||||
constructor(
|
||||
animatedSpriteLoader: AnimatedSpriteLoader,
|
||||
protected x: number,
|
||||
protected y: number,
|
||||
public x: number,
|
||||
public y: number,
|
||||
fxType: FxType,
|
||||
duration?: number,
|
||||
owner?: PlayerView,
|
||||
|
||||
@@ -85,7 +85,6 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
this.updateTroopIncrease();
|
||||
}
|
||||
|
||||
this._troops = player.troops();
|
||||
this._maxTroops = this.game.config().maxTroops(player);
|
||||
this._gold = player.gold();
|
||||
this._troops = player.troops();
|
||||
|
||||
@@ -131,6 +131,22 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderToggleButton(src: string, category: MessageCategory) {
|
||||
// Adding the literal for the default size ensures tailwind will generate the class
|
||||
const toggleButtonSizeMap = { default: "h-5" };
|
||||
return this.renderButton({
|
||||
content: html`<img
|
||||
src="${src}"
|
||||
class="${toggleButtonSizeMap["default"]}"
|
||||
style="filter: ${this.eventsFilters.get(category)
|
||||
? "grayscale(1) opacity(0.5)"
|
||||
: "none"}"
|
||||
/>`,
|
||||
onClick: () => this.toggleEventFilter(category),
|
||||
className: "cursor-pointer pointer-events-auto",
|
||||
});
|
||||
}
|
||||
|
||||
private toggleHidden() {
|
||||
this._hidden = !this._hidden;
|
||||
if (this._hidden) {
|
||||
@@ -935,76 +951,20 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-4">
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="w-5 h-5"
|
||||
style="filter: ${this.eventsFilters.get(
|
||||
MessageCategory.ATTACK,
|
||||
)
|
||||
? "grayscale(1) opacity(0.5)"
|
||||
: "none"}"
|
||||
/>`,
|
||||
onClick: () =>
|
||||
this.toggleEventFilter(MessageCategory.ATTACK),
|
||||
className: "cursor-pointer pointer-events-auto",
|
||||
})}
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${nukeIcon}"
|
||||
class="w-5 h-5"
|
||||
style="filter: ${this.eventsFilters.get(
|
||||
MessageCategory.NUKE,
|
||||
)
|
||||
? "grayscale(1) opacity(0.5)"
|
||||
: "none"}"
|
||||
/>`,
|
||||
onClick: () =>
|
||||
this.toggleEventFilter(MessageCategory.NUKE),
|
||||
className: "cursor-pointer pointer-events-auto",
|
||||
})}
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${donateGoldIcon}"
|
||||
class="w-5 h-5"
|
||||
style="filter: ${this.eventsFilters.get(
|
||||
MessageCategory.TRADE,
|
||||
)
|
||||
? "grayscale(1) opacity(0.5)"
|
||||
: "none"}"
|
||||
/>`,
|
||||
onClick: () =>
|
||||
this.toggleEventFilter(MessageCategory.TRADE),
|
||||
className: "cursor-pointer pointer-events-auto",
|
||||
})}
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${allianceIcon}"
|
||||
class="w-5 h-5"
|
||||
style="filter: ${this.eventsFilters.get(
|
||||
MessageCategory.ALLIANCE,
|
||||
)
|
||||
? "grayscale(1) opacity(0.5)"
|
||||
: "none"}"
|
||||
/>`,
|
||||
onClick: () =>
|
||||
this.toggleEventFilter(MessageCategory.ALLIANCE),
|
||||
className: "cursor-pointer pointer-events-auto",
|
||||
})}
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${chatIcon}"
|
||||
class="w-5 h-5"
|
||||
style="filter: ${this.eventsFilters.get(
|
||||
MessageCategory.CHAT,
|
||||
)
|
||||
? "grayscale(1) opacity(0.5)"
|
||||
: "none"}"
|
||||
/>`,
|
||||
onClick: () =>
|
||||
this.toggleEventFilter(MessageCategory.CHAT),
|
||||
className: "cursor-pointer pointer-events-auto",
|
||||
})}
|
||||
${this.renderToggleButton(
|
||||
swordIcon,
|
||||
MessageCategory.ATTACK,
|
||||
)}
|
||||
${this.renderToggleButton(nukeIcon, MessageCategory.NUKE)}
|
||||
${this.renderToggleButton(
|
||||
donateGoldIcon,
|
||||
MessageCategory.TRADE,
|
||||
)}
|
||||
${this.renderToggleButton(
|
||||
allianceIcon,
|
||||
MessageCategory.ALLIANCE,
|
||||
)}
|
||||
${this.renderToggleButton(chatIcon, MessageCategory.CHAT)}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
${this.latestGoldAmount !== null
|
||||
|
||||
@@ -12,8 +12,9 @@ import { renderNumber } from "../../Utils";
|
||||
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { conquestFxFactory } from "../fx/ConquestFx";
|
||||
import { Fx, FxType } from "../fx/Fx";
|
||||
import { NukeAreaFx } from "../fx/NukeAreaFx";
|
||||
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
|
||||
import { SpriteFx } from "../fx/SpriteFx";
|
||||
import { FadeFx, MoveSpriteFx, SpriteFx } from "../fx/SpriteFx";
|
||||
import { TargetFx } from "../fx/TargetFx";
|
||||
import { TextFx } from "../fx/TextFx";
|
||||
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
|
||||
@@ -21,6 +22,8 @@ import { Layer } from "./Layer";
|
||||
export class FxLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
private lastRandomEvent: number = 0;
|
||||
private randomEventRate: number = 8;
|
||||
|
||||
private lastRefresh: number = 0;
|
||||
private refreshRate: number = 10;
|
||||
@@ -30,6 +33,7 @@ export class FxLayer implements Layer {
|
||||
|
||||
private allFx: Fx[] = [];
|
||||
private boatTargetFxByUnitId: Map<number, TargetFx> = new Map();
|
||||
private nukeTargetFxByUnitId: Map<number, NukeAreaFx> = new Map();
|
||||
|
||||
constructor(private game: GameView) {
|
||||
this.theme = this.game.config().theme();
|
||||
@@ -40,6 +44,14 @@ export class FxLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.game.config().userSettings()?.fxLayer()) {
|
||||
return;
|
||||
}
|
||||
this.lastRandomEvent += 1;
|
||||
if (this.lastRandomEvent > this.randomEventRate) {
|
||||
this.lastRandomEvent = 0;
|
||||
this.randomEvent();
|
||||
}
|
||||
this.manageBoatTargetFx();
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
@@ -87,6 +99,32 @@ export class FxLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
// Register a persistent nuke target marker for the current player or teammates
|
||||
private createNukeTargetFxIfOwned(unit: UnitView) {
|
||||
const my = this.game.myPlayer();
|
||||
if (!my) return;
|
||||
// Show nuke marker owned by the player or by players on the same team
|
||||
if (
|
||||
(unit.owner() === my || my.isOnSameTeam(unit.owner())) &&
|
||||
unit.isActive()
|
||||
) {
|
||||
if (!this.nukeTargetFxByUnitId.has(unit.id())) {
|
||||
const t = unit.targetTile();
|
||||
if (t !== undefined) {
|
||||
const x = this.game.x(t);
|
||||
const y = this.game.y(t);
|
||||
const fx = new NukeAreaFx(
|
||||
x,
|
||||
y,
|
||||
this.game.config().nukeMagnitudes(unit.type()),
|
||||
);
|
||||
this.allFx.push(fx);
|
||||
this.nukeTargetFxByUnitId.set(unit.id(), fx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBonusEvent(bonus: BonusEventUpdate) {
|
||||
if (this.game.player(bonus.player) !== this.game.myPlayer()) {
|
||||
// Only display text fx for the current player
|
||||
@@ -116,6 +154,72 @@ export class FxLayer implements Layer {
|
||||
this.allFx.push(textFx);
|
||||
}
|
||||
|
||||
randomEvent() {
|
||||
const randX = Math.floor(Math.random() * this.game.width());
|
||||
const randY = Math.floor(Math.random() * this.game.height());
|
||||
const ref = this.game.ref(randX, randY);
|
||||
if (this.game.isOcean(ref) && !this.game.isShoreline(ref)) {
|
||||
const animation = Math.floor(Math.random() * 4);
|
||||
if (animation === 0) {
|
||||
const fx = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.Shark,
|
||||
);
|
||||
this.allFx.push(fx);
|
||||
} else if (animation === 1) {
|
||||
const fx = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.Bubble,
|
||||
);
|
||||
this.allFx.push(fx);
|
||||
} else if (animation === 2) {
|
||||
const fx = new MoveSpriteFx(
|
||||
new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.Tornado,
|
||||
6000,
|
||||
),
|
||||
randX - 40,
|
||||
randY,
|
||||
0.1,
|
||||
0.8,
|
||||
);
|
||||
this.allFx.push(fx);
|
||||
} else if (animation === 3) {
|
||||
const fx = new FadeFx(
|
||||
new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.Tentacle,
|
||||
),
|
||||
0.1,
|
||||
0.8,
|
||||
);
|
||||
this.allFx.push(fx);
|
||||
}
|
||||
} else {
|
||||
const ghost = new FadeFx(
|
||||
new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
randX,
|
||||
randY,
|
||||
FxType.MiniSmoke,
|
||||
4000,
|
||||
),
|
||||
0.1,
|
||||
0.8,
|
||||
);
|
||||
this.allFx.push(ghost);
|
||||
}
|
||||
}
|
||||
|
||||
onUnitEvent(unit: UnitView) {
|
||||
switch (unit.type()) {
|
||||
case UnitType.TransportShip: {
|
||||
@@ -135,13 +239,19 @@ export class FxLayer implements Layer {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case UnitType.AtomBomb:
|
||||
case UnitType.AtomBomb: {
|
||||
this.createNukeTargetFxIfOwned(unit);
|
||||
this.onNukeEvent(unit, 70);
|
||||
break;
|
||||
}
|
||||
case UnitType.MIRVWarhead:
|
||||
this.onNukeEvent(unit, 70);
|
||||
break;
|
||||
case UnitType.HydrogenBomb:
|
||||
case UnitType.HydrogenBomb: {
|
||||
this.createNukeTargetFxIfOwned(unit);
|
||||
this.onNukeEvent(unit, 160);
|
||||
break;
|
||||
}
|
||||
case UnitType.Warship:
|
||||
this.onWarshipEvent(unit);
|
||||
break;
|
||||
@@ -270,6 +380,11 @@ export class FxLayer implements Layer {
|
||||
|
||||
onNukeEvent(unit: UnitView, radius: number) {
|
||||
if (!unit.isActive()) {
|
||||
const fx = this.nukeTargetFxByUnitId.get(unit.id());
|
||||
if (fx) {
|
||||
fx.end();
|
||||
this.nukeTargetFxByUnitId.delete(unit.id());
|
||||
}
|
||||
if (!unit.reachedTarget()) {
|
||||
this.handleSAMInterception(unit);
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as d3 from "d3";
|
||||
import backIcon from "../../../../resources/images/BackIconWhite.svg";
|
||||
import { EventBus, GameEvent } from "../../../core/EventBus";
|
||||
import { CloseViewEvent } from "../../InputHandler";
|
||||
import { translateText } from "../../Utils";
|
||||
import { getSvgAspectRatio, translateText } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
import {
|
||||
CenterButtonElement,
|
||||
@@ -542,7 +542,7 @@ export class RadialMenu implements Layer {
|
||||
.style("opacity", disabled ? 0.5 : 1)
|
||||
.text(d.data.text);
|
||||
} else {
|
||||
content
|
||||
const imgSel = content
|
||||
.append("image")
|
||||
.attr("xlink:href", d.data.icon!)
|
||||
.attr("width", this.config.iconSize)
|
||||
@@ -551,6 +551,25 @@ export class RadialMenu implements Layer {
|
||||
.attr("y", arc.centroid(d)[1] - this.config.iconSize / 2)
|
||||
.attr("opacity", disabled ? 0.5 : 1);
|
||||
|
||||
getSvgAspectRatio(d.data.icon!).then((aspect) => {
|
||||
if (!aspect || aspect === 1) return;
|
||||
|
||||
let width = this.config.iconSize;
|
||||
let height = this.config.iconSize;
|
||||
const biggerLength = Math.round(width * aspect);
|
||||
if (aspect > 1) {
|
||||
width = biggerLength;
|
||||
} else {
|
||||
height = biggerLength;
|
||||
}
|
||||
|
||||
imgSel
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.attr("x", arc.centroid(d)[0] - width / 2)
|
||||
.attr("y", arc.centroid(d)[1] - height / 2);
|
||||
});
|
||||
|
||||
if (this.params && d.data.cooldown?.(this.params)) {
|
||||
const cooldown = Math.ceil(d.data.cooldown?.(this.params));
|
||||
content
|
||||
|
||||
@@ -583,17 +583,11 @@ export const rootMenuElement: MenuElement = {
|
||||
|
||||
const menuItems: (MenuElement | null)[] = [
|
||||
infoMenuElement,
|
||||
boatMenuElement,
|
||||
ally,
|
||||
...(isOwnTerritory
|
||||
? [deleteUnitElement, ally, buildMenuElement]
|
||||
: [boatMenuElement, ally, attackMenuElement]),
|
||||
];
|
||||
|
||||
if (isOwnTerritory) {
|
||||
menuItems.push(buildMenuElement);
|
||||
menuItems.push(deleteUnitElement);
|
||||
} else {
|
||||
menuItems.push(attackMenuElement);
|
||||
}
|
||||
|
||||
return menuItems.filter((item): item is MenuElement => item !== null);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Colord } from "colord";
|
||||
import { colord } from "colord";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { PlayerID } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
@@ -184,7 +184,7 @@ export class RailroadLayer implements Layer {
|
||||
const recipient = owner.isPlayer() ? owner : null;
|
||||
const color = recipient
|
||||
? recipient.borderColor()
|
||||
: new Colord({ r: 255, g: 255, b: 255, a: 1 });
|
||||
: colord("rgba(255,255,255,1)");
|
||||
this.context.fillStyle = color.toRgbString();
|
||||
this.paintRailRects(this.context, x, y, railType);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { EventBus } from "../../../core/EventBus";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import type { GameView } from "../../../core/game/GameView";
|
||||
import { ToggleStructureEvent } from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
/**
|
||||
* Layer responsible for rendering SAM launcher defense radiuses
|
||||
*/
|
||||
export class SAMRadiusLayer implements Layer {
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
private readonly context: CanvasRenderingContext2D;
|
||||
private readonly samLaunchers: Map<number, number> = new Map(); // Track SAM launcher IDs -> ownerSmallID
|
||||
private needsRedraw = true;
|
||||
// track whether the stroke should be shown due to hover or due to an active build ghost
|
||||
private hoveredShow: boolean = false;
|
||||
private ghostShow: boolean = false;
|
||||
private showStroke: boolean = false;
|
||||
private dashOffset = 0;
|
||||
private rotationSpeed = 14; // px per second
|
||||
private lastTickTime = Date.now();
|
||||
|
||||
private handleToggleStructure(e: ToggleStructureEvent) {
|
||||
const types = e.structureTypes;
|
||||
this.hoveredShow = !!types && types.indexOf(UnitType.SAMLauncher) !== -1;
|
||||
this.updateStrokeVisibility();
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly game: GameView,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly transformHandler: TransformHandler,
|
||||
private readonly uiState: UIState,
|
||||
) {
|
||||
this.canvas = document.createElement("canvas");
|
||||
const ctx = this.canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("2d context not supported");
|
||||
}
|
||||
this.context = ctx;
|
||||
this.canvas.width = this.game.width();
|
||||
this.canvas.height = this.game.height();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Listen for game updates to detect SAM launcher changes
|
||||
// Also listen for UI toggle structure events so we can show borders when
|
||||
// the user is hovering the Atom/Hydrogen option (UnitDisplay emits
|
||||
// ToggleStructureEvent with SAMLauncher included in the list).
|
||||
this.eventBus.on(ToggleStructureEvent, (e) =>
|
||||
this.handleToggleStructure(e),
|
||||
);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private updateStrokeVisibility() {
|
||||
const next = this.hoveredShow || this.ghostShow;
|
||||
if (next !== this.showStroke) {
|
||||
this.showStroke = next;
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
tick() {
|
||||
// Check for updates to SAM launchers
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
const unitUpdates = updates?.[GameUpdateType.Unit];
|
||||
|
||||
if (unitUpdates) {
|
||||
let hasChanges = false;
|
||||
|
||||
for (const update of unitUpdates) {
|
||||
const unit = this.game.unit(update.id);
|
||||
if (unit && unit.type() === UnitType.SAMLauncher) {
|
||||
const wasTracked = this.samLaunchers.has(update.id);
|
||||
const shouldTrack = unit.isActive();
|
||||
const owner = unit.owner().smallID();
|
||||
|
||||
if (wasTracked && !shouldTrack) {
|
||||
// SAM was destroyed
|
||||
this.samLaunchers.delete(update.id);
|
||||
hasChanges = true;
|
||||
} else if (!wasTracked && shouldTrack) {
|
||||
// New SAM was built
|
||||
this.samLaunchers.set(update.id, owner);
|
||||
hasChanges = true;
|
||||
} else if (wasTracked && shouldTrack) {
|
||||
// SAM still exists; check if owner changed
|
||||
const prevOwner = this.samLaunchers.get(update.id);
|
||||
if (prevOwner !== owner) {
|
||||
this.samLaunchers.set(update.id, owner);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// show when in ghost mode for sam/atom/hydrogen
|
||||
this.ghostShow =
|
||||
this.uiState.ghostStructure === UnitType.SAMLauncher ||
|
||||
this.uiState.ghostStructure === UnitType.AtomBomb ||
|
||||
this.uiState.ghostStructure === UnitType.HydrogenBomb;
|
||||
this.updateStrokeVisibility();
|
||||
|
||||
// Redraw if transform changed or if we need to redraw
|
||||
const now = Date.now();
|
||||
const dt = now - this.lastTickTime;
|
||||
this.lastTickTime = now;
|
||||
|
||||
if (this.showStroke) {
|
||||
this.dashOffset += (this.rotationSpeed * dt) / 1000;
|
||||
if (this.dashOffset > 1e6) this.dashOffset = this.dashOffset % 1000000;
|
||||
// animate by redrawing each frame whilst visible
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
|
||||
if (this.transformHandler.hasChanged() || this.needsRedraw) {
|
||||
this.redraw();
|
||||
this.needsRedraw = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
}
|
||||
|
||||
redraw() {
|
||||
// Clear the canvas
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Get all active SAM launchers
|
||||
const samLaunchers = this.game
|
||||
.units(UnitType.SAMLauncher)
|
||||
.filter((unit) => unit.isActive());
|
||||
|
||||
// Update our tracking set
|
||||
this.samLaunchers.clear();
|
||||
samLaunchers.forEach((sam) =>
|
||||
this.samLaunchers.set(sam.id(), sam.owner().smallID()),
|
||||
);
|
||||
|
||||
// Draw union of SAM radiuses. Collect circle data then draw union outer arcs only
|
||||
const circles = samLaunchers.map((sam) => {
|
||||
const tile = sam.tile();
|
||||
return {
|
||||
x: this.game.x(tile),
|
||||
y: this.game.y(tile),
|
||||
r: this.game.config().defaultSamRange(),
|
||||
owner: sam.owner().smallID(),
|
||||
};
|
||||
});
|
||||
|
||||
this.drawCirclesUnion(circles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw union of multiple circles: fill the union, then stroke only the outer arcs
|
||||
* so overlapping circles appear as one combined shape.
|
||||
*/
|
||||
private drawCirclesUnion(
|
||||
circles: Array<{ x: number; y: number; r: number; owner: number }>,
|
||||
) {
|
||||
const ctx = this.context;
|
||||
if (circles.length === 0) return;
|
||||
|
||||
// styles
|
||||
const strokeStyleOuter = "rgba(0, 0, 0, 1)";
|
||||
|
||||
// 1) Fill union simply by drawing all full circle paths and filling once
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
for (const c of circles) {
|
||||
ctx.moveTo(c.x + c.r, c.y);
|
||||
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// 2) For stroke, compute for each circle which angular segments are NOT covered by any other circle,
|
||||
// and stroke only those segments. This produces a union outline without overlapping inner strokes.
|
||||
// Only draw the stroke when UI toggle indicates SAM launchers are focused (e.g. hovering Atom/Hydrogen option).
|
||||
if (!this.showStroke) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([12, 6]);
|
||||
ctx.lineDashOffset = this.dashOffset;
|
||||
ctx.strokeStyle = strokeStyleOuter;
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
// helper functions
|
||||
const normalize = (a: number) => {
|
||||
while (a < 0) a += TWO_PI;
|
||||
while (a >= TWO_PI) a -= TWO_PI;
|
||||
return a;
|
||||
};
|
||||
|
||||
// merge a list of intervals [s,e] (both between 0..2pi), taking wraparound into account
|
||||
const mergeIntervals = (
|
||||
intervals: Array<[number, number]>,
|
||||
): Array<[number, number]> => {
|
||||
if (intervals.length === 0) return [];
|
||||
// normalize to non-wrap intervals
|
||||
const flat: Array<[number, number]> = [];
|
||||
for (const [s, e] of intervals) {
|
||||
const ns = normalize(s);
|
||||
const ne = normalize(e);
|
||||
if (ne < ns) {
|
||||
// wraps, split
|
||||
flat.push([ns, TWO_PI]);
|
||||
flat.push([0, ne]);
|
||||
} else {
|
||||
flat.push([ns, ne]);
|
||||
}
|
||||
}
|
||||
flat.sort((a, b) => a[0] - b[0]);
|
||||
const merged: Array<[number, number]> = [];
|
||||
let cur = flat[0].slice() as [number, number];
|
||||
for (let i = 1; i < flat.length; i++) {
|
||||
const it = flat[i];
|
||||
if (it[0] <= cur[1] + 1e-9) {
|
||||
cur[1] = Math.max(cur[1], it[1]);
|
||||
} else {
|
||||
merged.push([cur[0], cur[1]]);
|
||||
cur = it.slice() as [number, number];
|
||||
}
|
||||
}
|
||||
merged.push([cur[0], cur[1]]);
|
||||
return merged;
|
||||
};
|
||||
|
||||
for (let i = 0; i < circles.length; i++) {
|
||||
const a = circles[i];
|
||||
// collect intervals on circle a that are covered by other circles
|
||||
const covered: Array<[number, number]> = [];
|
||||
let fullyCovered = false;
|
||||
|
||||
for (let j = 0; j < circles.length; j++) {
|
||||
if (i === j) continue;
|
||||
// Only consider coverage from circles owned by the same player.
|
||||
// This shows separate boundaries for different players' SAM coverage,
|
||||
// making contested areas visually distinct.
|
||||
if (a.owner !== circles[j].owner) continue;
|
||||
const b = circles[j];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d + a.r <= b.r + 1e-9) {
|
||||
// circle a is fully inside b
|
||||
fullyCovered = true;
|
||||
break;
|
||||
}
|
||||
if (d >= a.r + b.r - 1e-9) {
|
||||
// no overlap
|
||||
continue;
|
||||
}
|
||||
if (d <= 1e-9) {
|
||||
// coincident centers but not fully covered (should be covered by previous check if radii differ)
|
||||
if (b.r >= a.r) {
|
||||
fullyCovered = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// compute angular span on circle a that is inside circle b
|
||||
const theta = Math.atan2(dy, dx);
|
||||
// law of cosines for angle between center-line and intersection points
|
||||
const cosPhi = (a.r * a.r + d * d - b.r * b.r) / (2 * a.r * d);
|
||||
// numerical clamp
|
||||
const clamp = Math.max(-1, Math.min(1, cosPhi));
|
||||
const phi = Math.acos(clamp);
|
||||
const start = theta - phi;
|
||||
const end = theta + phi;
|
||||
covered.push([start, end]);
|
||||
}
|
||||
|
||||
if (fullyCovered) continue; // nothing to stroke for this circle
|
||||
|
||||
const merged = mergeIntervals(covered);
|
||||
|
||||
// subtract merged covered intervals from [0,2pi) to get uncovered intervals
|
||||
const uncovered: Array<[number, number]> = [];
|
||||
if (merged.length === 0) {
|
||||
uncovered.push([0, TWO_PI]);
|
||||
} else {
|
||||
let cursor = 0;
|
||||
for (const [s, e] of merged) {
|
||||
if (s > cursor + 1e-9) {
|
||||
uncovered.push([cursor, s]);
|
||||
}
|
||||
cursor = Math.max(cursor, e);
|
||||
}
|
||||
if (cursor < TWO_PI - 1e-9) uncovered.push([cursor, TWO_PI]);
|
||||
}
|
||||
|
||||
// draw uncovered arcs
|
||||
for (const [s, e] of uncovered) {
|
||||
// skip tiny arcs
|
||||
if (e - s < 1e-3) continue;
|
||||
ctx.beginPath();
|
||||
ctx.arc(a.x, a.y, a.r, s, e);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,7 @@ export class SpriteFactory {
|
||||
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;
|
||||
|
||||
@@ -453,7 +454,8 @@ export class SpriteFactory {
|
||||
}
|
||||
circle
|
||||
.circle(0, 0, radius)
|
||||
.stroke({ width: 1, color: 0xffffff, alpha: 0.2 });
|
||||
.fill({ color: 0xffffff, alpha: 0.2 })
|
||||
.stroke({ width: 1, color: 0xffffff, alpha: 0.5 });
|
||||
parentContainer.addChild(circle);
|
||||
parentContainer.position.set(pos.x, pos.y);
|
||||
parentContainer.scale.set(this.transformHandler.scale);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { euclDistFN, isometricDistFN } from "../../../core/game/GameMap";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
|
||||
const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
|
||||
const underConstructionColor = colord("rgb(150,150,150)");
|
||||
|
||||
// Base radius values and scaling factor for unit borders and territories
|
||||
const BASE_BORDER_RADIUS = 16.5;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Cell,
|
||||
ColoredTeams,
|
||||
PlayerType,
|
||||
Team,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
|
||||
@@ -197,15 +198,13 @@ export class TerritoryLayer implements Layer {
|
||||
// In Team games, the spawn highlight color becomes that player's team color
|
||||
// Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively
|
||||
const team = human.team();
|
||||
if (team !== null) {
|
||||
if (teamColors.includes(team)) {
|
||||
color = this.theme.teamColor(team);
|
||||
if (team !== null && teamColors.includes(team)) {
|
||||
color = this.theme.teamColor(team);
|
||||
} else {
|
||||
if (myPlayer.isFriendly(human)) {
|
||||
color = this.theme.spawnHighlightTeamColor();
|
||||
} else {
|
||||
if (myPlayer.isFriendly(human)) {
|
||||
color = this.theme.spawnHighlightTeamColor();
|
||||
} else {
|
||||
color = this.theme.spawnHighlightColor();
|
||||
}
|
||||
color = this.theme.spawnHighlightColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,13 +238,24 @@ export class TerritoryLayer implements Layer {
|
||||
const radius =
|
||||
minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
|
||||
|
||||
const baseColor = this.theme.spawnHighlightSelfColor(); //white
|
||||
let teamColor: Colord | null = null;
|
||||
|
||||
const team: Team | null = focusedPlayer.team();
|
||||
if (team !== null && Object.values(ColoredTeams).includes(team)) {
|
||||
teamColor = this.theme.teamColor(team).alpha(0.5);
|
||||
} else {
|
||||
teamColor = baseColor;
|
||||
}
|
||||
|
||||
this.drawBreathingRing(
|
||||
center.x,
|
||||
center.y,
|
||||
minRad,
|
||||
maxRad,
|
||||
radius,
|
||||
this.theme.spawnHighlightSelfColor(), // Always draw breathing ring with self spawn highlight color
|
||||
baseColor, // Always draw white static semi-transparent ring
|
||||
teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games.
|
||||
);
|
||||
}
|
||||
|
||||
@@ -582,7 +592,8 @@ export class TerritoryLayer implements Layer {
|
||||
minRad: number,
|
||||
maxRad: number,
|
||||
radius: number,
|
||||
color: Colord,
|
||||
transparentColor: Colord,
|
||||
breathingColor: Colord,
|
||||
) {
|
||||
const ctx = this.highlightContext;
|
||||
if (!ctx) return;
|
||||
@@ -590,17 +601,16 @@ export class TerritoryLayer implements Layer {
|
||||
// Draw a semi-transparent ring around the starting location
|
||||
ctx.beginPath();
|
||||
// Transparency matches the highlight color provided
|
||||
const transparent = color.toHex() + "00";
|
||||
const c = color.toHex();
|
||||
const transparent = transparentColor.alpha(0);
|
||||
const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
|
||||
|
||||
// Pixels with radius < minRad are transparent
|
||||
radGrad.addColorStop(0, transparent);
|
||||
radGrad.addColorStop(0, transparent.toRgbString());
|
||||
// The ring then starts with solid highlight color
|
||||
radGrad.addColorStop(0.01, c);
|
||||
radGrad.addColorStop(0.1, c);
|
||||
radGrad.addColorStop(0.01, transparentColor.toRgbString());
|
||||
radGrad.addColorStop(0.1, transparentColor.toRgbString());
|
||||
// The outer edge of the ring is transparent
|
||||
radGrad.addColorStop(1, transparent);
|
||||
radGrad.addColorStop(1, transparent.toRgbString());
|
||||
|
||||
// Draw an arc at the max radius and fill with the created radial gradient
|
||||
ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
|
||||
@@ -608,15 +618,16 @@ export class TerritoryLayer implements Layer {
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
const breatheInner = breathingColor.alpha(0);
|
||||
// Draw a solid ring around the starting location with outer radius = the breathing radius
|
||||
ctx.beginPath();
|
||||
const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
|
||||
// Pixels with radius < minRad are transparent
|
||||
radGrad2.addColorStop(0, transparent);
|
||||
radGrad2.addColorStop(0, breatheInner.toRgbString());
|
||||
// The ring then starts with solid highlight color
|
||||
radGrad2.addColorStop(0.01, c);
|
||||
radGrad2.addColorStop(0.01, breathingColor.toRgbString());
|
||||
// The ring is solid throughout
|
||||
radGrad2.addColorStop(1, c);
|
||||
radGrad2.addColorStop(1, breathingColor.toRgbString());
|
||||
|
||||
// Draw an arc at the current breathing radius and fill with the created "gradient"
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
|
||||
@@ -295,7 +295,7 @@ export class UnitLayer implements Layer {
|
||||
|
||||
private handleWarShipEvent(unit: UnitView) {
|
||||
if (unit.targetUnitId()) {
|
||||
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
|
||||
this.drawSprite(unit, colord("rgb(200,0,0)"));
|
||||
} else {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
HumansVsNations,
|
||||
Quads,
|
||||
Trios,
|
||||
UnitType,
|
||||
@@ -149,6 +150,7 @@ const TeamCountConfigSchema = z.union([
|
||||
z.literal(Duos),
|
||||
z.literal(Trios),
|
||||
z.literal(Quads),
|
||||
z.literal(HumansVsNations),
|
||||
]);
|
||||
export type TeamCountConfig = z.infer<typeof TeamCountConfigSchema>;
|
||||
|
||||
@@ -545,6 +547,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||
|
||||
export const PlayerRecordSchema = PlayerSchema.extend({
|
||||
persistentID: PersistentIdSchema.nullable(), // WARNING: PII
|
||||
clanTag: z.string().optional(),
|
||||
stats: PlayerStatsSchema,
|
||||
});
|
||||
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
|
||||
|
||||
@@ -289,17 +289,17 @@ export function createRandomName(
|
||||
}
|
||||
|
||||
export const emojiTable = [
|
||||
["😀", "😊", "🥰", "😇", "😎"],
|
||||
["😀", "😊", "😇", "😎", "😈"],
|
||||
["😞", "🥺", "😭", "😱", "😡"],
|
||||
["😈", "🤡", "🖕", "🥱", "🤦♂️"],
|
||||
["👋", "👏", "🤌", "💪", "🫡"],
|
||||
["⏳", "🥱", "🤦♂️", "🖕", "🤡"],
|
||||
["👋", "👏", "👻", "💪", "🎃"],
|
||||
["👍", "👎", "❓", "🐔", "🐀"],
|
||||
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
|
||||
["🆘", "🤝", "🕊️", "🏳️", "🛡️"],
|
||||
["🔥", "💥", "💀", "☢️", "⚠️"],
|
||||
["↖️", "⬆️", "↗️", "👑", "🥇"],
|
||||
["⬅️", "🎯", "➡️", "🥈", "🥉"],
|
||||
["↙️", "⬇️", "↘️", "❤️", "💔"],
|
||||
["💰", "⚓", "⛵", "🏡", "🛡️"],
|
||||
["💰", "🏭", "🚂", "⚓", "⛵"],
|
||||
] as const;
|
||||
// 2d to 1d array
|
||||
export const flattenedEmojiTable = emojiTable.flat();
|
||||
@@ -320,3 +320,12 @@ export function sigmoid(
|
||||
): number {
|
||||
return 1 / (1 + Math.exp(-decayRate * (value - midpoint)));
|
||||
}
|
||||
|
||||
// Compute clan from name
|
||||
export function getClanTag(name: string): string | null {
|
||||
if (!name.includes("[") || !name.includes("]")) {
|
||||
return null;
|
||||
}
|
||||
const clanMatch = name.match(/\[([a-zA-Z0-9]{2,5})\]/);
|
||||
return clanMatch ? clanMatch[1].toUpperCase() : null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Colord, extend } from "colord";
|
||||
import { colord, Colord, extend } from "colord";
|
||||
import labPlugin from "colord/plugins/lab";
|
||||
import lchPlugin from "colord/plugins/lch";
|
||||
import Color from "colorjs.io";
|
||||
@@ -47,6 +47,10 @@ export class ColorAllocator {
|
||||
return greenTeamColors;
|
||||
case ColoredTeams.Bot:
|
||||
return botTeamColors;
|
||||
case ColoredTeams.Humans:
|
||||
return blueTeamColors;
|
||||
case ColoredTeams.Nations:
|
||||
return redTeamColors;
|
||||
default:
|
||||
return [this.assignColor(team)];
|
||||
}
|
||||
@@ -83,7 +87,11 @@ export class ColorAllocator {
|
||||
|
||||
assignTeamColor(team: Team): Colord {
|
||||
const teamColors = this.getTeamColorVariations(team);
|
||||
return teamColors[0];
|
||||
const rgb = teamColors[0].toRgb();
|
||||
rgb.r = Math.round(rgb.r);
|
||||
rgb.g = Math.round(rgb.g);
|
||||
rgb.b = Math.round(rgb.b);
|
||||
return colord(rgb);
|
||||
}
|
||||
|
||||
assignTeamPlayerColor(team: Team, playerId: string): Colord {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GameMode,
|
||||
GameType,
|
||||
Gold,
|
||||
HumansVsNations,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
@@ -50,6 +51,7 @@ const numPlayersConfig = {
|
||||
[GameMapType.Australia]: [70, 40, 30],
|
||||
[GameMapType.Achiran]: [40, 36, 30],
|
||||
[GameMapType.Baikal]: [100, 70, 50],
|
||||
[GameMapType.BaikalNukeWars]: [100, 70, 50],
|
||||
[GameMapType.BetweenTwoSeas]: [70, 50, 40],
|
||||
[GameMapType.BlackSea]: [50, 30, 30],
|
||||
[GameMapType.Britannia]: [50, 30, 20],
|
||||
@@ -195,6 +197,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
case Quads:
|
||||
p -= p % 4;
|
||||
break;
|
||||
case HumansVsNations:
|
||||
// For HumansVsNations, return the base team player count
|
||||
break;
|
||||
default:
|
||||
p -= p % numPlayerTeams;
|
||||
break;
|
||||
@@ -348,7 +353,7 @@ export class DefaultConfig implements Config {
|
||||
trainSpawnRate(numPlayerFactories: number): number {
|
||||
// hyperbolic decay, midpoint at 10 factories
|
||||
// expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories)
|
||||
return (numPlayerFactories + 10) * 20;
|
||||
return (numPlayerFactories + 10) * 16;
|
||||
}
|
||||
trainGold(rel: "self" | "team" | "ally" | "other"): Gold {
|
||||
switch (rel) {
|
||||
@@ -373,7 +378,9 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
tradeShipGold(dist: number, numPorts: number): Gold {
|
||||
const baseGold = Math.floor(100_000 + 100 * dist);
|
||||
// Sigmoid: concave start, sharp S-curve middle, linear end - heavily punishes trades under 200
|
||||
const baseGold =
|
||||
100_000 / (1 + Math.exp(-0.03 * (dist - 200))) + 100 * dist;
|
||||
const numPortBonus = numPorts - 1;
|
||||
// Hyperbolic decay, midpoint at 5 ports, 3x bonus max.
|
||||
const bonus = 1 + 2 * (numPortBonus / (numPortBonus + 5));
|
||||
@@ -580,7 +587,7 @@ export class DefaultConfig implements Config {
|
||||
return 15 * 10;
|
||||
}
|
||||
deleteUnitCooldown(): Tick {
|
||||
return 5 * 10;
|
||||
return 15 * 10;
|
||||
}
|
||||
emojiMessageDuration(): Tick {
|
||||
return 5 * 10;
|
||||
@@ -680,7 +687,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 (
|
||||
|
||||
@@ -17,35 +17,35 @@ export class PastelTheme implements Theme {
|
||||
private teamColorAllocator = new ColorAllocator(humanColors, fallbackColors);
|
||||
private nationColorAllocator = new ColorAllocator(nationColors, nationColors);
|
||||
|
||||
private background = colord({ r: 60, g: 60, b: 60 });
|
||||
private shore = colord({ r: 204, g: 203, b: 158 });
|
||||
private background = colord("rgb(60,60,60)");
|
||||
private shore = colord("rgb(204,203,158)");
|
||||
private falloutColors = [
|
||||
colord({ r: 120, g: 255, b: 71 }), // Original color
|
||||
colord({ r: 130, g: 255, b: 85 }), // Slightly lighter
|
||||
colord({ r: 110, g: 245, b: 65 }), // Slightly darker
|
||||
colord({ r: 125, g: 255, b: 75 }), // Warmer tint
|
||||
colord({ r: 115, g: 250, b: 68 }), // Cooler tint
|
||||
colord("rgb(120,255,71)"), // Original color
|
||||
colord("rgb(130,255,85)"), // Slightly lighter
|
||||
colord("rgb(110,245,65)"), // Slightly darker
|
||||
colord("rgb(125,255,75)"), // Warmer tint
|
||||
colord("rgb(115,250,68)"), // Cooler tint
|
||||
];
|
||||
private water = colord({ r: 70, g: 132, b: 180 });
|
||||
private shorelineWater = colord({ r: 100, g: 143, b: 255 });
|
||||
private water = colord("rgb(70,132,180)");
|
||||
private shorelineWater = colord("rgb(100,143,255)");
|
||||
|
||||
/** Alternate View colors for self, green */
|
||||
private _selfColor = colord({ r: 0, g: 255, b: 0 });
|
||||
private _selfColor = colord("rgb(0,255,0)");
|
||||
/** Alternate View colors for allies, yellow */
|
||||
private _allyColor = colord({ r: 255, g: 255, b: 0 });
|
||||
private _allyColor = colord("rgb(255,255,0)");
|
||||
/** Alternate View colors for neutral, gray */
|
||||
private _neutralColor = colord({ r: 128, g: 128, b: 128 });
|
||||
private _neutralColor = colord("rgb(128,128,128)");
|
||||
/** Alternate View colors for enemies, red */
|
||||
private _enemyColor = colord({ r: 255, g: 0, b: 0 });
|
||||
private _enemyColor = colord("rgb(255,0,0)");
|
||||
|
||||
/** Default spawn highlight colors for other players in FFA, yellow */
|
||||
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
|
||||
private _spawnHighlightColor = colord("rgb(255,213,79)");
|
||||
/** Added non-default spawn highlight colors for self, full white */
|
||||
private _spawnHighlightSelfColor = colord({ r: 255, g: 255, b: 255 });
|
||||
private _spawnHighlightSelfColor = colord("rgb(255,255,255)");
|
||||
/** Added non-default spawn highlight colors for teammates, green */
|
||||
private _spawnHighlightTeamColor = colord({ r: 0, g: 255, b: 0 });
|
||||
private _spawnHighlightTeamColor = colord("rgb(0,255,0)");
|
||||
/** Added non-default spawn highlight colors for enemies, red */
|
||||
private _spawnHighlightEnemyColor = colord({ r: 255, g: 0, b: 0 });
|
||||
private _spawnHighlightEnemyColor = colord("rgb(255,0,0)");
|
||||
|
||||
teamColor(team: Team): Colord {
|
||||
return this.teamColorAllocator.assignTeamColor(team);
|
||||
@@ -81,7 +81,7 @@ export class PastelTheme implements Theme {
|
||||
}
|
||||
|
||||
focusedBorderColor(): Colord {
|
||||
return colord({ r: 230, g: 230, b: 230 });
|
||||
return colord("rgb(230,230,230)");
|
||||
}
|
||||
|
||||
textColor(player: PlayerView): string {
|
||||
@@ -108,15 +108,15 @@ export class PastelTheme implements Theme {
|
||||
}
|
||||
case TerrainType.Plains:
|
||||
return colord({
|
||||
r: 190,
|
||||
g: 220 - 2 * mag,
|
||||
b: 138,
|
||||
r: 216,
|
||||
g: 205 - 2 * mag,
|
||||
b: 127,
|
||||
});
|
||||
case TerrainType.Highland:
|
||||
return colord({
|
||||
r: 200 + 2 * mag,
|
||||
g: 183 + 2 * mag,
|
||||
b: 138 + 2 * mag,
|
||||
r: 223 + 2 * mag,
|
||||
g: 187 + 2 * mag,
|
||||
b: 132 + 2 * mag,
|
||||
});
|
||||
case TerrainType.Mountain:
|
||||
return colord({
|
||||
|
||||
@@ -4,10 +4,10 @@ import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PastelTheme } from "./PastelTheme";
|
||||
|
||||
export class PastelThemeDark extends PastelTheme {
|
||||
private darkShore = colord({ r: 134, g: 133, b: 88 });
|
||||
private darkShore = colord("rgb(134,133,88)");
|
||||
|
||||
private darkWater = colord({ r: 14, g: 11, b: 30 });
|
||||
private darkShorelineWater = colord({ r: 50, g: 50, b: 50 });
|
||||
private darkWater = colord("rgb(14,11,30)");
|
||||
private darkShorelineWater = colord("rgb(50,50,50)");
|
||||
|
||||
terrainColor(gm: GameMap, tile: TileRef): Colord {
|
||||
const mag = gm.magnitude(tile);
|
||||
|
||||
@@ -181,6 +181,12 @@ export class AttackExecution implements Execution {
|
||||
this._owner.id(),
|
||||
);
|
||||
}
|
||||
if (this.removeTroops === false) {
|
||||
// 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
|
||||
this.attack.setTroops(this.attack.troops() - (this.startTroops ?? 0));
|
||||
}
|
||||
|
||||
const survivors = this.attack.troops() - deaths;
|
||||
this._owner.addTroops(survivors);
|
||||
this.attack.delete();
|
||||
|
||||
@@ -20,17 +20,18 @@ import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util";
|
||||
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { EmojiExecution } from "./EmojiExecution";
|
||||
import { MirvExecution } from "./MIRVExecution";
|
||||
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
|
||||
import { NukeExecution } from "./NukeExecution";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { closestTwoTiles } from "./Util";
|
||||
import { calculateTerritoryCenter, closestTwoTiles } from "./Util";
|
||||
import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior";
|
||||
|
||||
export class FakeHumanExecution implements Execution {
|
||||
private active = true;
|
||||
private random: PseudoRandom;
|
||||
private behavior: BotBehavior | null = null;
|
||||
private behavior: BotBehavior | null = null; // Shared behavior logic for both bots and fakehumans
|
||||
private mg: Game;
|
||||
private player: Player | null = null;
|
||||
|
||||
@@ -42,11 +43,32 @@ export class FakeHumanExecution implements Execution {
|
||||
|
||||
private readonly lastEmojiSent = new Map<Player, Tick>();
|
||||
private readonly lastNukeSent: [Tick, TileRef][] = [];
|
||||
private readonly lastMIRVSent: [Tick, TileRef][] = [];
|
||||
private readonly embargoMalusApplied = new Set<PlayerID>();
|
||||
|
||||
/** MIRV Strategy Constants */
|
||||
|
||||
/** Ticks until MIRV can be attempted again */
|
||||
private static readonly MIRV_COOLDOWN_TICKS = 20;
|
||||
|
||||
/** Odds of aborting a MIRV attempt */
|
||||
private static readonly MIRV_HESITATION_ODDS = 7;
|
||||
|
||||
/** Threshold for team victory denial */
|
||||
private static readonly VICTORY_DENIAL_TEAM_THRESHOLD = 0.8;
|
||||
|
||||
/** Threshold for individual victory denial */
|
||||
private static readonly VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65;
|
||||
|
||||
/** Multiplier for steamroll city gap threshold */
|
||||
private static readonly STEAMROLL_CITY_GAP_MULTIPLIER = 1.3;
|
||||
|
||||
/** Minimum city count for leader to trigger steam roll detection */
|
||||
private static readonly STEAMROLL_MIN_LEADER_CITIES = 10;
|
||||
|
||||
constructor(
|
||||
gameID: GameID,
|
||||
private nation: Nation,
|
||||
private nation: Nation, // Nation contains PlayerInfo with PlayerType.FakeHuman
|
||||
) {
|
||||
this.random = new PseudoRandom(
|
||||
simpleHash(nation.playerInfo.id) + simpleHash(gameID),
|
||||
@@ -111,7 +133,9 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number) {
|
||||
if (ticks % this.attackRate !== this.attackTick) return;
|
||||
if (ticks % this.attackRate !== this.attackTick) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mg.inSpawnPhase()) {
|
||||
const rl = this.randomSpawnLand();
|
||||
@@ -158,32 +182,10 @@ export class FakeHumanExecution implements Execution {
|
||||
this.behavior.handleAllianceExtensionRequests();
|
||||
this.handleUnits();
|
||||
this.handleEmbargoesToHostileNations();
|
||||
this.considerMIRV();
|
||||
this.maybeAttack();
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Implement strategic betrayal logic
|
||||
* Currently this just breaks alliances without strategic consideration.
|
||||
* Future implementation should consider:
|
||||
* - Relative strength (troop count, territory size) compared to target
|
||||
* - Risk vs reward of betrayal
|
||||
* - Potential impact on relations with other players
|
||||
* - Timing (don't betray when already fighting other enemies)
|
||||
* - Strategic value of target's territory
|
||||
* - If target is distracted
|
||||
*/
|
||||
private maybeConsiderBetrayal(target: Player): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
|
||||
const alliance = this.player.allianceWith(target);
|
||||
|
||||
if (!alliance) return false;
|
||||
|
||||
this.player.breakAlliance(alliance);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private maybeAttack() {
|
||||
if (this.player === null || this.behavior === null) {
|
||||
throw new Error("not initialized");
|
||||
@@ -230,6 +232,7 @@ export class FakeHumanExecution implements Execution {
|
||||
|
||||
this.behavior.forgetOldEnemies();
|
||||
this.behavior.assistAllies();
|
||||
|
||||
const enemy = this.behavior.selectEnemy(enemies);
|
||||
if (!enemy) return;
|
||||
this.maybeSendEmoji(enemy);
|
||||
@@ -262,7 +265,7 @@ export class FakeHumanExecution implements Execution {
|
||||
if (
|
||||
silos.length === 0 ||
|
||||
this.player.gold() < this.cost(UnitType.AtomBomb) ||
|
||||
other.type() === PlayerType.Bot ||
|
||||
other.type() === PlayerType.Bot || // Don't nuke bots (as opposed to fakehumans and humans)
|
||||
this.player.isOnSameTeam(other)
|
||||
) {
|
||||
return;
|
||||
@@ -656,6 +659,226 @@ export class FakeHumanExecution implements Execution {
|
||||
return null;
|
||||
}
|
||||
|
||||
// MIRV Strategy Methods
|
||||
private considerMIRV(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (this.player.units(UnitType.MissileSilo).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.player.gold() < this.cost(UnitType.MIRV)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeOldMIRVEvents();
|
||||
if (this.lastMIRVSent.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.random.chance(FakeHumanExecution.MIRV_HESITATION_ODDS)) {
|
||||
this.triggerMIRVCooldown();
|
||||
return false;
|
||||
}
|
||||
|
||||
const inboundMIRVSender = this.selectCounterMirvTarget();
|
||||
if (inboundMIRVSender) {
|
||||
this.maybeSendMIRV(inboundMIRVSender);
|
||||
return true;
|
||||
}
|
||||
|
||||
const victoryDenialTarget = this.selectVictoryDenialTarget();
|
||||
if (victoryDenialTarget) {
|
||||
this.maybeSendMIRV(victoryDenialTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
const steamrollStopTarget = this.selectSteamrollStopTarget();
|
||||
if (steamrollStopTarget) {
|
||||
this.maybeSendMIRV(steamrollStopTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private selectCounterMirvTarget(): Player | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const attackers = this.getValidMirvTargetPlayers().filter((p) =>
|
||||
this.isInboundMIRVFrom(p),
|
||||
);
|
||||
if (attackers.length === 0) return null;
|
||||
attackers.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
|
||||
return attackers[0];
|
||||
}
|
||||
|
||||
private selectVictoryDenialTarget(): Player | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const totalLand = this.mg.numLandTiles();
|
||||
if (totalLand === 0) return null;
|
||||
let best: { p: Player; severity: number } | null = null;
|
||||
for (const p of this.getValidMirvTargetPlayers()) {
|
||||
let severity = 0;
|
||||
const team = p.team();
|
||||
if (team !== null) {
|
||||
const teamMembers = this.mg
|
||||
.players()
|
||||
.filter((x) => x.team() === team && x.isPlayer());
|
||||
const teamTerritory = teamMembers
|
||||
.map((x) => x.numTilesOwned())
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const teamShare = teamTerritory / totalLand;
|
||||
if (teamShare >= FakeHumanExecution.VICTORY_DENIAL_TEAM_THRESHOLD) {
|
||||
// Only consider the largest team member as the target when team exceeds threshold
|
||||
let largestMember: Player | null = null;
|
||||
let largestTiles = -1;
|
||||
for (const member of teamMembers) {
|
||||
const tiles = member.numTilesOwned();
|
||||
if (tiles > largestTiles) {
|
||||
largestTiles = tiles;
|
||||
largestMember = member;
|
||||
}
|
||||
}
|
||||
if (largestMember === p) {
|
||||
severity = teamShare;
|
||||
} else {
|
||||
severity = 0; // Skip non-largest members
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const share = p.numTilesOwned() / totalLand;
|
||||
if (share >= FakeHumanExecution.VICTORY_DENIAL_INDIVIDUAL_THRESHOLD)
|
||||
severity = share;
|
||||
}
|
||||
if (severity > 0) {
|
||||
if (best === null || severity > best.severity) best = { p, severity };
|
||||
}
|
||||
}
|
||||
return best ? best.p : null;
|
||||
}
|
||||
|
||||
private selectSteamrollStopTarget(): Player | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const validTargets = this.getValidMirvTargetPlayers();
|
||||
|
||||
if (validTargets.length === 0) return null;
|
||||
|
||||
const allPlayers = this.mg
|
||||
.players()
|
||||
.filter((p) => p.isPlayer())
|
||||
.map((p) => ({ p, cityCount: this.countCities(p) }))
|
||||
.sort((a, b) => b.cityCount - a.cityCount);
|
||||
|
||||
if (allPlayers.length < 2) return null;
|
||||
|
||||
const topPlayer = allPlayers[0];
|
||||
|
||||
if (topPlayer.cityCount <= FakeHumanExecution.STEAMROLL_MIN_LEADER_CITIES)
|
||||
return null;
|
||||
|
||||
const secondHighest = allPlayers[1].cityCount;
|
||||
|
||||
const threshold =
|
||||
secondHighest * FakeHumanExecution.STEAMROLL_CITY_GAP_MULTIPLIER;
|
||||
|
||||
if (topPlayer.cityCount >= threshold) {
|
||||
return validTargets.some((p) => p === topPlayer.p) ? topPlayer.p : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// MIRV Helper Methods
|
||||
private mirvTargetsCache: {
|
||||
tick: number;
|
||||
players: Player[];
|
||||
} | null = null;
|
||||
|
||||
private getValidMirvTargetPlayers(): Player[] {
|
||||
const MIRV_TARGETS_CACHE_TICKS = 2 * 10; // 2 seconds
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
|
||||
if (
|
||||
this.mirvTargetsCache &&
|
||||
this.mg.ticks() - this.mirvTargetsCache.tick < MIRV_TARGETS_CACHE_TICKS
|
||||
) {
|
||||
return this.mirvTargetsCache.players;
|
||||
}
|
||||
|
||||
const players = this.mg.players().filter((p) => {
|
||||
return (
|
||||
p !== this.player &&
|
||||
p.isPlayer() &&
|
||||
p.type() !== PlayerType.Bot &&
|
||||
!this.player!.isOnSameTeam(p)
|
||||
);
|
||||
});
|
||||
|
||||
this.mirvTargetsCache = { tick: this.mg.ticks(), players };
|
||||
return players;
|
||||
}
|
||||
|
||||
private isInboundMIRVFrom(attacker: Player): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const enemyMirvs = attacker.units(UnitType.MIRV);
|
||||
for (const mirv of enemyMirvs) {
|
||||
const dst = mirv.targetTile();
|
||||
if (!dst) continue;
|
||||
if (!this.mg.hasOwner(dst)) continue;
|
||||
const owner = this.mg.owner(dst);
|
||||
if (owner === this.player) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private countCities(p: Player): number {
|
||||
return p.unitCount(UnitType.City);
|
||||
}
|
||||
|
||||
private calculateTerritoryCenter(target: Player): TileRef | null {
|
||||
return calculateTerritoryCenter(this.mg, target);
|
||||
}
|
||||
|
||||
// MIRV Execution Methods
|
||||
private maybeSendMIRV(enemy: Player): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
|
||||
this.maybeSendEmoji(enemy);
|
||||
|
||||
const centerTile = this.calculateTerritoryCenter(enemy);
|
||||
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
|
||||
this.sendMIRV(centerTile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private sendMIRV(tile: TileRef): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
this.triggerMIRVCooldown(tile);
|
||||
this.mg.addExecution(new MirvExecution(this.player, tile));
|
||||
}
|
||||
|
||||
private triggerMIRVCooldown(tile?: TileRef): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
this.removeOldMIRVEvents();
|
||||
const tick = this.mg.ticks();
|
||||
// Use provided tile or any tile from player's territory for cooldown tracking
|
||||
const cooldownTile =
|
||||
tile ?? Array.from(this.player.tiles())[0] ?? this.mg.ref(0, 0);
|
||||
this.lastMIRVSent.push([tick, cooldownTile]);
|
||||
}
|
||||
|
||||
private removeOldMIRVEvents() {
|
||||
const maxAge = FakeHumanExecution.MIRV_COOLDOWN_TICKS;
|
||||
const tick = this.mg.ticks();
|
||||
while (
|
||||
this.lastMIRVSent.length > 0 &&
|
||||
this.lastMIRVSent[0][0] + maxAge <= tick
|
||||
) {
|
||||
this.lastMIRVSent.shift();
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,9 @@ export class MirvExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {});
|
||||
this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {
|
||||
targetTile: this.dst,
|
||||
});
|
||||
const x = Math.floor(
|
||||
(this.mg.x(this.dst) + this.mg.x(this.mg.x(this.nuke.tile()))) / 2,
|
||||
);
|
||||
|
||||
@@ -33,13 +33,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 +177,43 @@ 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
|
||||
if (
|
||||
this.originalOwner.isDisconnected() &&
|
||||
this.boat.owner() !== this.originalOwner &&
|
||||
this.boat.owner().isOnSameTeam(this.originalOwner)
|
||||
) {
|
||||
this.attacker = this.boat.owner();
|
||||
this.originalOwner = this.boat.owner(); // 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 valid for the 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Game, Player } from "../game/Game";
|
||||
import { euclDistFN, GameMap, TileRef } from "../game/GameMap";
|
||||
|
||||
export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] {
|
||||
@@ -71,3 +72,61 @@ export function closestTwoTiles(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the center of a player's territory using geometric approach.
|
||||
* Uses the bounding box center and verifies ownership, falling back to nearest border tile if necessary.
|
||||
*
|
||||
* @param game - The game instance
|
||||
* @param target - The player whose territory center to calculate
|
||||
* @returns The tile reference for the territory center, or null if no valid center found
|
||||
*/
|
||||
export function calculateTerritoryCenter(
|
||||
game: Game,
|
||||
target: Player,
|
||||
): TileRef | null {
|
||||
const borderTiles = target.borderTiles();
|
||||
if (borderTiles.size === 0) return null;
|
||||
|
||||
// Calculate bounding box center in a single pass through border tiles
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity;
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
for (const tile of borderTiles) {
|
||||
const x = game.x(tile);
|
||||
const y = game.y(tile);
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
|
||||
const centerX = Math.floor((minX + maxX) / 2);
|
||||
const centerY = Math.floor((minY + maxY) / 2);
|
||||
|
||||
const centerTile = game.ref(centerX, centerY);
|
||||
|
||||
// Verify ownership of the center tile
|
||||
if (game.owner(centerTile) === target) {
|
||||
return centerTile;
|
||||
}
|
||||
|
||||
// Fall back to nearest border tile if center is not owned
|
||||
let closestTile: TileRef | null = null;
|
||||
let closestDistanceSquared = Infinity;
|
||||
|
||||
for (const tile of borderTiles) {
|
||||
const dx = game.x(tile) - centerX;
|
||||
const dy = game.y(tile) - centerY;
|
||||
const distSquared = dx * dx + dy * dy;
|
||||
|
||||
if (distSquared < closestDistanceSquared) {
|
||||
closestDistanceSquared = distSquared;
|
||||
closestTile = tile;
|
||||
}
|
||||
}
|
||||
|
||||
return closestTile;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,7 +20,7 @@ const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emoji
|
||||
const EMOJI_RELATION_TOO_LOW = (["🥱", "🤦♂️"] as const).map(emojiId);
|
||||
const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId);
|
||||
const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId);
|
||||
export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
|
||||
export const EMOJI_HECKLE = (["👻", "🎃"] as const).map(emojiId);
|
||||
|
||||
export class BotBehavior {
|
||||
private enemy: Player | null = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Config } from "../configuration/Config";
|
||||
import { AllPlayersStats, ClientID } from "../Schemas";
|
||||
import { getClanTag } from "../Util";
|
||||
import { GameMap, TileRef } from "./GameMap";
|
||||
import {
|
||||
GameUpdate,
|
||||
@@ -52,6 +53,7 @@ export type Team = string;
|
||||
export const Duos = "Duos" as const;
|
||||
export const Trios = "Trios" as const;
|
||||
export const Quads = "Quads" as const;
|
||||
export const HumansVsNations = "Humans Vs Nations" as const;
|
||||
|
||||
export const ColoredTeams: Record<string, Team> = {
|
||||
Red: "Red",
|
||||
@@ -62,6 +64,8 @@ export const ColoredTeams: Record<string, Team> = {
|
||||
Orange: "Orange",
|
||||
Green: "Green",
|
||||
Bot: "Bot",
|
||||
Humans: "Humans",
|
||||
Nations: "Nations",
|
||||
} as const;
|
||||
|
||||
export enum GameMapType {
|
||||
@@ -96,6 +100,7 @@ export enum GameMapType {
|
||||
Pluto = "Pluto",
|
||||
Montreal = "Montreal",
|
||||
Achiran = "Achiran",
|
||||
BaikalNukeWars = "Baikal (Nuke Wars)",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -137,6 +142,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.Mars,
|
||||
GameMapType.DeglaciatedAntarctica,
|
||||
GameMapType.Achiran,
|
||||
GameMapType.BaikalNukeWars,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -266,7 +272,9 @@ export interface UnitParamsMap {
|
||||
|
||||
[UnitType.City]: Record<string, never>;
|
||||
|
||||
[UnitType.MIRV]: Record<string, never>;
|
||||
[UnitType.MIRV]: {
|
||||
targetTile?: number;
|
||||
};
|
||||
|
||||
[UnitType.MIRVWarhead]: {
|
||||
targetTile?: number;
|
||||
@@ -407,13 +415,7 @@ export class PlayerInfo {
|
||||
public readonly id: PlayerID,
|
||||
public readonly nation?: Nation | null,
|
||||
) {
|
||||
// Compute clan from name
|
||||
if (!name.includes("[") || !name.includes("]")) {
|
||||
this.clan = null;
|
||||
} else {
|
||||
const clanMatch = name.match(/\[([a-zA-Z0-9]{2,5})\]/);
|
||||
this.clan = clanMatch ? clanMatch[1].toUpperCase() : null;
|
||||
}
|
||||
this.clan = getClanTag(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,7 +597,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[];
|
||||
@@ -608,6 +610,7 @@ export interface Player {
|
||||
canSendAllianceRequest(other: Player): boolean;
|
||||
breakAlliance(alliance: Alliance): void;
|
||||
createAllianceRequest(recipient: Player): AllianceRequest | null;
|
||||
betrayals(): number;
|
||||
|
||||
// Targeting
|
||||
canTarget(other: Player): boolean;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Game,
|
||||
GameMode,
|
||||
GameUpdates,
|
||||
HumansVsNations,
|
||||
MessageType,
|
||||
MutableAlliance,
|
||||
Nation,
|
||||
@@ -105,6 +106,13 @@ export class GameImpl implements Game {
|
||||
|
||||
private populateTeams() {
|
||||
let numPlayerTeams = this._config.playerTeams();
|
||||
|
||||
// HumansVsNations mode always has exactly 2 teams
|
||||
if (numPlayerTeams === HumansVsNations) {
|
||||
this.playerTeams = [ColoredTeams.Humans, ColoredTeams.Nations];
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof numPlayerTeams !== "number") {
|
||||
const players = this._humans.length + this._nations.length;
|
||||
switch (numPlayerTeams) {
|
||||
@@ -139,11 +147,21 @@ export class GameImpl implements Game {
|
||||
}
|
||||
|
||||
private addPlayers() {
|
||||
if (this.config().gameConfig().gameMode !== GameMode.Team) {
|
||||
if (this.config().gameConfig().gameMode === GameMode.FFA) {
|
||||
this._humans.forEach((p) => this.addPlayer(p));
|
||||
this._nations.forEach((n) => this.addPlayer(n.playerInfo));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._config.playerTeams() === HumansVsNations) {
|
||||
this._humans.forEach((p) => this.addPlayer(p, ColoredTeams.Humans));
|
||||
this._nations.forEach((n) =>
|
||||
this.addPlayer(n.playerInfo, ColoredTeams.Nations),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Team mode
|
||||
const allPlayers = [
|
||||
...this._humans,
|
||||
...this._nations.map((n) => n.playerInfo),
|
||||
@@ -877,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(
|
||||
|
||||
@@ -169,7 +169,7 @@ export interface PlayerUpdate {
|
||||
outgoingAllianceRequests: PlayerID[];
|
||||
alliances: AllianceView[];
|
||||
hasSpawned: boolean;
|
||||
betrayals?: bigint;
|
||||
betrayals: number;
|
||||
lastDeleteUnitTick: Tick;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export class PlayerImpl implements Player {
|
||||
private _troops: bigint;
|
||||
|
||||
markedTraitorTick = -1;
|
||||
private _betrayalCount: number = 0;
|
||||
|
||||
private embargoes = new Map<PlayerID, Embargo>();
|
||||
|
||||
@@ -124,7 +125,6 @@ export class PlayerImpl implements Player {
|
||||
const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) =>
|
||||
ar.recipient().id(),
|
||||
);
|
||||
const stats = this.mg.stats().getPlayerStats(this);
|
||||
|
||||
return {
|
||||
type: GameUpdateType.Player,
|
||||
@@ -175,7 +175,7 @@ export class PlayerImpl implements Player {
|
||||
}) satisfies AllianceView,
|
||||
),
|
||||
hasSpawned: this.hasSpawned(),
|
||||
betrayals: stats?.betrayals,
|
||||
betrayals: this._betrayalCount,
|
||||
lastDeleteUnitTick: this.lastDeleteUnitTick,
|
||||
};
|
||||
}
|
||||
@@ -442,11 +442,16 @@ export class PlayerImpl implements Player {
|
||||
|
||||
markTraitor(): void {
|
||||
this.markedTraitorTick = this.mg.ticks();
|
||||
this._betrayalCount++; // Keep count for FakeHumans too
|
||||
|
||||
// Record stats
|
||||
// Record stats (only for real Humans)
|
||||
this.mg.stats().betray(this);
|
||||
}
|
||||
|
||||
betrayals(): number {
|
||||
return this._betrayalCount;
|
||||
}
|
||||
|
||||
createAllianceRequest(recipient: Player): AllianceRequest | null {
|
||||
if (this.isAlliedWith(recipient)) {
|
||||
throw new Error(`cannot create alliance request, already allies`);
|
||||
@@ -785,8 +790,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);
|
||||
|
||||
@@ -74,11 +74,8 @@ export class StatsImpl implements Stats {
|
||||
private _addBetrayal(player: Player, value: BigIntLike) {
|
||||
const data = this._makePlayerStats(player);
|
||||
if (data === undefined) return;
|
||||
if (data.betrayals === undefined) {
|
||||
data.betrayals = _bigint(value);
|
||||
} else {
|
||||
data.betrayals += _bigint(value);
|
||||
}
|
||||
data.betrayals ??= 0n;
|
||||
data.betrayals += _bigint(value);
|
||||
}
|
||||
|
||||
private _addBoat(
|
||||
|
||||
@@ -156,6 +156,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) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
ServerTurnMessage,
|
||||
Turn,
|
||||
} from "../core/Schemas";
|
||||
import { createPartialGameRecord } from "../core/Util";
|
||||
import { createPartialGameRecord, getClanTag } from "../core/Util";
|
||||
import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
export enum GamePhase {
|
||||
@@ -689,6 +689,7 @@ export class GameServer {
|
||||
this.allClients.get(player.clientID)?.persistentID ?? "",
|
||||
stats,
|
||||
cosmetics: player.cosmetics,
|
||||
clanTag: getClanTag(player.username) ?? undefined,
|
||||
} satisfies PlayerRecord;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
HumansVsNations,
|
||||
Quads,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
@@ -67,6 +68,7 @@ const TEAM_COUNTS = [
|
||||
Duos,
|
||||
Trios,
|
||||
Quads,
|
||||
HumansVsNations,
|
||||
] as const satisfies TeamCountConfig[];
|
||||
|
||||
export class MapPlaylist {
|
||||
@@ -93,7 +95,7 @@ export class MapPlaylist {
|
||||
infiniteTroops: false,
|
||||
maxTimerValue: undefined,
|
||||
instantBuild: false,
|
||||
disableNPCs: mode === GameMode.Team,
|
||||
disableNPCs: mode === GameMode.Team && playerTeams !== HumansVsNations,
|
||||
gameMode: mode,
|
||||
playerTeams,
|
||||
bots: 400,
|
||||
@@ -130,19 +132,33 @@ export class MapPlaylist {
|
||||
|
||||
const rand = new PseudoRandom(Date.now());
|
||||
|
||||
const ffa: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const team: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const team1: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const team2: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const ffa3: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
|
||||
this.mapsPlaylist = [];
|
||||
for (let i = 0; i < maps.length; i++) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa, GameMode.FFA)) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa1, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.disableTeams) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, team1, GameMode.Team)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.disableTeams) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, team2, GameMode.Team)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,8 @@ describe("ColorAllocator", () => {
|
||||
expect(allocator.assignTeamColor(ColoredTeams.Orange)).toEqual(orange);
|
||||
expect(allocator.assignTeamColor(ColoredTeams.Green)).toEqual(green);
|
||||
expect(allocator.assignTeamColor(ColoredTeams.Bot)).toEqual(botColor);
|
||||
expect(allocator.assignTeamColor(ColoredTeams.Humans)).toEqual(blue);
|
||||
expect(allocator.assignTeamColor(ColoredTeams.Nations)).toEqual(red);
|
||||
});
|
||||
|
||||
test("assignTeamPlayerColor always returns the same color for the same playerID", () => {
|
||||
|
||||
@@ -53,6 +53,8 @@ describe("DeleteUnitExecution Security Tests", () => {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
executeTicks(game, game.config().deleteUnitCooldown() + 1);
|
||||
|
||||
player = game.player(player1Info.id);
|
||||
enemyPlayer = game.player(player2Info.id);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
import { FakeHumanExecution } from "../src/core/execution/FakeHumanExecution";
|
||||
import { MirvExecution } from "../src/core/execution/MIRVExecution";
|
||||
import {
|
||||
Cell,
|
||||
GameMode,
|
||||
Nation,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
UnitType,
|
||||
} from "../src/core/game/Game";
|
||||
import { setup } from "./util/Setup";
|
||||
import { executeTicks } from "./util/utils";
|
||||
|
||||
describe("FakeHuman MIRV Retaliation", () => {
|
||||
test("fakehuman retaliates with MIRV when attacked by MIRV", async () => {
|
||||
const game = await setup("big_plains", {
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
});
|
||||
|
||||
// Create two players
|
||||
const attackerInfo = new PlayerInfo(
|
||||
"attacker",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"attacker_id",
|
||||
);
|
||||
const fakehumanInfo = new PlayerInfo(
|
||||
"defender_fakehuman",
|
||||
PlayerType.FakeHuman,
|
||||
null,
|
||||
"fakehuman_id",
|
||||
);
|
||||
|
||||
game.addPlayer(attackerInfo);
|
||||
game.addPlayer(fakehumanInfo);
|
||||
|
||||
// Skip spawn phase
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
const attacker = game.player("attacker_id");
|
||||
const fakehuman = game.player("fakehuman_id");
|
||||
|
||||
// Give attacker territory and missile silo
|
||||
for (let x = 5; x < 15; x++) {
|
||||
for (let y = 5; y < 15; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile)) {
|
||||
attacker.conquer(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
attacker.buildUnit(UnitType.MissileSilo, game.ref(10, 10), {});
|
||||
|
||||
// Give fakehuman territory and missile silo
|
||||
for (let x = 25; x < 75; x++) {
|
||||
for (let y = 25; y < 75; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile)) {
|
||||
fakehuman.conquer(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
fakehuman.buildUnit(UnitType.MissileSilo, game.ref(50, 50), {});
|
||||
|
||||
// Give both players enough gold for MIRVs
|
||||
attacker.addGold(100_000_000n);
|
||||
fakehuman.addGold(100_000_000n);
|
||||
|
||||
// Verify preconditions
|
||||
expect(attacker.units(UnitType.MissileSilo)).toHaveLength(1);
|
||||
expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1);
|
||||
expect(attacker.gold()).toBeGreaterThan(35_000_000n);
|
||||
expect(fakehuman.gold()).toBeGreaterThan(35_000_000n);
|
||||
|
||||
// Track MIRVs before fakehuman retaliates
|
||||
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
|
||||
|
||||
// Initialize fakehuman with FakeHumanExecution to enable retaliation logic
|
||||
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
|
||||
|
||||
// Try different game IDs to account for hesitation odds
|
||||
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
|
||||
let retaliationAttempted = false;
|
||||
|
||||
for (const gameId of gameIds) {
|
||||
const testExecution = new FakeHumanExecution(gameId, fakehumanNation);
|
||||
testExecution.init(game);
|
||||
|
||||
// Launch MIRV from attacker to fakehuman
|
||||
const targetTile = Array.from(fakehuman.tiles())[0];
|
||||
game.addExecution(new MirvExecution(attacker, targetTile));
|
||||
|
||||
// Execute fakehuman's tick logic
|
||||
for (let tick = 0; tick < 200; tick++) {
|
||||
testExecution.tick(tick);
|
||||
// Allow the game to process executions
|
||||
if (tick % 10 === 0) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
// Check if fakehuman attempted retaliation
|
||||
if (fakehuman.units(UnitType.MIRV).length > mirvCountBefore) {
|
||||
retaliationAttempted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (retaliationAttempted) break;
|
||||
}
|
||||
|
||||
// Assert that retaliation was attempted
|
||||
expect(retaliationAttempted).toBe(true);
|
||||
|
||||
// Process the retaliation
|
||||
executeTicks(game, 2);
|
||||
|
||||
// Assert: Fakehuman launched a retaliatory MIRV
|
||||
const mirvCountAfter = fakehuman.units(UnitType.MIRV).length;
|
||||
expect(mirvCountAfter).toBeGreaterThan(mirvCountBefore);
|
||||
|
||||
// Verify the retaliatory MIRV targets the attacker's territory
|
||||
const fakehumanMirvs = fakehuman.units(UnitType.MIRV);
|
||||
expect(fakehumanMirvs.length).toBeGreaterThan(0);
|
||||
|
||||
const retaliationMirv = fakehumanMirvs[fakehumanMirvs.length - 1];
|
||||
const retaliationTarget = retaliationMirv.targetTile();
|
||||
expect(retaliationTarget).toBeDefined();
|
||||
|
||||
if (retaliationTarget) {
|
||||
const targetOwner = game.owner(retaliationTarget);
|
||||
expect(targetOwner).toBe(attacker);
|
||||
}
|
||||
});
|
||||
|
||||
test("fakehuman launches MIRV to prevent victory when player approaches win condition", async () => {
|
||||
// Setup game
|
||||
const game = await setup("big_plains", {
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
});
|
||||
|
||||
// Create two players
|
||||
const dominantPlayerInfo = new PlayerInfo(
|
||||
"dominant_player",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"dominant_id",
|
||||
);
|
||||
const fakehumanInfo = new PlayerInfo(
|
||||
"defender_fakehuman",
|
||||
PlayerType.FakeHuman,
|
||||
null,
|
||||
"fakehuman_id",
|
||||
);
|
||||
|
||||
game.addPlayer(dominantPlayerInfo);
|
||||
game.addPlayer(fakehumanInfo);
|
||||
|
||||
// Skip spawn phase
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
const dominantPlayer = game.player("dominant_id");
|
||||
const fakehuman = game.player("fakehuman_id");
|
||||
|
||||
// First, give fakehuman a small territory and missile silo
|
||||
let fakehumanTiles = 0;
|
||||
for (let x = 45; x < 55; x++) {
|
||||
for (let y = 45; y < 55; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
fakehuman.conquer(tile);
|
||||
fakehumanTiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find enough tiles, try a different area
|
||||
if (fakehumanTiles === 0) {
|
||||
for (let x = 60; x < 70; x++) {
|
||||
for (let y = 60; y < 70; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
fakehuman.conquer(tile);
|
||||
fakehumanTiles++;
|
||||
if (fakehumanTiles >= 10) break; // Need at least some territory
|
||||
}
|
||||
}
|
||||
if (fakehumanTiles >= 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build missile silo on one of the fakehuman's tiles
|
||||
const fakehumanTile = Array.from(fakehuman.tiles())[0];
|
||||
if (fakehumanTile) {
|
||||
fakehuman.buildUnit(UnitType.MissileSilo, fakehumanTile, {});
|
||||
}
|
||||
|
||||
// Then give dominant player a large amount of territory
|
||||
// This should trigger the victory denial threshold
|
||||
const totalLandTiles = game.map().numLandTiles();
|
||||
const targetTiles = Math.floor(totalLandTiles * 0.66);
|
||||
|
||||
let conqueredTiles = 0;
|
||||
for (
|
||||
let x = 0;
|
||||
x < game.map().width() && conqueredTiles < targetTiles;
|
||||
x++
|
||||
) {
|
||||
for (
|
||||
let y = 0;
|
||||
y < game.map().height() && conqueredTiles < targetTiles;
|
||||
y++
|
||||
) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
dominantPlayer.conquer(tile);
|
||||
conqueredTiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Give both players enough gold for MIRVs
|
||||
dominantPlayer.addGold(100_000_000n);
|
||||
fakehuman.addGold(100_000_000n);
|
||||
|
||||
// Verify preconditions
|
||||
expect(dominantPlayer.units(UnitType.MissileSilo)).toHaveLength(0);
|
||||
expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1);
|
||||
expect(fakehuman.units(UnitType.MIRV)).toHaveLength(0);
|
||||
expect(dominantPlayer.units(UnitType.MIRV)).toHaveLength(0);
|
||||
expect(dominantPlayer.gold()).toBeGreaterThan(35_000_000n);
|
||||
expect(fakehuman.gold()).toBeGreaterThan(35_000_000n);
|
||||
expect(fakehuman.isAlive()).toBe(true);
|
||||
expect(fakehuman.numTilesOwned()).toBeGreaterThan(0);
|
||||
|
||||
// Verify dominant player has enough territory to trigger victory denial
|
||||
const dominantTerritoryShare =
|
||||
dominantPlayer.numTilesOwned() / game.map().numLandTiles();
|
||||
expect(dominantTerritoryShare).toBeGreaterThan(0.65);
|
||||
|
||||
// Track MIRVs before fakehuman considers victory denial
|
||||
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
|
||||
|
||||
// Initialize fakehuman with FakeHumanExecution to enable victory denial logic
|
||||
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
|
||||
|
||||
// Try different game IDs to account for hesitation odds
|
||||
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
|
||||
let victoryDenialSuccessful = false;
|
||||
|
||||
for (const gameId of gameIds) {
|
||||
const testExecution = new FakeHumanExecution(gameId, fakehumanNation);
|
||||
testExecution.init(game);
|
||||
|
||||
for (let tick = 0; tick < 200; tick++) {
|
||||
testExecution.tick(game.ticks());
|
||||
// Allow the game to process executions
|
||||
if (tick % 10 === 0) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
if (fakehuman.units(UnitType.MIRV).length > mirvCountBefore) {
|
||||
victoryDenialSuccessful = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (victoryDenialSuccessful) break;
|
||||
}
|
||||
|
||||
// Assert that victory denial was successful
|
||||
expect(victoryDenialSuccessful).toBe(true);
|
||||
|
||||
// Process the victory denial MIRV
|
||||
executeTicks(game, 2);
|
||||
|
||||
// Assert: Fakehuman launched a victory denial MIRV
|
||||
const mirvCountAfter = fakehuman.units(UnitType.MIRV).length;
|
||||
expect(mirvCountAfter).toBeGreaterThan(mirvCountBefore);
|
||||
|
||||
// Verify the victory denial MIRV targets the dominant player's territory
|
||||
const fakehumanMirvs = fakehuman.units(UnitType.MIRV);
|
||||
expect(fakehumanMirvs.length).toBeGreaterThan(0);
|
||||
|
||||
const victoryDenialMirv = fakehumanMirvs[fakehumanMirvs.length - 1];
|
||||
const victoryDenialTarget = victoryDenialMirv.targetTile();
|
||||
expect(victoryDenialTarget).toBeDefined();
|
||||
|
||||
if (victoryDenialTarget) {
|
||||
const targetOwner = game.owner(victoryDenialTarget);
|
||||
expect(targetOwner).toBe(dominantPlayer);
|
||||
}
|
||||
});
|
||||
|
||||
test("fakehuman launches MIRV to stop steamrolling player with excessive cities", async () => {
|
||||
// Setup game
|
||||
const game = await setup("big_plains", {
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
});
|
||||
|
||||
// Create three players
|
||||
const steamrollerInfo = new PlayerInfo(
|
||||
"steamroller",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"steamroller_id",
|
||||
);
|
||||
const secondPlayerInfo = new PlayerInfo(
|
||||
"second_player",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"second_id",
|
||||
);
|
||||
const fakehumanInfo = new PlayerInfo(
|
||||
"defender_fakehuman",
|
||||
PlayerType.FakeHuman,
|
||||
null,
|
||||
"fakehuman_id",
|
||||
);
|
||||
|
||||
game.addPlayer(steamrollerInfo);
|
||||
game.addPlayer(secondPlayerInfo);
|
||||
game.addPlayer(fakehumanInfo);
|
||||
|
||||
// Skip spawn phase
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
const steamroller = game.player("steamroller_id");
|
||||
const secondPlayer = game.player("second_id");
|
||||
const fakehuman = game.player("fakehuman_id");
|
||||
|
||||
// Give fakehuman a small territory and missile silo
|
||||
for (let x = 45; x < 55; x++) {
|
||||
for (let y = 45; y < 55; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
fakehuman.conquer(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
const fakehumanTile = Array.from(fakehuman.tiles())[0];
|
||||
if (fakehumanTile) {
|
||||
fakehuman.buildUnit(UnitType.MissileSilo, fakehumanTile, {});
|
||||
}
|
||||
|
||||
// Give second player some territory and cities
|
||||
for (let x = 20; x < 30; x++) {
|
||||
for (let y = 20; y < 30; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
secondPlayer.conquer(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Give second player 5 cities
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const secondPlayerTile = Array.from(secondPlayer.tiles())[0];
|
||||
if (secondPlayerTile) {
|
||||
secondPlayer.buildUnit(UnitType.City, secondPlayerTile, {});
|
||||
}
|
||||
}
|
||||
|
||||
// Give steamroller territory and many cities
|
||||
for (let x = 5; x < 25; x++) {
|
||||
for (let y = 5; y < 25; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
steamroller.conquer(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Give steamroller cities
|
||||
const minLeaderCities = 10;
|
||||
for (let i = 0; i < minLeaderCities + 2; i++) {
|
||||
const steamrollerTile = Array.from(steamroller.tiles())[0];
|
||||
if (steamrollerTile) {
|
||||
steamroller.buildUnit(UnitType.City, steamrollerTile, {});
|
||||
}
|
||||
}
|
||||
|
||||
// Give all players enough gold for MIRVs
|
||||
steamroller.addGold(100_000_000n);
|
||||
secondPlayer.addGold(100_000_000n);
|
||||
fakehuman.addGold(100_000_000n);
|
||||
|
||||
// Verify preconditions
|
||||
expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1);
|
||||
expect(steamroller.unitCount(UnitType.City)).toBe(minLeaderCities + 2);
|
||||
expect(secondPlayer.unitCount(UnitType.City)).toBe(5);
|
||||
expect(fakehuman.units(UnitType.MIRV)).toHaveLength(0);
|
||||
|
||||
// Track MIRVs before fakehuman considers steamroll stop
|
||||
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
|
||||
|
||||
// Initialize fakehuman with FakeHumanExecution to enable steamroll stop logic
|
||||
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
|
||||
|
||||
// Try different game IDs to account for hesitation odds
|
||||
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
|
||||
let steamrollStopSuccessful = false;
|
||||
|
||||
for (const gameId of gameIds) {
|
||||
const testExecution = new FakeHumanExecution(gameId, fakehumanNation);
|
||||
testExecution.init(game);
|
||||
|
||||
for (let tick = 0; tick < 200; tick++) {
|
||||
testExecution.tick(game.ticks());
|
||||
// Allow the game to process executions
|
||||
if (tick % 10 === 0) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
if (fakehuman.units(UnitType.MIRV).length > mirvCountBefore) {
|
||||
steamrollStopSuccessful = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (steamrollStopSuccessful) break;
|
||||
}
|
||||
|
||||
// Assert that steamroll stop was successful
|
||||
expect(steamrollStopSuccessful).toBe(true);
|
||||
|
||||
// Process the steamroll stop MIRV
|
||||
executeTicks(game, 2);
|
||||
|
||||
// Assert: Fakehuman launched a steamroll stop MIRV
|
||||
const mirvCountAfter = fakehuman.units(UnitType.MIRV).length;
|
||||
expect(mirvCountAfter).toBeGreaterThan(mirvCountBefore);
|
||||
|
||||
// Verify the steamroll stop MIRV targets the steamroller's territory
|
||||
const fakehumanMirvs = fakehuman.units(UnitType.MIRV);
|
||||
expect(fakehumanMirvs.length).toBeGreaterThan(0);
|
||||
|
||||
const steamrollStopMirv = fakehumanMirvs[fakehumanMirvs.length - 1];
|
||||
const steamrollStopTarget = steamrollStopMirv.targetTile();
|
||||
expect(steamrollStopTarget).toBeDefined();
|
||||
|
||||
if (steamrollStopTarget) {
|
||||
const targetOwner = game.owner(steamrollStopTarget);
|
||||
expect(targetOwner).toBe(steamroller);
|
||||
}
|
||||
});
|
||||
|
||||
test("fakehuman does not launch MIRV for steamroll when leader has <= 10 cities", async () => {
|
||||
// Setup game
|
||||
const game = await setup("big_plains", {
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
});
|
||||
|
||||
// Create three players
|
||||
const steamrollerInfo = new PlayerInfo(
|
||||
"steamroller",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"steamroller_id",
|
||||
);
|
||||
const secondPlayerInfo = new PlayerInfo(
|
||||
"second_player",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"second_id",
|
||||
);
|
||||
const fakehumanInfo = new PlayerInfo(
|
||||
"defender_fakehuman",
|
||||
PlayerType.FakeHuman,
|
||||
null,
|
||||
"fakehuman_id",
|
||||
);
|
||||
|
||||
game.addPlayer(steamrollerInfo);
|
||||
game.addPlayer(secondPlayerInfo);
|
||||
game.addPlayer(fakehumanInfo);
|
||||
|
||||
// Skip spawn phase
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
const steamroller = game.player("steamroller_id");
|
||||
const secondPlayer = game.player("second_id");
|
||||
const fakehuman = game.player("fakehuman_id");
|
||||
|
||||
// Give fakehuman a small territory and missile silo
|
||||
for (let x = 45; x < 55; x++) {
|
||||
for (let y = 45; y < 55; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
fakehuman.conquer(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
const fakehumanTile = Array.from(fakehuman.tiles())[0];
|
||||
if (fakehumanTile) {
|
||||
fakehuman.buildUnit(UnitType.MissileSilo, fakehumanTile, {});
|
||||
}
|
||||
|
||||
// Give second player territory and cities (5 cities)
|
||||
for (let x = 25; x < 45; x++) {
|
||||
for (let y = 25; y < 45; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
secondPlayer.conquer(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const secondPlayerTile = Array.from(secondPlayer.tiles())[0];
|
||||
if (secondPlayerTile) {
|
||||
secondPlayer.buildUnit(UnitType.City, secondPlayerTile, {});
|
||||
}
|
||||
}
|
||||
|
||||
// Give steamroller territory and cities
|
||||
const minLeaderCities = 10;
|
||||
for (let x = 5; x < 25; x++) {
|
||||
for (let y = 5; y < 25; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
steamroller.conquer(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < minLeaderCities; i++) {
|
||||
const steamrollerTile = Array.from(steamroller.tiles())[0];
|
||||
if (steamrollerTile) {
|
||||
steamroller.buildUnit(UnitType.City, steamrollerTile, {});
|
||||
}
|
||||
}
|
||||
|
||||
// Give all players enough gold for MIRVs
|
||||
steamroller.addGold(100_000_000n);
|
||||
secondPlayer.addGold(100_000_000n);
|
||||
fakehuman.addGold(100_000_000n);
|
||||
|
||||
// Verify preconditions
|
||||
expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1);
|
||||
expect(steamroller.unitCount(UnitType.City)).toBe(minLeaderCities);
|
||||
expect(secondPlayer.unitCount(UnitType.City)).toBe(5);
|
||||
expect(fakehuman.units(UnitType.MIRV)).toHaveLength(0);
|
||||
|
||||
// Track MIRVs before fakehuman considers steamroll stop
|
||||
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
|
||||
|
||||
// Initialize fakehuman with FakeHumanExecution to enable steamroll stop logic
|
||||
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
|
||||
|
||||
// Try different game IDs to account for hesitation odds
|
||||
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
|
||||
let steamrollStopAttempted = false;
|
||||
|
||||
for (const gameId of gameIds) {
|
||||
const testExecution = new FakeHumanExecution(gameId, fakehumanNation);
|
||||
testExecution.init(game);
|
||||
|
||||
for (let tick = 0; tick < 200; tick++) {
|
||||
testExecution.tick(game.ticks());
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
// Check if any MIRVs were launched for steamroll stop
|
||||
const fakehumanMirvs = fakehuman.units(UnitType.MIRV);
|
||||
if (fakehumanMirvs.length > mirvCountBefore) {
|
||||
steamrollStopAttempted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert that steamroll stop was NOT attempted
|
||||
expect(steamrollStopAttempted).toBe(false);
|
||||
});
|
||||
|
||||
test("fakehuman launches MIRV to prevent team victory when team approaches victory denial threshold (targets biggest team member)", async () => {
|
||||
// Setup game
|
||||
const teamPlayer1Info = new PlayerInfo(
|
||||
"[ALPHA]team_player_1",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"team1_id",
|
||||
);
|
||||
const teamPlayer2Info = new PlayerInfo(
|
||||
"[ALPHA]team_player_2",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"team2_id",
|
||||
);
|
||||
const fakehumanInfo = new PlayerInfo(
|
||||
"defender_fakehuman",
|
||||
PlayerType.FakeHuman,
|
||||
null,
|
||||
"fakehuman_id",
|
||||
);
|
||||
const game = await setup(
|
||||
"big_plains",
|
||||
{
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
gameMode: GameMode.Team,
|
||||
playerTeams: 2,
|
||||
},
|
||||
[teamPlayer1Info, teamPlayer2Info, fakehumanInfo],
|
||||
);
|
||||
|
||||
// Players already added via setup() with Team mode and shared clan for humans
|
||||
|
||||
// Skip spawn phase
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
const teamPlayer1 = game.player("team1_id");
|
||||
const teamPlayer2 = game.player("team2_id");
|
||||
const fakehuman = game.player("fakehuman_id");
|
||||
|
||||
// Give fakehuman a small territory and missile silo
|
||||
for (let x = 45; x < 55; x++) {
|
||||
for (let y = 45; y < 55; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
fakehuman.conquer(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
const fakehumanTile = Array.from(fakehuman.tiles())[0];
|
||||
if (fakehumanTile) {
|
||||
fakehuman.buildUnit(UnitType.MissileSilo, fakehumanTile, {});
|
||||
}
|
||||
|
||||
// Give team players a large amount of territory to exceed team threshold,
|
||||
// but skew so teamPlayer1 is clearly the largest member
|
||||
const totalLandTiles = game.map().numLandTiles();
|
||||
const teamTargetTiles = Math.floor(totalLandTiles * 0.82);
|
||||
|
||||
let conqueredTiles = 0;
|
||||
for (
|
||||
let x = 0;
|
||||
x < game.map().width() && conqueredTiles < teamTargetTiles;
|
||||
x++
|
||||
) {
|
||||
for (
|
||||
let y = 0;
|
||||
y < game.map().height() && conqueredTiles < teamTargetTiles;
|
||||
y++
|
||||
) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile) && !game.map().hasOwner(tile)) {
|
||||
// 3:1 bias towards teamPlayer1 to ensure largest-member targeting is well-defined
|
||||
const teamPlayer =
|
||||
conqueredTiles % 4 === 0 ? teamPlayer2 : teamPlayer1;
|
||||
teamPlayer.conquer(tile);
|
||||
conqueredTiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Give all players enough gold for MIRVs
|
||||
teamPlayer1.addGold(100_000_000n);
|
||||
teamPlayer2.addGold(100_000_000n);
|
||||
fakehuman.addGold(100_000_000n);
|
||||
|
||||
// Verify preconditions
|
||||
expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1);
|
||||
expect(fakehuman.units(UnitType.MIRV)).toHaveLength(0);
|
||||
expect(teamPlayer1.gold()).toBeGreaterThan(35_000_000n);
|
||||
expect(teamPlayer2.gold()).toBeGreaterThan(35_000_000n);
|
||||
expect(fakehuman.gold()).toBeGreaterThan(35_000_000n);
|
||||
expect(fakehuman.isAlive()).toBe(true);
|
||||
expect(fakehuman.numTilesOwned()).toBeGreaterThan(0);
|
||||
|
||||
// Verify team has enough territory to trigger team victory denial
|
||||
const teamTerritory =
|
||||
teamPlayer1.numTilesOwned() + teamPlayer2.numTilesOwned();
|
||||
const teamShare = teamTerritory / game.map().numLandTiles();
|
||||
expect(teamShare).toBeGreaterThan(0.8); //
|
||||
|
||||
// Track MIRVs before fakehuman considers team victory denial
|
||||
const mirvCountBefore = fakehuman.units(UnitType.MIRV).length;
|
||||
|
||||
// Initialize fakehuman with FakeHumanExecution to enable team victory denial logic
|
||||
const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info());
|
||||
|
||||
// Try different game IDs to account for hesitation odds
|
||||
const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`);
|
||||
let teamVictoryDenialSuccessful = false;
|
||||
|
||||
for (const gameId of gameIds) {
|
||||
const testExecution = new FakeHumanExecution(gameId, fakehumanNation);
|
||||
testExecution.init(game);
|
||||
|
||||
for (let tick = 0; tick < 200; tick++) {
|
||||
testExecution.tick(game.ticks());
|
||||
// Allow the game to process executions
|
||||
if (tick % 10 === 0) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
if (fakehuman.units(UnitType.MIRV).length > mirvCountBefore) {
|
||||
teamVictoryDenialSuccessful = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (teamVictoryDenialSuccessful) break;
|
||||
}
|
||||
|
||||
// Assert that team victory denial was successful
|
||||
expect(teamVictoryDenialSuccessful).toBe(true);
|
||||
|
||||
// Process the team victory denial MIRV
|
||||
executeTicks(game, 2);
|
||||
|
||||
// Assert: Fakehuman launched a team victory denial MIRV
|
||||
const mirvCountAfter = fakehuman.units(UnitType.MIRV).length;
|
||||
expect(mirvCountAfter).toBeGreaterThan(mirvCountBefore);
|
||||
|
||||
// Verify the team victory denial MIRV targets the largest member of the team
|
||||
const fakehumanMirvs = fakehuman.units(UnitType.MIRV);
|
||||
expect(fakehumanMirvs.length).toBeGreaterThan(0);
|
||||
|
||||
const teamVictoryDenialMirv = fakehumanMirvs[fakehumanMirvs.length - 1];
|
||||
const teamVictoryDenialTarget = teamVictoryDenialMirv.targetTile();
|
||||
expect(teamVictoryDenialTarget).toBeDefined();
|
||||
|
||||
if (teamVictoryDenialTarget) {
|
||||
const targetOwner = game.owner(teamVictoryDenialTarget);
|
||||
// Should target the biggest member of the team
|
||||
const biggest =
|
||||
teamPlayer1.numTilesOwned() >= teamPlayer2.numTilesOwned()
|
||||
? teamPlayer1
|
||||
: teamPlayer2;
|
||||
expect(targetOwner).toBe(biggest);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -299,16 +299,18 @@ describe("RadialMenuElements", () => {
|
||||
expect(rootMenuElement.disabled(mockParams)).toBe(false);
|
||||
});
|
||||
|
||||
it("should show build menu on own territory", () => {
|
||||
it("should show build and delete menu on own territory", () => {
|
||||
const subMenu = rootMenuElement.subMenu!(mockParams);
|
||||
const buildMenu = subMenu.find((item) => item.id === Slot.Build);
|
||||
const attackMenu = subMenu.find((item) => item.id === Slot.Attack);
|
||||
const deleteMenu = subMenu.find((item) => item.id === Slot.Delete);
|
||||
|
||||
expect(buildMenu).toBeDefined();
|
||||
expect(attackMenu).toBeUndefined();
|
||||
expect(deleteMenu).toBeDefined();
|
||||
});
|
||||
|
||||
it("should show attack menu on enemy territory", () => {
|
||||
it("should show attack and boat menu on enemy territory", () => {
|
||||
const enemyPlayer = {
|
||||
id: () => 2,
|
||||
isPlayer: jest.fn(() => true),
|
||||
@@ -318,18 +320,18 @@ describe("RadialMenuElements", () => {
|
||||
const subMenu = rootMenuElement.subMenu!(mockParams);
|
||||
const buildMenu = subMenu.find((item) => item.id === Slot.Build);
|
||||
const attackMenu = subMenu.find((item) => item.id === Slot.Attack);
|
||||
const boatMenu = subMenu.find((item) => item.id === Slot.Boat);
|
||||
|
||||
expect(attackMenu).toBeDefined();
|
||||
expect(buildMenu).toBeUndefined();
|
||||
expect(boatMenu).toBeDefined();
|
||||
});
|
||||
|
||||
it("should include info and boat menus in both cases", () => {
|
||||
it("should include info menu in both cases", () => {
|
||||
const subMenu = rootMenuElement.subMenu!(mockParams);
|
||||
const infoMenu = subMenu.find((item) => item.id === Slot.Info);
|
||||
const boatMenu = subMenu.find((item) => item.id === Slot.Boat);
|
||||
|
||||
expect(infoMenu).toBeDefined();
|
||||
expect(boatMenu).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle ally menu correctly", () => {
|
||||
|
||||
@@ -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 = () => {};
|
||||
@@ -69,7 +70,7 @@ export async function setup(
|
||||
instantBuild: false,
|
||||
..._gameConfig,
|
||||
};
|
||||
const config = new TestConfig(
|
||||
const config = new ConfigClass(
|
||||
serverConfig,
|
||||
gameConfig,
|
||||
new UserSettings(),
|
||||
|
||||
@@ -81,3 +81,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||