Guard the definition catalog against silent .tres property loss

Opening the project in the Godot editor re-saves .tres files, and its writer
DROPS properties it considers default-valued. An empty-looking
PackedStringArray is the usual casualty, and it has now cost this project
twice - both times silently, and both times the damage surfaced somewhere
else entirely:

  GasBurnerBuildingDefinition lost `capabilities` and `collected_outputs`,
  which took the burner's whole conversion with it.

  InterceptorVesselDefinition lost `capabilities`, `hull_points`,
  `build_time`, `icon` and `glow_color` - so the starting vessel had no
  cargo hold, and a full suite came back with ten unrelated tests broken
  because nothing anywhere could mine.

Neither raised an error. The resource still loaded; it was just quietly less
than it had been.

test_definitions_intact asserts the SHAPE of the catalog rather than any one
value: every vessel has a name, stats, a hull and its components; a hold and
a "cargo" capability imply each other; every building has a description, a
price, hit points, its build-menu wiring and at least one capability; and a
converter keeps the fields its conversion needs. Whatever the editor eats
now fails here, next to the thing that was eaten.

Verified against both real incidents by reproducing them: deleting the
interceptor's capabilities line fails with "cargo_capacity 20.0 but
capabilities []", and deleting the burner's collected_outputs fails with
"converts but lists no collected outputs".

The first draft of that last check looked for the conversion fields on the
DEFINITION, where they are not - they live on its logistics sub-resource -
so it answered null and never fired. It passed while the burner sat there
broken, which is the same class of quiet as the bug it is guarding.

Also restores what the editor took from the gas burner this time round.
This commit is contained in:
Loïc Wisniewski 2026-08-29 14:38:51 +02:00
commit 721913c040
3 changed files with 135 additions and 1 deletions

View file

@ -21,7 +21,10 @@ definition_id = "gas_burner"
display_name = "Gas Burner" display_name = "Gas Burner"
description = "Burns gas into energy at its own site. Hub transporters keep it stocked with gas and collect the energy it makes." description = "Burns gas into energy at its own site. Hub transporters keep it stocked with gas and collect the energy it makes."
cost = 40 cost = 40
build_resource_costs = {"ore": 40, "gas": 22} build_resource_costs = {
"gas": 22,
"ore": 40
}
action_icon = 33 action_icon = 33
menu_icon = 33 menu_icon = 33
visual_scene = ExtResource("2") visual_scene = ExtResource("2")

View file

