Merge branch 'main' into team-names

This commit is contained in:
Mattia Migliorini
2026-03-13 10:31:40 +01:00
committed by GitHub
61 changed files with 737 additions and 372 deletions
+5 -2
View File
@@ -252,8 +252,11 @@
></ranked-modal>
</main-layout>
<!-- Desktop Footer -->
<page-footer></page-footer>
<!-- Ad above footer -->
<div class="[.in-game_&]:hidden mt-auto flex flex-col shrink-0">
<home-footer-ad></home-footer-ad>
<page-footer></page-footer>
</div>
<!-- Global Modals -->
<territory-patterns-modal
+1
View File
@@ -62,6 +62,7 @@ var maps = []struct {
{Name: "sierpinski"},
{Name: "southamerica"},
{Name: "straitofgibraltar"},
{Name: "straitofhormuz"},
{Name: "surrounded"},
{Name: "svalmel"},
{Name: "world"},
+11 -2
View File
@@ -89,6 +89,8 @@
"hotkeys": "Hotkeys",
"table_key": "Key",
"table_action": "Action",
"action_esc": "Closes menu. Cancels unit build preview.",
"action_enter": "Builds unit under cursor",
"action_alt_view": "Alternate view (terrain/countries)",
"action_coordinate_grid": "Toggle coordinate grid overlay",
"action_attack_altclick": "Attack (when left click is set to open menu)",
@@ -193,6 +195,7 @@
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
"compact_map": "Compact Map",
"disable_alliances": "Disable alliances",
"max_timer": "Game length (minutes)",
"max_timer_placeholder": "Mins",
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
@@ -414,6 +417,7 @@
"infinite_troops": "Infinite troops",
"donate_troops": "Donate troops",
"compact_map": "Compact Map",
"disable_alliances": "Disable alliances",
"enables_title": "Enable Settings",
"player": "Player",
"players": "Players",
@@ -431,9 +435,12 @@
"teams_Trios": "Trios (teams of 3)",
"teams_Quads": "Quads (teams of 4)",
"teams_Humans Vs Nations": "Humans vs Nations",
"starting_gold": "Starting gold",
"crowded": "Crowded modifier",
"hard_nations": "Hard Nations",
"gold_multiplier": "Gold multiplier",
"gold_multiplier_placeholder": "2.0x",
"starting_gold": "Starting Gold (Millions)",
"starting_gold_placeholder": "5",
"leave_confirmation": "Are you sure you want to leave the lobby?"
},
"team_colors": {
@@ -478,7 +485,9 @@
"compact_map": "Compact Map",
"crowded": "Crowded",
"hard_nations": "Hard Nations",
"starting_gold": "{amount}M Starting Gold"
"starting_gold": "{amount}M Starting Gold",
"gold_multiplier": "x{amount} Gold Multiplier",
"disable_alliances": "Alliances Disabled"
},
"select_lang": {
"title": "Select Language"
+48 -92
View File
@@ -1,284 +1,240 @@
{
"map": {
"height": 1828,
"num_land_tiles": 1678775,
"num_land_tiles": 1679064,
"width": 1828
},
"map16x": {
"height": 457,
"num_land_tiles": 97927,
"num_land_tiles": 97929,
"width": 457
},
"map4x": {
"height": 914,
"num_land_tiles": 409832,
"num_land_tiles": 409868,
"width": 914
},
"name": "arctic_circle",
"name": "Arctic",
"nations": [
{
"coordinates": [622, 1171],
"flag": "gl",
"name": "Greenland",
"strength": 2
"name": "Greenland"
},
{
"coordinates": [632, 1438],
"flag": "is",
"name": "Iceland",
"strength": 1
"name": "Iceland"
},
{
"coordinates": [90, 1046],
"flag": "Quebec",
"name": "Quebec",
"strength": 1
"name": "Quebec"
},
{
"coordinates": [747, 336],
"flag": "Alaska",
"name": "Alaska",
"strength": 2
"name": "Alaska"
},
{
"coordinates": [485, 927],
"flag": "ca",
"name": "Nunavut",
"strength": 1
"name": "Nunavut"
},
{
"coordinates": [432, 550],
"flag": "ca",
"name": "Northwest Territories",
"strength": 1
"name": "Northwest Territories"
},
{
"coordinates": [608, 447],
"flag": "ca",
"name": "Yukon",
"strength": 1
"name": "Yukon"
},
{
"coordinates": [344, 320],
"flag": "ca",
"name": "British Columbia",
"strength": 1
"name": "British Columbia"
},
{
"coordinates": [64, 601],
"flag": "ca",
"name": "Manitoba",
"strength": 1
"name": "Manitoba"
},
{
"coordinates": [247, 571],
"flag": "ca",
"name": "Saskatchewan",
"strength": 1
"name": "Saskatchewan"
},
{
"coordinates": [136, 401],
"flag": "ca",
"name": "Alberta",
"strength": 1
"name": "Alberta"
},
{
"coordinates": [67, 795],
"flag": "ca",
"name": "Ontario",
"strength": 2
"name": "Ontario"
},
{
"coordinates": [49, 1313],
"flag": "ca",
"name": "Newfoundland and Labrador",
"strength": 1
"name": "Newfoundland and Labrador"
},
{
"coordinates": [74, 226],
"flag": "us",
"name": "United States of America",
"strength": 3
"name": "United States of America"
},
{
"coordinates": [1457, 1381],
"flag": "Communist flag",
"name": "Soviet Union",
"strength": 3
"name": "Soviet Union"
},
{
"coordinates": [1683, 1414],
"flag": "Communist flag",
"name": "Kazakh SSR",
"strength": 2
"name": "Kazakh SSR"
},
{
"coordinates": [1709, 1603],
"flag": "Communist flag",
"name": "Uzbek SSR",
"strength": 1
"name": "Uzbek SSR"
},
{
"coordinates": [1784, 1744],
"flag": "Communist flag",
"name": "Turkmen SSR",
"strength": 2
"name": "Turkmen SSR"
},
{
"coordinates": [1803, 1265],
"flag": "Communist flag",
"name": "Kirghiz SSR",
"strength": 1
"name": "Kirghiz SSR"
},
{
"coordinates": [745, 1736],
"flag": "gb",
"name": "United Kingdom",
"strength": 3
"name": "United Kingdom"
},
{
"coordinates": [893, 1773],
"flag": "west_germany",
"name": "West Germany",
"strength": 1
"name": "West Germany"
},
{
"coordinates": [987, 1792],
"flag": "east_germany",
"name": "East Germany",
"strength": 2
"name": "East Germany"
},
{
"coordinates": [1333, 1774],
"flag": "Communist flag",
"name": "Ukrainian SSR",
"strength": 2
"name": "Ukrainian SSR"
},
{
"coordinates": [1194, 1814],
"flag": "Communist flag",
"name": "Moldovan SSR",
"strength": 1
"name": "Moldovan SSR"
},
{
"coordinates": [1197, 1626],
"flag": "Communist flag",
"name": "Belorussian SSR",
"strength": 1
"name": "Belorussian SSR"
},
{
"coordinates": [1091, 1744],
"flag": "pl",
"name": "Poland",
"strength": 2
"name": "Poland"
},
{
"coordinates": [1805, 1486],
"flag": "Communist flag",
"name": "Tajik SSR",
"strength": 1
"name": "Tajik SSR"
},
{
"coordinates": [1442, 1807],
"flag": "Communist flag",
"name": "Georgian SSR",
"strength": 1
"name": "Georgian SSR"
},
{
"coordinates": [1573, 1790],
"flag": "Communist flag",
"name": "Azerbaijan SSR",
"strength": 1
"name": "Azerbaijan SSR"
},
{
"coordinates": [1089, 1519],
"flag": "fi",
"name": "Finland",
"strength": 2
"name": "Finland"
},
{
"coordinates": [987, 1538],
"flag": "se",
"name": "Sweden",
"strength": 1
"name": "Sweden"
},
{
"coordinates": [889, 1587],
"flag": "no",
"name": "Norway",
"strength": 1
"name": "Norway"
},
{
"coordinates": [1793, 156],
"flag": "jp",
"name": "Japan",
"strength": 2
"name": "Japan"
},
{
"coordinates": [1776, 517],
"flag": "cn",
"name": "China",
"strength": 3
"name": "China"
},
{
"coordinates": [1792, 774],
"flag": "mn",
"name": "Mongolia",
"strength": 1
"name": "Mongolia"
},
{
"coordinates": [1773, 961],
"flag": "Communist flag",
"name": "Tannu Tuva",
"strength": 2
"name": "Tannu Tuva"
},
{
"coordinates": [1142, 382],
"flag": "Communist flag",
"name": "Far East",
"strength": 1
"name": "Far East"
},
{
"coordinates": [1410, 625],
"flag": "Communist flag",
"name": "Yakut ASSR",
"strength": 1
"name": "Yakut ASSR"
},
{
"coordinates": [1817, 364],
"flag": "kp",
"name": "North Korea",
"strength": 2
"name": "North Korea"
},
{
"coordinates": [1664, 689],
"flag": "Communist flag",
"name": "Buryat ASSR",
"strength": 1
"name": "Buryat ASSR"
},
{
"coordinates": [1440, 1170],
"flag": "Communist flag",
"name": "Komi ASSR",
"strength": 1
"name": "Komi ASSR"
},
{
"coordinates": [1383, 875],
"flag": "Siberia",
"name": "Siberia",
"strength": 2
"name": "Siberia"
},
{
"coordinates": [950, 1174],
"flag": "sj",
"name": "Svalbard",
"strength": 1
"name": "Svalbard"
},
{
"coordinates": [789, 1823],
"flag": "fr",
"name": "France",
"strength": 1
"name": "France"
}
]
}
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: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -14,7 +14,7 @@
"num_land_tiles": 228849,
"width": 1000
},
"name": "britanniaclassic",
"name": "Britannia Classic",
"nations": [
{
"coordinates": [960, 1258],
@@ -74,7 +74,7 @@
{
"coordinates": [1609, 1837],
"flag": "dz",
"name": "Algeria'"
"name": "Algeria"
},
{
"coordinates": [1733, 622],
+1 -1
View File
@@ -14,7 +14,7 @@
"num_land_tiles": 1048576,
"width": 1024
},
"name": "The Box",
"name": "TheBox",
"nations": [
{
"coordinates": [10, 10],
+5 -4
View File
@@ -11,6 +11,7 @@ const PERSISTENT_ID_KEY = "player_persistent_id";
let __jwt: string | null = null;
let __refreshPromise: Promise<void> | null = null;
let __expiresAt: number = 0;
export function discordLogin() {
const redirectUri = encodeURIComponent(window.location.href);
@@ -95,7 +96,7 @@ export async function userAuth(
// });
const payload = decodeJwt(jwt);
const { iss, aud, exp } = payload;
const { iss, aud } = payload;
if (iss !== getApiBase()) {
// JWT was not issued by the correct server
@@ -110,8 +111,7 @@ export async function userAuth(
logOut();
return false;
}
const now = Math.floor(Date.now() / 1000);
if (exp !== undefined && now >= exp - 3 * 60) {
if (Date.now() >= __expiresAt - 3 * 60 * 1000) {
console.log("jwt expired or about to expire");
if (!shouldRefresh) {
console.error("jwt expired and shouldRefresh is false");
@@ -163,7 +163,8 @@ async function doRefreshJwt(): Promise<void> {
return;
}
const json = await response.json();
const { jwt } = json;
const { jwt, expiresIn } = json;
__expiresAt = Date.now() + expiresIn * 1000;
console.log("Refresh succeeded");
__jwt = jwt;
} catch (e) {
+8 -8
View File
@@ -125,7 +125,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-sky-600",
"bg-sky-600 hover:bg-sky-500 active:bg-sky-700",
)}
</div>
<!-- Create/ranked/join: mobile only, below solo -->
@@ -133,17 +133,17 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
)}
${this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
)}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
)}
</div>
<!-- Game cards grid -->
@@ -196,7 +196,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-sky-600",
"bg-sky-600 hover:bg-sky-500 active:bg-sky-700",
)}
</div>
<!-- Bottom row: create + ranked + join (desktop only) -->
@@ -204,17 +204,17 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
)}
${this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
)}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
)}
</div>
</div>
+20 -2
View File
@@ -1,5 +1,6 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { FOOTER_AD_MIN_HEIGHT } from "./components/HomeFooterAd";
@customElement("gutter-ads")
export class GutterAds extends LitElement {
@@ -9,6 +10,14 @@ export class GutterAds extends LitElement {
@state()
private adLoaded: boolean = false;
@state()
private hasFooterAd: boolean = false;
private onResize = () => {
const isDesktop = window.innerWidth >= 640;
this.hasFooterAd = isDesktop && window.innerHeight >= FOOTER_AD_MIN_HEIGHT;
};
private leftAdType: string = "standard_iab_left2";
private rightAdType: string = "standard_iab_rght1";
private leftContainerId: string = "gutter-ad-container-left";
@@ -23,6 +32,8 @@ export class GutterAds extends LitElement {
connectedCallback() {
super.connectedCallback();
this.onResize();
window.addEventListener("resize", this.onResize);
document.addEventListener("userMeResponse", () => {
if (window.adsEnabled) {
console.log("showing gutter ads");
@@ -110,6 +121,7 @@ export class GutterAds extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this.onResize);
}
render() {
@@ -121,7 +133,10 @@ export class GutterAds extends LitElement {
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px);"
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px${this
.hasFooterAd
? " - 1.2cm"
: ""});"
>
<div
id="${this.leftContainerId}"
@@ -132,7 +147,10 @@ export class GutterAds extends LitElement {
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px);"
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px${this
.hasFooterAd
? " - 1.2cm"
: ""});"
>
<div
id="${this.rightContainerId}"
+18
View File
@@ -75,6 +75,8 @@ export class HelpModal extends BaseModal {
MetaLeft: "⌘",
MetaRight: "⌘",
Space: "Space",
Escape: "Esc",
Enter: "↵ Return",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
@@ -259,6 +261,22 @@ export class HelpModal extends BaseModal {
</tr>
</thead>
<tbody class="text-white/80">
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey("Escape")}
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_esc")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey("Enter")}
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_enter")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.toggleView)}
+17 -6
View File
@@ -71,6 +71,7 @@ export class HostLobbyModal extends BaseModal {
@state() private goldMultiplierValue: number | undefined = undefined;
@state() private startingGold: boolean = false;
@state() private startingGoldValue: number | undefined = undefined;
@state() private disableAlliances: boolean = false;
@state() private lobbyId = "";
@state() private lobbyUrlSuffix = "";
@state() private clients: ClientInfo[] = [];
@@ -174,16 +175,16 @@ export class HostLobbyModal extends BaseModal {
.onKeyDown=${this.handleSpawnImmunityDurationKeyDown}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"single_modal.gold_multiplier"}
.labelKey=${"host_modal.gold_multiplier"}
.checked=${this.goldMultiplier}
.inputId=${"gold-multiplier-value"}
.inputMin=${0.1}
.inputMax=${1000}
.inputStep=${"any"}
.inputValue=${this.goldMultiplierValue}
.inputAriaLabel=${translateText("single_modal.gold_multiplier")}
.inputAriaLabel=${translateText("host_modal.gold_multiplier")}
.inputPlaceholder=${translateText(
"single_modal.gold_multiplier_placeholder",
"host_modal.gold_multiplier_placeholder",
)}
.defaultInputValue=${2}
.minValidOnEnable=${0.1}
@@ -192,16 +193,16 @@ export class HostLobbyModal extends BaseModal {
.onKeyDown=${this.handleGoldMultiplierValueKeyDown}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"single_modal.starting_gold"}
.labelKey=${"host_modal.starting_gold"}
.checked=${this.startingGold}
.inputId=${"starting-gold-value"}
.inputMin=${0.1}
.inputMax=${1000}
.inputStep=${"any"}
.inputValue=${this.startingGoldValue}
.inputAriaLabel=${translateText("single_modal.starting_gold")}
.inputAriaLabel=${translateText("host_modal.starting_gold")}
.inputPlaceholder=${translateText(
"single_modal.starting_gold_placeholder",
"host_modal.starting_gold_placeholder",
)}
.defaultInputValue=${5}
.minValidOnEnable=${0.1}
@@ -294,6 +295,10 @@ export class HostLobbyModal extends BaseModal {
labelKey: "host_modal.compact_map",
checked: this.compactMap,
},
{
labelKey: "host_modal.disable_alliances",
checked: this.disableAlliances,
},
],
inputCards,
},
@@ -457,6 +462,7 @@ export class HostLobbyModal extends BaseModal {
this.goldMultiplierValue = undefined;
this.startingGold = false;
this.startingGoldValue = undefined;
this.disableAlliances = false;
this.leaveLobbyOnClose = true;
}
@@ -533,6 +539,10 @@ export class HostLobbyModal extends BaseModal {
case "host_modal.compact_map":
this.handleCompactMapChange(checked);
break;
case "host_modal.disable_alliances":
this.disableAlliances = checked;
this.putGameConfig();
break;
default:
break;
}
@@ -795,6 +805,7 @@ export class HostLobbyModal extends BaseModal {
this.startingGold === true && this.startingGoldValue !== undefined
? Math.round(this.startingGoldValue * 1_000_000)
: undefined,
disableAlliances: this.disableAlliances || undefined,
} satisfies Partial<GameConfig>,
},
bubbles: true,
+1 -1
View File
@@ -671,7 +671,7 @@ export class InputHandler {
}
if (element.tagName === "INPUT") {
const input = element as HTMLInputElement;
if (input.id === "attack-ratio" && input.type === "range") {
if (input.type === "range") {
return false;
}
return true;
+2 -5
View File
@@ -349,14 +349,11 @@ export class LangSelector extends LitElement {
id="lang-selector"
title="Change Language"
@click=${this.openModal}
class="border-none bg-none cursor-pointer p-0 flex items-center justify-center transition-transform duration-200 hover:scale-[1.1] active:scale-[0.9]"
style="width: 28px; height: 28px;"
class="border-none bg-none cursor-pointer p-0 flex items-center justify-center transition-transform duration-200 hover:scale-[1.1] active:scale-[0.9] opacity-60 hover:opacity-100 w-[40px] h-[40px] lg:w-[56px] lg:h-[56px]"
>
<img
id="lang-flag"
class="object-contain pointer-events-none transition-all"
style="filter: grayscale(1) sepia(1) saturate(3) hue-rotate(190deg) brightness(0.85)"
style="width: 28px; height: 28px;"
class="object-contain pointer-events-none transition-all w-[40px] h-[40px] lg:w-[48px] lg:h-[48px]"
src="/flags/${currentLang.svg}.svg"
alt="flag"
draggable="false"
+4
View File
@@ -18,6 +18,7 @@ export class PublicLobbySocket {
private wsConnectionAttempts = 0;
private wsAttemptCounted = false;
private workerPath: string = "";
private stopped = true;
private readonly reconnectDelay: number;
private readonly maxWsAttempts: number;
@@ -31,6 +32,7 @@ export class PublicLobbySocket {
}
async start() {
this.stopped = false;
this.wsConnectionAttempts = 0;
// Get config to determine number of workers, then pick a random one
const config = await getServerConfigFromClient();
@@ -39,6 +41,7 @@ export class PublicLobbySocket {
}
stop() {
this.stopped = true;
this.disconnectWebSocket();
}
@@ -96,6 +99,7 @@ export class PublicLobbySocket {
}
private handleClose() {
if (this.stopped) return;
console.log("WebSocket disconnected, attempting to reconnect...");
if (!this.wsAttemptCounted) {
this.wsAttemptCounted = true;
+1
View File
@@ -58,6 +58,7 @@ import {
} from "./Utils";
import "./components/DesktopNavBar";
import "./components/Footer";
import "./components/HomeFooterAd";
import "./components/MainLayout";
import "./components/MobileNavBar";
import "./components/PlayPage";
+12
View File
@@ -56,6 +56,7 @@ const DEFAULT_OPTIONS = {
startingGold: false,
startingGoldValue: undefined as number | undefined,
disabledUnits: [] as UnitType[],
disableAlliances: false,
} as const;
@customElement("single-player-modal")
@@ -90,6 +91,7 @@ export class SinglePlayerModal extends BaseModal {
@state() private disabledUnits: UnitType[] = [
...DEFAULT_OPTIONS.disabledUnits,
];
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
private mapLoader = terrainMapFileLoader;
@@ -313,6 +315,10 @@ export class SinglePlayerModal extends BaseModal {
labelKey: "single_modal.compact_map",
checked: this.compactMap,
},
{
labelKey: "single_modal.disable_alliances",
checked: this.disableAlliances,
},
],
inputCards,
},
@@ -383,6 +389,7 @@ export class SinglePlayerModal extends BaseModal {
this.gameMode !== DEFAULT_OPTIONS.gameMode ||
this.goldMultiplier !== DEFAULT_OPTIONS.goldMultiplier ||
this.startingGold !== DEFAULT_OPTIONS.startingGold ||
this.disableAlliances !== DEFAULT_OPTIONS.disableAlliances ||
this.disabledUnits.length > 0
);
}
@@ -409,6 +416,7 @@ export class SinglePlayerModal extends BaseModal {
this.goldMultiplierValue = DEFAULT_OPTIONS.goldMultiplierValue;
this.startingGold = DEFAULT_OPTIONS.startingGold;
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
this.disableAlliances = DEFAULT_OPTIONS.disableAlliances;
}
protected onOpen(): void {
@@ -488,6 +496,9 @@ export class SinglePlayerModal extends BaseModal {
case "single_modal.compact_map":
this.handleCompactMapChange(checked);
break;
case "single_modal.disable_alliances":
this.disableAlliances = checked;
break;
default:
break;
}
@@ -696,6 +707,7 @@ export class SinglePlayerModal extends BaseModal {
),
}
: {}),
...(this.disableAlliances ? { disableAlliances: true } : {}),
},
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
},
+17
View File
@@ -168,6 +168,23 @@ export function getActiveModifiers(
formattedValue: `${millions}M`,
});
}
if (modifiers.goldMultiplier) {
result.push({
labelKey: "host_modal.gold_multiplier",
badgeKey: "public_game_modifier.gold_multiplier",
badgeParams: {
amount: modifiers.goldMultiplier,
},
value: modifiers.goldMultiplier,
formattedValue: `x${modifiers.goldMultiplier}`,
});
}
if (modifiers.isAlliancesDisabled) {
result.push({
labelKey: "host_modal.disable_alliances",
badgeKey: "public_game_modifier.disable_alliances",
});
}
return result;
}
-1
View File
@@ -175,7 +175,6 @@ export class DesktopNavBar extends LitElement {
`
: ""}
</div>
<lang-selector></lang-selector>
<button
id="nav-account-button"
class="no-crazygames nav-menu-item relative h-10 rounded-full overflow-hidden flex items-center justify-center gap-2 px-3 bg-transparent border border-white/20 text-white/80 hover:text-white cursor-pointer transition-colors [&.active]:text-white"
+7 -2
View File
@@ -10,9 +10,11 @@ export class Footer extends LitElement {
render() {
return html`
<footer
class="[.in-game_&]:hidden bg-zinc-900/90 backdrop-blur-md flex flex-col items-center justify-center gap-1 pt-1 pb-3 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto relative z-50"
class="[.in-game_&]:hidden bg-zinc-900/90 backdrop-blur-md flex flex-col items-center justify-center gap-1 pt-1 pb-3 text-white/50 w-full border-t border-white/10 shrink-0 relative z-50"
>
<div class="flex items-center justify-center gap-4 lg:gap-6 pt-2">
<div
class="flex items-center justify-center gap-4 lg:gap-6 pt-2 w-full relative"
>
<a
href="https://github.com/openfrontio/OpenFrontIO"
target="_blank"
@@ -72,6 +74,9 @@ export class Footer extends LitElement {
draggable="false"
/>
</a>
<lang-selector
class="absolute right-4 top-0 sm:top-[10px]"
></lang-selector>
</div>
<div
class="text-xs mt-1 lg:mt-2 flex items-center justify-center gap-4 px-4"
+86
View File
@@ -0,0 +1,86 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
export const FOOTER_AD_MIN_HEIGHT = 880;
const FOOTER_AD_TYPE = "standard_iab_head2";
const FOOTER_AD_CONTAINER_ID = "home-footer-ad-container";
@customElement("home-footer-ad")
export class HomeFooterAd extends LitElement {
@state() private shouldShow: boolean = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
document.addEventListener("userMeResponse", this.onUserMeResponse);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("userMeResponse", this.onUserMeResponse);
this.destroyAd();
}
private onUserMeResponse = () => {
const isDesktop = window.innerWidth >= 640;
if (
!window.adsEnabled ||
(isDesktop && window.innerHeight < FOOTER_AD_MIN_HEIGHT)
) {
return;
}
this.shouldShow = true;
this.updateComplete.then(() => {
this.loadAd();
});
};
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for footer ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{ type: FOOTER_AD_TYPE, selectorId: FOOTER_AD_CONTAINER_ID },
]);
console.log("Footer ad loaded:", FOOTER_AD_TYPE);
} catch (e) {
console.error("Failed to add footer ad:", e);
}
});
} catch (error) {
console.error("Failed to load footer ad:", error);
}
}
private destroyAd(): void {
try {
window.ramp.destroyUnits(FOOTER_AD_TYPE);
console.log("successfully destroyed footer ad");
} catch (e) {
console.error("error destroying footer ad", e);
}
}
render() {
if (!this.shouldShow) {
return nothing;
}
return html`
<div
id="${FOOTER_AD_CONTAINER_ID}"
class="flex justify-center items-center w-full pointer-events-auto [&_*]:!m-0 [&_*]:!p-0"
style="margin: 0; padding: 0; line-height: 0;"
></div>
`;
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ export class MainLayout extends LitElement {
render() {
return html`
<main
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-0 lg:px-[clamp(1.5rem,3vw,3rem)] pt-0 lg:pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-0 lg:pb-[clamp(0.75rem,1.5vw,1.5rem)]"
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-0 lg:px-[clamp(1.5rem,3vw,3rem)] pt-0 lg:pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-0 lg:pb-[clamp(0.375rem,0.75vw,0.75rem)]"
>
<div
class="w-full lg:max-w-[20cm] 2xl:max-w-[24cm] mx-auto flex flex-col flex-1 gap-0 lg:gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden sm:px-4 lg:px-0"
+1 -3
View File
@@ -189,9 +189,7 @@ export class MobileNavBar extends LitElement {
</div>
<div
class="flex flex-col w-full mt-auto [.in-game_&]:hidden items-end justify-end pt-4 border-t border-white/10"
>
<lang-selector></lang-selector>
</div>
></div>
</div>
`;
}
+8 -1
View File
@@ -1,10 +1,11 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { PlayerType } from "../../../core/game/Game";
import {
BrokeAllianceUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { Layer } from "./Layer";
@@ -198,6 +199,12 @@ export class AlertFrame extends LitElement implements Layer {
for (const attack of incomingAttacks) {
// Only alert for non-retreating attacks
if (!attack.retreating && !this.seenAttackIds.has(attack.id)) {
const attacker = this.game.playerBySmallID(attack.attackerID);
if ((attacker as PlayerView).type() === PlayerType.Bot) {
this.seenAttackIds.add(attack.id);
continue;
}
// Check if this is a retaliation (we attacked them recently)
const ourAttackTick = this.outgoingAttackTicks.get(attack.attackerID);
const isRetaliation =
+25 -27
View File
@@ -22,6 +22,7 @@ import {
GoToPositionEvent,
GoToUnitEvent,
} from "./Leaderboard";
import soldierIcon from "/images/SoldierIcon.svg?url";
import swordIcon from "/images/SwordIcon.svg?url";
@customElement("attacks-display")
@@ -224,14 +225,13 @@ export class AttacksDisplay extends LitElement implements Layer {
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<img
src="${swordIcon}"
class="h-4 w-4 inline-block"
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
/>
<span class="inline-block min-w-[3rem] text-right"
>${renderTroops(attack.troops)}</span
>
content: html`<span class="inline-flex items-center"
><img
src="${soldierIcon}"
class="h-4 w-4"
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
/>↓</span
><span class="ml-1">${renderTroops(attack.troops)}</span>
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.attackerID) as PlayerView
@@ -272,14 +272,13 @@ export class AttacksDisplay extends LitElement implements Layer {
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<img
src="${swordIcon}"
class="h-4 w-4 inline-block"
style="filter: invert(1)"
/>
<span class="inline-block min-w-[3rem] text-right"
>${renderTroops(attack.troops)}</span
>
content: html`<span class="inline-flex items-center"
><img
src="${soldierIcon}"
class="h-4 w-4"
style="filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)"
/>↑</span
><span class="ml-1">${renderTroops(attack.troops)}</span>
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.targetID) as PlayerView
@@ -287,7 +286,7 @@ export class AttacksDisplay extends LitElement implements Layer {
> `,
onClick: async () => this.attackWarningOnClick(attack),
className:
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
"text-left text-sky-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!attack.retreating
@@ -314,17 +313,16 @@ export class AttacksDisplay extends LitElement implements Layer {
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<img
src="${swordIcon}"
class="h-4 w-4 inline-block"
style="filter: invert(1)"
/>
<span class="inline-block min-w-[3rem] text-right"
>${renderTroops(landAttack.troops)}</span
>
content: html`<span class="inline-flex items-center"
><img
src="${soldierIcon}"
class="h-4 w-4"
style="filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)"
/>↑</span
><span class="ml-1">${renderTroops(landAttack.troops)}</span>
${translateText("help_modal.ui_wilderness")}`,
className:
"text-left text-gray-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
"text-left text-sky-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!landAttack.retreating
@@ -441,7 +439,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return html`
<div
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 gap-1 text-white text-sm lg:text-base"
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 gap-1 text-white text-sm lg:text-base max-h-[7rem] overflow-y-auto"
>
${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()}
${this.renderBoats()} ${this.renderIncomingAttacks()}
+44 -18
View File
@@ -125,11 +125,16 @@ export class ControlPanel extends LitElement implements Layer {
}
private handleRatioSliderInput(e: Event) {
const value = Number((e.target as HTMLInputElement).value);
const input = e.target as HTMLInputElement;
const value = Number(input.value);
this.attackRatio = value / 100;
this.onAttackRatioChange(this.attackRatio);
}
private handleRatioSliderPointerUp(e: Event) {
(e.target as HTMLInputElement).blur();
}
private calculateTroopBar(): { greenPercent: number; orangePercent: number } {
const base = Math.max(this._maxTroops, 1);
const greenPercentRaw = (this._troops / base) * 100;
@@ -153,13 +158,13 @@ export class ControlPanel extends LitElement implements Layer {
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-green-500 transition-[width] duration-200"
class="h-full bg-sky-700 transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-orange-400 transition-[width] duration-200"
class="h-full bg-sky-600 transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
@@ -203,35 +208,51 @@ export class ControlPanel extends LitElement implements Layer {
const { greenPercent, orangePercent } = this.calculateTroopBar();
return html`
<div
class="w-full h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
class="w-full h-8 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-green-500 transition-[width] duration-200"
class="h-full bg-sky-700 transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-orange-400 transition-[width] duration-200"
class="h-full bg-sky-600 transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-start px-1.5 text-xs font-bold leading-none pointer-events-none gap-0.5"
class="absolute inset-0 flex items-center text-xl font-bold leading-none pointer-events-none"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._troops)}</span
>
<span class="text-white/60 drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
<span class="flex-1 flex justify-end h-full items-center pr-0.5">
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._troops)}</span
>
</span>
<span
class="h-full flex items-center px-0.5 text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>/</span
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._maxTroops)}</span
<span
class="flex-1 flex justify-start h-full items-center pl-0.5 gap-0.5"
>
<span
class="text-white tabular-nums w-[3.5rem] drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._maxTroops)}</span
>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="22"
height="22"
class="shrink-0 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ml-1.5"
/>
</span>
</div>
</div>
`;
@@ -243,7 +264,7 @@ export class ControlPanel extends LitElement implements Layer {
<div class="flex gap-1.5 items-center mb-1.5">
<!-- Troop rate -->
<div
class="flex items-center gap-1 shrink-0 border rounded-md font-bold text-xs p-1 w-[5.5rem] ${this
class="flex items-center gap-1 shrink-0 border rounded-md font-bold text-sm p-1 w-[5.5rem] ${this
._troopRateIsIncreasing
? "border-green-400"
: "border-orange-400"}"
@@ -261,7 +282,7 @@ export class ControlPanel extends LitElement implements Layer {
: "brightness(0) saturate(100%) invert(65%) sepia(60%) saturate(600%) hue-rotate(330deg) brightness(105%)"}"
/>
<span
class="text-xs font-bold tabular-nums ${this._troopRateIsIncreasing
class="text-sm font-bold tabular-nums ${this._troopRateIsIncreasing
? "text-green-400"
: "text-orange-400"}"
>+${renderTroops(this.troopRate)}/s</span
@@ -271,7 +292,7 @@ export class ControlPanel extends LitElement implements Layer {
<div class="flex-1">${this.renderDesktopTroopBar()}</div>
<!-- Gold -->
<div
class="flex items-center gap-1 shrink-0 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs p-1 w-[4.5rem]"
class="flex items-center gap-1 shrink-0 border rounded-md border-yellow-400 font-bold text-yellow-400 text-sm p-1 w-[4.5rem]"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" class="shrink-0" />
@@ -281,7 +302,7 @@ export class ControlPanel extends LitElement implements Layer {
<!-- Row 2: attack ratio | slider -->
<div class="flex items-center gap-2" translate="no">
<div
class="flex items-center gap-1 shrink-0 border border-gray-600 rounded-md p-1 text-xs font-bold text-white cursor-pointer w-[7rem]"
class="flex items-center gap-1 shrink-0 border border-gray-600 rounded-md p-1 text-sm font-bold text-white cursor-pointer w-[8rem]"
>
<img
src=${swordIcon}
@@ -304,6 +325,7 @@ export class ControlPanel extends LitElement implements Layer {
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
class="flex-1 h-2 accent-blue-500 cursor-pointer"
/>
</div>
@@ -326,7 +348,10 @@ export class ControlPanel extends LitElement implements Layer {
${this.renderMobileTroopBar()}
</div>
<!-- Sword + % label -->
<div class="flex flex-col items-center shrink-0 gap-0.5" translate="no">
<div
class="flex flex-col items-center shrink-0 gap-0.5 w-8"
translate="no"
>
<img
src=${swordIcon}
alt=""
@@ -347,6 +372,7 @@ export class ControlPanel extends LitElement implements Layer {
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
class="w-full h-1.5 accent-blue-500 cursor-pointer"
/>
</div>
@@ -153,6 +153,7 @@ export class CoordinateGridLayer implements Layer {
const bottomRight = this.transformHandler.worldToScreenCoordinates(
new Cell(width, height),
);
const darkMode = this.game.config().userSettings()?.darkMode() ?? false;
return [
width,
height,
@@ -163,6 +164,7 @@ export class CoordinateGridLayer implements Layer {
topLeft.y.toFixed(2),
bottomRight.x.toFixed(2),
bottomRight.y.toFixed(2),
darkMode ? "1" : "0",
].join("|");
}
@@ -268,10 +270,13 @@ export class CoordinateGridLayer implements Layer {
context.font = "12px monospace";
const isDarkMode = this.game.config().userSettings()?.darkMode() ?? false;
const drawLabel = (text: string, x: number, y: number) => {
context.textAlign = "left";
context.textBaseline = "top";
context.fillStyle = "rgba(20, 20, 20, 0.9)";
context.fillStyle = isDarkMode
? "rgba(255, 255, 255, 0.9)"
: "rgba(20, 20, 20, 0.9)";
context.fillText(text, x, y);
};
+1 -1
View File
@@ -864,7 +864,7 @@ export class EventsDisplay extends LitElement implements Layer {
<!-- Content Area -->
<div
class="bg-gray-800/70 max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
class="bg-gray-800/70 max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
>
<div>
<table
+1 -1
View File
@@ -144,7 +144,7 @@ export class MainRadialMenu extends LitElement implements Layer {
this.radialMenu.setCenterButtonAppearance(
isFriendlyTarget ? donateTroopIcon : swordIcon,
isFriendlyTarget ? "#34D399" : "#2c3e50",
isFriendlyTarget ? "#22d3ee" : "#0f2744",
isFriendlyTarget
? this.radialMenu.getDefaultCenterIconSize() * 0.75
: this.radialMenu.getDefaultCenterIconSize(),
+35 -13
View File
@@ -318,17 +318,39 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
const playerTeam = getTranslatedPlayerTeamLabel(player.team());
return html`
<div class="flex items-start gap-2 lg:gap-3 p-1.5 lg:p-2">
<div class="flex items-start gap-1 lg:gap-2 p-1.5 lg:p-2">
<!-- Left: Gold & Troop bar -->
<div class="flex flex-col gap-1 shrink-0 w-28">
<div
class="flex items-center justify-center p-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs w-28 lg:gap-1"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" />
<span class="px-0.5">${renderNumber(player.gold())}</span>
<div class="flex flex-col gap-1 shrink-0 w-28 md:w-36">
<div class="flex items-center gap-1">
<div
class="flex flex-1 items-center justify-center p-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-sm lg:gap-1"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" />
<span class="px-0.5">${renderNumber(player.gold())}</span>
</div>
<div
class="flex flex-1 flex-col items-center justify-center text-xs font-bold ${attackingTroops >
0
? "text-sky-400"
: "text-white/40"} drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
translate="no"
>
<span class="flex items-center gap-px leading-none text-xs"
><img
src=${soldierIcon}
class="w-2.5 h-2.5"
style="${attackingTroops > 0
? "filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%); opacity:1"
: "filter: brightness(0) invert(1); opacity:0.4"}"
/>↑</span
>
<span class="tabular-nums leading-none text-sm mt-0.5"
>${renderTroops(attackingTroops)}</span
>
</div>
</div>
<div class="w-28" translate="no">
<div class="w-28 md:w-36" translate="no">
${this.renderTroopBar(totalTroops, attackingTroops, maxTroops)}
</div>
</div>
@@ -418,24 +440,24 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div
class="w-full mt-1 lg:mt-2 h-5 lg:h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
class="w-full mt-0.5 lg:mt-1 h-5 lg:h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-green-500 transition-[width] duration-200"
class="h-full bg-sky-700 transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-orange-400 transition-[width] duration-200"
class="h-full bg-sky-600 transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-between px-1.5 text-xs font-bold leading-none pointer-events-none"
class="absolute inset-0 flex items-center justify-between px-1.5 text-sm font-bold leading-none pointer-events-none"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
+13 -20
View File
@@ -88,7 +88,7 @@ export class RadialMenu implements Layer {
private backButtonHoverTimeout: number | null = null;
private navigationInProgress: boolean = false;
private originalCenterButtonIcon: string = "";
private readonly defaultCenterButtonColor = "#2c3e50";
private readonly defaultCenterButtonColor = "#0f2744";
private centerButtonColor: string;
private centerButtonIconSize: number;
@@ -227,7 +227,7 @@ export class RadialMenu implements Layer {
this.tooltipElement.className = "radial-tooltip";
this.tooltipElement.style.position = "absolute";
this.tooltipElement.style.pointerEvents = "none";
this.tooltipElement.style.background = "rgba(0, 0, 0, 0.7)";
this.tooltipElement.style.background = "rgba(12, 35, 64, 0.88)";
this.tooltipElement.style.color = "white";
this.tooltipElement.style.padding = "6px 10px";
this.tooltipElement.style.borderRadius = "6px";
@@ -332,8 +332,8 @@ export class RadialMenu implements Layer {
const disabled = this.params === null || d.data.disabled(this.params);
const color = disabled
? this.config.disabledColor
: (resolveColor(d.data, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
: (resolveColor(d.data, this.params) ?? "#1e3a5f");
const opacity = disabled ? 0.4 : 0.82;
if (d.data.id === this.selectedItemId && this.currentLevel > level) {
return color;
@@ -341,8 +341,7 @@ export class RadialMenu implements Layer {
return d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color;
})
.attr("stroke", "#ffffff")
.attr("stroke-width", "2")
.attr("stroke", "none")
.style("cursor", (d) =>
this.params === null || d.data.disabled(this.params)
? "not-allowed"
@@ -353,9 +352,7 @@ export class RadialMenu implements Layer {
)
.style(
"transition",
`filter ${this.config.menuTransitionDuration / 2}ms, stroke-width ${
this.config.menuTransitionDuration / 2
}ms, fill ${this.config.menuTransitionDuration / 2}ms`,
`filter ${this.config.menuTransitionDuration / 2}ms, fill ${this.config.menuTransitionDuration / 2}ms`,
)
.attr("data-id", (d) => d.data.id);
@@ -366,8 +363,8 @@ export class RadialMenu implements Layer {
const disabled = this.params === null || d.data.disabled(this.params);
const baseColor = disabled
? this.config.disabledColor
: (resolveColor(d.data, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
: (resolveColor(d.data, this.params) ?? "#1e3a5f");
const opacity = disabled ? 0.4 : 0.82;
const normalColor =
d3.color(baseColor)?.copy({ opacity: opacity })?.toString() ??
@@ -419,12 +416,11 @@ export class RadialMenu implements Layer {
this.currentLevel > 0
) {
path.attr("filter", "url(#glow)");
path.attr("stroke-width", "3");
const color =
this.params === null || d.data.disabled(this.params)
? this.config.disabledColor
: (resolveColor(d.data, this.params) ?? "#333333");
: (resolveColor(d.data, this.params) ?? "#1e3a5f");
path.attr("fill", color);
}
});
@@ -466,8 +462,7 @@ export class RadialMenu implements Layer {
return;
}
path.attr("filter", "url(#glow)");
path.attr("stroke-width", "3");
path.style("filter", "brightness(1.5)");
};
const onMouseOut = (d: d3.PieArcDatum<MenuElement>, path: any) => {
@@ -486,12 +481,11 @@ export class RadialMenu implements Layer {
d.data.id === this.selectedItemId)
)
return;
path.attr("filter", null);
path.attr("stroke-width", "2");
path.style("filter", null);
const color = disabled
? this.config.disabledColor
: (resolveColor(d.data, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
const opacity = disabled ? 0.4 : 0.82;
if (d.data.timerFraction) {
path.attr("fill", `url(#timer-gradient-${d.data.id})`);
@@ -811,7 +805,6 @@ export class RadialMenu implements Layer {
const selectedPath = this.menuPaths.get(this.selectedItemId);
if (selectedPath) {
selectedPath.attr("filter", null);
selectedPath.attr("stroke-width", "2");
}
}
// Use refresh() to update all item appearances consistently
@@ -1127,7 +1120,7 @@ export class RadialMenu implements Layer {
const color = disabled
? this.config.disabledColor
: (resolveColor(item, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
const opacity = disabled ? 0.4 : 0.82;
// Update path appearance (skip fill for timer items — gradient handles it)
if (!item.timerFraction) {
@@ -82,32 +82,32 @@ export interface CenterButtonElement {
}
export const COLORS = {
build: "#ebe250",
building: "#2c2c2c",
boat: "#3f6ab1",
ally: "#53ac75",
breakAlly: "#c74848",
breakAllyNoDebuff: "#d4882b",
delete: "#ff0000",
info: "#64748B",
target: "#ff0000",
attack: "#ff0000",
build: "#e6c74a",
building: "#1e3a5f",
boat: "#2a82c9",
ally: "#4ade80",
breakAlly: "#dc2626",
breakAllyNoDebuff: "#d97706",
delete: "#ef4444",
info: "#475569",
target: "#ef4444",
attack: "#ef4444",
infoDetails: "#7f8c8d",
infoEmoji: "#f1c40f",
trade: "#008080",
embargo: "#6600cc",
infoEmoji: "#fbbf24",
trade: "#0891b2",
embargo: "#7c3aed",
tooltip: {
cost: "#ffd700",
count: "#aaa",
cost: "#f59e0b",
count: "#94a3b8",
},
chat: {
default: "#66c",
help: "#4caf50",
attack: "#f44336",
defend: "#2196f3",
greet: "#ff9800",
misc: "#9c27b0",
warnings: "#e3c532",
default: "#6366f1",
help: "#22c55e",
attack: "#ef4444",
defend: "#3b82f6",
greet: "#f97316",
misc: "#a855f7",
warnings: "#fbbf24",
},
};
@@ -484,7 +484,7 @@ const donateGoldRadialElement: MenuElement = {
params.game.inSpawnPhase() ||
!params.playerActions?.interaction?.canDonateGold,
icon: donateGoldIcon,
color: "#EAB308",
color: "#f59e0b",
action: (params: MenuElementParams) => {
if (!params.selected) return;
params.playerPanel.openSendGoldModal(
+1 -1
View File
@@ -99,7 +99,7 @@ export class GameRunner {
}
if (this.game.config().bots() > 0) {
this.game.addExecution(
...this.execManager.spawnBots(this.game.config().numBots()),
...this.execManager.spawnTribes(this.game.config().bots()),
);
}
if (this.game.config().spawnNations()) {
+3
View File
@@ -217,6 +217,8 @@ export const GameConfigSchema = z.object({
isCrowded: z.boolean(),
isHardNations: z.boolean(),
startingGold: z.number().int().min(0).optional(),
goldMultiplier: z.number().min(0.1).max(1000).optional(),
isAlliancesDisabled: z.boolean(),
})
.optional(),
nations: z
@@ -230,6 +232,7 @@ export const GameConfigSchema = z.object({
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
disableNavMesh: z.boolean().optional(),
disableAlliances: z.boolean().optional(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(), // In minutes
+7 -6
View File
@@ -13,9 +13,9 @@ import {
} from "./Schemas";
import {
BOT_NAME_PREFIXES,
BOT_NAME_SUFFIXES,
} from "./execution/utils/BotNames";
TRIBE_NAME_PREFIXES,
TRIBE_NAME_SUFFIXES,
} from "./execution/utils/TribeNames";
export function manhattanDistWrapped(
c1: Cell,
@@ -296,11 +296,12 @@ export function createRandomName(
let randomName: string | null = null;
if (playerType === PlayerType.Human) {
const hash = simpleHash(name);
const prefixIndex = hash % BOT_NAME_PREFIXES.length;
const prefixIndex = hash % TRIBE_NAME_PREFIXES.length;
const suffixIndex =
Math.floor(hash / BOT_NAME_PREFIXES.length) % BOT_NAME_SUFFIXES.length;
Math.floor(hash / TRIBE_NAME_PREFIXES.length) %
TRIBE_NAME_SUFFIXES.length;
randomName = `👤 ${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
randomName = `👤 ${TRIBE_NAME_PREFIXES[prefixIndex]} ${TRIBE_NAME_SUFFIXES[suffixIndex]}`;
}
return randomName;
}
+5 -1
View File
@@ -74,6 +74,7 @@ export interface Config {
donateTroops(): boolean;
instantBuild(): boolean;
disableNavMesh(): boolean;
disableAlliances(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
@@ -131,7 +132,10 @@ export interface Config {
tradeShipSpawnRejections: number,
numTradeShips: number,
): number;
trainGold(rel: "self" | "team" | "ally" | "other"): Gold;
trainGold(
rel: "self" | "team" | "ally" | "other",
citiesVisited: number,
): Gold;
trainSpawnRate(numPlayerFactories: number): number;
trainStationMinRange(): number;
trainStationMaxRange(): number;
+19 -10
View File
@@ -20,7 +20,7 @@ import { PlayerView } from "../game/GameView";
import { UserSettings } from "../game/UserSettings";
import { GameConfig, GameID, TeamCountConfig } from "../Schemas";
import { NukeType } from "../StatsSchemas";
import { assertNever, sigmoid, simpleHash, within } from "../Util";
import { assertNever, sigmoid, simpleHash, toInt, within } from "../Util";
import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config";
import { Env } from "./Env";
import { PastelTheme } from "./PastelTheme";
@@ -201,7 +201,7 @@ export class DefaultConfig implements Config {
return 5 - falloutRatio * 2;
}
SAMCooldown(): number {
return 75;
return 120;
}
SiloCooldown(): number {
return 75;
@@ -240,6 +240,9 @@ export class DefaultConfig implements Config {
disableNavMesh(): boolean {
return this._gameConfig.disableNavMesh ?? false;
}
disableAlliances(): boolean {
return this._gameConfig.disableAlliances ?? false;
}
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
@@ -270,22 +273,28 @@ export class DefaultConfig implements Config {
// expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories)
return (numPlayerFactories + 10) * 18;
}
trainGold(rel: "self" | "team" | "ally" | "other"): Gold {
const multiplier = this.goldMultiplier();
let baseGold: bigint;
trainGold(
rel: "self" | "team" | "ally" | "other",
citiesVisited: number,
): Gold {
// No penalty for the first 5 cities.
citiesVisited = Math.max(0, citiesVisited - 5);
let baseGold: number;
switch (rel) {
case "ally":
baseGold = 35_000n;
baseGold = 35_000;
break;
case "team":
case "other":
baseGold = 25_000n;
baseGold = 25_000;
break;
case "self":
baseGold = 10_000n;
baseGold = 10_000;
break;
}
return BigInt(Math.floor(Number(baseGold) * multiplier));
const distPenalty = citiesVisited * 5_000;
const gold = Math.max(5000, baseGold - distPenalty);
return toInt(gold * this.goldMultiplier());
}
trainStationMinRange(): number {
@@ -649,7 +658,7 @@ export class DefaultConfig implements Config {
const altAttackerLoss =
1.3 * defenderTroopLoss * (mag / 100) * traitorMod;
const attackerTroopLoss =
0.5 * currentAttackerLoss + 0.5 * altAttackerLoss;
0.7 * currentAttackerLoss + 0.3 * altAttackerLoss;
return {
attackerTroopLoss,
-43
View File
@@ -1,43 +0,0 @@
import { Game, PlayerInfo, PlayerType } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { simpleHash } from "../Util";
import { SpawnExecution } from "./SpawnExecution";
import { BOT_NAME_PREFIXES, BOT_NAME_SUFFIXES } from "./utils/BotNames";
export class BotSpawner {
private random: PseudoRandom;
private bots: SpawnExecution[] = [];
constructor(
private gs: Game,
private gameID: GameID,
) {
// Use a different seed than createGameRunner (which uses simpleHash(gameID))
// to avoid bot IDs colliding with nation/human IDs from the same PRNG sequence.
this.random = new PseudoRandom(simpleHash(gameID) + 2);
}
spawnBots(numBots: number): SpawnExecution[] {
for (let i = 0; i < numBots; i++) {
const name = this.randomBotName();
const spawn = this.spawnBot(name);
this.bots.push(spawn);
}
return this.bots;
}
spawnBot(botName: string): SpawnExecution {
return new SpawnExecution(
this.gameID,
new PlayerInfo(botName, PlayerType.Bot, null, this.random.nextID()),
);
}
private randomBotName(): string {
const prefixIndex = this.random.nextInt(0, BOT_NAME_PREFIXES.length);
const suffixIndex = this.random.nextInt(0, BOT_NAME_SUFFIXES.length);
return `${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
}
}
+4 -4
View File
@@ -8,7 +8,6 @@ import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution";
import { AttackExecution } from "./AttackExecution";
import { BoatRetreatExecution } from "./BoatRetreatExecution";
import { BotSpawner } from "./BotSpawner";
import { ConstructionExecution } from "./ConstructionExecution";
import { DeleteUnitExecution } from "./DeleteUnitExecution";
import { DonateGoldExecution } from "./DonateGoldExecution";
@@ -26,6 +25,7 @@ import { RetreatExecution } from "./RetreatExecution";
import { SpawnExecution } from "./SpawnExecution";
import { TargetPlayerExecution } from "./TargetPlayerExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { TribeSpawner } from "./TribeSpawner";
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
import { PlayerSpawner } from "./utils/PlayerSpawner";
@@ -38,7 +38,7 @@ export class Executor {
private gameID: GameID,
private clientID: ClientID | undefined,
) {
// Add one to avoid id collisions with bots.
// Add one to avoid id collisions with tribes.
this.random = new PseudoRandom(simpleHash(gameID) + 1);
}
@@ -126,8 +126,8 @@ export class Executor {
}
}
spawnBots(numBots: number): SpawnExecution[] {
return new BotSpawner(this.mg, this.gameID).spawnBots(numBots);
spawnTribes(numTribes: number): SpawnExecution[] {
return new TribeSpawner(this.mg, this.gameID).spawnTribes(numTribes);
}
spawnPlayers(): SpawnExecution[] {
+26 -7
View File
@@ -116,12 +116,20 @@ class SAMTargetingSystem {
detectionRange,
[UnitType.AtomBomb, UnitType.HydrogenBomb],
({ unit }) => {
return (
isUnit(unit) &&
unit.owner() !== this.sam.owner() &&
!this.sam.owner().isFriendly(unit.owner()) &&
!unit.targetedBySAM()
);
if (!isUnit(unit) || unit.targetedBySAM()) return false;
if (unit.owner() === this.sam.owner()) return false;
const samOwner = this.sam.owner();
const nukeOwner = unit.owner();
// After game-over in team games, SAMs also target teammate nukes (aftergame fun)
if (samOwner.isFriendly(nukeOwner)) {
return (
this.mg.getWinner() !== null && samOwner.isOnSameTeam(nukeOwner)
);
}
return true;
},
);
@@ -271,7 +279,18 @@ export class SAMLauncherExecution implements Execution {
({ unit }) => {
if (!isUnit(unit)) return false;
if (unit.owner() === this.player) return false;
if (this.player.isFriendly(unit.owner())) return false;
// After game-over in team games, SAMs also target teammate MIRVs (aftergame fun)
const nukeOwner = unit.owner();
if (this.player.isFriendly(nukeOwner)) {
if (
this.mg.getWinner() === null ||
!this.player.isOnSameTeam(nukeOwner)
) {
return false;
}
}
const dst = unit.targetTile();
return (
this.sam !== null &&
+2 -2
View File
@@ -10,8 +10,8 @@ import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { simpleHash } from "../Util";
import { BotExecution } from "./BotExecution";
import { PlayerExecution } from "./PlayerExecution";
import { TribeExecution } from "./TribeExecution";
import { getSpawnTiles } from "./Util";
type Spawn = { center: TileRef; tiles: TileRef[] };
@@ -71,7 +71,7 @@ export class SpawnExecution implements Execution {
if (!player.hasSpawned()) {
this.mg.addExecution(new PlayerExecution(player));
if (player.type() === PlayerType.Bot) {
this.mg.addExecution(new BotExecution(player));
this.mg.addExecution(new TribeExecution(player));
}
}
+9
View File
@@ -24,6 +24,7 @@ export class TrainExecution implements Execution {
private stations: TrainStation[] = [];
private currentRailroad: OrientedRailroad | null = null;
private speed: number = 2;
private _tradeStopsVisited: number = 0;
constructor(
private railNetwork: RailNetwork,
@@ -37,6 +38,10 @@ export class TrainExecution implements Execution {
return this.player;
}
public tradeStopsVisited(): number {
return this._tradeStopsVisited;
}
init(mg: Game, ticks: number): void {
this.mg = mg;
const stations = this.railNetwork.findStationsPath(
@@ -261,6 +266,10 @@ export class TrainExecution implements Execution {
throw new Error("Not initialized");
}
this.stations[1].onTrainStop(this);
const stationType = this.stations[1].unit.type();
if (stationType === UnitType.City || stationType === UnitType.Port) {
this._tradeStopsVisited++;
}
return;
}
@@ -1,11 +1,11 @@
import { Execution, Game, Player, Structures } from "../game/Game";
import { Execution, Game, Player, Structures } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
import { DeleteUnitExecution } from "./DeleteUnitExecution";
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
export class BotExecution implements Execution {
export class TribeExecution implements Execution {
private active = true;
private random: PseudoRandom;
private mg: Game;
@@ -18,8 +18,8 @@ export class BotExecution implements Execution {
private reserveRatio: number;
private expandRatio: number;
constructor(private bot: Player) {
this.random = new PseudoRandom(simpleHash(bot.id()));
constructor(private tribe: Player) {
this.random = new PseudoRandom(simpleHash(tribe.id()));
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
this.triggerRatio = this.random.nextInt(50, 60) / 100;
@@ -38,8 +38,8 @@ export class BotExecution implements Execution {
tick(ticks: number) {
if (ticks % this.attackRate !== this.attackTick) return;
if (!this.bot.isAlive()) {
//removeOnDeath is called from bot's PlayerExecution
if (!this.tribe.isAlive()) {
//removeOnDeath is called from tribe's PlayerExecution
this.active = false;
return;
}
@@ -48,7 +48,7 @@ export class BotExecution implements Execution {
this.attackBehavior = new AiAttackBehavior(
this.random,
this.mg,
this.bot,
this.tribe,
this.triggerRatio,
this.reserveRatio,
this.expandRatio,
@@ -66,27 +66,27 @@ export class BotExecution implements Execution {
private acceptAllAllianceRequests() {
// Accept all alliance requests
for (const req of this.bot.incomingAllianceRequests()) {
for (const req of this.tribe.incomingAllianceRequests()) {
req.accept();
}
// Accept all alliance extension requests
for (const alliance of this.bot.alliances()) {
for (const alliance of this.tribe.alliances()) {
// Alliance expiration tracked by Events Panel, only human ally can click Request to Renew
// Skip if no expiration yet/ ally didn't request extension yet / bot already agreed to extend
// Skip if no expiration yet/ ally didn't request extension yet / tribe already agreed to extend
if (!alliance.onlyOneAgreedToExtend()) continue;
const human = alliance.other(this.bot);
const human = alliance.other(this.tribe);
this.mg.addExecution(
new AllianceExtensionExecution(this.bot, human.id()),
new AllianceExtensionExecution(this.tribe, human.id()),
);
}
}
private deleteAllStructures() {
for (const unit of this.bot.units()) {
if (Structures.has(unit.type()) && this.bot.canDeleteUnit()) {
this.mg.addExecution(new DeleteUnitExecution(this.bot, unit.id()));
for (const unit of this.tribe.units()) {
if (Structures.has(unit.type()) && this.tribe.canDeleteUnit()) {
this.mg.addExecution(new DeleteUnitExecution(this.tribe, unit.id()));
}
}
}
@@ -97,13 +97,13 @@ export class BotExecution implements Execution {
}
const toAttack = this.attackBehavior.getNeighborTraitorToAttack();
if (toAttack !== null) {
const odds = this.bot.isFriendly(toAttack) ? 6 : 3;
const odds = this.tribe.isFriendly(toAttack) ? 6 : 3;
if (this.random.chance(odds)) {
// Check and break alliance before attacking if needed
const alliance = this.bot.allianceWith(toAttack);
const alliance = this.tribe.allianceWith(toAttack);
if (alliance !== null) {
this.bot.breakAlliance(alliance);
this.tribe.breakAlliance(alliance);
}
this.attackBehavior.sendAttack(toAttack);
@@ -112,7 +112,7 @@ export class BotExecution implements Execution {
}
if (this.neighborsTerraNullius) {
if (this.bot.neighbors().some((n) => !n.isPlayer())) {
if (this.tribe.neighbors().some((n) => !n.isPlayer())) {
this.attackBehavior.sendAttack(this.mg.terraNullius());
return;
}
+40
View File
@@ -0,0 +1,40 @@
import { Game, PlayerInfo, PlayerType } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { simpleHash } from "../Util";
import { SpawnExecution } from "./SpawnExecution";
import { TRIBE_NAME_PREFIXES, TRIBE_NAME_SUFFIXES } from "./utils/TribeNames";
export class TribeSpawner {
private random: PseudoRandom;
constructor(
private gs: Game,
private gameID: GameID,
) {
// Use a different seed than createGameRunner (which uses simpleHash(gameID))
// to avoid tribe IDs colliding with nation/human IDs from the same PRNG sequence.
this.random = new PseudoRandom(simpleHash(gameID) + 2);
}
spawnTribes(numTribes: number): SpawnExecution[] {
const tribes: SpawnExecution[] = [];
for (let i = 0; i < numTribes; i++) {
tribes.push(this.spawnTribe(this.randomTribeName()));
}
return tribes;
}
spawnTribe(tribeName: string): SpawnExecution {
return new SpawnExecution(
this.gameID,
new PlayerInfo(tribeName, PlayerType.Bot, null, this.random.nextID()),
);
}
private randomTribeName(): string {
const prefixIndex = this.random.nextInt(0, TRIBE_NAME_PREFIXES.length);
const suffixIndex = this.random.nextInt(0, TRIBE_NAME_SUFFIXES.length);
return `${TRIBE_NAME_PREFIXES[prefixIndex]} ${TRIBE_NAME_SUFFIXES[suffixIndex]}`;
}
}
@@ -51,7 +51,7 @@ export class NationNukeBehavior {
const silos = this.player.units(UnitType.MissileSilo);
if (
silos.length === 0 ||
nukeTarget.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans)
nukeTarget.type() === PlayerType.Bot || // Don't nuke tribes (as opposed to nations and humans)
this.player.isOnSameTeam(nukeTarget) ||
this.attackBehavior.shouldAttack(nukeTarget) === false
) {
@@ -697,7 +697,10 @@ export class NationStructureBehavior {
unitToCluster.set(station.unit, station.getCluster());
}
const maxTradeGold = Math.max(Number(game.config().trainGold("ally")), 1);
const maxTradeGold = Math.max(
Number(game.config().trainGold("ally", 0)),
1,
);
const result: Array<{
tile: TileRef;
cluster: Cluster | null;
@@ -705,7 +708,8 @@ export class NationStructureBehavior {
}> = [];
// Own structures — weighted by "self" trade gold.
const selfWeight = Number(game.config().trainGold("self")) / maxTradeGold;
const selfWeight =
Number(game.config().trainGold("self", 0)) / maxTradeGold;
for (const unit of player.units(
UnitType.City,
UnitType.Port,
@@ -730,7 +734,7 @@ export class NationStructureBehavior {
: player.isAlliedWith(neighbor)
? "ally"
: "other";
const weight = Number(game.config().trainGold(relType)) / maxTradeGold;
const weight = Number(game.config().trainGold(relType, 0)) / maxTradeGold;
for (const unit of neighbor.units(
UnitType.City,
UnitType.Port,
@@ -1,4 +1,4 @@
export const BOT_NAME_PREFIXES = [
export const TRIBE_NAME_PREFIXES = [
"Akkadian",
"Babylonian",
"Sumerian",
@@ -159,8 +159,6 @@ export const BOT_NAME_PREFIXES = [
"Armenian",
"Circassian",
"Georgian",
"Phoenician",
"Chaldean",
"Kurdish",
"Turkic",
"Kazakh",
@@ -171,7 +169,6 @@ export const BOT_NAME_PREFIXES = [
"Pashtun",
"Baloch",
"Afghan",
"Persian",
"Kenyan",
"Ugandan",
"Bhutanese",
@@ -180,7 +177,7 @@ export const BOT_NAME_PREFIXES = [
"Militant",
"Spartan",
];
export const BOT_NAME_SUFFIXES = [
export const TRIBE_NAME_SUFFIXES = [
"Empire",
"Dynasty",
"Kingdom",
@@ -218,7 +215,6 @@ export const BOT_NAME_SUFFIXES = [
"Confederacy",
"Order",
"Regime",
"Dominion",
"Syndicate",
"Guild",
"Corporation",
@@ -231,10 +227,7 @@ export const BOT_NAME_SUFFIXES = [
"Sisterhood",
"Ascendancy",
"Supremacy",
"Province",
"Tribe",
"Dominion",
"Assembly",
"Republics",
"Army",
"Dictatorship",
+3 -1
View File
@@ -243,6 +243,8 @@ export interface PublicGameModifiers {
isCrowded: boolean;
isHardNations: boolean;
startingGold?: number;
goldMultiplier?: number;
isAlliancesDisabled: boolean;
}
export interface UnitInfo {
@@ -506,7 +508,7 @@ export class PlayerInfo {
constructor(
public readonly name: string,
public readonly playerType: PlayerType,
// null if bot.
// null if tribe.
public readonly clientID: ClientID | null,
// TODO: make player id the small id
public readonly id: PlayerID,
+10 -3
View File
@@ -477,6 +477,9 @@ export class PlayerImpl implements Player {
}
canSendAllianceRequest(other: Player): boolean {
if (this.mg.config().disableAlliances()) {
return false;
}
if (other === this) {
return false;
}
@@ -1177,16 +1180,20 @@ export class PlayerImpl implements Player {
return false;
}
const owner = this.mg.owner(tile);
// Allow nuking teammates after the game is over (aftergame fun)
const gameOver = this.mg.getWinner() !== null;
if (owner.isPlayer()) {
if (this.isOnSameTeam(owner)) {
if (this.isOnSameTeam(owner) && !gameOver) {
return false;
}
}
// Prevent launching nukes that would hit teammate structures (only in team games)
// Prevent launching nukes that would hit teammate structures (only in team games).
// Disabled after game-over so players can nuke teammates in the aftergame.
if (
this.mg.config().gameConfig().gameMode === GameMode.Team &&
nukeType !== UnitType.MIRV
nukeType !== UnitType.MIRV &&
!gameOver
) {
const magnitude = this.mg.config().nukeMagnitudes(nukeType);
const wouldHitTeammate = this.mg.anyUnitNearby(
+6 -1
View File
@@ -20,7 +20,12 @@ class TradeStationStopHandler implements TrainStopHandler {
): void {
const stationOwner = station.unit.owner();
const trainOwner = trainExecution.owner();
const gold = mg.config().trainGold(rel(trainOwner, stationOwner));
const gold = mg
.config()
.trainGold(
rel(trainOwner, stationOwner),
trainExecution.tradeStopsVisited(),
);
// Share revenue with the station owner if it's not the current player
if (trainOwner !== stationOwner) {
stationOwner.addGold(gold, station.tile());
+4 -5
View File
@@ -104,11 +104,10 @@ export class GameManager {
}
desyncCount(): number {
let totalDesyncs = 0;
this.games.forEach((game: GameServer) => {
totalDesyncs += game.desyncCount;
});
return totalDesyncs;
return [...this.games.values()].reduce(
(acc, game) => acc + game.numDesyncedClients(),
0,
);
}
tick() {
+7 -4
View File
@@ -79,8 +79,6 @@ export class GameServer {
private _hasEnded = false;
public desyncCount = 0;
private lobbyInfoIntervalId: ReturnType<typeof setInterval> | null = null;
constructor(
@@ -157,6 +155,9 @@ export class GameServer {
if (gameConfig.startingGold !== undefined) {
this.gameConfig.startingGold = gameConfig.startingGold;
}
if (gameConfig.disableAlliances !== undefined) {
this.gameConfig.disableAlliances = gameConfig.disableAlliances;
}
}
private isKicked(clientID: ClientID): boolean {
@@ -530,6 +531,10 @@ export class GameServer {
return this.activeClients.length;
}
public numDesyncedClients(): number {
return this.outOfSyncClients.size;
}
public prestart() {
if (this.hasStarted()) {
return;
@@ -980,8 +985,6 @@ export class GameServer {
const { mostCommonHash, outOfSyncClients } =
this.findOutOfSyncClients(lastHashTurn);
this.desyncCount += outOfSyncClients.length;
if (outOfSyncClients.length === 0) {
this.turns[lastHashTurn].hash = mostCommonHash;
return;
+32 -5
View File
@@ -61,7 +61,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
SouthAmerica: 5,
StraitOfGibraltar: 5,
Svalmel: 8,
World: 8,
World: 20,
Lemnos: 3,
Passage: 4,
TwoLakes: 6,
@@ -103,7 +103,9 @@ type ModifierKey =
| "isCrowded"
| "isHardNations"
| "startingGold"
| "startingGoldHigh";
| "startingGoldHigh"
| "goldMultiplier"
| "isAlliancesDisabled";
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
@@ -113,6 +115,8 @@ const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array<ModifierKey>(1).fill("isHardNations"),
...Array<ModifierKey>(8).fill("startingGold"),
...Array<ModifierKey>(1).fill("startingGoldHigh"),
...Array<ModifierKey>(1).fill("goldMultiplier"),
...Array<ModifierKey>(1).fill("isAlliancesDisabled"),
];
// Modifiers that cannot be active at the same time.
@@ -197,6 +201,7 @@ export class MapPlaylist {
isCrowded,
isHardNations,
startingGold,
isAlliancesDisabled: false,
},
startingGold,
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
@@ -263,7 +268,14 @@ export class MapPlaylist {
undefined,
poolCountReduction,
);
let { isCrowded, startingGold, isCompact, isRandomSpawn } = poolResult;
let {
isCrowded,
startingGold,
isCompact,
isRandomSpawn,
goldMultiplier,
isAlliancesDisabled,
} = poolResult;
let isHardNations =
hardNationsFromIndependentRoll ?? poolResult.isHardNations;
@@ -280,7 +292,9 @@ export class MapPlaylist {
!isRandomSpawn &&
!isCompact &&
!isHardNations &&
startingGold === undefined
startingGold === undefined &&
goldMultiplier === undefined &&
!isAlliancesDisabled
) {
excludedModifiers.push("isCrowded");
const fallback = this.getRandomSpecialGameModifiers(
@@ -288,7 +302,13 @@ export class MapPlaylist {
1,
poolCountReduction,
);
({ isRandomSpawn, isCompact, startingGold } = fallback);
({
isRandomSpawn,
isCompact,
startingGold,
goldMultiplier,
isAlliancesDisabled,
} = fallback);
isHardNations =
hardNationsFromIndependentRoll ?? fallback.isHardNations;
}
@@ -321,8 +341,12 @@ export class MapPlaylist {
isCrowded,
isHardNations,
startingGold,
goldMultiplier,
isAlliancesDisabled,
},
startingGold,
goldMultiplier,
disableAlliances: isAlliancesDisabled,
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
@@ -482,6 +506,7 @@ export class MapPlaylist {
playerTeams === HumansVsNations
? Math.random() < HARD_NATIONS_HVN_PROBABILITY
: Math.random() < 0.025, // 2.5% chance
isAlliancesDisabled: false,
};
}
@@ -530,6 +555,8 @@ export class MapPlaylist {
: selected.has("startingGold")
? 5_000_000
: undefined,
goldMultiplier: selected.has("goldMultiplier") ? 2 : undefined,
isAlliancesDisabled: selected.has("isAlliancesDisabled"),
};
}
+7 -3
View File
@@ -50,9 +50,13 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
);
for (const word of bannedWords) {
customDataset.addPhrase((phrase) =>
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
);
try {
customDataset.addPhrase((phrase) =>
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
);
} catch (e) {
console.error(`Invalid banned word pattern "${word}": ${e}`);
}
}
return new RegExpMatcher({
+1 -1
View File
@@ -28,7 +28,7 @@ function makeStation(unit: any, cluster: Cluster | null = null): any {
function makeGame(stations: any[] = []): any {
return {
config: () => ({
trainGold: (rel: string) => TRAIN_GOLD[rel] ?? 0n,
trainGold: (rel: string, _citiesVisited: number) => TRAIN_GOLD[rel] ?? 0n,
}),
railNetwork: () => ({
stationManager: () => ({ getAll: () => new Set(stations) }),
+94 -3
View File
@@ -1,8 +1,22 @@
import { GameUpdateType } from "src/core/game/GameUpdates";
import { vi, type Mocked } from "vitest";
import { DefaultConfig } from "../../../src/core/configuration/DefaultConfig";
import { TrainExecution } from "../../../src/core/execution/TrainExecution";
import { Game, Player, Unit, UnitType } from "../../../src/core/game/Game";
import {
Difficulty,
Game,
GameMapSize,
GameMapType,
GameMode,
GameType,
Player,
Unit,
UnitType,
} from "../../../src/core/game/Game";
import { Cluster, TrainStation } from "../../../src/core/game/TrainStation";
import { UserSettings } from "../../../src/core/game/UserSettings";
import { GameConfig } from "../../../src/core/Schemas";
import { TestServerConfig } from "../../util/TestServerConfig";
vi.mock("../../../src/core/game/Game");
vi.mock("../../../src/core/execution/TrainExecution");
@@ -18,8 +32,8 @@ describe("TrainStation", () => {
game = {
ticks: vi.fn().mockReturnValue(123),
config: vi.fn().mockReturnValue({
trainGold: (isFriendly: boolean) =>
isFriendly ? BigInt(1000) : BigInt(500),
trainGold: (rel: string, _tradeStopsVisited: number) =>
rel !== "other" ? BigInt(1000) : BigInt(500),
}),
addUpdate: vi.fn(),
addExecution: vi.fn(),
@@ -48,6 +62,7 @@ describe("TrainStation", () => {
loadCargo: vi.fn(),
owner: vi.fn().mockReturnValue(player),
level: vi.fn(),
tradeStopsVisited: vi.fn().mockReturnValue(0),
} as any;
});
@@ -74,6 +89,20 @@ describe("TrainStation", () => {
);
});
it("passes tradeStopsVisited to trainGold", () => {
unit.type.mockReturnValue(UnitType.City);
const trainGoldSpy = vi.fn().mockReturnValue(500n);
(game.config as any).mockReturnValue({
trainGold: trainGoldSpy,
});
(trainExecution as any).tradeStopsVisited = vi.fn().mockReturnValue(3);
const station = new TrainStation(game, unit);
station.onTrainStop(trainExecution);
expect(trainGoldSpy).toHaveBeenCalledWith(expect.any(String), 3);
});
it("checks trade availability (same owner)", () => {
const otherUnit = {
owner: vi.fn().mockReturnValue(unit.owner()),
@@ -133,3 +162,65 @@ describe("TrainStation", () => {
expect(station.isActive()).toBe(true);
});
});
describe("DefaultConfig.trainGold trade stop penalty", () => {
let config: DefaultConfig;
beforeEach(() => {
const serverConfig = new TestServerConfig();
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
nations: "default",
donateGold: false,
donateTroops: false,
bots: 0,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNavMesh: false,
randomSpawn: false,
};
config = new DefaultConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
});
it("returns full base gold within free window (stops 0-5)", () => {
// first 6 stops (0-5) are free — no penalty
expect(config.trainGold("self", 0)).toBe(10_000n);
expect(config.trainGold("self", 5)).toBe(10_000n);
});
it("reduces gold by 5k per stop after the free window", () => {
// stop 6: effective = 6-5 = 1 -> 10k - 5k = 5k
expect(config.trainGold("self", 6)).toBe(5_000n);
});
it("floors at 5k when penalty exceeds base gold", () => {
// stop 8: effective = 3 -> 10k - 15k -> floor at 5k
expect(config.trainGold("self", 8)).toBe(5_000n);
});
it("floors at 5k for ally base even with heavy penalty", () => {
// ally base 35k, stop 20: effective = 15 -> penalty 75k -> floor at 5k
expect(config.trainGold("ally", 20)).toBe(5_000n);
});
it("ally base gold reduces correctly after free window", () => {
// ally base 35k, stop 7: effective = 2 -> 35k - 10k = 25k
expect(config.trainGold("ally", 7)).toBe(25_000n);
});
it("other/team base gold reduces correctly after free window", () => {
// other base 25k, stop 6: effective = 1 -> 25k - 5k = 20k
expect(config.trainGold("other", 6)).toBe(20_000n);
expect(config.trainGold("team", 6)).toBe(20_000n);
});
});
+1
View File
@@ -85,6 +85,7 @@ export class TestServerConfig implements ServerConfig {
isRandomSpawn: false,
isCrowded: false,
isHardNations: false,
isAlliancesDisabled: false,
};
}
async supportsCompactMapForTeams(): Promise<boolean> {