mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-09 18:08:09 +00:00
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.
<img width="279" height="145" alt="image"
src="https://github.com/user-attachments/assets/5a608e18-3811-4eef-a3a6-9344aaf667fe"
/>
<img width="270" height="152" alt="image"
src="https://github.com/user-attachments/assets/9d8a4d30-51e7-4e82-8ce0-121b12af1c61"
/>
<img width="270" height="144" alt="image"
src="https://github.com/user-attachments/assets/a65b20fb-0db5-4657-a964-880fad7c864e"
/>
## 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._
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
></toggle-input-card>`,
|
||||
html`<toggle-input-card
|
||||
.labelKey=${"host_modal.custom_alliances"}
|
||||
.checked=${this.customAlliances}
|
||||
.inputMin=${0}
|
||||
.inputMax=${15}
|
||||
.inputStep=${1}
|
||||
.inputValue=${this.customAllianceMinutes}
|
||||
.inputAriaLabel=${translateText("host_modal.custom_alliances")}
|
||||
.inputPlaceholder=${translateText("host_modal.mins_placeholder")}
|
||||
.defaultInputValue=${0}
|
||||
.minValidOnEnable=${0}
|
||||
.zeroLabel=${`(${translateText("public_game_modifier.disable_alliances")})`}
|
||||
.onToggle=${this.handleCustomAlliancesToggle}
|
||||
.onInput=${this.handleCustomAllianceMinutesInput}
|
||||
.onKeyDown=${this.handleCustomAllianceMinutesKeyDown}
|
||||
></toggle-input-card>`,
|
||||
html`<toggle-input-card
|
||||
.labelKey=${"host_modal.gold_multiplier"}
|
||||
.checked=${this.goldMultiplier}
|
||||
@@ -515,10 +532,6 @@ export class HostLobbyModal extends BaseModal {
|
||||
labelKey: "host_modal.compact_map",
|
||||
checked: this.compactMap,
|
||||
},
|
||||
{
|
||||
labelKey: "host_modal.disable_alliances",
|
||||
checked: this.disableAlliances,
|
||||
},
|
||||
{
|
||||
labelKey: "host_modal.anonymous_players",
|
||||
checked: this.anonymizeNames,
|
||||
@@ -767,7 +780,8 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.goldMultiplierValue = undefined;
|
||||
this.startingGold = false;
|
||||
this.startingGoldValue = undefined;
|
||||
this.disableAlliances = false;
|
||||
this.customAlliances = false;
|
||||
this.customAllianceMinutes = undefined;
|
||||
this.doomsdayClock = false;
|
||||
this.doomsdayClockSpeed = "normal";
|
||||
this.anonymizeNames = false;
|
||||
@@ -865,10 +879,6 @@ 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;
|
||||
case "host_modal.anonymous_players":
|
||||
this.anonymizeNames = checked;
|
||||
this.putGameConfig();
|
||||
@@ -1000,6 +1010,29 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.putGameConfig();
|
||||
};
|
||||
|
||||
private handleCustomAlliancesToggle = (
|
||||
checked: boolean,
|
||||
value: number | string | undefined,
|
||||
) => {
|
||||
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.
|
||||
|
||||
@@ -615,7 +615,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
.value=${`x${c.goldMultiplier}`}
|
||||
></lobby-config-item>`,
|
||||
);
|
||||
if (c.disableAlliances)
|
||||
if (c.customAllianceDuration === 0 || c.disableAlliances)
|
||||
cards.push(
|
||||
html`<lobby-config-item
|
||||
.label=${translateText(
|
||||
@@ -624,6 +624,15 @@ export class JoinLobbyModal extends BaseModal {
|
||||
.value=${translateText("common.disabled")}
|
||||
></lobby-config-item>`,
|
||||
);
|
||||
else if (typeof c.customAllianceDuration === "number")
|
||||
cards.push(
|
||||
html`<lobby-config-item
|
||||
.label=${translateText(
|
||||
"public_game_modifier.disable_alliances_label",
|
||||
)}
|
||||
.value=${`${c.customAllianceDuration}m`}
|
||||
></lobby-config-item>`,
|
||||
);
|
||||
if (c.waterNukes)
|
||||
cards.push(
|
||||
html`<lobby-config-item
|
||||
|
||||
@@ -60,7 +60,8 @@ const DEFAULT_OPTIONS = {
|
||||
startingGold: false,
|
||||
startingGoldValue: undefined as number | undefined,
|
||||
disabledUnits: [] as UnitType[],
|
||||
disableAlliances: false,
|
||||
customAlliances: false,
|
||||
customAllianceMinutes: undefined as number | undefined,
|
||||
waterNukes: false,
|
||||
doomsdayClock: false,
|
||||
doomsdayClockSpeed: "normal" as DoomsdayClockSpeed,
|
||||
@@ -145,7 +146,9 @@ export class SinglePlayerModal extends BaseModal {
|
||||
@state() private disabledUnits: UnitType[] = [
|
||||
...DEFAULT_OPTIONS.disabledUnits,
|
||||
];
|
||||
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
|
||||
@state() private customAlliances: boolean = DEFAULT_OPTIONS.customAlliances;
|
||||
@state() private customAllianceMinutes: number | undefined =
|
||||
DEFAULT_OPTIONS.customAllianceMinutes;
|
||||
@state() private waterNukes: boolean = DEFAULT_OPTIONS.waterNukes;
|
||||
@state() private doomsdayClock: boolean = DEFAULT_OPTIONS.doomsdayClock;
|
||||
@state() private doomsdayClockSpeed: DoomsdayClockSpeed =
|
||||
@@ -379,6 +382,22 @@ export class SinglePlayerModal extends BaseModal {
|
||||
.onChange=${this.handleStartingGoldValueChanges}
|
||||
.onKeyDown=${this.handleStartingGoldValueKeyDown}
|
||||
></toggle-input-card>`,
|
||||
html`<toggle-input-card
|
||||
.labelKey=${"single_modal.custom_alliances"}
|
||||
.checked=${this.customAlliances}
|
||||
.inputMin=${0}
|
||||
.inputMax=${15}
|
||||
.inputStep=${1}
|
||||
.inputValue=${this.customAllianceMinutes}
|
||||
.inputAriaLabel=${translateText("single_modal.custom_alliances")}
|
||||
.inputPlaceholder=${translateText("single_modal.mins_placeholder")}
|
||||
.defaultInputValue=${0}
|
||||
.minValidOnEnable=${0}
|
||||
.zeroLabel=${`(${translateText("public_game_modifier.disable_alliances")})`}
|
||||
.onToggle=${this.handleCustomAlliancesToggle}
|
||||
.onInput=${this.handleCustomAllianceMinutesInput}
|
||||
.onKeyDown=${this.handleCustomAllianceMinutesKeyDown}
|
||||
></toggle-input-card>`,
|
||||
];
|
||||
|
||||
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
|
||||
? {
|
||||
|
||||
@@ -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`<div
|
||||
class="pointer-events-none absolute left-0 right-0 top-full mt-0.5 text-center text-[10px] leading-none text-white/70"
|
||||
>
|
||||
${this.zeroLabel}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<GameConfig>): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user