Merge branch 'main' into canbuildtransport-perf

This commit is contained in:
VariableVince
2025-11-02 00:36:23 +01:00
committed by GitHub
93 changed files with 3312 additions and 879 deletions
+28
View File
@@ -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/)
+6 -2
View File
@@ -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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

@@ -0,0 +1,4 @@
{
"name": "Baikal (Nuke Wars)",
"nations": []
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 KiB

After

Width:  |  Height:  |  Size: 655 KiB

+59 -5
View File
@@ -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
}
]
}
+1
View File
@@ -18,6 +18,7 @@ var maps = []struct {
{Name: "australia"},
{Name: "achiran"},
{Name: "baikal"},
{Name: "baikalnukewars"},
{Name: "betweentwoseas"},
{Name: "blacksea"},
{Name: "britannia"},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 B

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 B

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 726 B

+13 -7
View File
@@ -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 (1100%)",
"troop_ratio_desc": "Adjust the balance between troops (for combat) and workers (for gold production) (1100%)",
"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": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+3 -3
View File
@@ -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",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+59 -5
View File
@@ -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
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+31 -2
View File
@@ -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>
`;
}
+65 -33
View File
@@ -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>),
+4 -7
View File
@@ -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(
+2
View File
@@ -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) {
+47 -18
View File
@@ -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,
+53
View File
@@ -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;
}
+1
View File
@@ -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")
+79 -38
View File
@@ -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
+8
View File
@@ -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),
+4 -4
View File
@@ -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,
+38
View File
@@ -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;
}
-1
View File
@@ -26,7 +26,6 @@ export function conquestFxFactory(
x,
y,
FxType.Conquest,
2500,
);
const fadeAnimation = new FadeFx(swordAnimation, 0.1, 0.6);
conquestFx.push(fadeAnimation);
+4
View File
@@ -16,4 +16,8 @@ export enum FxType {
UnderConstruction = "UnderConstruction",
Dust = "Dust",
Conquest = "Conquest",
Tentacle = "Tentacle",
Shark = "Shark",
Bubble = "Bubble",
Tornado = "Tornado",
}
+76
View File
@@ -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;
}
}
+31 -2
View File
@@ -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();
+30 -70
View File
@@ -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
+118 -3
View File
@@ -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 {
+21 -2
View File
@@ -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);
},
};
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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;
+30 -19
View File
@@ -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);
+1 -1
View File
@@ -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);
}
+3
View File
@@ -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>;
+14 -5
View File
@@ -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;
}
+10 -2
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -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 (
+24 -24
View File
@@ -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({
+3 -3
View File
@@ -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);
+6
View File
@@ -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();
+251 -28
View File
@@ -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;
}
+3 -1
View File
@@ -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,
);
+41 -5
View File
@@ -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);
}
}
}
+59
View File
@@ -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;
}
+1 -5
View File
@@ -55,10 +55,6 @@ export class WarshipExecution implements Execution {
this.warship.delete();
return;
}
if (this.warship.owner().isDisconnected()) {
this.warship.delete();
return;
}
const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
if (hasPort) {
@@ -93,7 +89,7 @@ export class WarshipExecution implements Execution {
if (
unit.owner() === this.warship.owner() ||
unit === this.warship ||
unit.owner().isFriendly(this.warship.owner()) ||
unit.owner().isFriendly(this.warship.owner(), true) ||
this.alreadySentShell.has(unit)
) {
continue;
+1 -1
View File
@@ -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;
+12 -9
View File
@@ -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;
+33 -1
View File
@@ -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(
+1 -1
View File
@@ -169,7 +169,7 @@ export interface PlayerUpdate {
outgoingAllianceRequests: PlayerID[];
alliances: AllianceView[];
hasSpawned: boolean;
betrayals?: bigint;
betrayals: number;
lastDeleteUnitTick: Tick;
}
+10 -5
View File
@@ -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);
+2 -5
View File
@@ -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(
+2
View File
@@ -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) {
+2 -1
View File
@@ -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;
},
);
+21 -5
View File
@@ -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;
}
+2
View File
@@ -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", () => {
+2
View File
@@ -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);
+321 -1
View File
@@ -1,12 +1,25 @@
import { AttackExecution } from "../src/core/execution/AttackExecution";
import { MarkDisconnectedExecution } from "../src/core/execution/MarkDisconnectedExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { TransportShipExecution } from "../src/core/execution/TransportShipExecution";
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
import {
Game,
GameMode,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { toInt } from "../src/core/Util";
import { setup } from "./util/Setup";
import { UseRealAttackLogic } from "./util/TestConfig";
import { executeTicks } from "./util/utils";
let game: Game;
let player1: Player;
let player2: Player;
let enemy: Player;
describe("Disconnected", () => {
beforeEach(async () => {
@@ -158,4 +171,311 @@ describe("Disconnected", () => {
expect(player1.isDisconnected()).toBe(true);
});
});
describe("Disconnected team member interactions", () => {
const coastX = 7;
beforeEach(async () => {
const player1Info = new PlayerInfo(
"[CLAN]Player1",
PlayerType.Human,
null,
"player_1_id",
);
const player2Info = new PlayerInfo(
"[CLAN]Player2",
PlayerType.Human,
null,
"player_2_id",
);
game = await setup(
"half_land_half_ocean",
{
infiniteGold: true,
instantBuild: true,
gameMode: GameMode.Team,
playerTeams: 2, // ignore player2 "kicked" console warn
},
[player1Info, player2Info],
undefined,
UseRealAttackLogic, // don't use TestConfig's mock attackLogic
);
game.addExecution(
new SpawnExecution(player1Info, game.map().ref(coastX - 2, 1)),
new SpawnExecution(player2Info, game.map().ref(coastX - 2, 4)),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player(player1Info.id);
player2 = game.player(player2Info.id);
player2.markDisconnected(false);
expect(player1.team()).not.toBeNull();
expect(player2.team()).not.toBeNull();
expect(player1.isOnSameTeam(player2)).toBe(true);
});
test("Team Warships should not attack disconnected team mate ships", () => {
const warship = player1.buildUnit(
UnitType.Warship,
game.map().ref(coastX + 1, 10),
{
patrolTile: game.map().ref(coastX + 1, 10),
},
);
game.addExecution(new WarshipExecution(warship));
const transportShip = player2.buildUnit(
UnitType.TransportShip,
game.map().ref(coastX + 1, 11),
{
troops: 100,
},
);
player2.markDisconnected(true);
executeTicks(game, 10);
expect(warship.targetUnit()).toBe(undefined);
expect(transportShip.isActive()).toBe(true);
expect(transportShip.owner()).toBe(player2);
});
test("Disconnected player Warship should not attack team members' ships", () => {
const warship = player2.buildUnit(
UnitType.Warship,
game.map().ref(coastX + 1, 5),
{
patrolTile: game.map().ref(coastX + 1, 10),
},
);
game.addExecution(new WarshipExecution(warship));
const transportShip = player1.buildUnit(
UnitType.TransportShip,
game.map().ref(coastX + 1, 6),
{
troops: 100,
},
);
player2.markDisconnected(true);
executeTicks(game, 10);
expect(warship.targetUnit()).toBe(undefined);
expect(transportShip.isActive()).toBe(true);
expect(transportShip.owner()).toBe(player1);
});
test("Player can attack disconnected team mate without troop loss", () => {
player2.conquer(game.map().ref(coastX - 2, 2));
player2.conquer(game.map().ref(coastX - 2, 3));
player2.markDisconnected(true);
const troopsBeforeAttack = player1.troops();
const startTroops = troopsBeforeAttack * 0.25;
game.addExecution(
new AttackExecution(startTroops, player1, player2.id(), null),
);
let expectedTotalGrowth = 0n;
let afterTickZero = false;
while (player2.isAlive()) {
if (afterTickZero) {
// No growth on tick 0, troop additions start from tick 1
const troopIncThisTick = game.config().troopIncreaseRate(player1);
expectedTotalGrowth += toInt(troopIncThisTick);
}
game.executeNextTick();
afterTickZero = true;
}
// Tick for retreat() in AttackExecution to add back startTtoops to owner troops
const troopIncThisTick1 = game.config().troopIncreaseRate(player1);
expectedTotalGrowth += toInt(troopIncThisTick1);
game.executeNextTick();
const expectedFinalTroops = Number(
toInt(troopsBeforeAttack) + expectedTotalGrowth,
);
// Verify no troop loss
expect(player1.troops()).toBe(expectedFinalTroops);
});
test("Conqueror gets conquered disconnected team member's transport- and warships", () => {
const warship = player2.buildUnit(
UnitType.Warship,
game.map().ref(coastX + 1, 1),
{
patrolTile: game.map().ref(coastX + 1, 1),
},
);
const transportShip = player2.buildUnit(
UnitType.TransportShip,
game.map().ref(coastX + 1, 3),
{
troops: 100,
},
);
player2.conquer(game.map().ref(coastX - 2, 1));
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(warship.owner()).toBe(player1);
expect(transportShip.owner()).toBe(player1);
});
test("Captured transport ship landing attack should be in name of new owner", () => {
player2.conquer(game.map().ref(coastX, 1));
player2.conquer(game.map().ref(coastX - 1, 1));
player2.conquer(game.map().ref(coastX, 2));
const enemyShoreTile = game.map().ref(coastX, 15);
game.addExecution(
new TransportShipExecution(
player2,
null,
enemyShoreTile,
100,
game.map().ref(coastX, 1),
),
);
executeTicks(game, 1);
expect(player2.isAlive()).toBe(true);
const transportShip = player2.units(UnitType.TransportShip)[0];
expect(player2.units(UnitType.TransportShip).length).toBe(1);
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
executeTicks(game, 30);
// Verify ship landed and tile ownership transferred to new ship owner
expect(game.owner(enemyShoreTile)).toBe(player1);
});
test("Captured transport ship should retreat to owner's shore tile", () => {
player1.conquer(game.map().ref(coastX, 4));
player2.conquer(game.map().ref(coastX, 1));
const enemyShoreTile = game.map().ref(coastX, 8);
game.addExecution(
new TransportShipExecution(
player2,
null,
enemyShoreTile,
100,
game.map().ref(coastX, 1),
),
);
executeTicks(game, 1);
const transportShip = player2.units(UnitType.TransportShip)[0];
expect(player2.units(UnitType.TransportShip).length).toBe(1);
expect(transportShip.targetTile()).toBe(enemyShoreTile);
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
transportShip.orderBoatRetreat();
executeTicks(game, 2);
expect(transportShip.targetTile()).not.toBe(enemyShoreTile);
expect(game.owner(transportShip.targetTile()!)).toBe(player1);
});
test("Retreating transport ship is deleted if new owner has no shore tiles", () => {
player2.conquer(game.map().ref(coastX, 1));
player2.conquer(game.map().ref(coastX - 6, 2));
player1.conquer(game.map().ref(coastX - 6, 3));
const enemyShoreTile = game.map().ref(coastX, 15);
const boatTroops = 100;
game.addExecution(
new TransportShipExecution(
player2,
null,
enemyShoreTile,
boatTroops,
game.map().ref(coastX, 1),
),
);
executeTicks(game, 1);
const transportShip = player2.units(UnitType.TransportShip)[0];
expect(player2.units(UnitType.TransportShip).length).toBe(1);
player2.markDisconnected(true);
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
executeTicks(game, 10);
expect(player2.isAlive()).toBe(false);
expect(transportShip.owner()).toBe(player1);
// Make sure player1 has no shore tiles for the ship to retreat to anymore
const enemyInfo = new PlayerInfo(
"Enemy",
PlayerType.Human,
null,
"enemy_id",
);
enemy = game.addPlayer(enemyInfo);
const shoreTiles = Array.from(player1.borderTiles()).filter((t) =>
game.isShore(t),
);
shoreTiles.forEach((tile) => {
enemy.conquer(tile);
});
expect(
Array.from(player1.borderTiles()).filter((t) => game.isShore(t)).length,
).toBe(0);
executeTicks(game, 1);
const troopIncPerTick = game.config().troopIncreaseRate(player1);
const expectedTroopGrowth = toInt(troopIncPerTick * 1);
const expectedFinalTroops = Number(
toInt(player1.troops()) + expectedTroopGrowth,
);
transportShip.orderBoatRetreat();
executeTicks(game, 1);
expect(transportShip.isActive()).toBe(false);
// Also test if boat troops were returned to player1 as new ship owner
expect(player1.troops()).toBe(expectedFinalTroops + boatTroops);
});
});
});
+741
View File
@@ -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", () => {
+2 -1
View File
@@ -25,6 +25,7 @@ export async function setup(
_gameConfig: Partial<GameConfig> = {},
humans: PlayerInfo[] = [],
currentDir: string = __dirname,
ConfigClass: typeof TestConfig = TestConfig,
): Promise<Game> {
// Suppress console.debug for tests.
console.debug = () => {};
@@ -69,7 +70,7 @@ export async function setup(
instantBuild: false,
..._gameConfig,
};
const config = new TestConfig(
const config = new ConfigClass(
serverConfig,
gameConfig,
new UserSettings(),
+23
View File
@@ -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,
);
}
}