Add alliance renewal action to Radial Menu (#3148)

## Description:

The following PR replaces the (disabled) alliance request button with an
alliance extension/renewal button when the alliance with the target
player is expiring.

Agreeing to renewal via radial menu also hides the message in the
EventsDisplay.

<img width="369" height="364" alt="image"
src="https://github.com/user-attachments/assets/d8040f5c-ad7b-47d0-852f-925ecbf273a8"
/>


https://github.com/user-attachments/assets/aa589edf-6505-46bf-88a3-aa4c2df9137f

Icon size adjusted:

<img width="294" height="252" alt="image"
src="https://github.com/user-attachments/assets/7ca63500-b1fb-427b-965c-cf121a5213da"
/>

## 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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

deshack_82603

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Mattia Migliorini
2026-02-20 02:47:57 +01:00
committed by GitHub
parent f6a08e16db
commit 90204f6628
10 changed files with 491 additions and 36 deletions
@@ -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;
@@ -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));
}
+2 -2
View File
@@ -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
+279 -28
View File
@@ -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<MenuElement>, 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<SVGImageElement, unknown, null, undefined>,
): 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
}
@@ -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,
+1 -4
View File
@@ -223,11 +223,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;
+13
View File
@@ -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_;
}
+12 -1
View File
@@ -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<TileRef>;
}
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 {
+25
View File
@@ -12,6 +12,7 @@ import {
import { AttackImpl } from "./AttackImpl";
import {
Alliance,
AllianceInfo,
AllianceRequest,
AllPlayers,
Attack,
@@ -450,6 +451,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;
@@ -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", () => {