{
@@ -290,13 +292,23 @@ export class UnitDisplay extends LitElement implements Controller {
@mouseleave=${() =>
this.eventBus?.emit(new ToggleStructureEvent(null))}
>
- ${html`
+ ${html`
${displayHotkey}
`}
-
-

+
+

${number !== null
- ? html`
${renderNumber(number)}`
+ ? html`
${renderNumber(number)}`
: null}
diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts
index 5d7c86aee3..5931ebb686 100644
--- a/tests/InputHandler.test.ts
+++ b/tests/InputHandler.test.ts
@@ -3,6 +3,9 @@ import {
ConfirmGhostStructureEvent,
ContextMenuEvent,
InputHandler,
+ TouchGhostPlacementDragStartEvent,
+ TouchGhostPlacementEvent,
+ TouchGhostPlacementMoveEvent,
UnitSelectionEvent,
WarshipSelectionBoxCancelEvent,
WarshipSelectionBoxCompleteEvent,
@@ -1171,6 +1174,141 @@ describe("Warship box selection (Shift+drag)", () => {
});
});
+describe("mobile tap-preview placement", () => {
+ let inputHandler: InputHandler;
+ let eventBus: EventBus;
+ let uiState: UIState;
+
+ beforeEach(() => {
+ eventBus = new EventBus();
+ uiState = {
+ attackRatio: 20,
+ ghostStructure: UnitType.City,
+ rocketDirectionUp: true,
+ } as UIState;
+ inputHandler = new InputHandler(
+ { inSpawnPhase: () => false } as GameView,
+ uiState,
+ document.createElement("canvas"),
+ eventBus,
+ );
+ });
+
+ afterEach(() => {
+ inputHandler.destroy();
+ });
+
+ test("touch taps are delegated to the preview controller", () => {
+ const placements: TouchGhostPlacementEvent[] = [];
+ eventBus.on(TouchGhostPlacementEvent, (event) => placements.push(event));
+
+ const tap = (x: number, y: number, pointerId: number) => {
+ inputHandler["onPointerDown"](
+ new PointerEvent("pointerdown", {
+ button: 0,
+ clientX: x,
+ clientY: y,
+ pointerId,
+ pointerType: "touch",
+ }),
+ );
+ inputHandler["onPointerUp"](
+ new PointerEvent("pointerup", {
+ button: 0,
+ clientX: x,
+ clientY: y,
+ pointerId,
+ pointerType: "touch",
+ }),
+ );
+ };
+
+ tap(100, 110, 1);
+ tap(140, 150, 2);
+
+ expect(placements).toEqual([
+ expect.objectContaining({ x: 100, y: 110 }),
+ expect.objectContaining({ x: 140, y: 150 }),
+ ]);
+ });
+
+ test("movement starting on the preview drags it without a hold delay", () => {
+ const moves: TouchGhostPlacementMoveEvent[] = [];
+ eventBus.on(TouchGhostPlacementDragStartEvent, (event) => {
+ event.handled = true;
+ });
+ eventBus.on(TouchGhostPlacementMoveEvent, (event) => moves.push(event));
+
+ inputHandler["onPointerDown"](
+ new PointerEvent("pointerdown", {
+ button: 0,
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ pointerType: "touch",
+ }),
+ );
+ inputHandler["onPointerMove"](
+ new PointerEvent("pointermove", {
+ button: 0,
+ clientX: 104,
+ clientY: 104,
+ pointerId: 1,
+ pointerType: "touch",
+ }),
+ );
+ expect(moves).toHaveLength(0);
+
+ inputHandler["onPointerMove"](
+ new PointerEvent("pointermove", {
+ button: 0,
+ clientX: 125,
+ clientY: 130,
+ pointerId: 1,
+ pointerType: "touch",
+ }),
+ );
+
+ expect(moves).toEqual([expect.objectContaining({ x: 125, y: 130 })]);
+ });
+
+ test("a touch-generated context menu does not cancel placement", () => {
+ inputHandler["onPointerDown"](
+ new PointerEvent("pointerdown", {
+ button: 0,
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ pointerType: "touch",
+ }),
+ );
+
+ inputHandler["onContextMenu"](
+ new MouseEvent("contextmenu", { clientX: 100, clientY: 100 }),
+ );
+
+ expect(uiState.ghostStructure).toBe(UnitType.City);
+ });
+
+ test("a mouse context menu still cancels placement", () => {
+ inputHandler["onPointerDown"](
+ new PointerEvent("pointerdown", {
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ pointerId: 1,
+ pointerType: "mouse",
+ }),
+ );
+
+ inputHandler["onContextMenu"](
+ new MouseEvent("contextmenu", { clientX: 100, clientY: 100 }),
+ );
+
+ expect(uiState.ghostStructure).toBeNull();
+ });
+});
+
describe("InputHandler right-click cancels unit selection (#4692)", () => {
let inputHandler: InputHandler;
let eventBus: EventBus;
diff --git a/tests/client/controllers/BuildPreviewController.test.ts b/tests/client/controllers/BuildPreviewController.test.ts
index 267997cb46..227d4eaa87 100644
--- a/tests/client/controllers/BuildPreviewController.test.ts
+++ b/tests/client/controllers/BuildPreviewController.test.ts
@@ -1,8 +1,15 @@
-import { describe, expect, test } from "vitest";
+import { describe, expect, test, vi } from "vitest";
import {
+ BuildPreviewController,
samThreatensNukePreview,
shouldPreserveGhostAfterBuild,
} from "../../../src/client/controllers/BuildPreviewController";
+import {
+ MouseUpEvent,
+ TouchGhostPlacementEvent,
+} from "../../../src/client/InputHandler";
+import { SendUpgradeStructureIntentEvent } from "../../../src/client/Transport";
+import { EventBus } from "../../../src/core/EventBus";
import { UnitType } from "../../../src/core/game/Game";
describe("BuildPreviewController ghost preservation (locked nuke / Enter confirm)", () => {
@@ -72,3 +79,115 @@ describe("samThreatensNukePreview (nuke trajectory threat set, #4226)", () => {
).toBe(false);
});
});
+
+describe("BuildPreviewController confirmation validation", () => {
+ test("uses canUpgrade from the tapped tile instead of the cached preview", async () => {
+ const eventBus = new EventBus();
+ const upgrades: SendUpgradeStructureIntentEvent[] = [];
+ eventBus.on(SendUpgradeStructureIntentEvent, (event) =>
+ upgrades.push(event),
+ );
+ const buildables = vi.fn().mockResolvedValue([
+ {
+ type: UnitType.City,
+ canBuild: false,
+ canUpgrade: 22,
+ cost: 0n,
+ overlappingRailroads: [],
+ ghostRailPaths: [],
+ },
+ ]);
+ const uiState = {
+ ghostStructure: UnitType.City,
+ upgradeMultiplier: 1,
+ };
+ const controller = new BuildPreviewController(
+ {
+ myPlayer: () => ({ buildables }),
+ isValidCoord: () => true,
+ ref: () => 123,
+ isImpassable: () => false,
+ } as any,
+ eventBus,
+ uiState as any,
+ { screenToWorldCoordinates: () => ({ x: 4, y: 5 }) } as any,
+ {
+ updateGhostPreview: vi.fn(),
+ updateNukeTrajectory: vi.fn(),
+ } as any,
+ { nukeAllianceSafetyDuration: () => 0 } as any,
+ );
+ (controller as any).ghostUnit = {
+ buildableUnit: {
+ type: UnitType.City,
+ canBuild: false,
+ canUpgrade: 11,
+ },
+ };
+
+ (controller as any).requestConfirmStructure(new MouseUpEvent(40, 50));
+ await vi.waitFor(() => expect(upgrades).toHaveLength(1));
+
+ expect(buildables).toHaveBeenCalledWith(123, [UnitType.City]);
+ expect(upgrades[0].unitId).toBe(22);
+ });
+
+ test("a distant tap moves the anchored preview and a nearby tap confirms it", async () => {
+ const eventBus = new EventBus();
+ const buildables = vi.fn().mockResolvedValue([
+ {
+ type: UnitType.City,
+ canBuild: true,
+ canUpgrade: false,
+ cost: 0n,
+ overlappingRailroads: [],
+ ghostRailPaths: [],
+ },
+ ]);
+ const uiState = { ghostStructure: UnitType.City, upgradeMultiplier: 1 };
+ const controller = new BuildPreviewController(
+ {
+ myPlayer: () => ({ buildables }),
+ isValidCoord: () => true,
+ ref: (x: number, y: number) => x * 100 + y,
+ x: (ref: number) => Math.floor(ref / 100),
+ y: (ref: number) => ref % 100,
+ isImpassable: () => false,
+ } as any,
+ eventBus,
+ uiState as any,
+ {
+ screenToWorldCoordinates: (x: number) =>
+ x < 150 ? { x: 1, y: 1 } : { x: 2, y: 2 },
+ worldToScreenCoordinates: (cell: { x: number }) =>
+ cell.x < 2 ? { x: 100, y: 100 } : { x: 200, y: 200 },
+ } as any,
+ {
+ updateGhostPreview: vi.fn(),
+ updateNukeTrajectory: vi.fn(),
+ } as any,
+ { nukeAllianceSafetyDuration: () => 0 } as any,
+ );
+ (controller as any).ghostUnit = {
+ buildableUnit: { type: UnitType.City },
+ };
+
+ (controller as any).handleTouchPlacement(
+ new TouchGhostPlacementEvent(100, 100),
+ );
+ expect((controller as any).touchPreviewTile).toBe(101);
+
+ (controller as any).handleTouchPlacement(
+ new TouchGhostPlacementEvent(200, 200),
+ );
+ expect((controller as any).touchPreviewTile).toBe(202);
+ expect(buildables).not.toHaveBeenCalled();
+
+ (controller as any).handleTouchPlacement(
+ new TouchGhostPlacementEvent(210, 210),
+ );
+ await vi.waitFor(() =>
+ expect(buildables).toHaveBeenCalledWith(202, [UnitType.City]),
+ );
+ });
+});