From 9a45e09d0df6927d5992c32575b990ef1b0a0cee Mon Sep 17 00:00:00 2001 From: Pesinario Date: Thu, 10 Sep 2026 09:12:50 -0300 Subject: [PATCH 01/10] Initial re-implementation of click+hold behavior --- src/client/InputHandler.ts | 68 ++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 312891bc98..316eed3059 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -247,6 +247,17 @@ export class InputHandler { private suppressNextTap: boolean = false; private readonly LONG_PRESS_MS = 800; + // Wait in MS before assuming mouse stationary. + private readonly HOLD_POINTER_WAIT_MS = 100; + private clickHoldPastGrace = false; + private clickHoldGrace: ReturnType | null = null; + // Wait in MS before starting repeat + private readonly HOLD_SECOND_ACTION_DELAY_MS = 150; + private clickHoldEnsureIntent: ReturnType | null = null; + // Repeated trigger behavior + private readonly HOLD_REPEATED_ACTION_TRIGGER_RATE = 90; // hold-to-deploy firerate (multiplier affects this) + private clickHoldRepeat: ReturnType | null = null; + private moveInterval: NodeJS.Timeout | null = null; private activeKeys = new Set(); private keybinds: Record = {}; @@ -760,6 +771,7 @@ export class InputHandler { this.lastPointerDownY = event.clientY; this.eventBus.emit(new MouseDownEvent(event.clientX, event.clientY)); + this.clickHold(); // Start long-press timer for touch devices if (event.pointerType === "touch") { @@ -806,6 +818,7 @@ export class InputHandler { } this.pointerDown = false; this.pointers.clear(); + this.clickHoldCleanup(); // Clean up long-press state if (this.longPressTimer !== null) { @@ -963,16 +976,20 @@ export class InputHandler { if (this.pointers.size === 1) { const deltaX = event.clientX - this.lastPointerX; const deltaY = event.clientY - this.lastPointerY; + const moveDist = + Math.abs(event.clientX - this.lastPointerDownX) + + Math.abs(event.clientY - this.lastPointerDownY); - // Cancel long-press if finger moved significantly before timer fires - if (this.longPressTimer !== null) { - const moveDist = - Math.abs(event.clientX - this.lastPointerDownX) + - Math.abs(event.clientY - this.lastPointerDownY); - if (moveDist >= this.DRAG_THRESHOLD_PX) { + if (moveDist >= this.DRAG_THRESHOLD_PX) { + // Cancel long-press if finger moved significantly before timer fires + if (this.longPressTimer !== null) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } + // Cancel clickHold if dragged quickly + if (!this.clickHoldPastGrace) { + this.clickHoldCleanup(); + } } // If shift is held OR touch long-press is active OR selection box already @@ -1188,6 +1205,45 @@ export class InputHandler { return false; } + private clickHold() { + if (this.uiState.ghostStructure === null) { + this.clickHoldCleanup(); + return; + } + const repeatBehavior = () => { + this.eventBus.emit(new ConfirmGhostStructureEvent()); + }; + + // first: ensure grace period for click+drag has passed + if (!this.clickHoldPastGrace) { + this.clickHoldGrace = setTimeout(() => { + // if nothing stopped this externally + this.clickHoldPastGrace = true; + }, this.HOLD_POINTER_WAIT_MS); + } + // second: launch first event, and wait before repeating + repeatBehavior(); + this.clickHoldEnsureIntent = setTimeout(() => { + // if mouse still held down, begin repeated events + this.clickHoldRepeat = setInterval(() => { + repeatBehavior(); + }, this.HOLD_REPEATED_ACTION_TRIGGER_RATE); + }, this.HOLD_SECOND_ACTION_DELAY_MS); + } + + private clickHoldCleanup() { + this.clickHoldPastGrace = false; + if (this.clickHoldGrace !== null) { + clearTimeout(this.clickHoldGrace); + } + if (this.clickHoldEnsureIntent !== null) { + clearTimeout(this.clickHoldEnsureIntent); + } + if (this.clickHoldRepeat !== null) { + clearInterval(this.clickHoldRepeat); + } + } + destroy() { if (this.moveInterval !== null) { clearInterval(this.moveInterval); From 507bfec2ccc89f3d934a3ad048b4e57e3d065869 Mon Sep 17 00:00:00 2001 From: Pesinario Date: Thu, 10 Sep 2026 14:59:24 -0300 Subject: [PATCH 02/10] Complete implementation with tests --- src/client/InputHandler.ts | 57 +++++---- tests/InputHandler.test.ts | 242 +++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 24 deletions(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 316eed3059..18ee33128e 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -248,14 +248,14 @@ export class InputHandler { private readonly LONG_PRESS_MS = 800; // Wait in MS before assuming mouse stationary. - private readonly HOLD_POINTER_WAIT_MS = 100; - private clickHoldPastGrace = false; + public readonly HOLD_POINTER_WAIT_MS = 100; + private isClickHoldPastGrace = false; private clickHoldGrace: ReturnType | null = null; // Wait in MS before starting repeat - private readonly HOLD_SECOND_ACTION_DELAY_MS = 150; + public readonly HOLD_SECOND_ACTION_DELAY_MS = 1500; private clickHoldEnsureIntent: ReturnType | null = null; // Repeated trigger behavior - private readonly HOLD_REPEATED_ACTION_TRIGGER_RATE = 90; // hold-to-deploy firerate (multiplier affects this) + public readonly HOLD_REPEATED_ACTION_TRIGGER_RATE = 90; // hold-to-deploy firerate (multiplier affects this) private clickHoldRepeat: ReturnType | null = null; private moveInterval: NodeJS.Timeout | null = null; @@ -987,7 +987,7 @@ export class InputHandler { this.longPressTimer = null; } // Cancel clickHold if dragged quickly - if (!this.clickHoldPastGrace) { + if (!this.isClickHoldPastGrace) { this.clickHoldCleanup(); } } @@ -1206,33 +1206,41 @@ export class InputHandler { } private clickHold() { - if (this.uiState.ghostStructure === null) { - this.clickHoldCleanup(); - return; - } + // for redefining valid ghosts + const isValidTarget = () => { + if (this.uiState.ghostStructure === null) { + return false; + } else { + return true; + } + }; + const repeatBehavior = () => { - this.eventBus.emit(new ConfirmGhostStructureEvent()); + isValidTarget() + ? this.eventBus.emit(new ConfirmGhostStructureEvent()) + : this.clickHoldCleanup(); }; // first: ensure grace period for click+drag has passed - if (!this.clickHoldPastGrace) { - this.clickHoldGrace = setTimeout(() => { - // if nothing stopped this externally - this.clickHoldPastGrace = true; - }, this.HOLD_POINTER_WAIT_MS); - } - // second: launch first event, and wait before repeating - repeatBehavior(); - this.clickHoldEnsureIntent = setTimeout(() => { - // if mouse still held down, begin repeated events - this.clickHoldRepeat = setInterval(() => { + this.clickHoldGrace = setTimeout(() => { + this.isClickHoldPastGrace = true; + // second: launch first event, and wait before repeating + repeatBehavior(); + // finally, we are past initial hold delay + // and have launched the first event. + this.clickHoldEnsureIntent = setTimeout(() => { + // if mouse still held down, begin repeated events + // HOWEVER: we do not need to delay the first repeat behavior repeatBehavior(); - }, this.HOLD_REPEATED_ACTION_TRIGGER_RATE); - }, this.HOLD_SECOND_ACTION_DELAY_MS); + this.clickHoldRepeat = setInterval(() => { + repeatBehavior(); + }, this.HOLD_REPEATED_ACTION_TRIGGER_RATE); + }, this.HOLD_SECOND_ACTION_DELAY_MS); + }, this.HOLD_POINTER_WAIT_MS); } private clickHoldCleanup() { - this.clickHoldPastGrace = false; + this.isClickHoldPastGrace = false; if (this.clickHoldGrace !== null) { clearTimeout(this.clickHoldGrace); } @@ -1248,6 +1256,7 @@ export class InputHandler { if (this.moveInterval !== null) { clearInterval(this.moveInterval); } + this.clickHoldCleanup(); this.activeKeys.clear(); this.lastGestureScale = null; this.keybindAndEvent = []; diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 5d7c86aee3..19cb7e6b58 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -992,6 +992,248 @@ describe("InputHandler AutoUpgrade", () => { }); }); +describe("Click and hold when ghost is bomb", () => { + let inputHandler: InputHandler; + let mockGameView: GameView; + let eventBus: EventBus; + let mockCanvas: HTMLCanvasElement; + let uiState: UIState; + + beforeEach(() => { + mockGameView = { + inSpawnPhase: () => false, + myPlayer: () => ({ isAlive: () => true }), + } as GameView; + mockCanvas = document.createElement("canvas"); + mockCanvas.width = 800; + mockCanvas.height = 600; + + eventBus = new EventBus(); + uiState = { + attackRatio: 20, + ghostStructure: UnitType.AtomBomb, + rocketDirectionUp: true, + upgradeMultiplier: 1, + } as UIState; + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + }); + + afterEach(() => { + inputHandler.destroy(); + }); + + test("does not prevent single-click behavior within grace period", () => { + vi.useFakeTimers(); + const mockEmit = vi.spyOn(eventBus, "emit"); + + const downEvent = new PointerEvent("pointerdown", { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + }); + const upEvent = new PointerEvent("pointerup", { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + }); + inputHandler["onPointerDown"](downEvent); + + vi.advanceTimersByTime(inputHandler.HOLD_POINTER_WAIT_MS - 1); + inputHandler["onPointerUp"](upEvent); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("MouseUpEvent"); + vi.useRealTimers(); + }); + + test("triggers events on expected timeline when fully stationary", () => { + vi.useFakeTimers(); + const mockEmit = vi.spyOn(eventBus, "emit"); + let el = 0; + let multi = 15; + + const downEvent = new PointerEvent("pointerdown", { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + }); + + inputHandler["onPointerDown"](downEvent); + + vi.advanceTimersByTime(inputHandler.HOLD_POINTER_WAIT_MS - 1); + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + vi.advanceTimersByTime(2); + el = el + 1; + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + vi.advanceTimersByTime(inputHandler.HOLD_SECOND_ACTION_DELAY_MS); + el = el + 1; + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + vi.advanceTimersByTime(inputHandler.HOLD_REPEATED_ACTION_TRIGGER_RATE); + el = el + 1; + + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + vi.advanceTimersByTime( + inputHandler.HOLD_REPEATED_ACTION_TRIGGER_RATE * multi, + ); + el = el + multi; + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("MouseDownEvent"); + expect(emittedTypes).toContain("ConfirmGhostStructureEvent"); + + vi.useRealTimers(); + }); + + test("triggers event on expected timeline when drag started after grace period", () => { + vi.useFakeTimers(); + const mockEmit = vi.spyOn(eventBus, "emit"); + let el = 0; + let multi = 15; + + const downEvent = new PointerEvent("pointerdown", { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + }); + + const moveEvent = new PointerEvent("pointermove", { + button: 0, + clientX: 130, // 30px move > DRAG_THRESHOLD_PX (10) + clientY: 100, + pointerId: 1, + }); + + inputHandler["onPointerMove"](moveEvent); + inputHandler["onPointerDown"](downEvent); + + vi.advanceTimersByTime(inputHandler.HOLD_POINTER_WAIT_MS - 1); + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + vi.advanceTimersByTime(2); + inputHandler["onPointerMove"](moveEvent); + el = el + 1; + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + vi.advanceTimersByTime(inputHandler.HOLD_SECOND_ACTION_DELAY_MS); + el = el + 1; + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + vi.advanceTimersByTime(inputHandler.HOLD_REPEATED_ACTION_TRIGGER_RATE); + el = el + 1; + + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + vi.advanceTimersByTime( + inputHandler.HOLD_REPEATED_ACTION_TRIGGER_RATE * multi, + ); + el = el + multi; + expect( + mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ), + ).toHaveLength(el); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("MouseDownEvent"); + expect(emittedTypes).toContain("ConfirmGhostStructureEvent"); + + vi.useRealTimers(); + }); + + test("clickHold does nothing when pointer moved before the grace period completes", () => { + vi.useFakeTimers(); + const mockEmit = vi.spyOn(eventBus, "emit"); + + const downEvent = new PointerEvent("pointerdown", { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + }); + + const moveEvent = new PointerEvent("pointermove", { + button: 0, + clientX: 130, // 30px move > DRAG_THRESHOLD_PX (10) + clientY: 100, + pointerId: 1, + }); + + inputHandler["onPointerDown"](downEvent); + inputHandler["onPointerMove"](moveEvent); + + vi.advanceTimersByTime(inputHandler.HOLD_POINTER_WAIT_MS - 1); + inputHandler["onPointerMove"](moveEvent); + + vi.advanceTimersByTime( + inputHandler.HOLD_REPEATED_ACTION_TRIGGER_RATE + + inputHandler.HOLD_REPEATED_ACTION_TRIGGER_RATE, + ); + + const confirmCalls = mockEmit.mock.calls.filter( + ([event]) => event instanceof ConfirmGhostStructureEvent, + ); + expect(confirmCalls).toHaveLength(0); + vi.useRealTimers(); + }); +}); + describe("Warship box selection (Shift+drag)", () => { let inputHandler: InputHandler; let eventBus: EventBus; From 23680c4d12b52d2b4ca4a7feb6e5718c71e09601 Mon Sep 17 00:00:00 2001 From: Pesinario Date: Fri, 11 Sep 2026 21:18:24 -0300 Subject: [PATCH 03/10] Improvement on performance and bug prevention via guard clause --- src/client/InputHandler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index a043a0606e..fe1018e63f 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1250,6 +1250,11 @@ export class InputHandler { } }; + // Saves performance via guard clause and prevents some potential bugs + if (!isValidTarget()) { + return; + } + const repeatBehavior = () => { isValidTarget() ? this.eventBus.emit(new ConfirmGhostStructureEvent()) From 49a93f312f4e3a2a79db15e790fff1fd0ed7c8dd Mon Sep 17 00:00:00 2001 From: Pesinario Date: Fri, 11 Sep 2026 21:28:45 -0300 Subject: [PATCH 04/10] Refactors into switch statement; limited to bombs. --- src/client/InputHandler.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index fe1018e63f..01d3b52682 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1243,10 +1243,13 @@ export class InputHandler { private clickHold() { // for redefining valid ghosts const isValidTarget = () => { - if (this.uiState.ghostStructure === null) { - return false; - } else { - return true; + switch (this.uiState.ghostStructure) { + case UnitType.AtomBomb: + case UnitType.HydrogenBomb: + // MIRV seemed excessive to click hold. + return true; + default: + return false; } }; From 7f850470954c26a0ecf045321485a70845ffb608 Mon Sep 17 00:00:00 2001 From: Pesinario Date: Fri, 11 Sep 2026 21:34:16 -0300 Subject: [PATCH 05/10] Limits implementation to mouse only --- src/client/InputHandler.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 01d3b52682..5fbd2410fd 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -797,8 +797,10 @@ export class InputHandler { this.lastPointerDownY = event.clientY; this.eventBus.emit(new MouseDownEvent(event.clientX, event.clientY)); - this.clickHold(); - + // clickHold only for real mouse + if (event.pointerType === "mouse") { + this.clickHold(); + } // Start long-press timer for touch devices if (event.pointerType === "touch") { this.longPressActive = false; From ceccca4ca5096d4dd25d657f6c16c6a564f1644d Mon Sep 17 00:00:00 2001 From: Pesinario Date: Fri, 11 Sep 2026 21:44:46 -0300 Subject: [PATCH 06/10] lint --- src/client/InputHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 5fbd2410fd..f52d76eeca 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1248,7 +1248,7 @@ export class InputHandler { switch (this.uiState.ghostStructure) { case UnitType.AtomBomb: case UnitType.HydrogenBomb: - // MIRV seemed excessive to click hold. + // MIRV seemed excessive to click hold. return true; default: return false; From dbf155e133e660bcaa91624419be8ceeab7169a0 Mon Sep 17 00:00:00 2001 From: Pesinario Date: Fri, 11 Sep 2026 22:08:27 -0300 Subject: [PATCH 07/10] fix:"...when drag started after..." had a move before the grace and before the click --- tests/InputHandler.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 19cb7e6b58..76e5d973d1 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -1142,7 +1142,6 @@ describe("Click and hold when ghost is bomb", () => { pointerId: 1, }); - inputHandler["onPointerMove"](moveEvent); inputHandler["onPointerDown"](downEvent); vi.advanceTimersByTime(inputHandler.HOLD_POINTER_WAIT_MS - 1); From 639dc981df3b85a94ac25a854c6c4ad0ef5cd53d Mon Sep 17 00:00:00 2001 From: Pesinario Date: Fri, 11 Sep 2026 22:09:14 -0300 Subject: [PATCH 08/10] test readability improvement --- tests/InputHandler.test.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 76e5d973d1..a3d047caae 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -1059,7 +1059,7 @@ describe("Click and hold when ghost is bomb", () => { test("triggers events on expected timeline when fully stationary", () => { vi.useFakeTimers(); const mockEmit = vi.spyOn(eventBus, "emit"); - let el = 0; + let el = 0; // expected launches let multi = 15; const downEvent = new PointerEvent("pointerdown", { @@ -1079,7 +1079,7 @@ describe("Click and hold when ghost is bomb", () => { ).toHaveLength(el); vi.advanceTimersByTime(2); - el = el + 1; + el++; expect( mockEmit.mock.calls.filter( ([event]) => event instanceof ConfirmGhostStructureEvent, @@ -1087,7 +1087,7 @@ describe("Click and hold when ghost is bomb", () => { ).toHaveLength(el); vi.advanceTimersByTime(inputHandler.HOLD_SECOND_ACTION_DELAY_MS); - el = el + 1; + el++; expect( mockEmit.mock.calls.filter( ([event]) => event instanceof ConfirmGhostStructureEvent, @@ -1095,7 +1095,7 @@ describe("Click and hold when ghost is bomb", () => { ).toHaveLength(el); vi.advanceTimersByTime(inputHandler.HOLD_REPEATED_ACTION_TRIGGER_RATE); - el = el + 1; + el++; expect( mockEmit.mock.calls.filter( @@ -1125,7 +1125,7 @@ describe("Click and hold when ghost is bomb", () => { test("triggers event on expected timeline when drag started after grace period", () => { vi.useFakeTimers(); const mockEmit = vi.spyOn(eventBus, "emit"); - let el = 0; + let el = 0; // expected launches let multi = 15; const downEvent = new PointerEvent("pointerdown", { @@ -1144,7 +1144,8 @@ describe("Click and hold when ghost is bomb", () => { inputHandler["onPointerDown"](downEvent); - vi.advanceTimersByTime(inputHandler.HOLD_POINTER_WAIT_MS - 1); + vi.advanceTimersByTime(inputHandler.HOLD_POINTER_WAIT_MS - 2); + // right before grace period expect( mockEmit.mock.calls.filter( ([event]) => event instanceof ConfirmGhostStructureEvent, @@ -1152,8 +1153,9 @@ describe("Click and hold when ghost is bomb", () => { ).toHaveLength(el); vi.advanceTimersByTime(2); + // right after grace period, move the mouse inputHandler["onPointerMove"](moveEvent); - el = el + 1; + el++; expect( mockEmit.mock.calls.filter( ([event]) => event instanceof ConfirmGhostStructureEvent, @@ -1161,7 +1163,7 @@ describe("Click and hold when ghost is bomb", () => { ).toHaveLength(el); vi.advanceTimersByTime(inputHandler.HOLD_SECOND_ACTION_DELAY_MS); - el = el + 1; + el++; expect( mockEmit.mock.calls.filter( ([event]) => event instanceof ConfirmGhostStructureEvent, @@ -1169,7 +1171,7 @@ describe("Click and hold when ghost is bomb", () => { ).toHaveLength(el); vi.advanceTimersByTime(inputHandler.HOLD_REPEATED_ACTION_TRIGGER_RATE); - el = el + 1; + el++; expect( mockEmit.mock.calls.filter( @@ -1215,9 +1217,11 @@ describe("Click and hold when ghost is bomb", () => { }); inputHandler["onPointerDown"](downEvent); + vi.advanceTimersByTime(1); inputHandler["onPointerMove"](moveEvent); - vi.advanceTimersByTime(inputHandler.HOLD_POINTER_WAIT_MS - 1); + vi.advanceTimersByTime(inputHandler.HOLD_POINTER_WAIT_MS - 2); + // still within grace period inputHandler["onPointerMove"](moveEvent); vi.advanceTimersByTime( From 742835156b6d4eb26569d424114f5f2bf7ea997b Mon Sep 17 00:00:00 2001 From: Pesinario Date: Fri, 11 Sep 2026 23:34:31 -0300 Subject: [PATCH 09/10] Reduce waiting for intent time to a realistic value --- src/client/InputHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index f52d76eeca..e9226ef5d4 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -256,7 +256,7 @@ export class InputHandler { private isClickHoldPastGrace = false; private clickHoldGrace: ReturnType | null = null; // Wait in MS before starting repeat - public readonly HOLD_SECOND_ACTION_DELAY_MS = 1500; + public readonly HOLD_SECOND_ACTION_DELAY_MS = 500; private clickHoldEnsureIntent: ReturnType | null = null; // Repeated trigger behavior public readonly HOLD_REPEATED_ACTION_TRIGGER_RATE = 90; // hold-to-deploy firerate (multiplier affects this) From 5d270e751c9b242c2e481b273e3b2adae72d292e Mon Sep 17 00:00:00 2001 From: Pesinario Date: Sat, 12 Sep 2026 00:00:26 -0300 Subject: [PATCH 10/10] Refactored to satisfy ESlint --- src/client/InputHandler.ts | 9 +++++---- tests/InputHandler.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index e9226ef5d4..7698fbd477 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1261,11 +1261,12 @@ export class InputHandler { } const repeatBehavior = () => { - isValidTarget() - ? this.eventBus.emit(new ConfirmGhostStructureEvent()) - : this.clickHoldCleanup(); + if (isValidTarget()) { + this.eventBus.emit(new ConfirmGhostStructureEvent()); + } else { + this.clickHoldCleanup(); + } }; - // first: ensure grace period for click+drag has passed this.clickHoldGrace = setTimeout(() => { this.isClickHoldPastGrace = true; diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index a3d047caae..60338684ef 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -1060,7 +1060,7 @@ describe("Click and hold when ghost is bomb", () => { vi.useFakeTimers(); const mockEmit = vi.spyOn(eventBus, "emit"); let el = 0; // expected launches - let multi = 15; + const multi = 15; const downEvent = new PointerEvent("pointerdown", { button: 0, @@ -1126,7 +1126,7 @@ describe("Click and hold when ghost is bomb", () => { vi.useFakeTimers(); const mockEmit = vi.spyOn(eventBus, "emit"); let el = 0; // expected launches - let multi = 15; + const multi = 15; const downEvent = new PointerEvent("pointerdown", { button: 0,