2026-08-16 19:21:54 +02:00
|
|
|
extends SceneTree
|
|
|
|
|
|
|
|
|
|
# Headless gameplay test: run with
|
|
|
|
|
# godot --headless -s res://tests/test_distorder_energy.gd
|
|
|
|
|
# Forgejo #62: sustaining a boost above the fleet's default speed drains
|
|
|
|
|
# energy from the fleet hold, continuously, proportional to how far above
|
|
|
|
|
# 1.0x the multiplier is.
|
|
|
|
|
#
|
|
|
|
|
# Most of this is pure GameState math (GameState._advance_distorder_drain,
|
|
|
|
|
# called directly the same way other tests call _advance_lab_research/
|
|
|
|
|
# _advance_production) - no Main.tscn/Fleet needed for it, which is itself
|
|
|
|
|
# proof the drain is scene-independent (it lives in the autoload's own
|
|
|
|
|
# _process(), unlike a component's tick() which only runs while its ship's
|
|
|
|
|
# scene is loaded) and so "continues correctly across a landing and
|
|
|
|
|
# return" without a scene-swap simulation to re-prove that.
|
|
|
|
|
#
|
|
|
|
|
# Covers:
|
|
|
|
|
# A. sustained boost drains energy at the configured rate; half boost
|
|
|
|
|
# drains proportionally less than full boost
|
|
|
|
|
# B. a small multiplier still drains a nonzero amount over time - no
|
|
|
|
|
# fractional rounding loss
|
|
|
|
|
# D. at exactly 1.0x, energy is never consumed
|
|
|
|
|
# C. hitting zero energy forces the multiplier back to 1.0, and the
|
|
|
|
|
# SpeedFader visibly follows it down with the reason shown
|
|
|
|
|
|
|
|
|
|
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.fleet_speed_multiplier = 1.0
|
|
|
|
|
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
|
|
|
elapsed += delta
|
|
|
|
|
match stage:
|
|
|
|
|
0:
|
|
|
|
|
# --- D. nothing drains at exactly 1.0x ----------------------------
|
|
|
|
|
var before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
|
|
|
|
for i in range(60):
|
|
|
|
|
game_state._advance_distorder_drain(1.0)
|
|
|
|
|
if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != before:
|
|
|
|
|
return _fail_now("energy drained at 1.0x: %d -> %d" \
|
|
|
|
|
% [before, game_state.fleet_resource(GameState.RESOURCE_ENERGY)])
|
|
|
|
|
print("no drain at 1.0x, even over 60 simulated seconds")
|
|
|
|
|
|
|
|
|
|
# --- A. full boost drains at the configured rate ------------------
|
|
|
|
|
game_state.fleet_speed_multiplier = GameState.MAX_FLEET_SPEED_MULTIPLIER
|
|
|
|
|
var full_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
|
|
|
|
for i in range(10):
|
|
|
|
|
game_state._advance_distorder_drain(1.0)
|
|
|
|
|
var full_drained: int = full_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
|
|
|
|
if full_drained != 20:
|
|
|
|
|
return _fail_now("full boost drained %d over 10s, expected 20 (ENERGY_DRAIN_PER_SECOND=2.0)" \
|
|
|
|
|
% full_drained)
|
|
|
|
|
print("full boost drained %d over 10s, matches ENERGY_DRAIN_PER_SECOND" % full_drained)
|
|
|
|
|
|
|
|
|
|
# --- A continued: half boost drains proportionally less -----------
|
|
|
|
|
game_state.fleet_speed_multiplier = 2.0
|
|
|
|
|
var half_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
|
|
|
|
for i in range(10):
|
|
|
|
|
game_state._advance_distorder_drain(1.0)
|
|
|
|
|
var half_drained: int = half_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
|
|
|
|
if half_drained <= 0 or half_drained >= full_drained:
|
|
|
|
|
return _fail_now("half boost drained %d over 10s, expected between 0 and full's %d" \
|
|
|
|
|
% [half_drained, full_drained])
|
|
|
|
|
print("half boost drained %d over 10s - less than full's %d, still nonzero" \
|
|
|
|
|
% [half_drained, full_drained])
|
|
|
|
|
|
|
|
|
|
# --- B. a small multiplier still drains a nonzero amount ----------
|
|
|
|
|
game_state.fleet_speed_multiplier = 1.05
|
|
|
|
|
var small_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
|
|
|
|
for i in range(600):
|
|
|
|
|
game_state._advance_distorder_drain(0.1)
|
|
|
|
|
var small_drained: int = small_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
|
|
|
|
if small_drained <= 0:
|
|
|
|
|
return _fail_now("a small (1.05x) sustained boost drained nothing over 60s - fractional rounding loss")
|
|
|
|
|
print("small boost (1.05x) still drained %d over 60s (no rounding loss)" % small_drained)
|
|
|
|
|
|
|
|
|
|
# --- C. running out resets the multiplier and raises the notice ---
|
|
|
|
|
game_state.withdraw_fleet_resource(GameState.RESOURCE_ENERGY,
|
|
|
|
|
game_state.fleet_resource(GameState.RESOURCE_ENERGY))
|
|
|
|
|
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 5)
|
|
|
|
|
game_state.fleet_speed_multiplier = GameState.MAX_FLEET_SPEED_MULTIPLIER
|
|
|
|
|
game_state.boost_depleted_notice = false
|
|
|
|
|
for i in range(10):
|
|
|
|
|
game_state._advance_distorder_drain(1.0)
|
|
|
|
|
if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != 0:
|
|
|
|
|
return _fail_now("fleet energy did not reach exactly 0, got %d" \
|
|
|
|
|
% game_state.fleet_resource(GameState.RESOURCE_ENERGY))
|
|
|
|
|
if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0):
|
|
|
|
|
return _fail_now("multiplier was not forced back to 1.0 after running out: %f" \
|
|
|
|
|
% game_state.fleet_speed_multiplier)
|
|
|
|
|
if not game_state.boost_depleted_notice:
|
|
|
|
|
return _fail_now("no depleted notice was raised when energy ran out")
|
|
|
|
|
print("running out of energy dropped the multiplier to 1.0 and raised the notice")
|
|
|
|
|
|
|
|
|
|
stage = 1
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
return false
|
|
|
|
|
1:
|
|
|
|
|
# --- C continued: the SpeedFader visibly follows it down ----------
|
|
|
|
|
if elapsed < 0.2:
|
|
|
|
|
return false
|
#84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.
buildings core controllers enemies logistics planets
resources shaders surface ui vessels vfx world
332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.
Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.
The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
|
|
|
main = load("res://world/Main.tscn").instantiate()
|
2026-08-16 19:21:54 +02:00
|
|
|
root.add_child(main)
|
|
|
|
|
fleet = main.get_node("Fleet")
|
|
|
|
|
fader = main.get_node("UI/SpeedFader")
|
2026-09-04 20:01:56 +02:00
|
|
|
# A carrier supplies the hold the Propulser burns from. The previous
|
|
|
|
|
# one-Propulser fixture had zero cargo capacity, so its depletion check
|
|
|
|
|
# accidentally began with no energy regardless of what it deposited.
|
|
|
|
|
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
|
|
|
|
|
GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER]))
|
2026-08-16 19:21:54 +02:00
|
|
|
fader._process(0.0)
|
|
|
|
|
if not fader.visible:
|
|
|
|
|
return _fail_now("fader hidden with a distorder ship in the fleet")
|
2026-09-04 20:01:56 +02:00
|
|
|
if not bool(ProjectSettings.get_setting(
|
|
|
|
|
"physics/common/physics_interpolation", false)):
|
|
|
|
|
return _fail_now("physics interpolation is disabled for boosted flight")
|
|
|
|
|
if fleet.ships[0].physics_interpolation_mode \
|
|
|
|
|
!= Node.PHYSICS_INTERPOLATION_MODE_ON:
|
|
|
|
|
return _fail_now("the boosted vessel is not physics-interpolated")
|
|
|
|
|
if main.get_node("Camera2D").physics_interpolation_mode \
|
|
|
|
|
!= Node.PHYSICS_INTERPOLATION_MODE_ON:
|
|
|
|
|
return _fail_now("the fleet camera is not physics-interpolated")
|
2026-08-16 19:21:54 +02:00
|
|
|
|
2026-09-04 20:01:56 +02:00
|
|
|
# #199: choosing a higher cruise speed is free while parked. Once a
|
|
|
|
|
# heading is active, the exact same multiplier resumes its normal drain.
|
|
|
|
|
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 30)
|
|
|
|
|
game_state.set_fleet_speed_multiplier(GameState.MAX_FLEET_SPEED_MULTIPLIER)
|
|
|
|
|
var parked_energy: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
|
|
|
|
game_state._advance_distorder_drain(5.0,
|
|
|
|
|
game_state._distorder_boost_in_use())
|
|
|
|
|
if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != parked_energy:
|
|
|
|
|
return _fail_now("a parked fleet consumed boost energy")
|
|
|
|
|
fleet.move_in_direction(Vector2.RIGHT)
|
|
|
|
|
if not fleet.is_using_speed_boost():
|
|
|
|
|
return _fail_now("boosted cruise did not report its energy use")
|
|
|
|
|
game_state._advance_distorder_drain(1.0,
|
|
|
|
|
game_state._distorder_boost_in_use())
|
|
|
|
|
if game_state.fleet_resource(GameState.RESOURCE_ENERGY) >= parked_energy:
|
|
|
|
|
return _fail_now("moving at boosted cruise consumed no energy")
|
|
|
|
|
fleet.stop()
|
|
|
|
|
print("parked boost was free; moving boost resumed drain")
|
|
|
|
|
|
|
|
|
|
game_state.withdraw_fleet_resource(GameState.RESOURCE_ENERGY,
|
|
|
|
|
game_state.fleet_resource(GameState.RESOURCE_ENERGY))
|
2026-08-16 19:21:54 +02:00
|
|
|
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 4)
|
|
|
|
|
game_state.set_fleet_speed_multiplier(GameState.MAX_FLEET_SPEED_MULTIPLIER)
|
|
|
|
|
fader._process(0.0)
|
|
|
|
|
if fader.knob_t < 0.9:
|
|
|
|
|
return _fail_now("fader knob did not reflect the boosted multiplier before draining: %f" \
|
|
|
|
|
% fader.knob_t)
|
|
|
|
|
|
|
|
|
|
for i in range(10):
|
|
|
|
|
game_state._advance_distorder_drain(1.0)
|
|
|
|
|
if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0):
|
|
|
|
|
return _fail_now("multiplier was not forced back to 1.0 after running out (with a live fleet)")
|
|
|
|
|
fader._process(0.0)
|
|
|
|
|
if fader.knob_t > 0.05:
|
|
|
|
|
return _fail_now("fader knob did not snap down after the boost was depleted: %f" % fader.knob_t)
|
|
|
|
|
if fader.label.text != "Out of energy!":
|
|
|
|
|
return _fail_now("fader did not show the depleted reason, text was '%s'" % fader.label.text)
|
|
|
|
|
if game_state.boost_depleted_notice:
|
|
|
|
|
return _fail_now("boost_depleted_notice was not consumed by the fader")
|
|
|
|
|
print("fader visibly snapped its knob down and explained the depletion")
|
|
|
|
|
|
|
|
|
|
print("TEST_DISTORDER_ENERGY: PASS")
|
|
|
|
|
quit(0)
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _fail_now(message: String) -> bool:
|
|
|
|
|
print("TEST_DISTORDER_ENERGY: FAIL (%s)" % message)
|
|
|
|
|
quit(1)
|
|
|
|
|
return true
|