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: image In game logs: image ## 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} > +

+ ${translateText("user_setting.ally_keybinds")} +

+ + + + +

diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 1192a3ce7..bd4b02adb 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -190,7 +190,26 @@ export class EventsDisplay extends LitElement implements Layer { this.events = []; } - init() {} + init() { + this.eventBus.on( + SendAllianceRequestIntentEvent, + this.onAllianceRequestSentConfirmation.bind(this), + ); + } + + private onAllianceRequestSentConfirmation(e: SendAllianceRequestIntentEvent) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer || e.requestor.id() !== myPlayer.id()) { + return; + } + this.addEvent({ + description: translateText("events_display.alliance_request_sent", { + name: e.recipient.name(), + }), + type: MessageType.ALLIANCE_REQUEST, + createdAt: this.game.ticks(), + }); + } tick() { this.active = true; diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 5d191f06d..018eb26f8 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -19,6 +19,8 @@ export function getDefaultKeybinds(isMac: boolean): Record { attackRatioUp: "KeyY", boatAttack: "KeyB", groundAttack: "KeyG", + requestAlliance: "KeyK", + breakAlliance: "KeyL", swapDirection: "KeyU", zoomOut: "KeyQ", zoomIn: "KeyE",