mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
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:
committed by
GitHub
parent
f6a08e16db
commit
90204f6628
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user