Skip to content

Add alliance renewal action to Radial Menu#3148

Open
deshack wants to merge 22 commits intomainfrom
feature/alliance-extend-radial-menu
Open

Add alliance renewal action to Radial Menu#3148
deshack wants to merge 22 commits intomainfrom
feature/alliance-extend-radial-menu

Conversation

@deshack
Copy link
Contributor

@deshack deshack commented Feb 7, 2026

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.

image
Screen.Recording.2026-02-07.at.13.14.59.mov

Icon size adjusted:

image

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • 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

deshack and others added 8 commits February 7, 2026 07:45
Replace clip-path overlay with CSS linear-gradient on the main arc path
so the entire button is clickable. Use the 30s extension window (not the
full alliance duration) for the timer fraction, and lighten the expired
portion to match the name overlay style. Preserve the gradient fill on
hover/mouseout so the countdown remains visible during interaction.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Preserve SVG animate elements by comparing a canonical state key
before re-invoking customRender. Renderers can opt in via
customRenderStateKey and handle in-place updates via the update flag.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds an alliance-extension flow: radial menu emits a SendAllianceExtensionIntentEvent; EventsDisplay listens, finds the alliance for the recipient, removes renewal events when present, and requests a UI update. Core types, player, and alliance logic expose and enforce extension-related fields and checks.

Changes

Cohort / File(s) Summary
Event & Action Handling
src/client/graphics/layers/EventsDisplay.ts, src/client/graphics/layers/PlayerActionHandler.ts
EventsDisplay.init() now subscribes to SendAllianceExtensionIntentEvent, looks up the alliance for the recipient, calls removeAllianceRenewalEvents(alliance.id) if found, and requestUpdate(). PlayerActionHandler adds handleExtendAlliance(recipient) to emit the event.
Radial Menu UI & Elements
src/client/graphics/layers/RadialMenu.ts, src/client/graphics/layers/RadialMenuElements.ts
Adds per-item timerFraction support and per-item SVG linearGradient fills, centroid attrs for custom renders, and a new allyExtendElement with customRender/customRenderStateKey. Root menu swaps ally_extend in extension windows and updates gradients on render/hover.
Core Game Logic & Types
src/core/game/Game.ts, src/core/GameRunner.ts, src/core/game/AllianceImpl.ts, src/core/game/PlayerImpl.ts
Adds agreedToExtend(player) on alliances and canExtendAlliance(other) on players. PlayerInteraction and GameRunner now include canExtendAlliance, inAllianceExtensionWindow, myPlayerAgreedToExtend, and otherPlayerAgreedToExtend. PlayerImpl implements extension eligibility and refines alliance request cooldown logic.
Tests
tests/client/graphics/RadialMenuElements.test.ts
Adds unit tests for the ally_extend menu item: visibility during extension window, enabled/disabled state tied to canExtendAlliance, and presence/absence depending on extension-window flag.

Sequence Diagram

sequenceDiagram
    participant Player
    participant RadialMenu
    participant ActionHandler
    participant EventBus
    participant EventsDisplay
    participant GameCore

    Player->>RadialMenu: select "ally_extend"
    RadialMenu->>ActionHandler: handleExtendAlliance(recipient)
    ActionHandler->>EventBus: emit SendAllianceExtensionIntentEvent(recipient)
    EventBus->>EventsDisplay: deliver SendAllianceExtensionIntentEvent
    EventsDisplay->>GameCore: lookup current player & alliance by recipient id
    GameCore-->>EventsDisplay: alliance or null
    alt alliance found
        EventsDisplay->>GameCore: removeAllianceRenewalEvents(alliance.id)
        EventsDisplay->>RadialMenu: requestUpdate()
        RadialMenu->>RadialMenu: refresh gradients and re-render
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🤝 Timers fade, a gradient plea,
⏳ A click sends word across the sea,
Old renewals fall away,
The menu brightens—choose to stay,
Two hands align to longer days.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add alliance renewal action to Radial Menu' directly reflects the main feature: implementing an alliance extension/renewal button in the radial menu when alliances are expiring.
Description check ✅ Passed The description clearly explains the change: replacing the disabled alliance request button with an alliance extension button when expiring, hiding related messages, and noting UI adjustments with screenshots and testing completed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/graphics/layers/RadialMenuElements.ts (1)

742-755: ⚠️ Potential issue | 🔴 Critical

