spaceship/tests/test_distorder_energy.gd
Loïc Wisniewski 1f5fecd371 Buy fleet berths with Fleet Offices
#219: fleet capacity is a built, supplied thing rather than a researched
one. The eight fleet-size doctrines are gone; capacity is one berth free
plus one per Fleet Office standing. An office charges every 25s for the
vessel in its berth, priced off that vessel's own build cost so an expensive
hull is expensive to keep, and drawn from its own store the way a gas burner
draws gas. An office that cannot pay goes dark, its vessel is scuttled, and
the player is told through the same queue quest lines use - which reaches
them on a planet surface too.

An empty berth is idle rather than starved: an office is only failing while
there is something in it to fail, and holding it disabled would leave a
berth that could never be crewed and so could never be paid for again.

Ships have no hit points and a shield absorbs a hit wholesale, so the lost
vessel is scuttled rather than damaged - otherwise a shielded hull would
survive its own eviction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-05 17:09:27 +02:00

188 lines
8.8 KiB
GDScript

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
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
fader = main.get_node("UI/SpeedFader")
# 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]))
fader._process(0.0)
if not fader.visible:
return _fail_now("fader hidden with a distorder ship in the fleet")
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")
# #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))
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