From 050c7604e87aeb35ba27b51b1df5960e471bea5d Mon Sep 17 00:00:00 2001
From: Zixer1 <99333209+Zixer1@users.noreply.github.com>
Date: Wed, 8 Jul 2026 15:13:35 -0400
Subject: [PATCH] feat(alliances): custom alliance duration lobby control
(#4522)
## Description:
Replaces the "Disable alliances" toggle in the host and single-player
lobbies with a "Custom alliances" control: a toggle plus a minutes input
(0 to 15, step 1).
- 0 minutes disables alliances, same behavior as the old toggle.
- 1 to 15 sets the alliance duration in minutes.
How it works:
- Adds one game-config field, `customAllianceDuration` (minutes).
- `Config.allianceDuration()` uses it when set (and falls back to the
existing 5 minute default), and `Config.disableAlliances()` returns true
when it is 0.
- The legacy `disableAlliances` boolean is still read, so older/archived
configs keep working.
- Validation reuses the existing `parseBoundedIntegerFromInput` and
`toggle-input-card` helpers, so it behaves like the other numeric lobby
options (spawn immunity, max timer).
- The join-lobby screen shows "Alliances: {x}m", or "Alliances:
Disabled" at 0.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
zixer._
---
resources/lang/en.json | 5 +-
src/client/HostLobbyModal.ts | 57 +++++++++++++++++-----
src/client/JoinLobbyModal.ts | 11 ++++-
src/client/SinglePlayerModal.ts | 61 +++++++++++++++++++-----
src/client/components/ToggleInputCard.ts | 12 +++++
src/core/Schemas.ts | 1 +
src/core/configuration/Config.ts | 11 ++++-
src/server/GameServer.ts | 4 ++
tests/CustomAllianceDuration.test.ts | 37 ++++++++++++++
9 files changed, 172 insertions(+), 27 deletions(-)
create mode 100644 tests/CustomAllianceDuration.test.ts
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 5ccd4825c..852b4c33a 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -759,7 +759,7 @@
"bots_disabled": "Disabled",
"compact_map": "Compact Map",
"crowded": "Crowded modifier",
- "disable_alliances": "Disable alliances",
+ "custom_alliances": "Alliance Duration",
"donate_gold": "Donate gold",
"donate_troops": "Donate troops",
"doomsday_clock": "Doomsday Clock",
@@ -1281,7 +1281,7 @@
"bots": "Tribes: ",
"bots_disabled": "Disabled",
"compact_map": "Compact Map",
- "disable_alliances": "Disable alliances",
+ "custom_alliances": "Alliance Duration",
"doomsday_clock": "Doomsday Clock",
"enables_title": "Disable Units",
"gold_multiplier": "Gold multiplier",
@@ -1294,6 +1294,7 @@
"max_timer_placeholder": "Mins",
"medals_earned": "Medals",
"medals_of_maps": "out of {total} maps",
+ "mins_placeholder": "Mins",
"nations": "Nations: ",
"nations_disabled": "Disabled",
"options_changed_no_achievements": "Custom settings – achievements disabled",
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index 079beca70..eb36443c7 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -84,7 +84,8 @@ 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 customAlliances: boolean = false;
+ @state() private customAllianceMinutes: number | undefined = undefined;
@state() private doomsdayClock: boolean = false;
@state() private doomsdayClockSpeed: DoomsdayClockSpeed = "normal";
@state() private anonymizeNames: boolean = false;
@@ -352,6 +353,22 @@ export class HostLobbyModal extends BaseModal {
.onInput=${this.handleSpawnImmunityDurationInput}
.onKeyDown=${this.handleSpawnImmunityDurationKeyDown}
>`,
+ html``,
html` {
+ this.customAlliances = checked;
+ this.customAllianceMinutes = toOptionalNumber(value);
+ this.putGameConfig();
+ };
+
+ private handleCustomAllianceMinutesKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e", "E"]);
+ };
+
+ private handleCustomAllianceMinutesInput = (e: Event) => {
+ const input = e.target as HTMLInputElement;
+ const value = parseBoundedIntegerFromInput(input, { min: 0, max: 15 });
+ if (value === undefined) {
+ return;
+ }
+ this.customAllianceMinutes = value;
+ this.putGameConfig();
+ };
+
private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["+", "-", "e", "E"]);
};
@@ -1294,7 +1327,9 @@ export class HostLobbyModal extends BaseModal {
this.startingGold === true && this.startingGoldValue !== undefined
? Math.round(this.startingGoldValue * 1_000_000)
: null,
- disableAlliances: this.disableAlliances || null,
+ customAllianceDuration: this.customAlliances
+ ? (this.customAllianceMinutes ?? 0)
+ : null,
// Send {enabled:false} (not undefined) when off: undefined is dropped
// by JSON.stringify, so the server's "!== undefined" merge would keep a
// previously-enabled config and the toggle could never turn off.
diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts
index cdc603763..b0362d39c 100644
--- a/src/client/JoinLobbyModal.ts
+++ b/src/client/JoinLobbyModal.ts
@@ -615,7 +615,7 @@ export class JoinLobbyModal extends BaseModal {
.value=${`x${c.goldMultiplier}`}
>`,
);
- if (c.disableAlliances)
+ if (c.customAllianceDuration === 0 || c.disableAlliances)
cards.push(
html``,
);
+ else if (typeof c.customAllianceDuration === "number")
+ cards.push(
+ html``,
+ );
if (c.waterNukes)
cards.push(
html``,
+ html``,
];
return html`
@@ -440,10 +459,6 @@ export class SinglePlayerModal extends BaseModal {
labelKey: "single_modal.compact_map",
checked: this.compactMap,
},
- {
- labelKey: "single_modal.disable_alliances",
- checked: this.disableAlliances,
- },
{
labelKey: "single_modal.water_nukes",
checked: this.waterNukes,
@@ -510,7 +525,8 @@ 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.customAlliances !== DEFAULT_OPTIONS.customAlliances ||
+ this.customAllianceMinutes !== DEFAULT_OPTIONS.customAllianceMinutes ||
this.waterNukes !== DEFAULT_OPTIONS.waterNukes ||
this.doomsdayClock !== DEFAULT_OPTIONS.doomsdayClock ||
// Pace only matters when the mode is on (startGame drops it when off).
@@ -542,7 +558,8 @@ 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;
+ this.customAlliances = DEFAULT_OPTIONS.customAlliances;
+ this.customAllianceMinutes = DEFAULT_OPTIONS.customAllianceMinutes;
this.waterNukes = DEFAULT_OPTIONS.waterNukes;
this.doomsdayClock = DEFAULT_OPTIONS.doomsdayClock;
this.doomsdayClockSpeed = DEFAULT_OPTIONS.doomsdayClockSpeed;
@@ -630,9 +647,6 @@ export class SinglePlayerModal extends BaseModal {
case "single_modal.compact_map":
this.handleCompactMapChange(checked);
break;
- case "single_modal.disable_alliances":
- this.disableAlliances = checked;
- break;
case "single_modal.water_nukes":
this.waterNukes = checked;
break;
@@ -696,6 +710,27 @@ export class SinglePlayerModal extends BaseModal {
this.startingGoldValue = toOptionalNumber(value);
};
+ private handleCustomAlliancesToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.customAlliances = checked;
+ this.customAllianceMinutes = toOptionalNumber(value);
+ };
+
+ private handleCustomAllianceMinutesKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e"]);
+ };
+
+ private handleCustomAllianceMinutesInput = (e: Event) => {
+ const input = e.target as HTMLInputElement;
+ const value = parseBoundedIntegerFromInput(input, { min: 0, max: 15 });
+ if (value === undefined) {
+ return;
+ }
+ this.customAllianceMinutes = value;
+ };
+
private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e"]);
};
@@ -845,7 +880,9 @@ export class SinglePlayerModal extends BaseModal {
),
}
: {}),
- ...(this.disableAlliances ? { disableAlliances: true } : {}),
+ ...(this.customAlliances
+ ? { customAllianceDuration: this.customAllianceMinutes ?? 0 }
+ : {}),
...(this.waterNukes ? { waterNukes: true } : {}),
...(this.doomsdayClock
? {
diff --git a/src/client/components/ToggleInputCard.ts b/src/client/components/ToggleInputCard.ts
index b9e10bb03..f07d5bff8 100644
--- a/src/client/components/ToggleInputCard.ts
+++ b/src/client/components/ToggleInputCard.ts
@@ -15,6 +15,9 @@ export class ToggleInputCard extends LitElement {
@property({ attribute: false }) inputValue?: number | string;
@property({ attribute: false }) inputAriaLabel?: string;
@property({ attribute: false }) inputPlaceholder?: string;
+ // Optional hint shown under the input when its value is 0 (e.g. "Disabled"),
+ // so a 0 that means "off" isn't cryptic.
+ @property({ attribute: false }) zeroLabel?: string;
@property({ attribute: false }) defaultInputValue?: number | string;
@property({ attribute: false }) minValidOnEnable?: number;
@property({ attribute: false }) onToggle?: (
@@ -157,6 +160,15 @@ export class ToggleInputCard extends LitElement {
@change=${this.onChange}
@keydown=${this.onKeyDown}
/>
+ ${this.checked &&
+ this.zeroLabel !== undefined &&
+ this.toOptionalNumber(this.inputValue) === 0
+ ? html`
+ ${this.zeroLabel}
+
`
+ : nothing}
`;
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 425f8d994..9432d6005 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -358,6 +358,7 @@ export const GameConfigSchema = z.object({
// OFM: allowlist of publicIds allowed to join (admin-only, see create_game).
allowedPublicIds: z.array(z.string()).max(200).optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
+ customAllianceDuration: z.number().int().min(0).max(15).nullable().optional(), // In minutes; 0 disables alliances
startDelay: z.number().int().min(0).max(600).nullable().optional(), // In seconds
spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks
disabledUnits: z.enum(UnitType).array().optional(),
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index eae63105b..5186cb9b8 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -219,7 +219,12 @@ export class Config {
return this._gameConfig.disableNavMesh ?? false;
}
disableAlliances(): boolean {
- return this._gameConfig.disableAlliances ?? false;
+ // customAllianceDuration === 0 disables alliances (the "custom alliances"
+ // control at 0). The legacy boolean is still honored for older configs.
+ return (
+ this._gameConfig.customAllianceDuration === 0 ||
+ (this._gameConfig.disableAlliances ?? false)
+ );
}
waterNukes(): boolean {
return this._gameConfig.waterNukes ?? false;
@@ -566,6 +571,10 @@ export class Config {
return 30 * 10;
}
allianceDuration(): Tick {
+ // Host can set a custom alliance duration in minutes (1-15); 0 disables
+ // alliances (see disableAlliances). Falls back to the 5 minute default.
+ const m = this._gameConfig.customAllianceDuration;
+ if (typeof m === "number" && m > 0) return m * 60 * 10;
return 300 * 10; // 5 minutes.
}
temporaryEmbargoDuration(): Tick {
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index b321f380a..c82914222 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -273,6 +273,10 @@ export class GameServer {
this.gameConfig.disableAlliances =
gameConfig.disableAlliances ?? undefined;
}
+ if (gameConfig.customAllianceDuration !== undefined) {
+ this.gameConfig.customAllianceDuration =
+ gameConfig.customAllianceDuration ?? undefined;
+ }
if (gameConfig.allowedPublicIds !== undefined) {
this.gameConfig.allowedPublicIds = gameConfig.allowedPublicIds;
// A join whitelist and public listing are mutually exclusive: a listed
diff --git a/tests/CustomAllianceDuration.test.ts b/tests/CustomAllianceDuration.test.ts
new file mode 100644
index 000000000..8017ef17f
--- /dev/null
+++ b/tests/CustomAllianceDuration.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import { Config } from "../src/core/configuration/Config";
+import { GameConfig } from "../src/core/Schemas";
+
+// The "custom alliances" lobby control writes customAllianceDuration (minutes):
+// 0 disables alliances, 1-15 sets the alliance duration, unset = default.
+function cfg(over: Partial): Config {
+ return new Config(over as unknown as GameConfig, null, false);
+}
+
+describe("custom alliance duration", () => {
+ it("0 minutes disables alliances", () => {
+ expect(cfg({ customAllianceDuration: 0 }).disableAlliances()).toBe(true);
+ });
+
+ it("a positive value keeps alliances on, minutes converted to ticks", () => {
+ const c = cfg({ customAllianceDuration: 5 });
+ expect(c.disableAlliances()).toBe(false);
+ expect(c.allianceDuration()).toBe(5 * 60 * 10);
+ });
+
+ it("15 minutes (the max) converts correctly", () => {
+ expect(cfg({ customAllianceDuration: 15 }).allianceDuration()).toBe(
+ 15 * 60 * 10,
+ );
+ });
+
+ it("unset falls back to the 5 minute default with alliances on", () => {
+ const c = cfg({});
+ expect(c.disableAlliances()).toBe(false);
+ expect(c.allianceDuration()).toBe(300 * 10);
+ });
+
+ it("the legacy disableAlliances boolean still disables", () => {
+ expect(cfg({ disableAlliances: true }).disableAlliances()).toBe(true);
+ });
+});