From 0801798fbdb75612bf950f88486fb51f13d60ec0 Mon Sep 17 00:00:00 2001
From: Jarifa <39035187+Jarifa@users.noreply.github.com>
Date: Tue, 21 Apr 2026 23:34:51 +0200
Subject: [PATCH] Feat: Alliance and betrayal hotkeys (#3110)
Original Feature request by @FloPinguin
Resolves #3077
## Description:
Adds hotkeys for Requesting alliances and breaking alliances. This
allows for players to send or break alliances whose tile is under the
cursor, without opening the radial menu.
Keybinds:
New "Ally Keybinds" section in Settings -> Keybinds
Request alliance: Default: K - sends an alliance request to the
player/bot/nation under the cursor
Break alliance: Default: L - breaks the alliance with the player at the
cursor
Behavior:
- Cursor must be over a tile owned by the target player. The action runs
only when the game allows it, following the same logic as the radial
menu. (canSendAllianceRequest and canBreakAlliance)
- When an alliance request is sent, the events log shows: "Alliance
request sent to [target]" for confirmation. No extra message for
breaking an alliance (betrayal/debuff message already exists and is sent
upon breaking an alliance)
## Screenshots:
Keybind menu:
In game logs:
## 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
Discord username:
_Dave9595_
---
resources/lang/en.json | 6 ++
src/client/ClientGameRunner.ts | 64 +++++++++++++++++++++
src/client/InputHandler.ts | 14 +++++
src/client/UserSettingModal.ts | 26 +++++++++
src/client/graphics/layers/EventsDisplay.ts | 21 ++++++-
src/core/game/UserSettings.ts | 2 +
6 files changed, 132 insertions(+), 1 deletion(-)
diff --git a/resources/lang/en.json b/resources/lang/en.json
index bd2c2b74a..a28b78b39 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -633,6 +633,11 @@
"boat_attack_desc": "Send a boat attack to the tile under your cursor.",
"ground_attack": "Ground Attack",
"ground_attack_desc": "Send a ground attack to the tile under your cursor.",
+ "ally_keybinds": "Ally Keybinds",
+ "request_alliance": "Request Alliance",
+ "request_alliance_desc": "Send an alliance request to the player whose tile is under your cursor.",
+ "break_alliance": "Break Alliance (Betray)",
+ "break_alliance_desc": "Break alliance with the player whose tile is under your cursor.",
"swap_direction": "Swap Rocket Direction",
"swap_direction_desc": "Toggle rocket launch direction (up/down).",
"zoom_controls": "Zoom Controls",
@@ -818,6 +823,7 @@
"sent_emoji": "Sent {name}: {emoji}",
"renew_alliance": "Request to renew",
"request_alliance": "{name} requests an alliance!",
+ "alliance_request_sent": "Alliance request sent to {name}.",
"focus": "Focus",
"accept_alliance": "Accept",
"reject_alliance": "Reject",
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index aea335ffe..8d55d0cd8 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -31,7 +31,9 @@ import { getPersistentID } from "./Auth";
import {
AutoUpgradeEvent,
DoBoatAttackEvent,
+ DoBreakAllianceEvent,
DoGroundAttackEvent,
+ DoRequestAllianceEvent,
InputHandler,
MouseMoveEvent,
MouseUpEvent,
@@ -40,8 +42,10 @@ import {
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
+ SendAllianceRequestIntentEvent,
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
+ SendBreakAllianceIntentEvent,
SendHashEvent,
SendSpawnIntentEvent,
SendUpgradeStructureIntentEvent,
@@ -387,6 +391,14 @@ export class ClientGameRunner {
DoGroundAttackEvent,
this.doGroundAttackUnderCursor.bind(this),
);
+ this.eventBus.on(
+ DoRequestAllianceEvent,
+ this.doRequestAllianceUnderCursor.bind(this),
+ );
+ this.eventBus.on(
+ DoBreakAllianceEvent,
+ this.doBreakAllianceUnderCursor.bind(this),
+ );
this.renderer.initialize();
this.input.initialize();
@@ -760,6 +772,58 @@ export class ClientGameRunner {
});
}
+ private doRequestAllianceUnderCursor(): void {
+ const tile = this.getTileUnderCursor();
+ if (tile === null) return;
+
+ if (this.myPlayer === null) {
+ if (!this.clientID) return;
+ const myPlayer = this.gameView.playerByClientID(this.clientID);
+ if (myPlayer === null) return;
+ this.myPlayer = myPlayer;
+ }
+
+ const myPlayer = this.myPlayer;
+
+ const tileOwner = this.gameView.owner(tile);
+ if (!tileOwner.isPlayer()) return;
+ const recipient = tileOwner as PlayerView;
+
+ myPlayer.actions(tile).then((actions) => {
+ if (actions.interaction?.canSendAllianceRequest) {
+ this.eventBus.emit(
+ new SendAllianceRequestIntentEvent(myPlayer, recipient),
+ );
+ }
+ });
+ }
+
+ private doBreakAllianceUnderCursor(): void {
+ const tile = this.getTileUnderCursor();
+ if (tile === null) return;
+
+ if (this.myPlayer === null) {
+ if (!this.clientID) return;
+ const myPlayer = this.gameView.playerByClientID(this.clientID);
+ if (myPlayer === null) return;
+ this.myPlayer = myPlayer;
+ }
+
+ const myPlayer = this.myPlayer;
+
+ const tileOwner = this.gameView.owner(tile);
+ if (!tileOwner.isPlayer()) return;
+ const recipient = tileOwner as PlayerView;
+
+ myPlayer.actions(tile).then((actions) => {
+ if (actions.interaction?.canBreakAlliance) {
+ this.eventBus.emit(
+ new SendBreakAllianceIntentEvent(myPlayer, recipient),
+ );
+ }
+ });
+ }
+
private getTileUnderCursor(): TileRef | null {
if (!this.isActive || !this.lastMousePosition) {
return null;
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index e3ffdda92..1c12cf61b 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -153,6 +153,10 @@ export class DoBoatAttackEvent implements GameEvent {}
export class DoGroundAttackEvent implements GameEvent {}
+export class DoRequestAllianceEvent implements GameEvent {}
+
+export class DoBreakAllianceEvent implements GameEvent {}
+
export class AttackRatioEvent implements GameEvent {
constructor(public readonly attackRatio: number) {}
}
@@ -521,6 +525,16 @@ export class InputHandler {
this.setGhostStructure(matchedBuild);
}
+ if (this.keybindMatchesEvent(e, this.keybinds.requestAlliance)) {
+ e.preventDefault();
+ this.eventBus.emit(new DoRequestAllianceEvent());
+ }
+
+ if (this.keybindMatchesEvent(e, this.keybinds.breakAlliance)) {
+ e.preventDefault();
+ this.eventBus.emit(new DoBreakAllianceEvent());
+ }
+
if (this.keybindMatchesEvent(e, this.keybinds.swapDirection)) {
e.preventDefault();
const nextDirection = !this.uiState.rocketDirectionUp;
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index 77a0f33d1..30da04355 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -657,6 +657,32 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
>
+