From 5b5ac7bfca970922e193f75c6fbb81bb3a6e699a Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 2 Jul 2025 15:25:20 -0700 Subject: [PATCH] allow alliance extension Fixes #491 (#1314) ## Description: About 30s before an alliance is about to expire, both players receive a prompt to extend the alliance. If both players agree the alliance is extended. ## 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 - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- resources/lang/en.json | 7 +- src/client/Transport.ts | 17 +++++ src/client/Utils.ts | 1 + src/client/graphics/layers/EventsDisplay.ts | 66 ++++++++++++++++- src/client/graphics/layers/PlayerPanel.ts | 8 +-- src/core/GameRunner.ts | 3 +- src/core/Schemas.ts | 10 +++ src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 4 ++ src/core/execution/ExecutionManager.ts | 5 ++ src/core/execution/PlayerExecution.ts | 5 +- .../alliance/AllianceExtensionExecution.ts | 61 ++++++++++++++++ src/core/game/AllianceImpl.ts | 42 ++++++++++- src/core/game/Game.ts | 15 +++- src/core/game/GameImpl.ts | 18 +++++ src/core/game/GameUpdates.ts | 16 +++++ src/core/game/GameView.ts | 5 ++ src/core/game/PlayerImpl.ts | 21 +++++- tests/AllianceExtensionExecution.test.ts | 72 +++++++++++++++++++ 19 files changed, 358 insertions(+), 19 deletions(-) create mode 100644 src/core/execution/alliance/AllianceExtensionExecution.ts create mode 100644 tests/AllianceExtensionExecution.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 101be75e7..6418a2954 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -432,7 +432,12 @@ }, "events_display": { "retreating": "retreating", - "boat": "Boat" + "boat": "Boat", + "about_to_expire": "Your alliance with {name} is about to expire!", + "renew_alliance": "Request to renew", + "focus": "Focus", + "alliance_renewed": "Your alliance with {name} has been renewed", + "ignore": "Ignore" }, "unit_info_modal": { "structure_info": "Structure Info", diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 3c8d7d364..6ae2c7c4b 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -67,6 +67,10 @@ export class SendAllianceReplyIntentEvent implements GameEvent { ) {} } +export class SendAllianceExtensionIntentEvent implements GameEvent { + constructor(public readonly recipient: PlayerView) {} +} + export class SendSpawnIntentEvent implements GameEvent { constructor(public readonly cell: Cell) {} } @@ -194,6 +198,9 @@ export class Transport { this.eventBus.on(SendAllianceReplyIntentEvent, (e) => this.onAllianceRequestReplyUIEvent(e), ); + this.eventBus.on(SendAllianceExtensionIntentEvent, (e) => + this.onSendAllianceExtensionIntent(e), + ); this.eventBus.on(SendBreakAllianceIntentEvent, (e) => this.onBreakAllianceRequestUIEvent(e), ); @@ -419,6 +426,16 @@ export class Transport { }); } + private onSendAllianceExtensionIntent( + event: SendAllianceExtensionIntentEvent, + ) { + this.sendIntent({ + type: "allianceExtension", + clientID: this.lobbyConfig.clientID, + recipient: event.recipient.id(), + }); + } + private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) { this.sendIntent({ type: "spawn", diff --git a/src/client/Utils.ts b/src/client/Utils.ts index c6445c170..88e8fe1b3 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -141,6 +141,7 @@ export function getMessageTypeClasses(type: MessageType): string { case MessageType.SAM_MISS: case MessageType.ALLIANCE_EXPIRED: case MessageType.NAVAL_INVASION_INBOUND: + case MessageType.RENEW_ALLIANCE: return severityColors["warn"]; case MessageType.CHAT: case MessageType.ALLIANCE_REQUEST: diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 316b5f6ae..d2e857a86 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -32,6 +32,7 @@ import { import { CancelAttackIntentEvent, CancelBoatIntentEvent, + SendAllianceExtensionIntentEvent, SendAllianceReplyIntentEvent, } from "../../Transport"; import { Layer } from "./Layer"; @@ -45,7 +46,6 @@ import { GoToUnitEvent, } from "./Leaderboard"; -import { UserSettings } from "../../../core/game/UserSettings"; import { getMessageTypeClasses, translateText } from "../../Utils"; interface GameEvent { @@ -73,9 +73,11 @@ export class EventsDisplay extends LitElement implements Layer { public eventBus: EventBus; public game: GameView; - private userSettings: UserSettings = new UserSettings(); private active: boolean = false; private events: GameEvent[] = []; + + // allianceID -> last checked at tick + private alliancesCheckedAt = new Map(); @state() private incomingAttacks: AttackUpdate[] = []; @state() private outgoingAttacks: AttackUpdate[] = []; @state() private outgoingLandAttacks: AttackUpdate[] = []; @@ -182,6 +184,8 @@ export class EventsDisplay extends LitElement implements Layer { return; } + this.checkForAllianceExpirations(); + const updates = this.game.updatesSinceLastTick(); if (updates) { for (const [ut, fn] of this.updateMap) { @@ -235,6 +239,64 @@ export class EventsDisplay extends LitElement implements Layer { } } + private checkForAllianceExpirations() { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return; + + for (const alliance of myPlayer.alliances()) { + if ( + alliance.expiresAt > + this.game.ticks() + this.game.config().allianceExtensionPromptOffset() + ) { + continue; + } + + if ( + (this.alliancesCheckedAt.get(alliance.id) ?? 0) >= + this.game.ticks() - this.game.config().allianceExtensionPromptOffset() + ) { + // We've already displayed a message for this alliance. + continue; + } + + this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); + + const other = this.game.player(alliance.other) as PlayerView; + + this.addEvent({ + description: translateText("events_display.about_to_expire", { + name: other.name(), + }), + type: MessageType.RENEW_ALLIANCE, + duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer + buttons: [ + { + text: translateText("events_display.focus"), + className: "btn-gray", + action: () => this.eventBus.emit(new GoToPlayerEvent(other)), + preventClose: true, + }, + { + text: translateText("events_display.renew_alliance", { + name: other.name(), + }), + className: "btn", + action: () => + this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)), + }, + { + text: translateText("events_display.ignore"), + className: "btn-info", + action: () => {}, + }, + ], + highlight: true, + createdAt: this.game.ticks(), + focusID: other.smallID(), + }); + } + } + private addEvent(event: GameEvent) { this.events = [...this.events, event]; if (this._hidden === true) { diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index b9804d2d1..ef8819a76 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -176,11 +176,9 @@ export class PlayerPanel extends LitElement implements Layer { if (myPlayer !== null && myPlayer.isAlive()) { this.actions = await myPlayer.actions(this.tile); - if (this.actions?.interaction?.allianceCreatedAtTick !== undefined) { - const createdAt = this.actions.interaction.allianceCreatedAtTick; - const durationTicks = this.g.config().allianceDuration(); - const expiryTick = createdAt + durationTicks; - const remainingTicks = expiryTick - this.g.ticks(); + if (this.actions?.interaction?.allianceExpiresAt !== undefined) { + const expiresAt = this.actions.interaction.allianceExpiresAt; + const remainingTicks = expiresAt - this.g.ticks(); if (remainingTicks > 0) { const remainingSeconds = Math.max( diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 93ad45fe6..c2567aeb3 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -196,12 +196,13 @@ export class GameRunner { }; const alliance = player.allianceWith(other as Player); if (alliance) { - actions.interaction.allianceCreatedAtTick = alliance.createdAt(); + actions.interaction.allianceExpiresAt = alliance.expiresAt(); } } return actions; } + public playerProfile(playerID: number): PlayerProfile { const player = this.game.playerBySmallID(playerID); if (!player.isPlayer()) { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 873278125..5970deae8 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -25,6 +25,7 @@ export type Intent = | CancelBoatIntent | AllianceRequestIntent | AllianceRequestReplyIntent + | AllianceExtensionIntent | BreakAllianceIntent | TargetPlayerIntent | EmojiIntent @@ -67,6 +68,9 @@ export type QuickChatIntent = z.infer; export type MarkDisconnectedIntent = z.infer< typeof MarkDisconnectedIntentSchema >; +export type AllianceExtensionIntent = z.infer< + typeof AllianceExtensionIntentSchema +>; export type Turn = z.infer; export type GameConfig = z.infer; @@ -213,6 +217,11 @@ const BaseIntentSchema = z.object({ clientID: ID, }); +export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({ + type: z.literal("allianceExtension"), + recipient: ID, +}); + export const AttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("attack"), targetID: ID.nullable(), @@ -354,6 +363,7 @@ const IntentSchema = z.discriminatedUnion("type", [ EmbargoIntentSchema, MoveWarshipIntentSchema, QuickChatIntentSchema, + AllianceExtensionIntentSchema, ]); // diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index e2c953bc5..cdf6c3167 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -158,6 +158,7 @@ export interface Config { nukeDeathFactor(humans: number, tilesOwned: number): number; structureMinDist(): number; isReplay(): boolean; + allianceExtensionPromptOffset(): number; } export interface Theme { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 6a759a031..dc7588858 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -839,4 +839,8 @@ export class DefaultConfig implements Config { defensePostTargettingRange(): number { return 75; } + + allianceExtensionPromptOffset(): number { + return 300; // 30 seconds before expiration + } } diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 27888e41a..829705cba 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -2,6 +2,7 @@ import { Execution, Game } from "../game/Game"; import { PseudoRandom } from "../PseudoRandom"; import { ClientID, GameID, Intent, Turn } from "../Schemas"; import { simpleHash } from "../Util"; +import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution"; import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution"; import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution"; import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution"; @@ -111,6 +112,10 @@ export class Executor { this.mg.ref(intent.x, intent.y), intent.unit, ); + case "allianceExtension": { + return new AllianceExtensionExecution(player, intent.recipient); + } + case "upgrade_structure": return new UpgradeStructureExecution(player, intent.unitId); case "create_station": diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 4c81d4bb7..c93bcf44f 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -72,10 +72,7 @@ export class PlayerExecution implements Execution { const alliances = Array.from(this.player.alliances()); for (const alliance of alliances) { - if ( - this.mg.ticks() - alliance.createdAt() > - this.mg.config().allianceDuration() - ) { + if (alliance.expiresAt() <= this.mg.ticks()) { alliance.expire(); } } diff --git a/src/core/execution/alliance/AllianceExtensionExecution.ts b/src/core/execution/alliance/AllianceExtensionExecution.ts new file mode 100644 index 000000000..e43693f89 --- /dev/null +++ b/src/core/execution/alliance/AllianceExtensionExecution.ts @@ -0,0 +1,61 @@ +import { + Execution, + Game, + MessageType, + Player, + PlayerID, +} from "../../game/Game"; + +export class AllianceExtensionExecution implements Execution { + constructor( + private readonly from: Player, + private readonly toID: PlayerID, + ) {} + + init(mg: Game, ticks: number): void { + if (!mg.hasPlayer(this.toID)) { + console.warn( + `[AllianceExtensionExecution] Player ${this.toID} not found`, + ); + return; + } + const to = mg.player(this.toID); + const alliance = this.from.allianceWith(to); + if (!alliance) { + console.warn( + `[AllianceExtensionExecution] No alliance to extend between ${this.from.id()} and ${this.toID}`, + ); + return; + } + + // Mark this player's intent to extend + alliance.addExtensionRequest(this.from); + + if (alliance.canExtend()) { + alliance.extend(); + + mg.displayMessage( + "alliance.renewed", + MessageType.ALLIANCE_ACCEPTED, + this.from.id(), + ); + mg.displayMessage( + "alliance.renewed", + MessageType.ALLIANCE_ACCEPTED, + this.toID, + ); + } + } + + tick(ticks: number): void { + // No-op + } + + isActive(): boolean { + return false; + } + + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/game/AllianceImpl.ts b/src/core/game/AllianceImpl.ts index b5c2c5836..6d2782595 100644 --- a/src/core/game/AllianceImpl.ts +++ b/src/core/game/AllianceImpl.ts @@ -1,12 +1,20 @@ import { Game, MutableAlliance, Player, Tick } from "./Game"; export class AllianceImpl implements MutableAlliance { + private extensionRequestedRequestor_: boolean = false; + private extensionRequestedRecipient_: boolean = false; + + private expiresAt_: Tick; + constructor( private readonly mg: Game, readonly requestor_: Player, readonly recipient_: Player, - readonly createdAtTick_: Tick, - ) {} + private readonly createdAt_: Tick, + private readonly id_: number, + ) { + this.expiresAt_ = createdAt_ + mg.config().allianceDuration(); + } other(player: Player): Player { if (this.requestor_ === player) { @@ -24,10 +32,38 @@ export class AllianceImpl implements MutableAlliance { } createdAt(): Tick { - return this.createdAtTick_; + return this.createdAt_; } expire(): void { this.mg.expireAlliance(this); } + + addExtensionRequest(player: Player): void { + if (this.requestor_ === player) { + this.extensionRequestedRequestor_ = true; + } else if (this.recipient_ === player) { + this.extensionRequestedRecipient_ = true; + } + } + + canExtend(): boolean { + return ( + this.extensionRequestedRequestor_ && this.extensionRequestedRecipient_ + ); + } + + public id(): number { + return this.id_; + } + + extend(): void { + this.extensionRequestedRequestor_ = false; + this.extensionRequestedRecipient_ = false; + this.expiresAt_ = this.mg.ticks() + this.mg.config().allianceDuration(); + } + + expiresAt(): Tick { + return this.expiresAt_; + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index e36c0f010..1241484e8 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -339,12 +339,17 @@ export interface Alliance { requestor(): Player; recipient(): Player; createdAt(): Tick; + expiresAt(): Tick; other(player: Player): Player; } export interface MutableAlliance extends Alliance { expire(): void; other(player: Player): Player; + canExtend(): boolean; + addExtensionRequest(player: Player): void; + id(): number; + extend(): void; } export class PlayerInfo { @@ -536,6 +541,7 @@ export interface Player { incomingAllianceRequests(): AllianceRequest[]; outgoingAllianceRequests(): AllianceRequest[]; alliances(): MutableAlliance[]; + expiredAlliances(): Alliance[]; allies(): Player[]; isAlliedWith(other: Player): boolean; allianceWith(other: Player): MutableAlliance | null; @@ -591,7 +597,6 @@ export interface Player { } export interface Game extends GameMap { - expireAlliance(alliance: Alliance); // Map & Dimensions isOnMap(cell: Cell): boolean; width(): number; @@ -613,6 +618,10 @@ export interface Game extends GameMap { teams(): Team[]; + // Alliances + alliances(): MutableAlliance[]; + expireAlliance(alliance: Alliance): void; + // Game State ticks(): Tick; inSpawnPhase(): boolean; @@ -694,7 +703,7 @@ export interface PlayerInteraction { canTarget: boolean; canDonate: boolean; canEmbargo: boolean; - allianceCreatedAtTick?: Tick; + allianceExpiresAt?: Tick; } export interface EmojiMessage { @@ -729,6 +738,7 @@ export enum MessageType { SENT_TROOPS_TO_PLAYER, RECEIVED_TROOPS_FROM_PLAYER, CHAT, + RENEW_ALLIANCE, } // Message categories used for filtering events in the EventsDisplay @@ -759,6 +769,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record = { [MessageType.ALLIANCE_REQUEST]: MessageCategory.ALLIANCE, [MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE, [MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE, + [MessageType.RENEW_ALLIANCE]: 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 e8cef0a77..d1deab5fb 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -15,6 +15,7 @@ import { GameMode, GameUpdates, MessageType, + MutableAlliance, Nation, Player, PlayerID, @@ -77,6 +78,9 @@ export class GameImpl implements Game { private botTeam: Team = ColoredTeams.Bot; private _railNetwork: RailNetwork = createRailNetwork(this); + // Used to assign unique IDs to each new alliance + private nextAllianceID: number = 0; + constructor( private _humans: PlayerInfo[], private _nations: Nation[], @@ -146,6 +150,11 @@ export class GameImpl implements Game { owner(ref: TileRef): Player | TerraNullius { return this.playerBySmallID(this.ownerID(ref)); } + + alliances(): MutableAlliance[] { + return this.alliances_; + } + playerBySmallID(id: number): Player | TerraNullius { if (id === 0) { return this.terraNullius(); @@ -231,11 +240,20 @@ export class GameImpl implements Game { const requestor = request.requestor(); const recipient = request.recipient(); + const existing = requestor.allianceWith(recipient); + if (existing) { + throw new Error( + `cannot accept alliance request, already allied with ${recipient.name()}`, + ); + } + + // Create and register the new alliance const alliance = new AllianceImpl( this, requestor as PlayerImpl, recipient as PlayerImpl, this._ticks, + this.nextAllianceID++, ); this.alliances_.push(alliance); (request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push( diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 4f2be287c..a471c41df 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -36,6 +36,7 @@ export enum GameUpdateType { AllianceRequestReply, BrokeAlliance, AllianceExpired, + AllianceExtension, TargetPlayer, Emoji, Win, @@ -60,6 +61,7 @@ export type GameUpdate = | WinUpdate | HashUpdate | UnitIncomingUpdate + | AllianceExtensionUpdate | BonusEventUpdate | RailroadUpdate; @@ -155,10 +157,18 @@ export interface PlayerUpdate { outgoingAttacks: AttackUpdate[]; incomingAttacks: AttackUpdate[]; outgoingAllianceRequests: PlayerID[]; + alliances: AllianceView[]; hasSpawned: boolean; betrayals?: bigint; } +export interface AllianceView { + id: number; + other: PlayerID; + createdAt: Tick; + expiresAt: Tick; +} + export interface AllianceRequestUpdate { type: GameUpdateType.AllianceRequest; requestorID: number; @@ -184,6 +194,12 @@ export interface AllianceExpiredUpdate { player2ID: number; } +export interface AllianceExtensionUpdate { + type: GameUpdateType.AllianceExtension; + playerID: number; + allianceID: number; +} + export interface TargetPlayerUpdate { type: GameUpdateType.TargetPlayer; playerID: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 284447b51..72ed071e5 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -24,6 +24,7 @@ import { } from "./Game"; import { GameMap, TileRef, TileUpdate } from "./GameMap"; import { + AllianceView, AttackUpdate, GameUpdateType, GameUpdateViewData, @@ -291,6 +292,10 @@ export class PlayerView { return this.data.outgoingAllianceRequests.some((id) => other.id() === id); } + alliances(): AllianceView[] { + return this.data.alliances; + } + hasEmbargoAgainst(other: PlayerView): boolean { return this.data.embargoes.has(other.id()); } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index ddc0658dd..828fbca77 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -42,7 +42,12 @@ import { } from "./Game"; import { GameImpl } from "./GameImpl"; import { andFN, manhattanDistFN, TileRef } from "./GameMap"; -import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates"; +import { + AllianceView, + AttackUpdate, + GameUpdateType, + PlayerUpdate, +} from "./GameUpdates"; import { bestShoreDeploymentSource, canBuildTransportShip, @@ -85,6 +90,7 @@ export class PlayerImpl implements Player { private _displayName: string; public pastOutgoingAllianceRequests: AllianceRequest[] = []; + private _expiredAlliances: Alliance[] = []; private targets_: Target[] = []; @@ -166,6 +172,15 @@ export class PlayerImpl implements Player { } satisfies AttackUpdate; }), outgoingAllianceRequests: outgoingAllianceRequests, + alliances: this.alliances().map( + (a) => + ({ + id: a.id(), + other: a.other(this).id(), + createdAt: a.createdAt(), + expiresAt: a.expiresAt(), + }) satisfies AllianceView, + ), hasSpawned: this.hasSpawned(), betrayals: stats?.betrayals, }; @@ -342,6 +357,10 @@ export class PlayerImpl implements Player { ); } + expiredAlliances(): Alliance[] { + return [...this._expiredAlliances]; + } + allies(): Player[] { return this.alliances().map((a) => a.other(this)); } diff --git a/tests/AllianceExtensionExecution.test.ts b/tests/AllianceExtensionExecution.test.ts new file mode 100644 index 000000000..39289c0fe --- /dev/null +++ b/tests/AllianceExtensionExecution.test.ts @@ -0,0 +1,72 @@ +import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution"; +import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution"; +import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution"; +import { Game, Player, PlayerType } from "../src/core/game/Game"; +import { playerInfo, setup } from "./util/Setup"; + +let game: Game; +let player1: Player; +let player2: Player; + +describe("AllianceExtensionExecution", () => { + 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("Successfully extends existing alliance", () => { + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.executeNextTick(); + + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + game.executeNextTick(); + + expect(player1.allianceWith(player2)).toBeTruthy(); + expect(player2.allianceWith(player1)).toBeTruthy(); + + const allianceBefore = player1.allianceWith(player2)!; + const expirationBefore = + allianceBefore.createdAt() + game.config().allianceDuration(); + + game.addExecution(new AllianceExtensionExecution(player1, player2.id())); + game.executeNextTick(); + + const allianceAfter = player1.allianceWith(player2)!; + + expect(allianceAfter.id()).toBe(allianceBefore.id()); + + const expirationAfter = + allianceAfter.createdAt() + game.config().allianceDuration(); + + expect(expirationAfter).toBeGreaterThanOrEqual(expirationBefore); + }); + + test("Fails gracefully if no alliance exists", () => { + game.addExecution(new AllianceExtensionExecution(player1, player2.id())); + game.executeNextTick(); + + expect(player1.allianceWith(player2)).toBeFalsy(); + expect(player2.allianceWith(player1)).toBeFalsy(); + }); +});