displayed property is never evaluated in the rendering pipeline.

The displayed property is defined in MenuElement but never checked during rendering. renderPaths (line 322) only evaluates the disabled property, and isItemDisabled (line 564) ignores displayed entirely. Multiple menu elements define displayed callbacks (lines 184, 200, 215, 231, 332 in RadialMenuElements.ts), but these are never invoked. This means conditional visibility based on displayed will not work. Either remove the unused property or implement evaluation in isItemDisabled and the rendering pipeline.

🤖 Fix all issues with AI agents
In `@src/client/graphics/layers/RadialMenuElements.ts`:
- Around line 242-254: The local variable name "window" in the timerFraction
function shadows the global window object and should be renamed to a clearer
identifier; inside timerFraction (use params, interaction,
interaction.allianceExpiresAt and
params.game.config().allianceExtensionPromptOffset() to locate), replace const
window = ... with a descriptive name like alliancePromptWindow or promptOffset
and update its usages (remaining / alliancePromptWindow) so the code no longer
hides the global window.
🧹 Nitpick comments (6)
tests/client/graphics/RadialMenuElements.test.ts (1)

352-442: Good coverage of ally_extend visibility and enabled/disabled states.

The four tests cover the key scenarios well. One small note: there is repeated mock setup (creating allyPlayer, overriding mockGame.owner, spreading mockPlayerActions.interaction) across all four tests. Consider extracting a small helper function to reduce duplication — but this is a minor nit for a test file.

♻️ Optional: extract shared setup
function setupAllyExtensionTest(
  mockParams: MenuElementParams,
  mockGame: GameView,
  mockPlayerActions: any,
  interactionOverrides: Record<string, unknown>,
) {
  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,
    ...interactionOverrides,
  };
}

Then each test becomes a one-liner setup + assertions.

src/core/GameRunner.ts (1)

219-228: The extension-window threshold logic is duplicated in multiple places.

The same check alliance.expiresAt() <= ticks + config.allianceExtensionPromptOffset() appears here, in PlayerImpl.canExtendAlliance (lines 427-432), and in EventsDisplay.checkForAllianceExpirations (lines 302-304). If the threshold logic ever changes, all three must be updated together.

Consider extracting a shared helper (e.g., alliance.isInExtensionWindow(ticks) on AllianceImpl) to keep this in one place.

♻️ Suggested: add helper to AllianceImpl

Add to AllianceImpl:

isInExtensionWindow(): boolean {
  return this.expiresAt_ <= this.mg.ticks() + this.mg.config().allianceExtensionPromptOffset();
}

Then replace the duplicated checks in GameRunner.ts, PlayerImpl.ts, and EventsDisplay.ts with alliance.isInExtensionWindow().

src/core/game/PlayerImpl.ts (1)

405-435: Redundant allianceWith call and dead null-check.

this.allianceWith(other) is called at line 417 (as a boolean guard) and again at line 421 (to get the value). The null check at lines 423-425 is dead code since line 417 already returns false when no alliance exists. Simplify by retrieving the alliance once.

