${translateText(
@@ -243,6 +243,13 @@ export class UnitDisplay extends LitElement implements Layer {
${translateText("build_menu.desc." + structureKey)}
+ ${unitType === UnitType.Warship
+ ? html`
+ ⇧ ${translateText("build_menu.warship_shift_hint")}
+
`
+ : null}
this.onMouseUp(e));
this.eventBus.on(TouchEvent, (e) => this.onTouch(e));
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e));
+ this.eventBus.on(WarshipSelectionBoxCompleteEvent, (e) =>
+ this.onSelectionBoxComplete(e),
+ );
+ this.eventBus.on(WarshipSelectionBoxCancelEvent, () =>
+ this.onSelectionBoxCancel(),
+ );
+ this.eventBus.on(CloseViewEvent, () => this.onSelectionBoxCancel());
+ this.eventBus.on(SelectAllWarshipsEvent, () => this.onSelectAllWarships());
this.redraw();
loadAllSprites();
@@ -139,9 +154,24 @@ export class UnitLayer implements Layer {
}
if (!this.game.isWater(clickRef)) return;
+ // If we have multi-selected warships, send them all to this tile
+ if (this.selectedWarships.length > 0) {
+ const myPlayer = this.game.myPlayer();
+ const activeIds = this.selectedWarships
+ .filter((u) => u.isActive() && u.owner() === myPlayer)
+ .map((u) => u.id());
+
+ if (activeIds.length > 0) {
+ this.eventBus.emit(new MoveWarshipIntentEvent(activeIds, clickRef));
+ }
+ this.selectedWarships = [];
+ this.eventBus.emit(new UnitSelectionEvent(null, false));
+ return;
+ }
+
if (this.selectedUnit) {
this.eventBus.emit(
- new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef),
+ new MoveWarshipIntentEvent([this.selectedUnit.id()], clickRef),
);
// Deselect
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
@@ -187,6 +217,12 @@ export class UnitLayer implements Layer {
return;
}
+ // Also delegate if we have multi-selected warships
+ if (this.selectedWarships.length > 0) {
+ this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef);
+ return;
+ }
+
const nearbyWarships = this.findWarshipsNearCell(clickRef);
if (nearbyWarships.length > 0) {
@@ -212,6 +248,66 @@ export class UnitLayer implements Layer {
}
}
+ /**
+ * Handle completion of shift+drag selection box.
+ * Finds all player-owned warships within the screen rectangle.
+ */
+ private onSelectionBoxComplete(event: WarshipSelectionBoxCompleteEvent) {
+ const x1 = Math.min(event.startX, event.endX);
+ const y1 = Math.min(event.startY, event.endY);
+ const x2 = Math.max(event.startX, event.endX);
+ const y2 = Math.max(event.startY, event.endY);
+
+ const myPlayer = this.game.myPlayer();
+ if (!myPlayer) return;
+
+ this.selectedWarships = this.game.units(UnitType.Warship).filter((unit) => {
+ if (!unit.isActive() || unit.owner() !== myPlayer) return false;
+ const screen = this.transformHandler.worldToScreenCoordinates(
+ new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())),
+ );
+ return (
+ screen.x >= x1 && screen.x <= x2 && screen.y >= y1 && screen.y <= y2
+ );
+ });
+
+ // Clear single selection if we got a box selection
+ if (this.selectedWarships.length > 0 && this.selectedUnit) {
+ this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
+ }
+
+ // Notify UILayer to draw selection boxes for all selected warships
+ this.eventBus.emit(
+ new UnitSelectionEvent(null, true, this.selectedWarships),
+ );
+ }
+
+ private onSelectionBoxCancel() {
+ this.selectedWarships = [];
+ this.eventBus.emit(new UnitSelectionEvent(null, false));
+ }
+
+ private onSelectAllWarships() {
+ const myPlayer = this.game.myPlayer();
+ if (!myPlayer) return;
+
+ const allWarships = this.game
+ .units(UnitType.Warship)
+ .filter((u) => u.isActive() && u.owner() === myPlayer);
+
+ if (allWarships.length === 0) return;
+
+ // Clear single selection if active
+ if (this.selectedUnit) {
+ this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
+ }
+
+ this.selectedWarships = allWarships;
+ this.eventBus.emit(
+ new UnitSelectionEvent(null, true, this.selectedWarships),
+ );
+ }
+
/**
* Handle unit deactivation or destruction
* If the selected unit is removed from the game, deselect it
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index ffd5a3e4e..870dcff55 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -416,7 +416,7 @@ export const CancelBoatIntentSchema = z.object({
export const MoveWarshipIntentSchema = z.object({
type: z.literal("move_warship"),
- unitId: z.number(),
+ unitIds: z.array(z.number().int()).nonempty(),
tile: z.number(),
});
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index 009780d3b..ccdb792d6 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -68,7 +68,7 @@ export class Executor {
case "cancel_boat":
return new BoatRetreatExecution(player, intent.unitID);
case "move_warship":
- return new MoveWarshipExecution(player, intent.unitId, intent.tile);
+ return new MoveWarshipExecution(player, intent.unitIds, intent.tile);
case "spawn":
return new SpawnExecution(this.gameID, player.info(), intent.tile);
case "boat":
diff --git a/src/core/execution/MoveWarshipExecution.ts b/src/core/execution/MoveWarshipExecution.ts
index 0a0aad166..648d6eab9 100644
--- a/src/core/execution/MoveWarshipExecution.ts
+++ b/src/core/execution/MoveWarshipExecution.ts
@@ -4,31 +4,36 @@ import { TileRef } from "../game/GameMap";
export class MoveWarshipExecution implements Execution {
constructor(
private readonly owner: Player,
- private readonly unitId: number,
+ private readonly unitIds: number[],
private readonly position: TileRef,
) {}
- init(mg: Game, ticks: number): void {
+ init(mg: Game, _ticks: number): void {
if (!mg.isValidRef(this.position)) {
console.warn(`MoveWarshipExecution: position ${this.position} not valid`);
return;
}
- const warship = this.owner
- .units(UnitType.Warship)
- .find((u) => u.id() === this.unitId);
- if (!warship) {
- console.warn("MoveWarshipExecution: warship not found");
- return;
+ // Cache warship list and build a lookup map — avoids repeated iteration
+ const warshipMap = new Map(
+ this.owner.units(UnitType.Warship).map((u) => [u.id(), u]),
+ );
+ // Deduplicate ids so each warship is only moved once
+ for (const unitId of new Set(this.unitIds)) {
+ const warship = warshipMap.get(unitId);
+ if (!warship) {
+ console.warn(`MoveWarshipExecution: warship ${unitId} not found`);
+ continue;
+ }
+ if (!warship.isActive()) {
+ console.warn(`MoveWarshipExecution: warship ${unitId} is not active`);
+ continue;
+ }
+ warship.setPatrolTile(this.position);
+ warship.setTargetTile(undefined);
}
- if (!warship.isActive()) {
- console.warn("MoveWarshipExecution: warship is not active");
- return;
- }
- warship.setPatrolTile(this.position);
- warship.setTargetTile(undefined);
}
- tick(ticks: number): void {}
+ tick(_ticks: number): void {}
isActive(): boolean {
return false;
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index 158d10894..5d191f06d 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -31,6 +31,7 @@ export function getDefaultKeybinds(isMac: boolean): Record {
altKey: "AltLeft",
shiftKey: "ShiftLeft",
resetGfx: "KeyR",
+ selectAllWarships: "KeyF",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts
index 15185ff20..19633f96a 100644
--- a/tests/InputHandler.test.ts
+++ b/tests/InputHandler.test.ts
@@ -2,6 +2,9 @@ import {
AutoUpgradeEvent,
ConfirmGhostStructureEvent,
InputHandler,
+ WarshipSelectionBoxCancelEvent,
+ WarshipSelectionBoxCompleteEvent,
+ WarshipSelectionBoxUpdateEvent,
} from "../src/client/InputHandler";
import { UIState } from "../src/client/graphics/UIState";
import { EventBus } from "../src/core/EventBus";
@@ -860,3 +863,187 @@ describe("InputHandler AutoUpgrade", () => {
});
});
});
+
+describe("Warship box selection (Shift+drag)", () => {
+ let inputHandler: InputHandler;
+ let eventBus: EventBus;
+ let mockCanvas: HTMLCanvasElement;
+ let uiState: UIState;
+
+ beforeEach(() => {
+ const mockGameView = { inSpawnPhase: () => false } as GameView;
+ mockCanvas = document.createElement("canvas");
+ eventBus = new EventBus();
+ uiState = {
+ attackRatio: 20,
+ ghostStructure: null,
+ rocketDirectionUp: true,
+ overlappingRailroads: [],
+ ghostRailPaths: [],
+ } as UIState;
+ inputHandler = new InputHandler(
+ mockGameView,
+ uiState,
+ mockCanvas,
+ eventBus,
+ );
+ inputHandler.initialize();
+ });
+
+ afterEach(() => {
+ inputHandler.destroy();
+ });
+
+ test("Shift keydown sets canvas cursor to crosshair", () => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
+ expect(mockCanvas.style.cursor).toBe("crosshair");
+ });
+
+ test("ShiftRight keydown also sets cursor to crosshair", () => {
+ // ShiftRight is not the default shiftKey keybind (ShiftLeft is).
+ // This test verifies the configured shiftKey works, not a hardcoded ShiftRight.
+ window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
+ expect(mockCanvas.style.cursor).toBe("crosshair");
+ });
+
+ test("Shift keyup resets cursor when no selection box active", () => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
+ window.dispatchEvent(new KeyboardEvent("keyup", { code: "ShiftLeft" }));
+ expect(mockCanvas.style.cursor).toBe("");
+ });
+
+ test("Shift keydown discards active ghostStructure", () => {
+ uiState.ghostStructure = UnitType.Warship;
+ const emitSpy = vi.spyOn(eventBus, "emit");
+
+ window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
+
+ expect(uiState.ghostStructure).toBeNull();
+ const types = emitSpy.mock.calls.map((c) => c[0].constructor.name);
+ expect(types).toContain("GhostStructureChangedEvent");
+ });
+
+ test("Shift+drag emits WarshipSelectionBoxUpdateEvent", () => {
+ const listener = vi.fn();
+ eventBus.on(WarshipSelectionBoxUpdateEvent, listener);
+
+ inputHandler["onPointerDown"](
+ new PointerEvent("pointerdown", {
+ button: 0,
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ }),
+ );
+ inputHandler["activeKeys"].add("ShiftLeft");
+ inputHandler["onPointerMove"](
+ new PointerEvent("pointermove", {
+ button: 0,
+ clientX: 200,
+ clientY: 200,
+ pointerId: 1,
+ }),
+ );
+
+ expect(listener).toHaveBeenCalledWith(
+ expect.objectContaining({
+ startX: 100,
+ startY: 100,
+ endX: 200,
+ endY: 200,
+ }),
+ );
+ });
+
+ test("Shift+drag then pointerup emits WarshipSelectionBoxCompleteEvent", () => {
+ const listener = vi.fn();
+ eventBus.on(WarshipSelectionBoxCompleteEvent, listener);
+
+ inputHandler["onPointerDown"](
+ new PointerEvent("pointerdown", {
+ button: 0,
+ clientX: 50,
+ clientY: 50,
+ pointerId: 1,
+ }),
+ );
+ inputHandler["activeKeys"].add("ShiftLeft");
+ inputHandler["onPointerMove"](
+ new PointerEvent("pointermove", {
+ button: 0,
+ clientX: 200,
+ clientY: 200,
+ pointerId: 1,
+ }),
+ );
+ expect(inputHandler["selectionBoxActive"]).toBe(true);
+
+ inputHandler["onPointerUp"](
+ new PointerEvent("pointerup", {
+ button: 0,
+ clientX: 200,
+ clientY: 200,
+ pointerId: 1,
+ }),
+ );
+
+ expect(listener).toHaveBeenCalledWith(
+ expect.objectContaining({ startX: 50, startY: 50, endX: 200, endY: 200 }),
+ );
+ expect(inputHandler["selectionBoxActive"]).toBe(false);
+ });
+
+ test("Escape cancels active selection box", () => {
+ const listener = vi.fn();
+ eventBus.on(WarshipSelectionBoxCancelEvent, listener);
+
+ inputHandler["selectionBoxActive"] = true;
+ window.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" }));
+
+ expect(listener).toHaveBeenCalled();
+ expect(inputHandler["selectionBoxActive"]).toBe(false);
+ });
+
+ test("tiny drag (< 10px) cancels selection box instead of completing it", () => {
+ const cancelListener = vi.fn();
+ const completeListener = vi.fn();
+ eventBus.on(WarshipSelectionBoxCancelEvent, cancelListener);
+ eventBus.on(WarshipSelectionBoxCompleteEvent, completeListener);
+
+ inputHandler["onPointerDown"](
+ new PointerEvent("pointerdown", {
+ button: 0,
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ }),
+ );
+ inputHandler["activeKeys"].add("ShiftLeft");
+ inputHandler["onPointerMove"](
+ new PointerEvent("pointermove", {
+ button: 0,
+ clientX: 104,
+ clientY: 104,
+ pointerId: 1,
+ }),
+ );
+ inputHandler["onPointerUp"](
+ new PointerEvent("pointerup", {
+ button: 0,
+ clientX: 104,
+ clientY: 104,
+ pointerId: 1,
+ }),
+ );
+
+ expect(cancelListener).toHaveBeenCalled();
+ expect(completeListener).not.toHaveBeenCalled();
+ });
+
+ test("window blur resets cursor", () => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
+ expect(mockCanvas.style.cursor).toBe("crosshair");
+ window.dispatchEvent(new Event("blur"));
+ expect(mockCanvas.style.cursor).toBe("");
+ });
+});
diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts
index 33e0dfc88..4ea53f9fb 100644
--- a/tests/Warship.test.ts
+++ b/tests/Warship.test.ts
@@ -164,7 +164,11 @@ describe("Warship", () => {
game.addExecution(new WarshipExecution(warship));
game.addExecution(
- new MoveWarshipExecution(player1, warship.id(), game.ref(coastX + 5, 15)),
+ new MoveWarshipExecution(
+ player1,
+ [warship.id()],
+ game.ref(coastX + 5, 15),
+ ),
);
executeTicks(game, 10);
@@ -244,7 +248,7 @@ describe("Warship", () => {
);
new MoveWarshipExecution(
player2,
- warship.id(),
+ [warship.id()],
game.ref(coastX + 5, 15),
).init(game, 0);
expect(warship.patrolTile()).toBe(originalPatrolTile);
@@ -262,7 +266,7 @@ describe("Warship", () => {
warship.delete();
new MoveWarshipExecution(
player1,
- warship.id(),
+ [warship.id()],
game.ref(coastX + 5, 15),
).init(game, 0);
expect(warship.patrolTile()).toBe(originalPatrolTile);
@@ -271,7 +275,7 @@ describe("Warship", () => {
test("MoveWarshipExecution fails gracefully if warship not found", async () => {
const exec = new MoveWarshipExecution(
player1,
- 123,
+ [123],
game.ref(coastX + 5, 15),
);
diff --git a/tests/WarshipMultiSelection.test.ts b/tests/WarshipMultiSelection.test.ts
new file mode 100644
index 000000000..0f5e9009e
--- /dev/null
+++ b/tests/WarshipMultiSelection.test.ts
@@ -0,0 +1,144 @@
+import { MoveWarshipExecution } from "../src/core/execution/MoveWarshipExecution";
+import { WarshipExecution } from "../src/core/execution/WarshipExecution";
+import {
+ Game,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+} from "../src/core/game/Game";
+import { setup } from "./util/Setup";
+import { executeTicks } from "./util/utils";
+
+const coastX = 7;
+let game: Game;
+let player1: Player;
+let player2: Player;
+
+describe("Warship multi-selection (MoveWarshipExecution)", () => {
+ beforeEach(async () => {
+ game = await setup(
+ "half_land_half_ocean",
+ { infiniteGold: true, instantBuild: true },
+ [
+ new PlayerInfo("p1", PlayerType.Human, null, "p1"),
+ new PlayerInfo("p2", PlayerType.Human, null, "p2"),
+ ],
+ );
+ while (game.inSpawnPhase()) game.executeNextTick();
+ player1 = game.player("p1");
+ player2 = game.player("p2");
+ });
+
+ test("moving multiple warships via array MoveWarshipExecution updates all patrol tiles", () => {
+ const w1 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 1, 10), {
+ patrolTile: game.ref(coastX + 1, 10),
+ });
+ const w2 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 2, 10), {
+ patrolTile: game.ref(coastX + 2, 10),
+ });
+ const w3 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 3, 10), {
+ patrolTile: game.ref(coastX + 3, 10),
+ });
+
+ game.addExecution(new WarshipExecution(w1));
+ game.addExecution(new WarshipExecution(w2));
+ game.addExecution(new WarshipExecution(w3));
+
+ const sharedTarget = game.ref(coastX + 5, 15);
+ // Single execution with array of ids — the new unified API
+ game.addExecution(
+ new MoveWarshipExecution(
+ player1,
+ [w1.id(), w2.id(), w3.id()],
+ sharedTarget,
+ ),
+ );
+
+ executeTicks(game, 5);
+
+ expect(w1.patrolTile()).toBe(sharedTarget);
+ expect(w2.patrolTile()).toBe(sharedTarget);
+ expect(w3.patrolTile()).toBe(sharedTarget);
+ });
+
+ test("moving multiple warships to different targets works independently", () => {
+ const w1 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 1, 10), {
+ patrolTile: game.ref(coastX + 1, 10),
+ });
+ const w2 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 2, 10), {
+ patrolTile: game.ref(coastX + 2, 10),
+ });
+
+ game.addExecution(new WarshipExecution(w1));
+ game.addExecution(new WarshipExecution(w2));
+
+ const target1 = game.ref(coastX + 3, 12);
+ const target2 = game.ref(coastX + 4, 14);
+
+ game.addExecution(new MoveWarshipExecution(player1, [w1.id()], target1));
+ game.addExecution(new MoveWarshipExecution(player1, [w2.id()], target2));
+
+ executeTicks(game, 5);
+
+ expect(w1.patrolTile()).toBe(target1);
+ expect(w2.patrolTile()).toBe(target2);
+ });
+
+ test("enemy cannot move player's warships via MoveWarshipExecution", () => {
+ const originalTile = game.ref(coastX + 1, 10);
+ const w1 = player1.buildUnit(UnitType.Warship, originalTile, {
+ patrolTile: originalTile,
+ });
+ game.addExecution(new WarshipExecution(w1));
+
+ new MoveWarshipExecution(player2, [w1.id()], game.ref(coastX + 5, 15)).init(
+ game,
+ 0,
+ );
+
+ expect(w1.patrolTile()).toBe(originalTile);
+ });
+
+ test("MoveWarshipExecution on destroyed warship does not throw", () => {
+ const w1 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 1, 10), {
+ patrolTile: game.ref(coastX + 1, 10),
+ });
+ w1.delete();
+
+ const exec = new MoveWarshipExecution(
+ player1,
+ [w1.id()],
+ game.ref(coastX + 5, 15),
+ );
+ expect(() => exec.init(game, 0)).not.toThrow();
+ expect(exec.isActive()).toBe(false);
+ });
+
+ test("batch move does not affect warships owned by other players", () => {
+ const p1tile = game.ref(coastX + 1, 10);
+ const p2tile = game.ref(coastX + 2, 10);
+
+ const w1 = player1.buildUnit(UnitType.Warship, p1tile, {
+ patrolTile: p1tile,
+ });
+ const w2 = player2.buildUnit(UnitType.Warship, p2tile, {
+ patrolTile: p2tile,
+ });
+
+ game.addExecution(new WarshipExecution(w1));
+ game.addExecution(new WarshipExecution(w2));
+
+ const target = game.ref(coastX + 5, 15);
+
+ // player1 sends both IDs — but w2 belongs to player2
+ game.addExecution(
+ new MoveWarshipExecution(player1, [w1.id(), w2.id()], target),
+ );
+
+ executeTicks(game, 5);
+
+ expect(w1.patrolTile()).toBe(target);
+ expect(w2.patrolTile()).toBe(p2tile); // unchanged — wrong owner
+ });
+});