diff --git a/.gitignore b/.gitignore
index f97753a021..c65b3f251a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,6 @@ src/assets/
*.log
*debug*.txt
eslint_out.txt
+
+# Kiro IDE workspace files
+.kiro/
diff --git a/index.html b/index.html
index 2bb0093668..091f0db5e8 100644
--- a/index.html
+++ b/index.html
@@ -329,6 +329,8 @@
+
+
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 3ee0af4814..f03b94c4c4 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -958,6 +958,22 @@
"delete_unit_title": "Delete Unit",
"delete_unit_description": "Click to delete the nearest unit"
},
+ "quick_chat": {
+ "configure_presets": "Customize Presets",
+ "preset_label": "Preset",
+ "add_preset": "+ Add",
+ "done": "Done",
+ "reset_defaults": "Reset defaults",
+ "confirm_reset": "⚠ Confirm reset",
+ "emoji_panel": "😀 Emoji Panel",
+ "trade_toggle": "🤝 Start / Stop Trade",
+ "editing_hint": "Editing preset {n} — pick a category and phrase, or choose an action",
+ "select_hint": "Click a preset to edit it",
+ "actions": "Actions",
+ "select_target": "Select a player",
+ "player_info": "Player Info",
+ "needs_target": "[needs target]"
+ },
"discord_user_header": {
"avatar_alt": "Avatar"
},
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index aefbac8ed3..59efa59d33 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -50,6 +50,7 @@ import {
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
+import { TargetSelectionMode } from "./graphics/layers/TargetSelectionMode";
import SoundManager from "./sound/SoundManager";
export interface LobbyConfig {
@@ -544,6 +545,10 @@ export class ClientGameRunner {
if (!this.isActive || this.renderer.uiState.ghostStructure !== null) {
return;
}
+ // Don't process attacks while the player is picking a target for a quick-chat action
+ if (TargetSelectionMode.getInstance().active) {
+ return;
+ }
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index e42d622411..f7f9ac48b0 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -54,6 +54,8 @@ export class ContextMenuEvent implements GameEvent {
constructor(
public readonly x: number,
public readonly y: number,
+ /** true when triggered by a genuine right-click / long-press */
+ public readonly isRightClick: boolean = false,
) {}
}
@@ -596,7 +598,9 @@ export class InputHandler {
this.setGhostStructure(null);
return;
}
- this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
+ this.eventBus.emit(
+ new ContextMenuEvent(event.clientX, event.clientY, true),
+ );
}
private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) {
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 1937b4a3e6..1e9b612229 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -32,6 +32,7 @@ import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer"
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
+import { QuickChatConfigModal } from "./layers/QuickChatConfigModal";
import { RailroadLayer } from "./layers/RailroadLayer";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
@@ -39,6 +40,7 @@ import { SettingsModal } from "./layers/SettingsModal";
import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
import { StructureLayer } from "./layers/StructureLayer";
+import { TargetSelectionLayer } from "./layers/TargetSelectionLayer";
import { TeamStats } from "./layers/TeamStats";
import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
@@ -227,6 +229,25 @@ export function createRenderer(
}
headsUpMessage.game = game;
+ const targetSelectionLayer = document.querySelector(
+ "target-selection-layer",
+ ) as TargetSelectionLayer;
+ if (!(targetSelectionLayer instanceof TargetSelectionLayer)) {
+ console.error("target-selection-layer not found");
+ } else {
+ targetSelectionLayer.eventBus = eventBus;
+ targetSelectionLayer.game = game;
+ targetSelectionLayer.transformHandler = transformHandler;
+ }
+
+ // QuickChatConfigModal needs no runtime wiring — it reads from QuickChatPresetService directly.
+ const quickChatConfigModal = document.querySelector(
+ "quick-chat-config-modal",
+ ) as QuickChatConfigModal;
+ if (!(quickChatConfigModal instanceof QuickChatConfigModal)) {
+ console.error("quick-chat-config-modal not found");
+ }
+
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
const samRadiusLayer = new SAMRadiusLayer(game, eventBus, uiState);
@@ -317,6 +338,9 @@ export function createRenderer(
inGamePromo,
alertFrame,
performanceOverlay,
+ ...(targetSelectionLayer instanceof TargetSelectionLayer
+ ? [targetSelectionLayer]
+ : []),
];
return new GameRenderer(
diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts
index b6adba9291..365359425c 100644
--- a/src/client/graphics/layers/MainRadialMenu.ts
+++ b/src/client/graphics/layers/MainRadialMenu.ts
@@ -5,6 +5,13 @@ import { EventBus } from "../../../core/EventBus";
import { PlayerActions } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
+import {
+ CloseViewEvent,
+ ContextMenuEvent,
+ MouseUpEvent,
+ TouchEvent,
+} from "../../InputHandler";
+import { SendQuickChatEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { BuildMenu } from "./BuildMenu";
@@ -20,11 +27,11 @@ import {
MenuElementParams,
rootMenuElement,
} from "./RadialMenuElements";
+import { TargetSelectionMode } from "./TargetSelectionMode";
+
const donateTroopIcon = assetUrl("images/DonateTroopIconWhite.svg");
const swordIcon = assetUrl("images/SwordIconWhite.svg");
-import { ContextMenuEvent } from "../../InputHandler";
-
@customElement("main-radial-menu")
export class MainRadialMenu extends LitElement implements Layer {
private radialMenu: RadialMenu;
@@ -79,7 +86,59 @@ export class MainRadialMenu extends LitElement implements Layer {
init() {
this.radialMenu.init();
+
+ // Handle left-click and touch: if target-selection mode is active, resolve
+ // the clicked tile as the chat target instead of performing a normal action.
+ const handleSelectClick = (x: number, y: number) => {
+ const mode = TargetSelectionMode.getInstance();
+ if (!mode.active) return false;
+
+ const worldCoords = this.transformHandler.screenToWorldCoordinates(x, y);
+ if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) return true;
+
+ const tile = this.game.ref(worldCoords.x, worldCoords.y);
+ const owner = this.game.owner(tile);
+
+ if (owner.isPlayer()) {
+ this.eventBus.emit(
+ new SendQuickChatEvent(
+ mode.pendingRecipient!,
+ mode.pendingKey!,
+ (owner as PlayerView).id(),
+ ),
+ );
+ mode.exit();
+ }
+ // If tile has no owner, stay in mode and wait for another click.
+ return true;
+ };
+
+ this.eventBus.on(MouseUpEvent, (event) => {
+ if (handleSelectClick(event.x, event.y)) return;
+ });
+
+ this.eventBus.on(TouchEvent, (event) => {
+ if (handleSelectClick(event.x, event.y)) return;
+ });
+
+ // Escape cancels target-selection mode.
+ this.eventBus.on(CloseViewEvent, () => {
+ TargetSelectionMode.getInstance().exit();
+ });
+
this.eventBus.on(ContextMenuEvent, (event) => {
+ // While in target-selection mode:
+ // - left-click (isRightClick=false) → attempt to resolve the target
+ // - right-click (isRightClick=true) → cancel the mode
+ if (TargetSelectionMode.getInstance().active) {
+ if (event.isRightClick) {
+ TargetSelectionMode.getInstance().exit();
+ } else {
+ handleSelectClick(event.x, event.y);
+ }
+ return;
+ }
+
const worldCoords = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
diff --git a/src/client/graphics/layers/QuickChatConfigModal.ts b/src/client/graphics/layers/QuickChatConfigModal.ts
new file mode 100644
index 0000000000..87d74c6cd4
--- /dev/null
+++ b/src/client/graphics/layers/QuickChatConfigModal.ts
@@ -0,0 +1,321 @@
+import { LitElement, html } from "lit";
+import { customElement, query, state } from "lit/decorators.js";
+import { translateText } from "../../Utils";
+import { quickChatPhrases } from "./ChatModal";
+import {
+ DEFAULT_PRESETS,
+ PresetSlot,
+ QuickChatPresetService,
+} from "./QuickChatPresetService";
+
+const MAX_SLOTS = 5;
+
+@customElement("quick-chat-config-modal")
+export class QuickChatConfigModal extends LitElement {
+ @query("o-modal") private modalEl!: HTMLElement & {
+ open: () => void;
+ close: () => void;
+ };
+
+ @state() private slots: PresetSlot[] = [];
+ @state() private editingIndex: number | null = null;
+ @state() private selectedCategory: string | null = null;
+ @state() private confirmReset = false;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ open() {
+ this.slots = QuickChatPresetService.getInstance().load();
+ this.editingIndex = null;
+ this.selectedCategory = null;
+ this.confirmReset = false;
+ this.requestUpdate();
+ this.modalEl?.open();
+ }
+
+ close() {
+ this.editingIndex = null;
+ this.selectedCategory = null;
+ this.confirmReset = false;
+ this.modalEl?.close();
+ }
+
+ private persist() {
+ try {
+ QuickChatPresetService.getInstance().save(this.slots);
+ } catch (e) {
+ console.error("[QuickChatConfigModal] Auto-save failed:", e);
+ }
+ }
+
+ private reset() {
+ if (!this.confirmReset) {
+ this.confirmReset = true;
+ this.requestUpdate();
+ setTimeout(() => {
+ this.confirmReset = false;
+ this.requestUpdate();
+ }, 3000);
+ return;
+ }
+ this.slots = [...DEFAULT_PRESETS];
+ this.editingIndex = null;
+ this.selectedCategory = null;
+ this.confirmReset = false;
+ this.persist();
+ this.requestUpdate();
+ }
+
+ private addSlot() {
+ if (this.slots.length >= MAX_SLOTS) return;
+ this.slots = [
+ ...this.slots,
+ { type: "quickchat", category: "help", key: "troops" },
+ ];
+ this.editingIndex = this.slots.length - 1;
+ this.selectedCategory = null;
+ this.persist();
+ this.requestUpdate();
+ }
+
+ private removeSlot(index: number) {
+ if (this.slots.length <= 1) return;
+ this.slots = this.slots.filter((_, i) => i !== index);
+ if (this.editingIndex !== null) {
+ if (this.editingIndex === index) {
+ this.editingIndex = null;
+ this.selectedCategory = null;
+ } else if (this.editingIndex > index) {
+ this.editingIndex--;
+ }
+ }
+ this.persist();
+ this.requestUpdate();
+ }
+
+ private selectSlot(index: number) {
+ if (this.editingIndex === index) {
+ this.editingIndex = null;
+ this.selectedCategory = null;
+ } else {
+ this.editingIndex = index;
+ const slot = this.slots[index];
+ this.selectedCategory =
+ slot.type === "quickchat" ? (slot.category ?? null) : null;
+ }
+ this.requestUpdate();
+ }
+
+ private selectCategory(cat: string) {
+ this.selectedCategory = cat;
+ this.requestUpdate();
+ }
+
+ private assignQcPhrase(category: string, key: string) {
+ if (this.editingIndex === null) return;
+ this.slots = this.slots.map((s, i) =>
+ i === this.editingIndex ? { type: "quickchat", category, key } : s,
+ );
+ this.persist();
+ const next = this.editingIndex + 1;
+ if (next < this.slots.length) {
+ this.editingIndex = next;
+ const nextSlot = this.slots[next];
+ this.selectedCategory =
+ nextSlot.type === "quickchat" ? (nextSlot.category ?? null) : null;
+ } else {
+ this.editingIndex = null;
+ this.selectedCategory = null;
+ }
+ this.requestUpdate();
+ }
+
+ private assignSpecial(type: "emoji" | "trade") {
+ if (this.editingIndex === null) return;
+ this.slots = this.slots.map((s, i) =>
+ i === this.editingIndex ? { type } : s,
+ );
+ this.persist();
+ this.editingIndex = null;
+ this.selectedCategory = null;
+ this.requestUpdate();
+ }
+
+ private slotLabel(slot: PresetSlot): string {
+ if (slot.type === "quickchat" && slot.category && slot.key)
+ return translateText(`chat.${slot.category}.${slot.key}`);
+ if (slot.type === "emoji") return translateText("quick_chat.emoji_panel");
+ if (slot.type === "trade") return translateText("quick_chat.trade_toggle");
+ return "?";
+ }
+
+ render() {
+ const categories = Object.keys(quickChatPhrases);
+ const editing =
+ this.editingIndex !== null ? this.slots[this.editingIndex] : null;
+
+ return html`
+
+
+
+
+
+ ${translateText("quick_chat.preset_label")}
+
+
+ ${this.slots.map(
+ (slot, i) => html`
+
+
+ ${this.slots.length > 1
+ ? html``
+ : null}
+
+ `,
+ )}
+ ${this.slots.length < MAX_SLOTS
+ ? html`
`
+ : null}
+
+
+
+ ${editing !== null
+ ? html`
+
+
+ ${translateText("chat.category")}
+
+
+ ${categories.map(
+ (cat) => html`
+
+ `,
+ )}
+
+
+ ${translateText("quick_chat.actions")}
+
+
+
+
+
+
+ `
+ : null}
+
+
+ ${editing !== null && this.selectedCategory
+ ? html`
+
+
+ ${translateText("chat.phrase")}
+
+
+ ${(quickChatPhrases[this.selectedCategory] ?? []).map(
+ (phrase) => {
+ const label = translateText(
+ `chat.${this.selectedCategory}.${phrase.key}`,
+ );
+ const isActive =
+ editing.type === "quickchat" &&
+ editing.category === this.selectedCategory &&
+ editing.key === phrase.key;
+ return html`
+
+ `;
+ },
+ )}
+
+
+ `
+ : null}
+
+
+
+ ${editing !== null
+ ? translateText("quick_chat.editing_hint", {
+ n: (this.editingIndex ?? 0) + 1,
+ })
+ : translateText("quick_chat.select_hint")}
+
+
+
+
+
+
+
+ `;
+ }
+}
diff --git a/src/client/graphics/layers/QuickChatPresetService.ts b/src/client/graphics/layers/QuickChatPresetService.ts
new file mode 100644
index 0000000000..b4bd67d960
--- /dev/null
+++ b/src/client/graphics/layers/QuickChatPresetService.ts
@@ -0,0 +1,71 @@
+import { quickChatPhrases } from "./ChatModal";
+
+export type PresetSlotType = "quickchat" | "emoji" | "trade";
+
+export interface PresetSlot {
+ type: PresetSlotType;
+ // quickchat only
+ category?: string;
+ key?: string;
+}
+
+export const DEFAULT_PRESETS: PresetSlot[] = [
+ { type: "quickchat", category: "help", key: "troops" },
+ { type: "emoji" },
+ { type: "quickchat", category: "attack", key: "attack" },
+];
+
+const STORAGE_KEY = "quickchat.presets.v4";
+const MIN_SLOTS = 1;
+const MAX_SLOTS = 5;
+
+/** Singleton service that persists and retrieves quick-chat preset configuration from localStorage. */
+export class QuickChatPresetService {
+ private static instance: QuickChatPresetService;
+
+ /** Returns the singleton instance, creating it on first call. */
+ static getInstance(): QuickChatPresetService {
+ if (!QuickChatPresetService.instance) {
+ QuickChatPresetService.instance = new QuickChatPresetService();
+ }
+ return QuickChatPresetService.instance;
+ }
+
+ /** Returns saved presets from localStorage, falling back to DEFAULT_PRESETS if missing or invalid. */
+ load(): PresetSlot[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [...DEFAULT_PRESETS];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed) || parsed.length === 0)
+ return [...DEFAULT_PRESETS];
+ const valid = (parsed.slice(0, MAX_SLOTS) as PresetSlot[]).filter((s) =>
+ this.isValidSlot(s),
+ );
+ return valid.length > 0 ? valid : [...DEFAULT_PRESETS];
+ } catch {
+ return [...DEFAULT_PRESETS];
+ }
+ }
+
+ /** Persists the given preset slots to localStorage. Throws if count is out of range. */
+ save(slots: PresetSlot[]): void {
+ if (slots.length < MIN_SLOTS || slots.length > MAX_SLOTS) {
+ throw new Error(
+ `Preset count must be between ${MIN_SLOTS} and ${MAX_SLOTS}`,
+ );
+ }
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(slots));
+ }
+
+ /** Returns true if the slot has a valid type and all required fields for that type. */
+ isValidSlot(slot: PresetSlot): boolean {
+ if (!slot?.type) return false;
+ if (slot.type === "quickchat") {
+ if (!slot.category || !slot.key) return false;
+ return !!quickChatPhrases[slot.category]?.some((p) => p.key === slot.key);
+ }
+ // emoji and trade are always valid
+ return slot.type === "emoji" || slot.type === "trade";
+ }
+}
diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts
index 9f8b6c6549..9a02c8c573 100644
--- a/src/client/graphics/layers/RadialMenu.ts
+++ b/src/client/graphics/layers/RadialMenu.ts
@@ -616,6 +616,10 @@ export class RadialMenu implements Layer {
);
}
} else if (d.data.text) {
+ const resolvedText =
+ typeof d.data.text === "function"
+ ? d.data.text(this.params!)
+ : d.data.text;
content
.append("text")
.attr("text-anchor", "middle")
@@ -626,7 +630,7 @@ export class RadialMenu implements Layer {
.attr("font-size", d.data.fontSize ?? "12px")
.attr("font-family", "Arial, sans-serif")
.style("opacity", disabled ? 0.5 : 1)
- .text(d.data.text);
+ .text(resolvedText);
} else {
const imgSel = content
.append("image")
diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts
index 1364bb41d0..dcfc5f04af 100644
--- a/src/client/graphics/layers/RadialMenuElements.ts
+++ b/src/client/graphics/layers/RadialMenuElements.ts
@@ -15,12 +15,16 @@ import { renderNumber, translateText } from "../../Utils";
import { UIState } from "../UIState";
import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu";
import { ChatIntegration } from "./ChatIntegration";
+import { quickChatPhrases } from "./ChatModal";
import { EmojiTable } from "./EmojiTable";
import { PlayerActionHandler } from "./PlayerActionHandler";
import { PlayerPanel } from "./PlayerPanel";
+import { PresetSlot, QuickChatPresetService } from "./QuickChatPresetService";
import { TooltipItem } from "./RadialMenu";
+import { TargetSelectionMode } from "./TargetSelectionMode";
import { EventBus } from "../../../core/EventBus";
+import { SendQuickChatEvent } from "../../Transport";
const allianceIcon = assetUrl("images/AllianceIconWhite.svg");
const boatIcon = assetUrl("images/BoatIconWhite.svg");
const buildIcon = assetUrl("images/BuildIconWhite.svg");
@@ -34,6 +38,8 @@ const targetIcon = assetUrl("images/TargetIconWhite.svg");
const traitorIcon = assetUrl("images/TraitorIconWhite.svg");
const xIcon = assetUrl("images/XIcon.svg");
+const settingsIcon = assetUrl("images/SettingIconWhite.svg");
+
export interface MenuElementParams {
myPlayer: PlayerView;
selected: PlayerView | null;
@@ -56,7 +62,7 @@ export interface MenuElement {
displayed?: boolean | ((params: MenuElementParams) => boolean);
color?: string | ((params: MenuElementParams) => string);
icon?: string;
- text?: string;
+ text?: string | ((params: MenuElementParams) => string);
fontSize?: string;
tooltipItems?: TooltipItem[];
tooltipKeys?: TooltipKey[];
@@ -367,6 +373,124 @@ const infoEmojiElement: MenuElement = {
},
};
+/** Shortens a phrase label to fit inside a radial menu arc segment. */
+function shortenPresetText(text: string, maxLength = 15): string {
+ return text.length <= maxLength
+ ? text
+ : text.substring(0, maxLength - 3) + "...";
+}
+
+/** Builds a single MenuElement from a PresetSlot at runtime. */
+function buildPresetItem(slot: PresetSlot, recipient: PlayerView): MenuElement {
+ // --- Quick Chat ---
+ if (slot.type === "quickchat" && slot.category && slot.key) {
+ const phrase = quickChatPhrases[slot.category]?.find(
+ (p) => p.key === slot.key,
+ );
+ const fullKey = `${slot.category}.${slot.key}`;
+ const label = translateText(`chat.${slot.category}.${slot.key}`);
+ const color =
+ COLORS.chat[slot.category as keyof typeof COLORS.chat] ??
+ COLORS.chat.default;
+ return {
+ id: `preset-qc-${slot.category}-${slot.key}`,
+ name: label,
+ disabled: () => false,
+ text: shortenPresetText(label),
+ fontSize: "10px",
+ color,
+ tooltipItems: [{ text: label, className: "description" }],
+ action: (p: MenuElementParams) => {
+ if (phrase?.requiresPlayer) {
+ TargetSelectionMode.getInstance().enter(fullKey, recipient);
+ p.closeMenu();
+ } else {
+ p.eventBus.emit(new SendQuickChatEvent(recipient, fullKey));
+ p.closeMenu();
+ }
+ },
+ };
+ }
+
+ // --- Emoji: opens the full emoji panel, same as the existing emoji button ---
+ if (slot.type === "emoji") {
+ return {
+ id: "preset-emoji-panel",
+ name: "emoji",
+ disabled: () => false,
+ icon: emojiIcon,
+ color: COLORS.infoEmoji,
+ action: (p: MenuElementParams) => {
+ const target =
+ p.selected === p.game.myPlayer() ? AllPlayers : p.selected;
+ p.emojiTable.showTable((emoji) => {
+ p.playerActionHandler.handleEmoji(
+ target!,
+ flattenedEmojiTable.indexOf(emoji as Emoji),
+ );
+ p.emojiTable.hideTable();
+ });
+ // Do NOT call closeMenu() here — the emoji table needs to stay interactive
+ },
+ };
+ }
+
+ // --- Trade: smart toggle — label/color reflect live canEmbargo state ---
+ if (slot.type === "trade") {
+ return {
+ id: "preset-trade-toggle",
+ name: "trade",
+ disabled: (p: MenuElementParams) =>
+ p.selected === null ||
+ p.selected?.id() === p.myPlayer?.id() ||
+ (!p.playerActions?.interaction?.canEmbargo &&
+ !p.playerActions?.interaction?.canDonateGold),
+ color: (p: MenuElementParams) =>
+ p.playerActions?.interaction?.canEmbargo
+ ? COLORS.embargo
+ : COLORS.trade,
+ text: (p: MenuElementParams) => {
+ const label = p.playerActions?.interaction?.canEmbargo
+ ? translateText("player_panel.stop_trade")
+ : translateText("player_panel.start_trade");
+ return shortenPresetText(label);
+ },
+ fontSize: "10px",
+ action: (p: MenuElementParams) => {
+ if (
+ p.selected === null ||
+ p.selected?.id() === p.myPlayer?.id() ||
+ (!p.playerActions?.interaction?.canEmbargo &&
+ !p.playerActions?.interaction?.canDonateGold)
+ )
+ return;
+ const canEmbargo = !!p.playerActions?.interaction?.canEmbargo;
+ p.playerActionHandler.handleEmbargo(
+ p.selected,
+ canEmbargo ? "start" : "stop",
+ );
+ p.closeMenu();
+ },
+ };
+ }
+
+ // Fallback (should never happen)
+ return {
+ id: "preset-unknown",
+ name: "?",
+ disabled: () => true,
+ color: COLORS.infoDetails,
+ };
+}
+
+/**
+ * The "i" (Info) button — opens a Quick Chat / actions submenu.
+ *
+ * Submenu contains:
+ * 1. User-configured preset slots (up to 5): quickchat, emoji, or trade
+ * 2. A permanent "Info" button that opens the player panel
+ * 3. A permanent "Customize Presets" settings button
+ */
export const infoMenuElement: MenuElement = {
id: Slot.Info,
name: "info",
@@ -374,8 +498,57 @@ export const infoMenuElement: MenuElement = {
!params.selected || params.game.inSpawnPhase(),
icon: infoIcon,
color: COLORS.info,
- action: (params: MenuElementParams) => {
- params.playerPanel.show(params.playerActions, params.tile);
+ subMenu: (params: MenuElementParams): MenuElement[] => {
+ if (params === undefined || !params.selected) return [];
+
+ const recipient = params.selected;
+ const isSelf = params.selected?.id() === params.myPlayer?.id();
+
+ // On own territory: skip chat presets, just show Info and Configure
+ const presets = isSelf ? [] : QuickChatPresetService.getInstance().load();
+
+ const presetItems = presets.map((slot) => buildPresetItem(slot, recipient));
+
+ // Permanent: open the player info panel (original Info button behaviour)
+ const playerInfoItem: MenuElement = {
+ id: "player-info",
+ name: "info",
+ disabled: () => false,
+ icon: infoIcon,
+ color: COLORS.info,
+ tooltipItems: [
+ { text: translateText("quick_chat.player_info"), className: "title" },
+ ],
+ action: (p: MenuElementParams) => {
+ p.playerPanel.show(p.playerActions, p.tile);
+ },
+ };
+
+ // Permanent: open the preset config modal
+ const configureItem: MenuElement = {
+ id: "configure-quick-chat",
+ name: translateText("quick_chat.configure_presets"),
+ disabled: () => false,
+ icon: settingsIcon,
+ color: COLORS.infoDetails,
+ tooltipItems: [
+ {
+ text: translateText("quick_chat.configure_presets"),
+ className: "title",
+ },
+ ],
+ action: (p: MenuElementParams) => {
+ const modal = document.querySelector("quick-chat-config-modal") as any;
+ if (modal) {
+ modal.open();
+ } else {
+ console.error("[QuickChat] quick-chat-config-modal not found");
+ }
+ p.closeMenu();
+ },
+ };
+
+ return [...presetItems, playerInfoItem, configureItem];
},
};
diff --git a/src/client/graphics/layers/TargetSelectionLayer.ts b/src/client/graphics/layers/TargetSelectionLayer.ts
new file mode 100644
index 0000000000..726f109893
--- /dev/null
+++ b/src/client/graphics/layers/TargetSelectionLayer.ts
@@ -0,0 +1,91 @@
+import { LitElement, html } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { EventBus } from "../../../core/EventBus";
+import { GameView } from "../../../core/game/GameView";
+import { MouseOverEvent } from "../../InputHandler";
+import { translateText } from "../../Utils";
+import { TransformHandler } from "../TransformHandler";
+import { Layer } from "./Layer";
+import { TargetSelectionMode } from "./TargetSelectionMode";
+
+/**
+ * While in target-selection mode, renders a small badge that follows the
+ * cursor and sets a pointer cursor on document.body.
+ */
+@customElement("target-selection-layer")
+export class TargetSelectionLayer extends LitElement implements Layer {
+ @state() private isActive = false;
+ @state() private cursorX = 0;
+ @state() private cursorY = 0;
+
+ eventBus!: EventBus;
+ game!: GameView;
+ transformHandler!: TransformHandler;
+
+ private _mouseHandler: ((e: MouseOverEvent) => void) | null = null;
+
+ /** Uses light DOM so the layer shares global game CSS. */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** No-op — subscriptions are set up lazily in tick() when mode activates. */
+ init() {}
+
+ /** Polls TargetSelectionMode each frame; subscribes/unsubscribes the mouse-over handler as mode changes. */
+ tick() {
+ const mode = TargetSelectionMode.getInstance();
+ const wasActive = this.isActive;
+ this.isActive = mode.active;
+
+ if (this.isActive && !wasActive) {
+ this._mouseHandler = (e: MouseOverEvent) => {
+ this.cursorX = e.x;
+ this.cursorY = e.y;
+ this.requestUpdate();
+ };
+ this.eventBus.on(MouseOverEvent, this._mouseHandler);
+ document.body.style.cursor = "pointer";
+ this.requestUpdate();
+ } else if (!this.isActive && wasActive) {
+ if (this._mouseHandler) {
+ this.eventBus.off(MouseOverEvent, this._mouseHandler);
+ this._mouseHandler = null;
+ }
+ document.body.style.cursor = "";
+ this.requestUpdate();
+ }
+ }
+
+ /** Renders a floating badge that follows the cursor while target-selection mode is active. */
+ render() {
+ if (!this.isActive) return html``;
+
+ // Offset badge slightly so it doesn't sit under the cursor tip
+ const x = this.cursorX + 14;
+ const y = this.cursorY - 10;
+
+ return html`
+
+ ${translateText("quick_chat.select_target")}
+
+ `;
+ }
+}
diff --git a/src/client/graphics/layers/TargetSelectionMode.ts b/src/client/graphics/layers/TargetSelectionMode.ts
new file mode 100644
index 0000000000..1acf872874
--- /dev/null
+++ b/src/client/graphics/layers/TargetSelectionMode.ts
@@ -0,0 +1,53 @@
+import { PlayerView } from "../../../core/game/GameView";
+
+/**
+ * Singleton that tracks whether the player is in "pick a target country" mode
+ * after selecting a requiresPlayer quick-chat preset.
+ *
+ * Intentionally has no EventBus dependency — callers poll `active` and call
+ * `enter` / `exit` directly, keeping the state machine simple and testable.
+ */
+export class TargetSelectionMode {
+ private static instance: TargetSelectionMode;
+
+ private _active = false;
+ private _pendingKey: string | null = null;
+ private _pendingRecipient: PlayerView | null = null;
+
+ static getInstance(): TargetSelectionMode {
+ if (!TargetSelectionMode.instance) {
+ TargetSelectionMode.instance = new TargetSelectionMode();
+ }
+ return TargetSelectionMode.instance;
+ }
+
+ get active(): boolean {
+ return this._active;
+ }
+
+ get pendingKey(): string | null {
+ return this._pendingKey;
+ }
+
+ get pendingRecipient(): PlayerView | null {
+ return this._pendingRecipient;
+ }
+
+ /**
+ * Activates target-selection mode.
+ * @param key Full quick-chat key, e.g. "attack.attack"
+ * @param recipient The player whose tile was right-clicked (message recipient)
+ */
+ enter(key: string, recipient: PlayerView): void {
+ this._active = true;
+ this._pendingKey = key;
+ this._pendingRecipient = recipient;
+ }
+
+ /** Deactivates target-selection mode and clears all pending state. */
+ exit(): void {
+ this._active = false;
+ this._pendingKey = null;
+ this._pendingRecipient = null;
+ }
+}
diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts
index e5d72f0e8f..62320bc2e3 100644
--- a/src/client/graphics/layers/UnitLayer.ts
+++ b/src/client/graphics/layers/UnitLayer.ts
@@ -15,6 +15,7 @@ import {
import { MoveWarshipIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
+import { TargetSelectionMode } from "./TargetSelectionMode";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import {
@@ -127,6 +128,7 @@ export class UnitLayer implements Layer {
clickRef?: TileRef,
nearbyWarships?: UnitView[],
) {
+ if (TargetSelectionMode.getInstance().active) return;
if (clickRef === undefined) {
// Convert screen coordinates to world coordinates
const cell = this.transformHandler.screenToWorldCoordinates(
@@ -157,6 +159,7 @@ export class UnitLayer implements Layer {
}
private onTouch(event: TouchEvent) {
+ if (TargetSelectionMode.getInstance().active) return;
const cell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
diff --git a/tests/setup.ts b/tests/setup.ts
index a4b1368ce2..8e81a8ca7f 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -1,2 +1,31 @@
// Add global mocks or configuration here if needed
import "vitest-canvas-mock";
+
+// Provide a fully-functional in-memory localStorage for all tests.
+// jsdom's built-in localStorage can be a no-op stub when the environment
+// is initialised without a valid URL (e.g. the --localstorage-file warning),
+// which causes `removeItem` / `setItem` to be missing or throw.
+const localStorageMock = (() => {
+ let store: Record = {};
+ return {
+ getItem: (key: string) => store[key] ?? null,
+ setItem: (key: string, value: string) => {
+ store[key] = String(value);
+ },
+ removeItem: (key: string) => {
+ delete store[key];
+ },
+ clear: () => {
+ store = {};
+ },
+ get length() {
+ return Object.keys(store).length;
+ },
+ key: (index: number) => Object.keys(store)[index] ?? null,
+ };
+})();
+
+Object.defineProperty(globalThis, "localStorage", {
+ value: localStorageMock,
+ writable: true,
+});