Orange betrayal button for no-debuff-betrayals 🖌️ (#3161)

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.

<img width="268" height="257" alt="image"
src="https://github.com/user-attachments/assets/276e91ce-e49d-474c-afaa-ffa18d45a2c7"
/>

## 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>
This commit is contained in:
FloPinguin
2026-02-10 00:23:20 +01:00
committed by GitHub
parent 3cd4ffff0c
commit c212735f09
4 changed files with 51 additions and 12 deletions
+15 -8
View File
@@ -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
@@ -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(
@@ -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);
+26 -2
View File
@@ -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>): 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", () => {