fix: v3.0.0 -- thresholds on the free-fraction scale the assembly actually uses
Disassembling Cosmoteer.dll (tools/ildump.py) shows PartThresholdToggle compares plain floats with no percent logic, and that a NetworkResourceInput's Value is the free fraction (0..1) of the connected network. v2.1.0 compared that against 2000000, so it always vented. Thresholds are now 5% / 10% free as local constants. New guard test_threshold_amounts_match_value_scale fails on the old values. README corrects the earlier wrong diagnosis of v2.0.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni
This commit is contained in:
@@ -425,6 +425,10 @@ def test_component_fields_exist_in_schema():
|
||||
for field in component.order:
|
||||
if field == "Type":
|
||||
continue
|
||||
# ALL_CAPS entries are local constants for &references, the
|
||||
# vanilla convention (thermal_battery.rules: MAX_STATUS_HEAT).
|
||||
if field.isupper():
|
||||
continue
|
||||
check(
|
||||
field in schema,
|
||||
f"{name}: {component_name} ({type_node.value}) has no field "
|
||||
@@ -627,6 +631,73 @@ def test_added_indicators_are_valid():
|
||||
# 8. The mod manifest must declare the fields Cosmoteer requires.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threshold amounts must live on the same scale as the value they read.
|
||||
#
|
||||
# From Cosmoteer.dll (see tools/ildump.py): PartThresholdToggle compares
|
||||
# ValueFrom.Value against MinAmount/MaxAmount as plain floats -- "5%" is 0.05,
|
||||
# nothing more. A ResourceStorage, NetworkResourceStore or NetworkResourceInput
|
||||
# with the default ProvidedValueRange reports a 0..1 fraction (fill for
|
||||
# storages, free space for inputs). v2.1.0 compared such a fraction against
|
||||
# 2000000, so its "space low" toggle was permanently on and the mod was a no-op.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FRACTION_VALUED_TYPES = {"ResourceStorage", "NetworkResourceStore", "NetworkResourceInput"}
|
||||
|
||||
|
||||
def scalar_number(node, scope=None):
|
||||
"""Turn a scalar like '5%', '0.25' or '&LOCAL_CONSTANT' into a float.
|
||||
|
||||
A leading '&NAME' is looked up in the given scope (the component itself),
|
||||
the vanilla convention for local constants. Returns None if not numeric.
|
||||
"""
|
||||
if node is None:
|
||||
return None
|
||||
text = node.value.strip()
|
||||
if text.startswith("&") and scope is not None:
|
||||
return scalar_number(scope.get(text[1:].strip("()")), None)
|
||||
try:
|
||||
if text.endswith("%"):
|
||||
return float(text[:-1]) / 100.0
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def test_threshold_amounts_match_value_scale():
|
||||
check(scalar_number(rules_parser.Node("scalar", "5%")) == 0.05, "'5%' should read as 0.05")
|
||||
scope = rules_parser.parse("K = 40%\nX = &K")
|
||||
check(scalar_number(scope.get("X"), scope) == 0.4, "'&K' should resolve through the scope")
|
||||
for mod_file, target in added_component_sets():
|
||||
added = rules_parser.parse_file(mod_file)
|
||||
for name in added.order:
|
||||
comp = added.get(name)
|
||||
if comp.kind != "group" or comp.get("Type") is None:
|
||||
continue
|
||||
if comp.get("Type").value != "ThresholdToggle":
|
||||
continue
|
||||
source_name = comp.get("ValueFrom").value if comp.get("ValueFrom") else None
|
||||
source = added.get(source_name) if source_name else None
|
||||
if source is None and target is not None:
|
||||
source = target.get(source_name)
|
||||
if source is None or source.get("Type") is None:
|
||||
continue
|
||||
source_type = source.get("Type").value
|
||||
if source_type not in FRACTION_VALUED_TYPES:
|
||||
continue
|
||||
if source.get("ProvidedValueRange") is not None or source.get("ProvidedValuePerResource") is not None:
|
||||
continue
|
||||
for field in ("MinAmount", "MaxAmount"):
|
||||
amount = scalar_number(comp.get(field), comp)
|
||||
if amount is None:
|
||||
continue
|
||||
check(
|
||||
0.0 <= amount <= 1.0,
|
||||
f"{os.path.basename(mod_file)}: {name}.{field} = {amount} but "
|
||||
f"{source_name} ({source_type}) reports a 0..1 fraction",
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_is_well_formed():
|
||||
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
||||
for field in ("ID", "Name", "Version", "CompatibleGameVersions", "ModifiesGameplay"):
|
||||
|
||||
Reference in New Issue
Block a user