diff --git a/src/client/hud/layers/RadialMenu.ts b/src/client/hud/layers/RadialMenu.ts index 740a0b90e4..f7b2ec1d1a 100644 --- a/src/client/hud/layers/RadialMenu.ts +++ b/src/client/hud/layers/RadialMenu.ts @@ -4,9 +4,10 @@ import { EventBus, GameEvent } from "../../../core/EventBus"; import { Controller } from "../../Controller"; import { CloseViewEvent } from "../../InputHandler"; import { PlaySoundEffectEvent } from "../../sound/Sounds"; -import { getSvgAspectRatio, translateText } from "../../Utils"; +import { getSvgAspectRatio, renderDuration, translateText } from "../../Utils"; import { CenterButtonElement, + COLORS, MenuElement, MenuElementParams, TooltipKey, @@ -332,10 +333,19 @@ export class RadialMenu implements Controller { .attr("d", arc) .attr("fill", (d) => { const disabled = this.params === null || d.data.disabled(this.params); - const color = disabled - ? this.config.disabledColor - : (resolveColor(d.data, this.params) ?? "#1e3a5f"); - const opacity = disabled ? 0.4 : 0.82; + + const isAllianceCooldown = + d.data.id === "ally_request" && + (this.params?.playerActions?.interaction + ?.allianceRequestCooldownRemaining ?? 0) > 0; + + const color = isAllianceCooldown + ? COLORS.allianceTimeLeft + : disabled + ? this.config.disabledColor + : (resolveColor(d.data, this.params) ?? "#1e3a5f"); + + const opacity = isAllianceCooldown ? 0.82 : disabled ? 0.4 : 0.82; if (d.data.id === this.selectedItemId && this.currentLevel > level) { return color; @@ -349,9 +359,17 @@ export class RadialMenu implements Controller { ? "not-allowed" : "pointer", ) - .style("opacity", (d) => - this.params === null || d.data.disabled(this.params) ? 0.5 : 1, - ) + + .style("opacity", (d) => { + const disabled = this.params === null || d.data.disabled(this.params); + const isAllianceCooldown = + d.data.id === "ally_request" && + (this.params?.playerActions?.interaction + ?.allianceRequestCooldownRemaining ?? 0) > 0; + //to remove the fade down change 0.85 to 1 + return isAllianceCooldown ? 0.85 : disabled ? 0.5 : 1; + }) + .style( "transition", `filter ${this.config.menuTransitionDuration / 2}ms, fill ${this.config.menuTransitionDuration / 2}ms`, @@ -419,11 +437,25 @@ export class RadialMenu implements Controller { ) { path.attr("filter", "url(#glow)"); - const color = - this.params === null || d.data.disabled(this.params) + const isAllianceCooldown = + d.data.id === "ally_request" && + (this.params?.playerActions?.interaction + ?.allianceRequestCooldownRemaining ?? 0) > 0; + const color = isAllianceCooldown + ? COLORS.allianceTimeLeft + : this.params === null || d.data.disabled(this.params) ? this.config.disabledColor : (resolveColor(d.data, this.params) ?? "#1e3a5f"); - path.attr("fill", color); + + const opacity = isAllianceCooldown + ? 0.82 + : this.params === null || d.data.disabled(this.params) + ? 0.4 + : 0.82; + path.attr( + "fill", + d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color, + ); } }); @@ -484,10 +516,16 @@ export class RadialMenu implements Controller { ) return; path.style("filter", null); - const color = disabled - ? this.config.disabledColor - : (resolveColor(d.data, this.params) ?? "#333333"); - const opacity = disabled ? 0.4 : 0.82; + const isAllianceCooldown = + d.data.id === "ally_request" && + (this.params?.playerActions?.interaction + ?.allianceRequestCooldownRemaining ?? 0) > 0; + const color = isAllianceCooldown + ? COLORS.allianceTimeLeft + : disabled + ? this.config.disabledColor + : (resolveColor(d.data, this.params) ?? "#333333"); + const opacity = isAllianceCooldown ? 0.82 : disabled ? 0.4 : 0.82; if (d.data.timerFraction) { path.attr("fill", `url(#timer-gradient-${d.data.id})`); @@ -596,6 +634,15 @@ export class RadialMenu implements Controller { const contentId = d.data.id; const content = d3.select(`g[data-id="${contentId}"]`); const disabled = this.isItemDisabled(d.data); + const isAllianceCooldown = + d.data.id === "ally_request" && + (this.params?.playerActions?.interaction + ?.allianceRequestCooldownRemaining ?? 0) > 0; + + content.attr( + "data-cooldown-active", + isAllianceCooldown ? "true" : "false", + ); if (d.data.renderType && this.params) { const stateKey = this.getStateKeyByType( @@ -627,7 +674,7 @@ export class RadialMenu implements Controller { .attr("fill", "white") .attr("font-size", d.data.fontSize ?? "12px") .attr("font-family", "Arial, sans-serif") - .style("opacity", disabled ? 0.5 : 1) + .style("opacity", isAllianceCooldown ? 0.82 : disabled ? 0.5 : 1) .text(d.data.text); } else { const imgSel = content @@ -637,38 +684,45 @@ export class RadialMenu implements Controller { .attr("height", this.config.iconSize) .attr("x", arc.centroid(d)[0] - this.config.iconSize / 2) .attr("y", arc.centroid(d)[1] - this.config.iconSize / 2) - .attr("opacity", disabled ? 0.5 : 1); + .attr("opacity", isAllianceCooldown ? 0.82 : disabled ? 0.5 : 1); getSvgAspectRatio(d.data.icon!).then((aspect) => { - if (!aspect || aspect === 1) return; - let width = this.config.iconSize; let height = this.config.iconSize; - const biggerLength = Math.round(width * aspect); - if (aspect > 1) { - width = biggerLength; - } else { - height = biggerLength; + + if (aspect !== null && aspect !== 1) { + const biggerLength = Math.round(width * aspect); + + if (aspect > 1) { + width = biggerLength; + } else { + height = biggerLength; + } } imgSel .attr("width", width) .attr("height", height) .attr("x", arc.centroid(d)[0] - width / 2) - .attr("y", arc.centroid(d)[1] - height / 2); + .attr( + "y", + isAllianceCooldown ? 42 : arc.centroid(d)[1] - height / 2, + ); }); if (this.params && d.data.cooldown?.(this.params)) { const cooldown = Math.ceil(d.data.cooldown?.(this.params)); + content .append("text") .attr("class", `cooldown-text`) - .text(cooldown + "s") + .text(renderDuration(cooldown)) .attr("fill", "white") - .attr("opacity", disabled ? 0.5 : 1) - .attr("font-size", "14px") + .attr("opacity", isAllianceCooldown ? 0.82 : disabled ? 0.5 : 1) + .attr("font-size", "20px") .attr("font-weight", "bold") - .attr("x", arc.centroid(d)[0] - this.config.iconSize / 4) + .attr("text-anchor", "middle") + .attr("x", arc.centroid(d)[0]) .attr("y", arc.centroid(d)[1] + this.config.iconSize / 2 + 7); } } @@ -1119,12 +1173,17 @@ export class RadialMenu implements Controller { this.menuPaths.forEach((path, itemId) => { const item = this.findMenuItem(itemId); if (item) { + const isAllianceCooldown = + item.id === "ally_request" && + (this.params?.playerActions?.interaction + ?.allianceRequestCooldownRemaining ?? 0) > 0; const disabled = this.isItemDisabled(item); - const color = disabled - ? this.config.disabledColor - : (resolveColor(item, this.params) ?? "#333333"); - const opacity = disabled ? 0.4 : 0.82; - + const color = isAllianceCooldown + ? COLORS.allianceTimeLeft + : disabled + ? this.config.disabledColor + : (resolveColor(item, this.params) ?? "#333333"); + const opacity = isAllianceCooldown ? 0.82 : disabled ? 0.4 : 0.82; // Update path appearance (skip fill for timer items — gradient handles it) if (!item.timerFraction) { path.attr( @@ -1132,7 +1191,7 @@ export class RadialMenu implements Controller { d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color, ); } - path.style("opacity", disabled ? 0.5 : 1); + path.style("opacity", isAllianceCooldown ? 0.82 : disabled ? 0.5 : 1); path.style("cursor", disabled ? "not-allowed" : "pointer"); // Update icon/text appearance using the same logic as renderIconsAndText @@ -1144,27 +1203,58 @@ export class RadialMenu implements Controller { // Update text opacity const textElement = icon.select("text"); if (!textElement.empty()) { - textElement.style("opacity", disabled ? 0.5 : 1); + textElement.style( + "opacity", + isAllianceCooldown ? 0.82 : disabled ? 0.5 : 1, + ); } - // Update image opacity + // Update image opacity and position const imageElement = icon.select("image"); if (!imageElement.empty()) { - imageElement.attr("opacity", disabled ? 0.5 : 1); + const height = parseFloat(imageElement.attr("height") ?? "0"); + const cy = parseFloat(icon.attr("data-cy") ?? "0"); + + imageElement.attr("y", isAllianceCooldown ? 42 : cy - height / 2); + + imageElement.attr( + "opacity", + isAllianceCooldown ? 0.82 : disabled ? 0.5 : 1, + ); } // Update cooldown text if applicable - const cooldownElement = icon.select(".cooldown-text"); - if (this.params && !cooldownElement.empty() && item.cooldown) { + if (this.params && item.cooldown) { const cooldown = Math.ceil(item.cooldown(this.params)); + let cooldownText = icon.select(".cooldown-text"); + if (cooldown <= 0) { - cooldownElement.remove(); + cooldownText.remove(); } else { - cooldownElement.text(cooldown + "s"); + if (cooldownText.empty()) { + const cx = parseFloat(icon.attr("data-cx") ?? "0"); + const cy = parseFloat(icon.attr("data-cy") ?? "0"); + + cooldownText = icon + .append("text") + .attr("class", "cooldown-text") + .attr("fill", "white") + .attr("font-size", "20px") + .attr("font-weight", "bold") + .attr("text-anchor", "middle") + .attr("x", cx) + .attr("y", cy + this.config.iconSize / 2 + 7); + } + + cooldownText + .text(renderDuration(cooldown)) + .attr( + "opacity", + isAllianceCooldown ? 0.82 : disabled ? 0.5 : 1, + ); } } } - // Update timer gradient this.maybeUpdateTimerGradient(item, color, opacity); } @@ -1191,29 +1281,39 @@ export class RadialMenu implements Controller { ); 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"); + const cooldownActive = + (this.params?.playerActions?.interaction + ?.allianceRequestCooldownRemaining ?? 0) > 0; - if (stateKey) { - icon.attr("data-prev-state", stateKey); - } else { - icon.selectAll("*").remove(); - } + const previousCooldownActive = icon.attr("data-cooldown-active") === "true"; - this.renderAllyExtendIcon( - icon.node()! as SVGGElement, - cx, - cy, - this.config.iconSize, - disabled, - this.params, - item.icon, - true, - ); + const cooldownChanged = cooldownActive !== previousCooldownActive; + + const stateChanged = stateKey !== prevState; + + if (!cooldownChanged && !stateChanged) { + return; + } + + icon.attr("data-cooldown-active", cooldownActive ? "true" : "false"); + + const cx = parseFloat(icon.attr("data-cx") || "0"); + const cy = parseFloat(icon.attr("data-cy") || "0"); + + if (stateKey) { + icon.attr("data-prev-state", stateKey); + // State unchanged, skip re-render to preserve animations } + this.renderAllyExtendIcon( + icon.node()! as SVGGElement, + cx, + cy, + this.config.iconSize, + disabled, + this.params, + item.icon, + true, + ); } private maybeUpdateTimerGradient( @@ -1279,6 +1379,11 @@ export class RadialMenu implements Controller { icon?: string, update?: boolean, ): void { + const generation = + Number(content.getAttribute("data-render-generation") ?? "0") + 1; + + content.setAttribute("data-render-generation", generation.toString()); + if (update) { while (content.firstChild) content.removeChild(content.firstChild); } @@ -1292,6 +1397,12 @@ export class RadialMenu implements Controller { const iconUrl = icon ?? ""; getSvgAspectRatio(iconUrl).then((ratio) => { + if ( + Number(content.getAttribute("data-render-generation")) !== generation + ) { + return; + } + const width = smallSize * (ratio ?? 1); const gap = 2; const totalWidth = width * 2 + gap; diff --git a/src/client/hud/layers/RadialMenuElements.ts b/src/client/hud/layers/RadialMenuElements.ts index 9ef673c83e..46d0fdbd9d 100644 --- a/src/client/hud/layers/RadialMenuElements.ts +++ b/src/client/hud/layers/RadialMenuElements.ts @@ -96,6 +96,7 @@ export const COLORS = { boat: "#2a82c9", disabled: "#94a3b8", ally: "#4ade80", + allianceTimeLeft: "#0891b2", breakAlly: "#dc2626", breakAllyNoDebuff: "#d97706", delete: "#ef4444", @@ -218,6 +219,8 @@ const allyRequestElement: MenuElement = { name: "request", disabled: (params: MenuElementParams) => !params.playerActions?.interaction?.canSendAllianceRequest, + cooldown: (params: MenuElementParams) => + params.playerActions?.interaction?.allianceRequestCooldownRemaining ?? 0, displayed: (params: MenuElementParams) => !params.playerActions?.interaction?.canBreakAlliance, color: COLORS.ally, diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 3532720953..996b215b39 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -262,6 +262,8 @@ export class GameRunner { canSendEmoji: player.canSendEmoji(other), canTarget: player.canTarget(other), canSendAllianceRequest: player.canSendAllianceRequest(other), + allianceRequestCooldownRemaining: + player.allianceRequestCooldownRemaining(other), canBreakAlliance: player.isAlliedWith(other), canDonateGold: player.canDonateGold(other), canDonateTroops: player.canDonateTroops(other), diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 4018ab690f..085afc4a30 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -668,6 +668,7 @@ export interface Player { allianceWith(other: Player): MutableAlliance | null; allianceInfo(other: Player): AllianceInfo | null; canSendAllianceRequest(other: Player): boolean; + allianceRequestCooldownRemaining(other: Player): number; breakAlliance(alliance: Alliance): void; removeAllAlliances(): void; createAllianceRequest(recipient: Player): AllianceRequest | null; @@ -964,6 +965,7 @@ export interface PlayerInteraction { sharedBorder: boolean; canSendEmoji: boolean; canSendAllianceRequest: boolean; + allianceRequestCooldownRemaining?: number; canBreakAlliance: boolean; canTarget: boolean; canDonateGold: boolean; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index b468916db9..879f5f81ee 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -736,6 +736,55 @@ export class PlayerImpl implements Player { return delta >= this.mg.config().allianceRequestCooldown(); } + allianceRequestCooldownRemaining(other: Player): number { + if (this.mg.config().disableAlliances()) { + return 0; + } + if (other === this) { + return 0; + } + if (this.isDisconnected() || other.isDisconnected()) { + // Disconnected players are marked as not-friendly even if they are allies, + // so we need to return early if either player is disconnected. + // Otherwise we could end up sending an alliance request to someone + // we are already allied with. + return 0; + } + if (this.isFriendly(other) || !this.isAlive()) { + return 0; + } + + const hasPending = this.outgoingAllianceRequests().some( + (ar) => ar.recipient() === other, + ); + + if (hasPending) { + return 0; + } + + const hasIncoming = this.incomingAllianceRequests().some( + (ar) => ar.requestor() === other, + ); + + if (hasIncoming) { + return 0; + } + + const recent = this.pastOutgoingAllianceRequests + .filter((ar) => ar.recipient() === other) + .sort((a, b) => b.createdAt() - a.createdAt()); + + if (recent.length === 0) { + return 0; + } + + const delta = this.mg.ticks() - recent[0].createdAt(); + + const remainingTicks = this.mg.config().allianceRequestCooldown() - delta; + + return Math.max(Math.floor((remainingTicks + 9) / 10), 0); + } + breakAlliance(alliance: MutableAlliance): void { this.mg.breakAlliance(this, alliance); } diff --git a/tests/PlayerImpl.test.ts b/tests/PlayerImpl.test.ts index ed6199596c..142edc6110 100644 --- a/tests/PlayerImpl.test.ts +++ b/tests/PlayerImpl.test.ts @@ -169,4 +169,110 @@ describe("PlayerImpl", () => { expect(player.numTilesOwned()).toBe(0); }); }); + + describe("allianceRequestCooldownRemaining()", () => { + let other: Player; + let friend: Player; + + beforeEach(async () => { + game = await setup("plains", { instantBuild: true }, [ + new PlayerInfo("player", PlayerType.Human, null, "player_id"), + new PlayerInfo("other", PlayerType.Human, null, "other_id"), + new PlayerInfo("friend", PlayerType.Human, null, "friend_id"), + ]); + + player = game.player("player_id"); + other = game.player("other_id"); + friend = game.player("friend_id"); + + player.conquer(game.ref(0, 0)); + other.conquer(game.ref(50, 50)); + friend.conquer(game.ref(30, 30)); + }); + + test("returns 0 when alliances are disabled", async () => { + const disabledGame = await setup( + "plains", + { instantBuild: true, disableAlliances: true }, + [ + new PlayerInfo("player", PlayerType.Human, null, "player_id"), + new PlayerInfo("other", PlayerType.Human, null, "other_id"), + ], + ); + const disabledPlayer = disabledGame.player("player_id"); + const disabledOther = disabledGame.player("other_id"); + disabledPlayer.conquer(disabledGame.ref(0, 0)); + disabledOther.conquer(disabledGame.ref(50, 50)); + + expect( + disabledPlayer.allianceRequestCooldownRemaining(disabledOther), + ).toBe(0); + }); + + test("returns 0 for self", () => { + expect(player.allianceRequestCooldownRemaining(player)).toBe(0); + }); + + test("returns 0 when either player is disconnected", () => { + player.markDisconnected(true); + expect(player.allianceRequestCooldownRemaining(other)).toBe(0); + player.markDisconnected(false); + other.markDisconnected(true); + expect(player.allianceRequestCooldownRemaining(other)).toBe(0); + }); + + test("returns 0 for friendly players", () => { + const request = player.createAllianceRequest(other); + expect(request).not.toBeNull(); + request!.accept(); + + expect(player.allianceRequestCooldownRemaining(other)).toBe(0); + }); + + test("returns 0 for dead players", () => { + const allTiles = Array.from(player.tiles()); + for (const tile of allTiles) { + player.relinquish(tile); + } + expect(player.isAlive()).toBe(false); + expect(player.allianceRequestCooldownRemaining(other)).toBe(0); + }); + + test("returns 0 while a request is pending", () => { + const request = player.createAllianceRequest(other); + expect(request).not.toBeNull(); + expect(player.allianceRequestCooldownRemaining(other)).toBe(0); + }); + + test("returns 0 while an incoming request exists", () => { + const request = other.createAllianceRequest(player); + expect(request).not.toBeNull(); + expect(player.allianceRequestCooldownRemaining(other)).toBe(0); + }); + + test("returns 0 when no recent request exists", () => { + expect(player.allianceRequestCooldownRemaining(other)).toBe(0); + }); + + test("rounds remaining cooldown up to whole seconds and expires", () => { + const request = player.createAllianceRequest(other); + expect(request).not.toBeNull(); + request!.reject(); + + const cooldownTicks = game.config().allianceRequestCooldown(); + const expectedSeconds = Math.floor((cooldownTicks + 9) / 10); + expect(player.allianceRequestCooldownRemaining(other)).toBe( + expectedSeconds, + ); + + const ticksUntilLastSecond = cooldownTicks - 1; + for (let i = 0; i < ticksUntilLastSecond; i++) { + game.executeNextTick(); + } + + expect(player.allianceRequestCooldownRemaining(other)).toBe(1); + game.executeNextTick(); + expect(player.allianceRequestCooldownRemaining(other)).toBe(0); + }); + }); }); diff --git a/tests/core/GameRunner.test.ts b/tests/core/GameRunner.test.ts index f02b078235..997e4f2ea7 100644 --- a/tests/core/GameRunner.test.ts +++ b/tests/core/GameRunner.test.ts @@ -1,6 +1,8 @@ +import { Executor } from "../../src/core/execution/ExecutionManager"; import { NationExecution } from "../../src/core/execution/NationExecution"; import { SpawnExecution } from "../../src/core/execution/SpawnExecution"; import { Cell, Nation, PlayerInfo, PlayerType } from "../../src/core/game/Game"; +import { GameRunner } from "../../src/core/GameRunner"; import { GameConfig, GameID } from "../../src/core/Schemas"; import { setup } from "../util/Setup"; import { executeTicks } from "../util/utils"; @@ -103,3 +105,34 @@ describe("Nation spawn ordering with random spawn", () => { expect(game.player(nations[0].info.id).isAlive()).toBe(true); }); }); + +describe("GameRunner playerActions interaction payload", () => { + test("forwards player alliance cooldown remaining through playerActions", async () => { + const game = await setup("plains", { instantBuild: true }, [ + new PlayerInfo("player", PlayerType.Human, null, "player_id"), + new PlayerInfo("other", PlayerType.Human, null, "other_id"), + ]); + + const player = game.player("player_id"); + const other = game.player("other_id"); + player.conquer(game.ref(0, 0)); + other.conquer(game.ref(50, 50)); + + const runner = new GameRunner( + game, + new Executor(game, gameID, undefined, []), + () => {}, + ); + + const request = player.createAllianceRequest(other); + expect(request).not.toBeNull(); + request!.reject(); + const expected = player.allianceRequestCooldownRemaining(other); + expect(expected).toBeGreaterThan(0); + const actions = runner.playerActions(player.id(), 50, 50); + expect(actions.interaction).toBeDefined(); + expect(actions.interaction?.allianceRequestCooldownRemaining).toBe( + expected, + ); + }); +});