diff --git a/resources/lang/en.json b/resources/lang/en.json
index 812cd07334..f789ac4323 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -946,6 +946,10 @@
"betrayed_you": "{name} broke their alliance with you",
"about_to_expire": "Your alliance with {name} is about to expire!",
"alliance_expired": "Your alliance with {name} expired",
+ "alliances_disabled_warning_5min": "⚠ All alliances will be disabled in 5 minutes (at the 45 minute mark) ⚠",
+ "alliances_disabled_warning": "⚠ All alliances will be disabled at the 45 minute mark (1 minute left) ⚠",
+ "alliances_disabled": "All alliances have been dissolved. No new alliances can be formed.",
+ "alliances_ending_countdown": "⚠ Alliances ending in {time}",
"attack_request": "{name} requests you attack {target}",
"sent_emoji": "Sent {name}: {emoji}",
"renew_alliance": "Request to renew",
diff --git a/src/client/Utils.ts b/src/client/Utils.ts
index 943e764a4a..6fb7f977ed 100644
--- a/src/client/Utils.ts
+++ b/src/client/Utils.ts
@@ -521,6 +521,7 @@ export function getMessageTypeClasses(type: MessageType): string {
case MessageType.HYDROGEN_BOMB_INBOUND:
case MessageType.SAM_MISS:
case MessageType.ALLIANCE_EXPIRED:
+ case MessageType.ALLIANCES_DISABLED:
case MessageType.NAVAL_INVASION_INBOUND:
case MessageType.RENEW_ALLIANCE:
return severityColors["warn"];
diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts
index 935de93881..c77a69a786 100644
--- a/src/client/graphics/PlayerIcons.ts
+++ b/src/client/graphics/PlayerIcons.ts
@@ -105,7 +105,10 @@ export function getPlayerIcons(
const userSettings = game.config().userSettings();
const isDarkMode = darkMode ?? userSettings?.darkMode() ?? false;
const emojisEnabled = userSettings?.emojis() ?? false;
- const alliancesOff = alliancesDisabled ?? game.config().disableAlliances();
+ const cutoff = game.config().alliancesCutoffTick();
+ const pastCutoff = cutoff !== null && game.ticks() >= cutoff;
+ const alliancesOff =
+ alliancesDisabled || game.config().disableAlliances() || pastCutoff;
const icons: PlayerIconDescriptor[] = [];
diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts
index cb6eb6211e..aa86738f6f 100644
--- a/src/client/graphics/layers/EventsDisplay.ts
+++ b/src/client/graphics/layers/EventsDisplay.ts
@@ -91,6 +91,8 @@ export class EventsDisplay extends LitElement implements Layer {
[MessageCategory.ALLIANCE, false],
[MessageCategory.CHAT, false],
]);
+ @state() private allianceCutoffCountdown: string | null = null;
+ @state() private allianceCutoffCenterWarning: string | null = null;
@query(".events-container")
private _eventsContainer?: HTMLDivElement;
@@ -237,6 +239,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
this.checkForAllianceExpirations();
+ this.updateAllianceCutoffCountdown();
const updates = this.game.updatesSinceLastTick();
if (updates) {
@@ -352,6 +355,38 @@ export class EventsDisplay extends LitElement implements Layer {
this.requestUpdate();
}
+ private updateAllianceCutoffCountdown() {
+ const cutoff = this.game.config().alliancesCutoffTick();
+ if (cutoff === null) {
+ this.allianceCutoffCountdown = null;
+ return;
+ }
+ const fiveMinStart = cutoff - 5 * 60 * 10;
+ const oneMinStart = cutoff - 60 * 10;
+ const ticks = this.game.ticks();
+ if (ticks < fiveMinStart || ticks >= cutoff) {
+ this.allianceCutoffCountdown = null;
+ return;
+ }
+ const remainingTicks = cutoff - ticks;
+ const seconds = Math.ceil(remainingTicks / 10);
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ this.allianceCutoffCountdown = `${mins}:${secs.toString().padStart(2, "0")}`;
+ const warningKey =
+ ticks === fiveMinStart
+ ? "events_display.alliances_disabled_warning_5min"
+ : ticks === oneMinStart
+ ? "events_display.alliances_disabled_warning"
+ : null;
+ if (warningKey && !this.allianceCutoffCenterWarning) {
+ this.allianceCutoffCenterWarning = warningKey;
+ setTimeout(() => {
+ this.allianceCutoffCenterWarning = null;
+ }, 5000);
+ }
+ }
+
private removeEvent(index: number) {
this.events = [
...this.events.slice(0, index),
@@ -799,6 +834,24 @@ export class EventsDisplay extends LitElement implements Layer {
transform: scale(1);
}
}
+ @keyframes fadeInOut {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.9);
+ }
+ 15% {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+ 85% {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.9);
+ }
+ }
`;
@@ -818,6 +871,22 @@ export class EventsDisplay extends LitElement implements Layer {
return html`
${styles}
+
+ ${this.allianceCutoffCenterWarning
+ ? html`
+
+
+ ${translateText(this.allianceCutoffCenterWarning)}
+
+
+ `
+ : ""}
${this._hidden
? html`
@@ -889,6 +958,20 @@ export class EventsDisplay extends LitElement implements Layer {
+
+ ${this.allianceCutoffCountdown
+ ? html`
+
+ ${translateText(
+ "events_display.alliances_ending_countdown",
+ { time: this.allianceCutoffCountdown },
+ )}
+
+ `
+ : ""}
+
= cutoff
+ ) {
+ this.alliancesDisabled = true;
+ }
+
for (const player of this.game.playerViews()) {
if (player.isAlive()) {
if (!this.seenPlayers.has(player)) {
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index e0cacf57ba..c4f15df944 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -182,6 +182,7 @@ export interface Config {
structureMinDist(): number;
isReplay(): boolean;
allianceExtensionPromptOffset(): number;
+ alliancesCutoffTick(): Tick | null;
}
export interface Theme {
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index da49c66ffd..39e8355744 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -1011,4 +1011,9 @@ export class DefaultConfig implements Config {
allianceExtensionPromptOffset(): number {
return 300; // 30 seconds before expiration
}
+
+ alliancesCutoffTick(): Tick | null {
+ if (this._gameConfig.disableAlliances) return 0;
+ return this.numSpawnPhaseTurns() + 45 * 60 * 10;
+ }
}
diff --git a/src/core/execution/TribeExecution.ts b/src/core/execution/TribeExecution.ts
index 57f998c20e..5096144237 100644
--- a/src/core/execution/TribeExecution.ts
+++ b/src/core/execution/TribeExecution.ts
@@ -65,7 +65,9 @@ export class TribeExecution implements Execution {
}
private acceptAllAllianceRequests() {
- // Accept all alliance requests
+ const cutoff = this.mg.config().alliancesCutoffTick();
+ if (cutoff !== null && this.mg.ticks() >= cutoff) return;
+
for (const req of this.tribe.incomingAllianceRequests()) {
req.accept();
}
diff --git a/src/core/execution/alliance/AllianceExtensionExecution.ts b/src/core/execution/alliance/AllianceExtensionExecution.ts
index 699d4182db..29b5121ac9 100644
--- a/src/core/execution/alliance/AllianceExtensionExecution.ts
+++ b/src/core/execution/alliance/AllianceExtensionExecution.ts
@@ -28,6 +28,9 @@ export class AllianceExtensionExecution implements Execution {
return;
}
+ const cutoff = mg.config().alliancesCutoffTick();
+ if (cutoff !== null && mg.ticks() >= cutoff) return;
+
const alliance = this.from.allianceWith(to);
if (!alliance) {
console.warn(
diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts
index 2fb8589447..46e43bdde9 100644
--- a/src/core/execution/nation/NationAllianceBehavior.ts
+++ b/src/core/execution/nation/NationAllianceBehavior.ts
@@ -26,8 +26,13 @@ export class NationAllianceBehavior {
private emojiBehavior: NationEmojiBehavior,
) {}
+ private isAlliancesBlocked(): boolean {
+ const cutoff = this.game.config().alliancesCutoffTick();
+ return cutoff !== null && this.game.ticks() >= cutoff;
+ }
+
handleAllianceRequests() {
- if (this.game.config().disableAlliances()) return;
+ if (this.isAlliancesBlocked()) return;
for (const req of this.player.incomingAllianceRequests()) {
// Alliance Request intents created during the spawn phase are executed on
@@ -46,7 +51,7 @@ export class NationAllianceBehavior {
}
handleAllianceExtensionRequests() {
- if (this.game.config().disableAlliances()) return;
+ if (this.isAlliancesBlocked()) return;
for (const alliance of this.player.alliances()) {
// Alliance expiration tracked by Events Panel, only human ally can click Request to Renew
@@ -63,7 +68,7 @@ export class NationAllianceBehavior {
}
maybeSendAllianceRequests(borderingEnemies: Player[]) {
- if (this.game.config().disableAlliances()) return;
+ if (this.isAlliancesBlocked()) return;
// Only easy nations are allowed to send alliance requests to bots
const isAcceptablePlayerType = (p: Player) =>
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 18dfcbb8a8..253d4a08f6 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -1039,6 +1039,7 @@ export enum MessageType {
RECEIVED_TROOPS_FROM_PLAYER,
CHAT,
RENEW_ALLIANCE,
+ ALLIANCES_DISABLED,
}
// Message categories used for filtering events in the EventsDisplay
@@ -1071,6 +1072,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record = {
[MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE,
[MessageType.RENEW_ALLIANCE]: MessageCategory.ALLIANCE,
+ [MessageType.ALLIANCES_DISABLED]: MessageCategory.ALLIANCE,
[MessageType.SENT_GOLD_TO_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_TRADE]: MessageCategory.TRADE,
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index a9e914e77e..dbe1b1d808 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -458,6 +458,7 @@ export class GameImpl implements Game {
for (const tile of waterChangedTiles) {
this.recordTileUpdate(tile);
}
+ this.checkAlliancesCutoff();
this._ticks++;
return this.updates;
}
@@ -817,6 +818,48 @@ export class GameImpl implements Game {
);
}
+ private broadcastToAlivePlayers(message: string, type: MessageType): void {
+ for (const player of this._players.values()) {
+ if (player.isAlive()) {
+ this.displayMessage(message, type, player.id());
+ }
+ }
+ }
+
+ private checkAlliancesCutoff(): void {
+ const cutoff = this._config.alliancesCutoffTick();
+ if (cutoff === null) return;
+
+ const fiveMinWarningTick = cutoff - 5 * 60 * 10;
+ if (this._ticks === fiveMinWarningTick && fiveMinWarningTick > 0) {
+ this.broadcastToAlivePlayers(
+ "events_display.alliances_disabled_warning_5min",
+ MessageType.ALLIANCES_DISABLED,
+ );
+ }
+
+ const oneMinWarningTick = cutoff - 60 * 10;
+ if (this._ticks === oneMinWarningTick && oneMinWarningTick > 0) {
+ this.broadcastToAlivePlayers(
+ "events_display.alliances_disabled_warning",
+ MessageType.ALLIANCES_DISABLED,
+ );
+ }
+
+ if (this._ticks !== cutoff) return;
+
+ for (const alliance of [...this.alliances_]) {
+ this.expireAlliance(alliance);
+ }
+ for (const req of [...this.allianceRequests]) {
+ req.reject();
+ }
+ this.broadcastToAlivePlayers(
+ "events_display.alliances_disabled",
+ MessageType.ALLIANCES_DISABLED,
+ );
+ }
+
public isSpawnImmunityActive(): boolean {
return (
this.config().numSpawnPhaseTurns() +
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 6f163598b3..2a1a8abe69 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -521,6 +521,10 @@ export class PlayerImpl implements Player {
if (this.mg.config().disableAlliances()) {
return false;
}
+ const cutoff = this.mg.config().alliancesCutoffTick();
+ if (cutoff !== null && this.mg.ticks() >= cutoff) {
+ return false;
+ }
if (other === this) {
return false;
}
diff --git a/tests/AlliancesCutoff.test.ts b/tests/AlliancesCutoff.test.ts
new file mode 100644
index 0000000000..d7cc34a1d5
--- /dev/null
+++ b/tests/AlliancesCutoff.test.ts
@@ -0,0 +1,86 @@
+import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
+import { Game, Player, PlayerType } from "../src/core/game/Game";
+import { playerInfo, setup } from "./util/Setup";
+import { TestConfig } from "./util/TestConfig";
+
+let game: Game;
+let player1: Player;
+let player2: Player;
+
+describe("AlliancesCutoff", () => {
+ beforeEach(async () => {
+ game = await setup(
+ "ocean_and_land",
+ {
+ infiniteGold: true,
+ instantBuild: true,
+ infiniteTroops: true,
+ },
+ [
+ playerInfo("player1", PlayerType.Human),
+ playerInfo("player2", PlayerType.Human),
+ ],
+ );
+
+ player1 = game.player("player1");
+ player2 = game.player("player2");
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+ });
+
+ test("alliances blocked after cutoff tick", () => {
+ const cutoffTick = game.ticks() + 10;
+ (game.config() as TestConfig).setAlliancesCutoffTick(cutoffTick);
+
+ vi.spyOn(player1, "isAlive").mockReturnValue(true);
+ vi.spyOn(player2, "isAlive").mockReturnValue(true);
+
+ expect(player1.canSendAllianceRequest(player2)).toBe(true);
+
+ for (let i = 0; i < 10; i++) {
+ game.executeNextTick();
+ }
+
+ expect(game.ticks()).toBe(cutoffTick);
+ expect(player1.canSendAllianceRequest(player2)).toBe(false);
+ });
+
+ test("existing alliances expire at cutoff tick", () => {
+ const cutoffTick = game.ticks() + 20;
+ (game.config() as TestConfig).setAlliancesCutoffTick(cutoffTick);
+
+ vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true);
+ vi.spyOn(player2, "canSendAllianceRequest").mockReturnValue(true);
+ vi.spyOn(player1, "isAlive").mockReturnValue(true);
+ vi.spyOn(player2, "isAlive").mockReturnValue(true);
+
+ game.addExecution(new AllianceRequestExecution(player1, player2.id()));
+ game.executeNextTick();
+ game.addExecution(new AllianceRequestExecution(player2, player1.id()));
+ game.executeNextTick();
+
+ expect(player1.allianceWith(player2)).toBeTruthy();
+
+ while (game.ticks() < cutoffTick) {
+ game.executeNextTick();
+ }
+
+ game.executeNextTick();
+ expect(player1.allianceWith(player2)).toBeFalsy();
+ });
+
+ test("no cutoff when set to null", () => {
+ (game.config() as TestConfig).setAlliancesCutoffTick(null);
+
+ vi.spyOn(player1, "isAlive").mockReturnValue(true);
+ vi.spyOn(player2, "isAlive").mockReturnValue(true);
+
+ for (let i = 0; i < 100; i++) {
+ game.executeNextTick();
+ }
+
+ expect(player1.canSendAllianceRequest(player2)).toBe(true);
+ });
+});
diff --git a/tests/NationAllianceBehavior.test.ts b/tests/NationAllianceBehavior.test.ts
index 528d2f844f..db35fc729c 100644
--- a/tests/NationAllianceBehavior.test.ts
+++ b/tests/NationAllianceBehavior.test.ts
@@ -166,7 +166,10 @@ describe("AllianceBehavior.handleAllianceExtensionRequests", () => {
beforeEach(() => {
mockGame = {
addExecution: vi.fn(),
- config: vi.fn(() => ({ disableAlliances: vi.fn(() => false) })),
+ config: vi.fn(() => ({
+ disableAlliances: vi.fn(() => false),
+ alliancesCutoffTick: vi.fn(() => null),
+ })),
};
mockHuman = { id: vi.fn(() => "human_id") };
mockAlliance = {
diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts
index 8681c5b027..ac2ba91a1d 100644
--- a/tests/util/TestConfig.ts
+++ b/tests/util/TestConfig.ts
@@ -14,6 +14,7 @@ export class TestConfig extends DefaultConfig {
private _defaultNukeSpeed: number = 4;
private _spawnImmunityDuration: number = 0;
private _nationSpawnImmunityDuration: number = 0;
+ private _alliancesCutoffTick: Tick | null | undefined = undefined;
disableNavMesh(): boolean {
return this.gameConfig().disableNavMesh ?? true;
@@ -98,6 +99,17 @@ export class TestConfig extends DefaultConfig {
): number {
return 1;
}
+
+ setAlliancesCutoffTick(tick: Tick | null): void {
+ this._alliancesCutoffTick = tick;
+ }
+
+ alliancesCutoffTick(): Tick | null {
+ if (this._alliancesCutoffTick !== undefined) {
+ return this._alliancesCutoffTick;
+ }
+ return super.alliancesCutoffTick();
+ }
}
export class UseRealAttackLogic extends TestConfig {
// Override to use DefaultConfig's real attackLogic