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