♻️ Proposed simplification
  canExtendAlliance(other: Player): boolean {
    if (other === this) {
      return false;
    }

    if (this.isDisconnected() || other.isDisconnected()) {
      return false;
    }
-   if (!this.allianceWith(other) || !this.isAlive() || !other.isAlive()) {
+
+   if (!this.isAlive() || !other.isAlive()) {
      return false;
    }

    const alliance = this.allianceWith(other);

    if (!alliance) {
      return false;
    }

    if (
      alliance.expiresAt() >
      this.mg.ticks() + this.mg.config().allianceExtensionPromptOffset()
    ) {
      return false;
    }

    return !alliance.agreedToExtend(this);
  }
src/client/graphics/layers/RadialMenu.ts (3)

352-399: Timer gradient setup looks good overall, but the this.params === null check on line 356 is always false.

Inside the arcs.each callback, line 354 already guards with && this.params, so this.params is guaranteed non-null at line 356. The disabled check can just use d.data.disabled(this.params).

Suggested fix
     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 disabled = d.data.disabled(this.params);
         const baseColor = disabled

396-397: Global d3.select for path lookup may match elements outside this menu.

d3.select(path[data-id="..."]) queries the entire document. If another SVG on the page happens to have a <path> with the same data-id, this picks the wrong element. This is a pre-existing pattern throughout the file (lines 403, 538, 590), but the new timer-gradient code adds another instance. Consider scoping the selection to the menu's SVG — for example, storing a reference to the menu's <svg> and querying within it.


1131-1163: When customRenderStateKey is absent, children are cleared before re-render, but when present and state changed, clearing is left to customRender(update=true).

This split responsibility is a bit fragile — the caller (refresh()) owns cleanup in one branch but the callee (customRender) owns it in the other. If a future customRender implementer forgets to clear children when update=true, stale content will accumulate.

Consider making the contract consistent: always clear children here before calling customRender, or always pass update and let customRender decide. Right now the current allyExtendElement.customRender handles it correctly (lines 270–272 in RadialMenuElements.ts), so this is not broken — just worth keeping tidy.

@github-project-automation github-project-automation bot moved this from Triage to Development in OpenFront Release Management Feb 7, 2026
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 7, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/client/graphics/layers/RadialMenuElements.ts`:
- Around line 29-32: The module-level call to getSvgAspectRatio sets
allianceIconAspectRatio at import time and breaks tests because the test mock
doesn't export that function; change this to lazy initialization by creating a
getter/initializer (e.g., getAllianceIconAspectRatio) that calls
getSvgAspectRatio and caches the result, remove the top-level getSvgAspectRatio
invocation, and inside customRender use allianceIconAspectRatio ?? 1 as a
fallback while kicking off getAllianceIconAspectRatio() so subsequent renders
use the real ratio; alternatively, if you must keep eager behavior, update the
test mock to re-export or stub getSvgAspectRatio (or use importOriginal) so the
import-time call doesn't throw.
🧹 Nitpick comments (1)
src/client/graphics/layers/RadialMenuElements.ts (1)

266-330: Consider extracting a helper for the repeated SVG image+animation creation.

The left and right handshake icons follow the exact same pattern: create <image>, set attributes, optionally append <animate>. Pulling that into a small local function removes the duplication and makes intent clearer.

Sketch
+  function appendHandshakeIcon(
+    parent: SVGGElement,
+    ns: string,
+    x: number,
+    y: number,
+    width: number,
+    height: number,
+    disabled: boolean,
+    animate: boolean,
+  ) {
+    const img = document.createElementNS(ns, "image");
+    img.setAttribute("href", allianceIcon);
+    img.setAttribute("width", width.toString());
+    img.setAttribute("height", height.toString());
+    img.setAttribute("x", x.toString());
+    img.setAttribute("y", y.toString());
+    img.setAttribute("opacity", disabled ? "0.5" : "1");
+    if (animate) {
+      const anim = document.createElementNS(ns, "animate");
+      anim.setAttribute("attributeName", "opacity");
+      anim.setAttribute("values", "1;0.2;1");
+      anim.setAttribute("dur", "1.5s");
+      anim.setAttribute("repeatCount", "indefinite");
+      img.appendChild(anim);
+    }
+    parent.appendChild(img);
+  }

Replace eager module-level getSvgAspectRatio call with a lazy
getter that fetches and caches on first render, falling back to 1
until the promise resolves.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/client/graphics/layers/RadialMenuElements.ts`:
- Around line 295-304: The animation on leftImg (created as animLeft when
myAgreed is false) currently cycles "1;0.2;1" so it overrides the static
leftImg.setAttribute("opacity", disabled ? "0.5" : "1"); change the animate
values to use the disabled ceiling (e.g. if disabled then "0.5;0.1;0.5" else
"1;0.2;1") so the animation never exceeds the disabled opacity, and apply the
same pattern to the right icon's animation (animRight/rightImg) to keep disabled
visuals consistent.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/client/graphics/layers/RadialMenuElements.ts`:
- Around line 321-328: The right-side animation block (when otherAgreed is
false) creates animRight and hardcodes values "1;0.2;1", which ignores the
disabled state and overrides the opacity set on rightImg; update the creation of
animRight in RadialMenuElements so that animRight.setAttribute("values", ...)
uses the same conditional as the left side (e.g., disabled ? "0.5;0.1;0.5" :
"1;0.2;1") or otherwise respects the disabled flag before appending animRight to
rightImg; reference the otherAgreed check, the animRight element, and rightImg
when making the change.

coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 7, 2026
@deshack deshack requested a review from evanpelle February 10, 2026 09:07
coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 12, 2026
@deshack deshack requested a review from evanpelle February 12, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

2 participants