From d68aac6444154123993ed1917ba6e70802982432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Mon, 17 Aug 2026 19:47:50 +0200 Subject: [PATCH] Fix pivot tap-spam speed exploit (#72) _finish_pivot_tap() granted a full PIVOT_MIN_STEP rotation on every button/key release with no rate limit, so tapping fast enough rotated the formation far beyond PIVOT_SPEED's smooth held-rate cap - well above what holding the button down can ever produce. Each of those artificially-frequent re-seats (Fleet.apply_formation()) forced every ship into a fresh max-speed catch-up dash toward its new slot with no time to settle between dashes, and since outer-wedge ships swing further per pivot than inner ones, spamming made them visibly diverge in speed while each ship's own bounded max_speed made the fleet read as constantly pinned at its speed ceiling. Added a per-direction cooldown (the time a genuine hold needs to reach PIVOT_MIN_STEP on its own) so tap-spam can't out-pace holding the button. Deliberately NOT shared between CW/CCW: a quick correction tap the other way right after an overshoot is normal play, and test_keyboard_input.gd already covers Q and E each independently producing their own minimum step back-to-back - only repeatedly re-tapping the SAME direction needed capping. --- GameController.gd | 33 +++++++++++++++++++++++++++++++-- tests/test_pivot_controls.gd | 16 +++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/GameController.gd b/GameController.gd index 957914b..b0659c7 100644 --- a/GameController.gd +++ b/GameController.gd @@ -42,8 +42,28 @@ var pinch_start_zoom: float = 1.0 # release, so tapping still produces a clear, visible nudge. const PIVOT_SPEED: float = 1.6 const PIVOT_MIN_STEP: float = PI / 10.0 +# #72: the tap top-up below used to fire in full on EVERY release, with no +# regard for how recently the last one landed - so tapping fast enough +# granted rotation far above PIVOT_SPEED's smooth rate, well beyond what +# holding the button can ever produce. Each of those artificially-frequent +# re-seats (Fleet.apply_formation()) forces every ship into a fresh +# max-speed catch-up dash toward its new slot with no time to settle +# between them, and since outer-wedge ships swing further per pivot than +# inner ones, spamming made them visibly diverge in speed ("desync") while +# each individual ship's own bounded max_speed made the fleet as a whole +# read as constantly at its speed ceiling ("build up speed"). This cooldown +# is exactly the time a genuine hold needs to reach PIVOT_MIN_STEP on its +# own, so tap-spam can't out-pace holding the button down. +const PIVOT_TAP_COOLDOWN: float = PIVOT_MIN_STEP / PIVOT_SPEED var pivot_hold_amount: float = 0.0 +# Tracked PER DIRECTION, not shared - a single quick correction (tap the +# other way right after an overshoot) is normal play, not the spam this +# cooldown targets, and Q/E each independently producing their own minimum +# step back-to-back is an existing, tested guarantee (test_keyboard_input.gd). +# Only repeatedly re-tapping the SAME direction needs to be capped. +var pivot_cw_tap_cooldown_left: float = 0.0 +var pivot_ccw_tap_cooldown_left: float = 0.0 # Touch index currently holding each pivot button, or -1. These buttons are # driven straight from raw touch events rather than through _gui_input, # because Godot's Control GUI treats touch as a single emulated pointer and @@ -186,9 +206,16 @@ func _update_pivot(delta: float) -> void: func _finish_pivot_tap(direction: float) -> void: # Fired on release. A press held long enough has already turned far - # enough on its own; a brief tap gets topped up to the minimum step. - if pivot_hold_amount < PIVOT_MIN_STEP: + # enough on its own; a brief tap gets topped up to the minimum step - + # but only once THAT DIRECTION's own cooldown has cleared, so spamming + # one side can't grant rotation faster than holding the button would. + var cooldown_left: float = pivot_cw_tap_cooldown_left if direction > 0.0 else pivot_ccw_tap_cooldown_left + if pivot_hold_amount < PIVOT_MIN_STEP and cooldown_left <= 0.0: _pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction) + if direction > 0.0: + pivot_cw_tap_cooldown_left = PIVOT_TAP_COOLDOWN + else: + pivot_ccw_tap_cooldown_left = PIVOT_TAP_COOLDOWN pivot_hold_amount = 0.0 @@ -211,6 +238,8 @@ func _on_joystick_direction(direction: Vector2) -> void: func _process(delta: float) -> void: + pivot_cw_tap_cooldown_left = maxf(pivot_cw_tap_cooldown_left - delta, 0.0) + pivot_ccw_tap_cooldown_left = maxf(pivot_ccw_tap_cooldown_left - delta, 0.0) _update_pivot(delta) # Keep the heading fresh while a control is held. Fleet advances its own diff --git a/tests/test_pivot_controls.gd b/tests/test_pivot_controls.gd index 85ef2e9..1d60512 100644 --- a/tests/test_pivot_controls.gd +++ b/tests/test_pivot_controls.gd @@ -5,7 +5,10 @@ extends SceneTree # Covers the fleet-pivot buttons: # 1. HOLDING one turns the formation continuously, not once per press # 2. a brief tap still produces a clear minimum step -# 3. pivoting works while the fleet is under way, i.e. the new layout +# 3. immediately repeating a tap grants nothing extra until the cooldown +# clears (#72) - spamming taps must never rotate faster than holding +# the button would +# 4. pivoting works while the fleet is under way, i.e. the new layout # survives the joystick's per-frame steering updates instead of being # overwritten by the old offsets @@ -76,6 +79,17 @@ func _process(delta: float) -> bool: quit(1) return true + # --- #72: spamming taps grants nothing extra during the cooldown --- + var before_spam: float = _angle() + for i in range(5): + controller._finish_pivot_tap(1.0) + var spammed: float = absf(angle_difference(before_spam, _angle())) + print("5 taps back-to-back -> turned %.4f rad (expected ~0, cooldown not yet cleared)" % spammed) + if spammed > 0.001: + print("TEST_PIVOT_CONTROLS: FAIL (tap-spam granted rotation during the cooldown)") + quit(1) + return true + # --- pivot WHILE the joystick is held ------------------------- # Two fingers at once. This is routed through the same raw-touch # path the real device uses, because Godot's Control GUI treats