diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index a84e762053..1d39d7af96 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -12,6 +12,7 @@ import { } from "../../../core/game/Game"; import { AllianceExpiredUpdate, + AllianceExtensionUpdate, AllianceRequestReplyUpdate, AllianceRequestUpdate, BrokeAllianceUpdate, @@ -176,6 +177,10 @@ export class EventsDisplay extends LitElement implements Layer { [GameUpdateType.Emoji, this.onEmojiMessageEvent.bind(this)], [GameUpdateType.UnitIncoming, this.onUnitIncomingEvent.bind(this)], [GameUpdateType.AllianceExpired, this.onAllianceExpiredEvent.bind(this)], + [ + GameUpdateType.AllianceExtension, + this.onAllianceExtensionEvent.bind(this), + ], ] as const; constructor() { @@ -620,6 +625,11 @@ export class EventsDisplay extends LitElement implements Layer { }); } + private onAllianceExtensionEvent(update: AllianceExtensionUpdate) { + this.removeAllianceRenewalEvents(update.allianceID); + this.requestUpdate(); + } + onTargetPlayerEvent(event: TargetPlayerUpdate) { const other = this.game.playerBySmallID(event.playerID) as PlayerView; const myPlayer = this.game.myPlayer() as PlayerView; diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 9a16e80e50..6e876579cb 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -2,6 +2,7 @@ import { EventBus } from "../../../core/EventBus"; import { TileRef } from "../../../core/game/GameMap"; import { PlayerView } from "../../../core/game/GameView"; import { + SendAllianceExtensionIntentEvent, SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, @@ -55,6 +56,10 @@ export class PlayerActionHandler { this.eventBus.emit(new SendAllianceRequestIntentEvent(player, recipient)); } + handleExtendAlliance(recipient: PlayerView) { + this.eventBus.emit(new SendAllianceExtensionIntentEvent(recipient)); + } + handleBreakAlliance(player: PlayerView, recipient: PlayerView) { this.eventBus.emit(new SendBreakAllianceIntentEvent(player, recipient)); } diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 674b83e155..d3b1ac5843 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -122,8 +122,8 @@ export class PlayerPanel extends LitElement implements Layer { const myPlayer = this.g.myPlayer(); if (myPlayer !== null && myPlayer.isAlive()) { this.actions = await myPlayer.actions(this.tile); - if (this.actions?.interaction?.allianceExpiresAt !== undefined) { - const expiresAt = this.actions.interaction.allianceExpiresAt; + if (this.actions?.interaction?.allianceInfo?.expiresAt !== undefined) { + const expiresAt = this.actions.interaction.allianceInfo.expiresAt; const remainingTicks = expiresAt - this.g.ticks(); const remainingSeconds = Math.max(0, Math.floor(remainingTicks / 10)); // 10 ticks per second diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 119c7e1a34..acc700a99e 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -359,6 +359,55 @@ export class RadialMenu implements Layer { ) .attr("data-id", (d) => d.data.id); + // Timer gradient for items with timerFraction + arcs.each((d) => { + if (d.data.timerFraction && this.params) { + const fraction = d.data.timerFraction(this.params); + const disabled = this.params === null || d.data.disabled(this.params); + const baseColor = disabled + ? this.config.disabledColor + : (resolveColor(d.data, this.params) ?? "#333333"); + const opacity = disabled ? 0.5 : 0.7; + + const normalColor = + d3.color(baseColor)?.copy({ opacity: opacity })?.toString() ?? + baseColor; + const interpolated = d3.color( + d3.interpolateRgb(baseColor, "white")(0.4), + ); + const fadedColor = + interpolated?.copy({ opacity })?.toString() ?? normalColor; + + const gradientId = `timer-gradient-${d.data.id}`; + const defs = this.menuElement.select("defs"); + defs.select(`#${gradientId}`).remove(); + + const offset = 1 - fraction; + const gradient = defs + .append("linearGradient") + .attr("id", gradientId) + .attr("x1", 0) + .attr("y1", 0) + .attr("x2", 0) + .attr("y2", 1); + + gradient + .append("stop") + .attr("class", "timer-stop-faded") + .attr("offset", offset) + .attr("stop-color", fadedColor); + + gradient + .append("stop") + .attr("class", "timer-stop-normal") + .attr("offset", offset) + .attr("stop-color", normalColor); + + const path = d3.select(`path[data-id="${d.data.id}"]`); + path.attr("fill", `url(#${gradientId})`); + } + }); + arcs.each((d) => { const pathId = d.data.id; const path = d3.select(`path[data-id="${pathId}"]`); @@ -443,10 +492,15 @@ export class RadialMenu implements Layer { ? this.config.disabledColor : (resolveColor(d.data, this.params) ?? "#333333"); const opacity = disabled ? 0.5 : 0.7; - path.attr( - "fill", - d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color, - ); + + if (d.data.timerFraction) { + path.attr("fill", `url(#timer-gradient-${d.data.id})`); + } else { + path.attr( + "fill", + d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color, + ); + } }; const onClick = (d: d3.PieArcDatum, event: Event) => { @@ -539,12 +593,34 @@ export class RadialMenu implements Layer { .attr("class", "menu-item-content") .style("pointer-events", "none") .attr("data-id", (d) => d.data.id) + .attr("data-cx", (d) => arc.centroid(d)[0].toString()) + .attr("data-cy", (d) => arc.centroid(d)[1].toString()) .each((d) => { const contentId = d.data.id; const content = d3.select(`g[data-id="${contentId}"]`); const disabled = this.isItemDisabled(d.data); - if (d.data.text) { + if (d.data.renderType && this.params) { + const stateKey = this.getStateKeyByType( + d.data.renderType, + disabled, + this.params, + ); + if (stateKey) { + content.attr("data-prev-state", stateKey); + } + if (d.data.renderType === "allyExtend") { + this.renderAllyExtendIcon( + content.node()! as SVGGElement, + arc.centroid(d)[0], + arc.centroid(d)[1], + this.config.iconSize, + disabled, + this.params, + d.data.icon, + ); + } + } else if (d.data.text) { content .append("text") .attr("text-anchor", "middle") @@ -1053,39 +1129,48 @@ export class RadialMenu implements Layer { : (resolveColor(item, this.params) ?? "#333333"); const opacity = disabled ? 0.5 : 0.7; - // Update path appearance - path.attr( - "fill", - d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color, - ); + // Update path appearance (skip fill for timer items — gradient handles it) + if (!item.timerFraction) { + path.attr( + "fill", + d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color, + ); + } path.style("opacity", disabled ? 0.5 : 1); path.style("cursor", disabled ? "not-allowed" : "pointer"); // Update icon/text appearance using the same logic as renderIconsAndText const icon = this.menuIcons.get(itemId); if (icon) { - // Update text opacity - const textElement = icon.select("text"); - if (!textElement.empty()) { - textElement.style("opacity", disabled ? 0.5 : 1); - } + if (item.renderType === "allyExtend" && this.params) { + this.refreshAllyExtendIcon(item, disabled, icon); + } else { + // Update text opacity + const textElement = icon.select("text"); + if (!textElement.empty()) { + textElement.style("opacity", disabled ? 0.5 : 1); + } - // Update image opacity - const imageElement = icon.select("image"); - if (!imageElement.empty()) { - imageElement.attr("opacity", disabled ? 0.5 : 1); - } + // Update image opacity + const imageElement = icon.select("image"); + if (!imageElement.empty()) { + imageElement.attr("opacity", disabled ? 0.5 : 1); + } - // Update cooldown text if applicable - const cooldownElement = icon.select(".cooldown-text"); - if (this.params && !cooldownElement.empty() && item.cooldown) { - const cooldown = Math.ceil(item.cooldown(this.params)); - if (cooldown <= 0) { - cooldownElement.remove(); - } else { - cooldownElement.text(cooldown + "s"); + // Update cooldown text if applicable + const cooldownElement = icon.select(".cooldown-text"); + if (this.params && !cooldownElement.empty() && item.cooldown) { + const cooldown = Math.ceil(item.cooldown(this.params)); + if (cooldown <= 0) { + cooldownElement.remove(); + } else { + cooldownElement.text(cooldown + "s"); + } } } + + // Update timer gradient + this.maybeUpdateTimerGradient(item, color, opacity); } } }); @@ -1094,6 +1179,172 @@ export class RadialMenu implements Layer { this.updateCenterButtonState(this.centerButtonState); } + private refreshAllyExtendIcon( + item: MenuElement, + disabled: boolean, + icon: d3.Selection, + ): void { + if (item.renderType !== "allyExtend" || !this.params) { + return; + } + + const stateKey = this.getStateKeyByType( + item.renderType, + disabled, + this.params, + ); + const prevState = icon.attr("data-prev-state"); + + if (stateKey && stateKey === prevState) { + // State unchanged, skip re-render to preserve animations + } else { + const cx = parseFloat(icon.attr("data-cx") || "0"); + const cy = parseFloat(icon.attr("data-cy") || "0"); + + if (stateKey) { + icon.attr("data-prev-state", stateKey); + } else { + icon.selectAll("*").remove(); + } + + this.renderAllyExtendIcon( + icon.node()! as SVGGElement, + cx, + cy, + this.config.iconSize, + disabled, + this.params, + item.icon, + true, + ); + } + } + + private maybeUpdateTimerGradient( + item: MenuElement, + color: string, + opacity: number, + ): void { + if (!item.timerFraction || !this.params) { + return; + } + + const fraction = item.timerFraction(this.params); + const gradient = this.menuElement.select(`#timer-gradient-${item.id}`); + if (!gradient.empty()) { + const offset = 1 - fraction; + const normalColor = + d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color; + const interpolated = d3.color(d3.interpolateRgb(color, "white")(0.4)); + const fadedColor = + interpolated?.copy({ opacity })?.toString() ?? normalColor; + + gradient + .select(".timer-stop-faded") + .attr("offset", offset) + .attr("stop-color", fadedColor); + gradient + .select(".timer-stop-normal") + .attr("offset", offset) + .attr("stop-color", normalColor); + } + } + + private getStateKeyByType( + type: string, + disabled: boolean, + params: MenuElementParams, + ): string | null { + switch (type) { + case "allyExtend": + return this.getAllyExtendStateKey(disabled, params); + default: + return null; + } + } + + private getAllyExtendStateKey( + disabled: boolean, + params: MenuElementParams, + ): string { + const interaction = params.playerActions?.interaction; + const myAgreed = interaction?.allianceInfo?.myPlayerAgreedToExtend ?? false; + const otherAgreed = interaction?.allianceInfo?.otherAgreedToExtend ?? false; + return `${disabled}:${myAgreed}:${otherAgreed}`; + } + + private renderAllyExtendIcon( + content: SVGGElement, + cx: number, + cy: number, + iconSize: number, + disabled: boolean, + params: MenuElementParams, + icon?: string, + update?: boolean, + ): void { + if (update) { + while (content.firstChild) content.removeChild(content.firstChild); + } + + const interaction = params.playerActions?.interaction; + const myAgreed = interaction?.allianceInfo?.myPlayerAgreedToExtend ?? false; + const otherAgreed = interaction?.allianceInfo?.otherAgreedToExtend ?? false; + + const ns = "http://www.w3.org/2000/svg"; + const smallSize = iconSize * 0.8; + const iconUrl = icon ?? ""; + + getSvgAspectRatio(iconUrl).then((ratio) => { + const width = smallSize * (ratio ?? 1); + const gap = 2; + const totalWidth = width * 2 + gap; + + // Left handshake = me + const leftImg = document.createElementNS(ns, "image"); + leftImg.setAttribute("href", iconUrl); + leftImg.setAttribute("width", width.toString()); + leftImg.setAttribute("height", smallSize.toString()); + leftImg.setAttribute("x", (cx - totalWidth / 2).toString()); + leftImg.setAttribute("y", (cy - smallSize / 2).toString()); + leftImg.setAttribute("opacity", disabled ? "0.5" : "1"); + + if (!myAgreed) { + const animLeft = document.createElementNS(ns, "animate"); + animLeft.setAttribute("attributeName", "opacity"); + animLeft.setAttribute("values", disabled ? "0.5;0.1;0.5" : "1;0.2;1"); + animLeft.setAttribute("dur", "1.5s"); + animLeft.setAttribute("repeatCount", "indefinite"); + leftImg.appendChild(animLeft); + } + + content.appendChild(leftImg); + + // Right handshake = them + const rightImg = document.createElementNS(ns, "image"); + rightImg.setAttribute("href", iconUrl); + rightImg.setAttribute("width", width.toString()); + rightImg.setAttribute("height", smallSize.toString()); + rightImg.setAttribute( + "x", + (cx - totalWidth / 2 + width + gap).toString(), + ); + rightImg.setAttribute("y", (cy - smallSize / 2).toString()); + rightImg.setAttribute("opacity", disabled ? "0.5" : "1"); + + if (!otherAgreed) { + const animRight = document.createElementNS(ns, "animate"); + animRight.setAttribute("attributeName", "opacity"); + animRight.setAttribute("values", disabled ? "0.5;0.1;0.5" : "1;0.2;1"); + animRight.setAttribute("dur", "1.5s"); + animRight.setAttribute("repeatCount", "indefinite"); + rightImg.appendChild(animRight); + } + + content.appendChild(rightImg); + }); + } + renderLayer(context: CanvasRenderingContext2D) { // No need to render anything on the canvas } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 23ea02fe4b..f331c9e318 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -62,6 +62,10 @@ export interface MenuElement { disabled: (params: MenuElementParams) => boolean; action?: (params: MenuElementParams) => void; // For leaf items that perform actions subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus + + renderType?: string; + + timerFraction?: (params: MenuElementParams) => number; // 0..1, for arc timer overlay } export interface TooltipKey { @@ -215,6 +219,36 @@ const allyRequestElement: MenuElement = { }, }; +const allyExtendElement: MenuElement = { + id: "ally_extend", + name: "extend", + displayed: (params: MenuElementParams) => + !!params.playerActions?.interaction?.allianceInfo?.inExtensionWindow, + disabled: (params: MenuElementParams) => + !params.playerActions?.interaction?.allianceInfo?.canExtend, + color: COLORS.ally, + icon: allianceIcon, + action: (params: MenuElementParams) => { + if (!params.playerActions?.interaction?.allianceInfo?.canExtend) return; + params.playerActionHandler.handleExtendAlliance(params.selected!); + params.closeMenu(); + }, + timerFraction: (params: MenuElementParams): number => { + const interaction = params.playerActions?.interaction; + if (!interaction?.allianceInfo) return 1; + const remaining = Math.max( + 0, + interaction.allianceInfo.expiresAt - params.game.ticks(), + ); + const extensionWindow = Math.max( + 1, + params.game.config().allianceExtensionPromptOffset(), + ); + return Math.max(0, Math.min(1, remaining / extensionWindow)); + }, + renderType: "allyExtend", +}; + const allyBreakElement: MenuElement = { id: "ally_break", name: "break", @@ -631,13 +665,16 @@ export const rootMenuElement: MenuElement = { tileOwner.isPlayer() && (tileOwner as PlayerView).id() === params.myPlayer.id(); + const inExtensionWindow = + params.playerActions.interaction?.allianceInfo?.inExtensionWindow; + const menuItems: (MenuElement | null)[] = [ infoMenuElement, ...(isOwnTerritory ? [deleteUnitElement, allyRequestElement, buildMenuElement] : [ isAllied && !isDisconnected ? allyBreakElement : boatMenuElement, - allyRequestElement, + inExtensionWindow ? allyExtendElement : allyRequestElement, isFriendlyTarget(params) && !isDisconnected ? donateGoldRadialElement : attackMenuElement, diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 2179d2df5b..8c2f8e5b1d 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -219,11 +219,8 @@ export class GameRunner { canDonateGold: player.canDonateGold(other), canDonateTroops: player.canDonateTroops(other), canEmbargo: !player.hasEmbargoAgainst(other), + allianceInfo: player.allianceInfo(other) ?? undefined, }; - const alliance = player.allianceWith(other as Player); - if (alliance) { - actions.interaction.allianceExpiresAt = alliance.expiresAt(); - } } return actions; diff --git a/src/core/game/AllianceImpl.ts b/src/core/game/AllianceImpl.ts index fa74ca766c..d84481cf19 100644 --- a/src/core/game/AllianceImpl.ts +++ b/src/core/game/AllianceImpl.ts @@ -1,4 +1,5 @@ import { Game, MutableAlliance, Player, Tick } from "./Game"; +import { GameUpdateType } from "./GameUpdates"; export class AllianceImpl implements MutableAlliance { private extensionRequestedRequestor_: boolean = false; @@ -45,6 +46,11 @@ export class AllianceImpl implements MutableAlliance { } else if (this.recipient_ === player) { this.extensionRequestedRecipient_ = true; } + this.mg.addUpdate({ + type: GameUpdateType.AllianceExtension, + playerID: player.smallID(), + allianceID: this.id_, + }); } bothAgreedToExtend(): boolean { @@ -62,6 +68,13 @@ export class AllianceImpl implements MutableAlliance { ); } + agreedToExtend(player: Player): boolean { + return ( + (this.requestor_ === player && this.extensionRequestedRequestor_) || + (this.recipient_ === player && this.extensionRequestedRecipient_) + ); + } + public id(): number { return this.id_; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 91f635af80..7381c35c7b 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -454,6 +454,8 @@ export interface MutableAlliance extends Alliance { id(): number; extend(): void; onlyOneAgreedToExtend(): boolean; + + agreedToExtend(player: Player): boolean; } export class PlayerInfo { @@ -661,6 +663,7 @@ export interface Player { allies(): Player[]; isAlliedWith(other: Player): boolean; allianceWith(other: Player): MutableAlliance | null; + allianceInfo(other: Player): AllianceInfo | null; canSendAllianceRequest(other: Player): boolean; breakAlliance(alliance: Alliance): void; removeAllAlliances(): void; @@ -862,6 +865,14 @@ export interface PlayerBorderTiles { borderTiles: ReadonlySet; } +export interface AllianceInfo { + expiresAt: Tick; + inExtensionWindow: boolean; + myPlayerAgreedToExtend: boolean; + otherAgreedToExtend: boolean; + canExtend: boolean; +} + export interface PlayerInteraction { sharedBorder: boolean; canSendEmoji: boolean; @@ -871,7 +882,7 @@ export interface PlayerInteraction { canDonateGold: boolean; canDonateTroops: boolean; canEmbargo: boolean; - allianceExpiresAt?: Tick; + allianceInfo?: AllianceInfo; } export interface EmojiMessage { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index e4bc3c7c9f..2d9778ddc5 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -12,6 +12,7 @@ import { import { AttackImpl } from "./AttackImpl"; import { Alliance, + AllianceInfo, AllianceRequest, AllPlayers, Attack, @@ -403,6 +404,30 @@ export class PlayerImpl implements Player { ); } + allianceInfo(other: Player): AllianceInfo | null { + const alliance = this.allianceWith(other); + if (!alliance) { + return null; + } + const inExtensionWindow = + alliance.expiresAt() <= + this.mg.ticks() + this.mg.config().allianceExtensionPromptOffset(); + const canExtend = + !this.isDisconnected() && + !other.isDisconnected() && + this.isAlive() && + other.isAlive() && + inExtensionWindow && + !alliance.agreedToExtend(this); + return { + expiresAt: alliance.expiresAt(), + inExtensionWindow, + myPlayerAgreedToExtend: alliance.agreedToExtend(this), + otherAgreedToExtend: alliance.agreedToExtend(other), + canExtend, + }; + } + canSendAllianceRequest(other: Player): boolean { if (other === this) { return false; diff --git a/tests/client/graphics/RadialMenuElements.test.ts b/tests/client/graphics/RadialMenuElements.test.ts index 8f645737aa..3404accbfa 100644 --- a/tests/client/graphics/RadialMenuElements.test.ts +++ b/tests/client/graphics/RadialMenuElements.test.ts @@ -352,6 +352,112 @@ describe("RadialMenuElements", () => { expect(allyMenu).toBeDefined(); }); + + it("should show extend element when inAllianceExtensionWindow is true", () => { + const allyPlayer = { + id: () => 2, + isAlliedWith: vi.fn(() => true), + isPlayer: vi.fn(() => true), + } as unknown as PlayerView; + mockParams.selected = allyPlayer; + mockGame.owner = vi.fn(() => allyPlayer); + mockPlayerActions.interaction = { + ...mockPlayerActions.interaction, + canBreakAlliance: true, + allianceInfo: { + expiresAt: 100, + inExtensionWindow: true, + myPlayerAgreedToExtend: true, + otherAgreedToExtend: false, + canExtend: false, + }, + }; + + const subMenu = rootMenuElement.subMenu!(mockParams); + const extendMenu = subMenu.find((item) => item.id === "ally_extend"); + + expect(extendMenu).toBeDefined(); + }); + + it("should not show extend element when inAllianceExtensionWindow is false", () => { + const allyPlayer = { + id: () => 2, + isAlliedWith: vi.fn(() => true), + isPlayer: vi.fn(() => true), + } as unknown as PlayerView; + mockParams.selected = allyPlayer; + mockGame.owner = vi.fn(() => allyPlayer); + mockPlayerActions.interaction = { + ...mockPlayerActions.interaction, + canBreakAlliance: true, + allianceInfo: { + expiresAt: 100, + inExtensionWindow: false, + myPlayerAgreedToExtend: false, + otherAgreedToExtend: false, + canExtend: false, + }, + }; + + const subMenu = rootMenuElement.subMenu!(mockParams); + const extendMenu = subMenu.find((item) => item.id === "ally_extend"); + + expect(extendMenu).toBeUndefined(); + }); + + it("should show extend element as disabled when canExtend is false", () => { + const allyPlayer = { + id: () => 2, + isAlliedWith: vi.fn(() => true), + isPlayer: vi.fn(() => true), + } as unknown as PlayerView; + mockParams.selected = allyPlayer; + mockGame.owner = vi.fn(() => allyPlayer); + mockPlayerActions.interaction = { + ...mockPlayerActions.interaction, + canBreakAlliance: true, + allianceInfo: { + expiresAt: 100, + inExtensionWindow: true, + myPlayerAgreedToExtend: true, + otherAgreedToExtend: false, + canExtend: false, + }, + }; + + const subMenu = rootMenuElement.subMenu!(mockParams); + const extendMenu = subMenu.find((item) => item.id === "ally_extend"); + + expect(extendMenu).toBeDefined(); + expect(extendMenu!.disabled(mockParams)).toBe(true); + }); + + it("should show extend element as enabled when canExtend is true", () => { + const allyPlayer = { + id: () => 2, + isAlliedWith: vi.fn(() => true), + isPlayer: vi.fn(() => true), + } as unknown as PlayerView; + mockParams.selected = allyPlayer; + mockGame.owner = vi.fn(() => allyPlayer); + mockPlayerActions.interaction = { + ...mockPlayerActions.interaction, + canBreakAlliance: true, + allianceInfo: { + expiresAt: 100, + inExtensionWindow: true, + myPlayerAgreedToExtend: false, + otherAgreedToExtend: false, + canExtend: true, + }, + }; + + const subMenu = rootMenuElement.subMenu!(mockParams); + const extendMenu = subMenu.find((item) => item.id === "ally_extend"); + + expect(extendMenu).toBeDefined(); + expect(extendMenu!.disabled(mockParams)).toBe(false); + }); }); describe("Menu element actions", () => {