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); + }); +});