extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_distorder_stacking.gd # Energy Propulsers raise the fleet's speed ceiling together, so a second one # is worth crewing rather than dead weight - but with DIMINISHING returns # (#77). Each doubling of them adds 1.0, not each vessel: a flat 1.0 apiece # meant a fleet that could afford six simply drove at six times cruise and the # vessel stopped being a choice. # # Covers: # A. one vessel gives the original ceiling, so nothing changed for a fleet # that carries a single propulser # B. the ceiling follows log2 of the count - two vessels are worth exactly # what they were worth before the change, and the growth tapers after # C. the fader's full travel reaches the WIDENED ceiling, not the old one # D. losing a vessel mid-boost pulls the fleet back down to the new # ceiling rather than leaving it faster than its crew can sustain # E. full boost still costs the same energy per second however wide the # ceiling is - more vessels buy more speed, not cheaper speed var game_state: Node var main: Node var fleet: Node var fader: Node var stage: int = 0 var elapsed: float = 0.0 func _initialize() -> void: game_state = root.get_node("GameState") game_state.reset_progression() game_state.vessel_levels["unlock_distorder"] = 1 game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) main = load("res://Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") fader = main.get_node("UI/SpeedFader") func _process(delta: float) -> bool: elapsed += delta match stage: 0: if elapsed < 0.2: return false main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) # Room for a fleet of distorders. game_state.unlocked.append_array(["wing", "squadron", "armada"]) # --- A. one vessel is unchanged ------------------------------ fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER])) if not is_equal_approx(game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER): return _fail("one distorder gives a ceiling of %.1f, expected the unchanged %.1f" \ % [game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER]) print("1 distorder -> ceiling %.1fx" % game_state.max_fleet_speed_multiplier) # --- B. the ceiling grows with log2 of the count --------------- var previous_gain: float = 1.0 for extra in range(1, 4): fleet.add_ship(GameState.VESSEL_DISTORDER) var count: int = extra + 1 var expected: float = GameState.MAX_FLEET_SPEED_MULTIPLIER \ + log(float(count)) / log(2.0) if not is_equal_approx(game_state.max_fleet_speed_multiplier, expected): return _fail("%d propulsers give a ceiling of %.2f, expected %.2f" \ % [count, game_state.max_fleet_speed_multiplier, expected]) # Each vessel must be worth strictly less than the one before # it. That is the whole point of the change, and asserting the # formula alone would pass just as happily on a linear one. var gain: float = game_state.max_fleet_speed_multiplier \ - (GameState.MAX_FLEET_SPEED_MULTIPLIER + log(float(count - 1)) / log(2.0)) if count > 2 and gain >= previous_gain: return _fail("propulser %d added %.2f, no less than the %.2f the one before it added - the returns are not diminishing" \ % [count, gain, previous_gain]) previous_gain = gain print("%d propulsers -> ceiling %.2fx (this one added %.2f)" \ % [count, game_state.max_fleet_speed_multiplier, gain]) # Two propulsers must still be worth exactly what they were before # the change, or an existing fleet was quietly devalued. if not is_equal_approx(GameState.MAX_FLEET_SPEED_MULTIPLIER + log(2.0) / log(2.0), GameState.MAX_FLEET_SPEED_MULTIPLIER + 1.0): return _fail("two propulsers no longer reach the ceiling they used to") # --- C. the fader reaches the widened ceiling ---------------- # Dragging the track to its top must mean the CURRENT ceiling, or # the extra vessels would be unreachable through the only control # that exists for them. fader._process(0.0) if not fader.visible: return _fail("the fader is hidden with four distorder vessels crewed") var press := InputEventScreenTouch.new() press.index = 3 press.position = fader.global_position + Vector2(fader.size.x * 0.5, fader.size.y - 4.0) press.pressed = true main._handle_touch(press) var drag := InputEventScreenDrag.new() drag.index = 3 drag.position = fader.global_position + Vector2(fader.size.x * 0.5, 0.0) main._handle_drag(drag) if not is_equal_approx(game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier): return _fail("dragging the fader to the top reached %.2fx, not the ceiling %.2fx" \ % [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier]) print("fader top reaches %.1fx" % game_state.fleet_speed_multiplier) # --- E. full boost costs the same per second ----------------- # The drain is normalized against the current ceiling, so sitting # at full boost is the same price whatever the ceiling is. var wide_energy: int = _energy_spent_over(1.0) # --- D. losing vessels pulls the boost back down ------------- var boosted_before: float = game_state.fleet_speed_multiplier for i in range(fleet.ships.size() - 1, -1, -1): if is_instance_valid(fleet.ships[i]) and fleet.ships[i].vessel_type == GameState.VESSEL_DISTORDER: fleet.store_ship(i) break if game_state.max_fleet_speed_multiplier >= boosted_before: return _fail("losing a distorder left the ceiling at %.1f, no lower than the %.1f the fleet was already doing" \ % [game_state.max_fleet_speed_multiplier, boosted_before]) if game_state.fleet_speed_multiplier > game_state.max_fleet_speed_multiplier + 0.001: return _fail("the fleet is still doing %.2fx after its ceiling dropped to %.2fx" \ % [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier]) print("losing one pulled %.1fx down to the new %.1fx ceiling" \ % [boosted_before, game_state.fleet_speed_multiplier]) # Same measurement at the narrower ceiling, also at full boost. game_state.set_fleet_speed_multiplier(game_state.max_fleet_speed_multiplier) var narrow_energy: int = _energy_spent_over(1.0) if absi(wide_energy - narrow_energy) > 1: return _fail("full boost cost %d energy with a wide ceiling and %d with a narrow one - extra vessels changed the PRICE, not just the speed" \ % [wide_energy, narrow_energy]) print("full boost costs %d/%d energy per second at either ceiling" % [wide_energy, narrow_energy]) print("TEST_DISTORDER_STACKING: PASS") quit(0) return true return false # Energy actually consumed by holding the current multiplier for `seconds`, # driven directly so the measurement does not depend on frame pacing. func _energy_spent_over(seconds: float) -> int: var before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) var step: float = 0.05 var t: float = 0.0 while t < seconds: game_state._advance_distorder_drain(step) t += step return before - game_state.fleet_resource(GameState.RESOURCE_ENERGY) func _fail(message: String) -> bool: print("TEST_DISTORDER_STACKING: FAIL (%s)" % message) quit(1) return true