@ -0,0 +1,130 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_definitions_intact.gd
#
# A guard against a failure mode that has now cost this project twice, and
# both times silently.
#
# Opening the project in the Godot editor re-saves .tres files, and its
# writer DROPS properties it considers default-valued. An empty-looking
# PackedStringArray is the usual casualty: GasBurnerBuildingDefinition lost
# `capabilities` and `collected_outputs` once (which took the gas burner's
# whole conversion with it), and InterceptorVesselDefinition later lost
# `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` -
# which cost the starting vessel its cargo hold, so nothing could be mined.
#
# Neither showed up as an error. The resource still loaded; it was just
# quietly less than it had been, and the failure surfaced somewhere else
# entirely - a full suite reporting ten unrelated tests broken.
#
# So this asserts the SHAPE of the catalog rather than any one field: every
# definition must carry the things that make it what it is, and a capability
# a definition claims must have a component behind it. Anything the editor
# eats fails here first, next to the thing that was eaten.
#
# Covers:
# A. every vessel is whole - name, cost, icon, hull, and stats
# B. a vessel with cargo declares it and has a capacity to go with it
# C. every building is whole, and its menu wiring is complete
# D. a converter building keeps the fields its conversion needs
# E. no definition has lost its component list
var failures: Array = []
func _fail(message: String) -> String:
failures.append(message)
return message
# get() answers null for a property a definition's script never declared,
# and null has no is_empty(). These keep a missing field from looking like
# an empty one - and from taking the rest of the sweep down with it.
func _text(definition: Resource, field: String) -> String:
var value: Variant = definition.get(field)
return "" if value == null else str(value)
func _list(definition: Resource, field: String) -> Array:
var value: Variant = definition.get(field)
if value == null:
return []
return Array(value.keys() if value is Dictionary else value)
func _check_vessels() -> void:
for definition in VesselDefinitions.all():
var id: String = String(definition.definition_id)
# --- A. whole ---------------------------------------------------
if String(definition.display_name).strip_edges() == "":
_fail("vessel %s has no display name" % id)
if definition.base_stats.is_empty():
_fail("vessel %s has no stats at all" % id)
# Only the hulls that actually join the fleet: a repair drone is a
# logistics unit driven by GameState, with nothing bolted to it.
if definition.fleet_eligible and definition.component_scenes.is_empty():
_fail("vessel %s flies in the fleet with no components - an empty hull" % id)
if definition.hull_points.is_empty():
_fail("vessel %s has no hull points - it would draw as the fallback triangle" % id)
# --- B. cargo --------------------------------------------------
var carries: bool = float(definition.base_stats.get("cargo_capacity", 0.0)) > 0.0
var claims: bool = "cargo" in definition.capabilities
if carries != claims:
_fail("vessel %s: cargo_capacity %s but capabilities %s - a hold nothing can fill, or the reverse" \
% [id, definition.base_stats.get("cargo_capacity", 0.0), definition.capabilities])
if "weapon" in definition.capabilities \
and float(definition.base_stats.get("fire_range", 0.0)) <= 0.0:
_fail("vessel %s claims a weapon and has no range" % id)
func _check_buildings() -> void:
for definition in BuildingDefinitions.all():
var id: String = String(definition.definition_id)
# --- C. whole --------------------------------------------------
if String(definition.display_name).strip_edges() == "":
_fail("building %s has no display name" % id)
if String(definition.description).strip_edges() == "":
_fail("building %s has no description - its info popup would be blank" % id)
if int(definition.cost) <= 0:
_fail("building %s is free" % id)
if int(definition.max_hp) <= 0:
_fail("building %s has no hit points" % id)
# Space-placed structures only: an extractor is a marker on a planet
# rather than a thing standing in the world, and has no scene.
if definition.placement_mode == "space" and definition.visual_scene == null:
_fail("building %s stands in space with nothing to draw" % id)
for field in ["menu_button_name", "menu_label_name", "menu_cost_name"]:
if str(definition.get(field)).strip_edges() == "":
_fail("building %s has no %s - the build menu could not place it" % [id, field])
if definition.capabilities.is_empty():
_fail("building %s declares no capabilities - it would do nothing at all" % id)
# --- D. converters ---------------------------------------------
# These live on the LOGISTICS profile, a sub-resource of the
# definition, not on the definition itself. Asking the definition
# for them answers null and the whole check quietly never fires -
# which is how the first draft of this test passed while the gas
# burner sat there with its collected_outputs missing.
var logistics: Resource = definition.logistics
if logistics != null and _text(logistics, "conversion_input") != "":
if _text(logistics, "conversion_output") == "":
_fail("building %s converts something into nothing" % id)
if _list(logistics, "collected_outputs").is_empty():
_fail("building %s converts but lists no collected outputs - what it makes would never be fetched" % id)
if _list(logistics, "input_buffers").is_empty():
_fail("building %s converts with no input buffer" % id)
func _initialize() -> void:
_check_vessels()
_check_buildings()
if not failures.is_empty():
for message in failures:
print(" - %s" % message)
print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" % failures.size())
quit(1)
return
print("%d vessels and %d buildings, all intact" \
% [VesselDefinitions.all().size(), BuildingDefinitions.all().size()])
print("TEST_DEFINITIONS_INTACT: PASS")
quit(0)

View file

@ -0,0 +1 @@
uid://d3ury2lcjwer7