From c212735f09deb0c2cf988acb7abe8f77961bb4ab Mon Sep 17 00:00:00 2001
From: FloPinguin <25036848+FloPinguin@users.noreply.github.com>
Date: Tue, 10 Feb 2026 00:23:20 +0100
Subject: [PATCH] =?UTF-8?q?Orange=20betrayal=20button=20for=20no-debuff-be?=
=?UTF-8?q?trayals=20=F0=9F=96=8C=EF=B8=8F=20(#3161)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolves #1276
## Description:
Orange betrayal button if the player is a traitor or disconnected.
So people can easier tell that this is a betrayal without consequences.
The color changes back to red without reopening the menu (live) when the
traitor debuff ends or the player reconnects.
## 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:
FloPinguin
---------
Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
---
src/client/graphics/layers/RadialMenu.ts | 23 +++++++++------
.../graphics/layers/RadialMenuElements.ts | 8 ++++--
.../graphics/RadialMenuElements.test.ts | 4 +++
tests/radialMenuElements.test.ts | 28 +++++++++++++++++--
4 files changed, 51 insertions(+), 12 deletions(-)
diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts
index ab4d0198f..119c7e1a3 100644
--- a/src/client/graphics/layers/RadialMenu.ts
+++ b/src/client/graphics/layers/RadialMenu.ts
@@ -11,6 +11,16 @@ import {
} from "./RadialMenuElements";
import backIcon from "/images/BackIconWhite.svg?url";
+function resolveColor(
+ item: MenuElement,
+ params: MenuElementParams | null,
+): string | undefined {
+ if (typeof item.color === "function") {
+ return params ? item.color(params) : undefined;
+ }
+ return item.color;
+}
+
export class CloseRadialMenuEvent implements GameEvent {
constructor() {}
}
@@ -322,7 +332,7 @@ export class RadialMenu implements Layer {
const disabled = this.params === null || d.data.disabled(this.params);
const color = disabled
? this.config.disabledColor
- : (d.data.color ?? "#333333");
+ : (resolveColor(d.data, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
if (d.data.id === this.selectedItemId && this.currentLevel > level) {
@@ -365,7 +375,7 @@ export class RadialMenu implements Layer {
const color =
this.params === null || d.data.disabled(this.params)
? this.config.disabledColor
- : (d.data.color ?? "#333333");
+ : (resolveColor(d.data, this.params) ?? "#333333");
path.attr("fill", color);
}
});
@@ -431,7 +441,7 @@ export class RadialMenu implements Layer {
path.attr("stroke-width", "2");
const color = disabled
? this.config.disabledColor
- : (d.data.color ?? "#333333");
+ : (resolveColor(d.data, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
path.attr(
"fill",
@@ -848,10 +858,7 @@ export class RadialMenu implements Layer {
public disableAllButtons() {
this.updateCenterButtonState("default");
-
- for (const item of this.currentMenuItems) {
- item.color = this.config.disabledColor;
- }
+ this.refresh();
}
public updateCenterButtonState(state: CenterButtonState) {
@@ -1043,7 +1050,7 @@ export class RadialMenu implements Layer {
const disabled = this.isItemDisabled(item);
const color = disabled
? this.config.disabledColor
- : (item.color ?? "#333333");
+ : (resolveColor(item, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
// Update path appearance
diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts
index 4ced271d0..67ef1d05a 100644
--- a/src/client/graphics/layers/RadialMenuElements.ts
+++ b/src/client/graphics/layers/RadialMenuElements.ts
@@ -46,7 +46,7 @@ export interface MenuElement {
id: string;
name: string;
displayed?: boolean | ((params: MenuElementParams) => boolean);
- color?: string;
+ color?: string | ((params: MenuElementParams) => string);
icon?: string;
text?: string;
fontSize?: string;
@@ -76,6 +76,7 @@ export const COLORS = {
boat: "#3f6ab1",
ally: "#53ac75",
breakAlly: "#c74848",
+ breakAllyNoDebuff: "#d4882b",
delete: "#ff0000",
info: "#64748B",
target: "#ff0000",
@@ -216,7 +217,10 @@ const allyBreakElement: MenuElement = {
!params.playerActions?.interaction?.canBreakAlliance,
displayed: (params: MenuElementParams) =>
!!params.playerActions?.interaction?.canBreakAlliance,
- color: COLORS.breakAlly,
+ color: (params: MenuElementParams) =>
+ params.selected?.isTraitor() || params.selected?.isDisconnected()
+ ? COLORS.breakAllyNoDebuff
+ : COLORS.breakAlly,
icon: traitorIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleBreakAlliance(
diff --git a/tests/client/graphics/RadialMenuElements.test.ts b/tests/client/graphics/RadialMenuElements.test.ts
index e1162f4eb..8f645737a 100644
--- a/tests/client/graphics/RadialMenuElements.test.ts
+++ b/tests/client/graphics/RadialMenuElements.test.ts
@@ -92,6 +92,8 @@ describe("RadialMenuElements", () => {
id: () => 1,
isAlliedWith: vi.fn(() => false),
isPlayer: vi.fn(() => true),
+ isTraitor: vi.fn(() => false),
+ isDisconnected: vi.fn(() => false),
} as unknown as PlayerView;
mockGame = {
@@ -339,6 +341,8 @@ describe("RadialMenuElements", () => {
id: () => 2,
isAlliedWith: vi.fn(() => true),
isPlayer: vi.fn(() => true),
+ isTraitor: vi.fn(() => false),
+ isDisconnected: vi.fn(() => false),
} as unknown as PlayerView;
mockParams.selected = allyPlayer;
mockGame.owner = vi.fn(() => allyPlayer);
diff --git a/tests/radialMenuElements.test.ts b/tests/radialMenuElements.test.ts
index 15a8885fc..e5ac9d34f 100644
--- a/tests/radialMenuElements.test.ts
+++ b/tests/radialMenuElements.test.ts
@@ -19,13 +19,18 @@ import {
} from "../src/client/graphics/layers/RadialMenuElements";
// Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions
-const makePlayer = (id: string) =>
+const makePlayer = (
+ id: string,
+ opts?: { isTraitor?: boolean; isDisconnected?: boolean },
+) =>
({
id: () => id,
isAlliedWith: (other: any) =>
other && typeof other.id === "function" && other.id() !== id
? true
: true,
+ isTraitor: () => opts?.isTraitor ?? false,
+ isDisconnected: () => opts?.isDisconnected ?? false,
}) as unknown as import("../src/core/game/GameView").PlayerView;
const makeParams = (opts?: Partial): MenuElementParams => {
@@ -82,7 +87,26 @@ describe("RadialMenuElements ally break", () => {
const ally = findAllyBreak(items)!;
expect(ally).toBeTruthy();
expect(ally.name).toBe("break");
- expect(ally.color).toBe(COLORS.breakAlly);
+ expect(typeof ally.color).toBe("function");
+ expect(ally.color(params)).toBe(COLORS.breakAlly);
+ });
+
+ test("shows break option with orange color when allied to traitor", () => {
+ const params = makeParams({
+ selected: makePlayer("p2", { isTraitor: true }),
+ });
+ const items = rootMenuElement.subMenu!(params);
+ const ally = findAllyBreak(items)!;
+ expect(ally.color(params)).toBe(COLORS.breakAllyNoDebuff);
+ });
+
+ test("shows boat button instead of break when allied to disconnected player", () => {
+ const params = makeParams({
+ selected: makePlayer("p2", { isDisconnected: true }),
+ });
+ const items = rootMenuElement.subMenu!(params);
+ expect(findAllyBreak(items)).toBeUndefined();
+ expect(items.find((i) => i.id === "boat")).toBeDefined();
});
test("break action calls handleBreakAlliance and closes menu", () => {