Players install from Steam Workshop instead of install.sh, which failed on Windows (bash) and a Manjaro box (hard-coded Mods globs). Adds Logo + logo.png the uploader ships, tools/steam_paths.py reading Steam's libraryfolders.vdf (Flatpak/Snap/second disk/Windows), and README install tiers plus in-game publishing steps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TkQMhvEyoFqKzeCC69N6o
783 lines
31 KiB
Python
783 lines
31 KiB
Python
# ABOUT ME
|
|
# Validation suite for the Auto Radiator mod.
|
|
#
|
|
# Why this exists: Cosmoteer applies a mod's "Actions" to vanilla data at load
|
|
# time. A path that does not resolve, a component reference that points at
|
|
# nothing, or a misspelled component Type all fail the same way -- the game
|
|
# crashes on startup and leaves a log file. These tests catch all three offline,
|
|
# against the player's actual installed game data, before the game is launched.
|
|
#
|
|
# Run: python3 tests/test_mod.py
|
|
# The game install is found through Steam's own library list (tools/steam_paths.py),
|
|
# or set COSMOTEER_DATA to override.
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.join(MOD_ROOT, "tools"))
|
|
|
|
import rules_parser
|
|
import steam_paths
|
|
|
|
|
|
# Component fields whose value names another component in the same part.
|
|
COMPONENT_REF_FIELDS = {
|
|
"OperationalToggle",
|
|
"Toggle",
|
|
"ToggleSource",
|
|
"ValueFrom",
|
|
"ValueSource",
|
|
"ResourceStorage",
|
|
"StartTrigger",
|
|
"StopTrigger",
|
|
"ResetTrigger",
|
|
"OnTrigger",
|
|
"OffTrigger",
|
|
"Trigger",
|
|
"FromStorage",
|
|
"ToStorage",
|
|
}
|
|
|
|
_failures = []
|
|
_checks = 0
|
|
|
|
|
|
def check(condition, message):
|
|
global _checks
|
|
_checks += 1
|
|
if not condition:
|
|
_failures.append(message)
|
|
|
|
|
|
def find_data_dir():
|
|
"""Locate the installed game's Data folder via tools/steam_paths.py."""
|
|
return steam_paths.find_data_dir()
|
|
|
|
|
|
DATA = find_data_dir()
|
|
|
|
_vanilla_cache = {}
|
|
|
|
|
|
def vanilla(rel_path):
|
|
"""Parse a vanilla .rules file, relative to the Data folder."""
|
|
if rel_path not in _vanilla_cache:
|
|
full = os.path.join(DATA, rel_path)
|
|
_vanilla_cache[rel_path] = rules_parser.parse_file(full) if os.path.isfile(full) else None
|
|
return _vanilla_cache[rel_path]
|
|
|
|
|
|
def split_target(target):
|
|
"""Split '<file.rules>/A/B' into ('file.rules', 'A/B')."""
|
|
match = re.match(r"^<([^>]+)>/?(.*)$", target.strip().strip('"'))
|
|
if not match:
|
|
return None, None
|
|
return match.group(1), match.group(2)
|
|
|
|
|
|
def resolve(target):
|
|
"""Resolve a mod action target against vanilla data. Returns a Node or None."""
|
|
rel_path, inner = split_target(target)
|
|
if rel_path is None:
|
|
return None
|
|
root = vanilla(rel_path)
|
|
if root is None:
|
|
return None
|
|
return root.get(inner) if inner else root
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Parser self-check, against facts read directly out of the vanilla files.
|
|
# If these fail the parser is broken and every later result is meaningless.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_parser_reads_vanilla_correctly():
|
|
radiator = vanilla("ships/terran/radiator/radiator.rules")
|
|
check(radiator is not None, "cannot read vanilla radiator.rules")
|
|
if radiator is None:
|
|
return
|
|
|
|
power = radiator.get("Part/Components/PowerToggle")
|
|
check(power is not None, "radiator PowerToggle not found by parser")
|
|
check(
|
|
power is not None and power.get("Type").value == "UIToggle",
|
|
"radiator PowerToggle should be a UIToggle",
|
|
)
|
|
|
|
converter = radiator.get("Part/Components/HeatDissipationConverter")
|
|
check(converter is not None, "radiator HeatDissipationConverter not found")
|
|
check(
|
|
converter is not None and converter.get("OperationalToggle").value == "PowerToggle",
|
|
"vanilla HeatDissipationConverter should gate on PowerToggle",
|
|
)
|
|
|
|
battery = vanilla("ships/terran/thermal_battery/thermal_battery.rules")
|
|
storage = battery.get("Part/Components/HeatStorage") if battery else None
|
|
check(storage is not None, "thermal battery HeatStorage not found")
|
|
check(
|
|
storage is not None and storage.get("Type").value == "ResourceStorage",
|
|
"thermal battery HeatStorage should be a ResourceStorage",
|
|
)
|
|
|
|
overclock = vanilla("ships/base_part_overclock.rules")
|
|
overflow = overclock.get("Part/Components/OverflowHeatStorage") if overclock else None
|
|
check(overflow is not None, "base_part_overclock OverflowHeatStorage not found")
|
|
check(
|
|
overflow is not None and overflow.get("Type").value == "ResourceConsumingStorage",
|
|
"OverflowHeatStorage should be a ResourceConsumingStorage",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Every path the mod's actions touch must exist in vanilla data.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def iter_actions(mod):
|
|
actions = mod.get("Actions")
|
|
if actions is None:
|
|
return []
|
|
return [actions.children[key] for key in actions.order]
|
|
|
|
|
|
def test_action_targets_resolve():
|
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
|
actions = iter_actions(mod)
|
|
check(len(actions) > 0, "mod.rules declares no Actions")
|
|
|
|
for action in actions:
|
|
kind = action.get("Action")
|
|
kind = kind.value if kind else "?"
|
|
for field in ("Replace", "OverrideIn", "AddTo", "AddBaseTo", "Remove"):
|
|
node = action.get(field)
|
|
if node is None or node.kind != "scalar":
|
|
continue
|
|
target = node.value
|
|
check(
|
|
resolve(target) is not None,
|
|
f"{kind} action target does not exist in vanilla data: {target}",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Component references must resolve.
|
|
# A component the mod adds may reference its own siblings or any component
|
|
# already on the vanilla part it is being merged into.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def added_component_sets():
|
|
"""Yield (mod_file, vanilla_components_node) for each AddBase action."""
|
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
|
for action in iter_actions(mod):
|
|
kind = action.get("Action")
|
|
if not kind or kind.value != "AddBase":
|
|
continue
|
|
target_node = action.get("AddBaseTo")
|
|
base_node = action.get("BaseToAdd")
|
|
if target_node is None or base_node is None:
|
|
continue
|
|
base_ref = base_node.value.strip()
|
|
match = re.match(r"^&?<([^>]+)>$", base_ref)
|
|
check(match is not None, f"cannot parse BaseToAdd reference: {base_ref}")
|
|
if match is None:
|
|
continue
|
|
mod_file = os.path.join(MOD_ROOT, match.group(1))
|
|
check(os.path.isfile(mod_file), f"BaseToAdd file missing: {match.group(1)}")
|
|
if not os.path.isfile(mod_file):
|
|
continue
|
|
yield mod_file, resolve(target_node.value)
|
|
|
|
|
|
def test_component_references_resolve():
|
|
for mod_file, vanilla_components in added_component_sets():
|
|
added = rules_parser.parse_file(mod_file)
|
|
name = os.path.basename(mod_file)
|
|
check(
|
|
vanilla_components is not None,
|
|
f"{name}: AddBaseTo target missing from vanilla data",
|
|
)
|
|
if vanilla_components is None:
|
|
continue
|
|
|
|
known = set(added.order) | set(vanilla_components.order)
|
|
for component_name in added.order:
|
|
component = added.children[component_name]
|
|
if component.kind != "group":
|
|
continue
|
|
for field in component.order:
|
|
if field not in COMPONENT_REF_FIELDS:
|
|
continue
|
|
value = component.children[field]
|
|
if value.kind != "scalar":
|
|
continue
|
|
ref = value.value.strip()
|
|
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", ref):
|
|
continue # a path or expression, not a plain sibling name
|
|
check(
|
|
ref in known,
|
|
f"{name}: {component_name}.{field} references unknown component '{ref}'",
|
|
)
|
|
|
|
toggles = component.get("Toggles")
|
|
if toggles is not None and toggles.kind == "list":
|
|
for key in toggles.order:
|
|
entry = toggles.children[key]
|
|
if entry.kind == "group":
|
|
# Inline form: { Toggle = Something; Invert = true }
|
|
inner = entry.get("Toggle")
|
|
ref = inner.value.strip() if inner is not None else None
|
|
check(
|
|
ref is not None,
|
|
f"{name}: {component_name}.Toggles has an inline entry with no Toggle",
|
|
)
|
|
else:
|
|
ref = entry.value.strip()
|
|
if ref is None:
|
|
continue
|
|
check(
|
|
ref in known,
|
|
f"{name}: {component_name}.Toggles references unknown component '{ref}'",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Every component Type the mod uses must be a Type the engine already knows,
|
|
# proven by it appearing somewhere in vanilla data. Catches misspellings.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_vanilla_types = None
|
|
|
|
|
|
def vanilla_types():
|
|
global _vanilla_types
|
|
if _vanilla_types is None:
|
|
found = set()
|
|
pattern = re.compile(r"Type\s*=\s*([A-Za-z_][A-Za-z0-9_]*)")
|
|
for root, _dirs, files in os.walk(DATA):
|
|
for filename in files:
|
|
if not filename.endswith(".rules"):
|
|
continue
|
|
with open(os.path.join(root, filename), "r", encoding="utf-8", errors="replace") as handle:
|
|
found.update(pattern.findall(handle.read()))
|
|
_vanilla_types = found
|
|
return _vanilla_types
|
|
|
|
|
|
def test_component_types_exist():
|
|
for mod_file, _ in added_component_sets():
|
|
added = rules_parser.parse_file(mod_file)
|
|
name = os.path.basename(mod_file)
|
|
for component_name in added.order:
|
|
component = added.children[component_name]
|
|
if component.kind != "group":
|
|
continue
|
|
type_node = component.get("Type")
|
|
check(type_node is not None, f"{name}: {component_name} has no Type")
|
|
if type_node is None:
|
|
continue
|
|
check(
|
|
type_node.value in vanilla_types(),
|
|
f"{name}: {component_name} uses unknown component Type '{type_node.value}'",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Signals must use a mechanism that works on a physical ship.
|
|
# Regression guard: version 1.0.0 broadcast with NetworkToggle, which loaded
|
|
# without error and did nothing in game. Every NetworkToggle and
|
|
# NetworkToggleReceiver in vanilla sets IncludeInPhysical = false -- they are
|
|
# blueprint stat-display plumbing only. NetworkValue carries the signal on a
|
|
# real ship, and is what vanilla's thermal amplification pump uses.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
BLUEPRINT_ONLY_TYPES = {"NetworkToggle", "NetworkToggleReceiver"}
|
|
|
|
|
|
def test_no_blueprint_only_signal_types():
|
|
for mod_file, _ in added_component_sets():
|
|
added = rules_parser.parse_file(mod_file)
|
|
name = os.path.basename(mod_file)
|
|
for component_name in added.order:
|
|
component = added.children[component_name]
|
|
if component.kind != "group":
|
|
continue
|
|
type_node = component.get("Type")
|
|
if type_node is None:
|
|
continue
|
|
check(
|
|
type_node.value not in BLUEPRINT_ONLY_TYPES,
|
|
f"{name}: {component_name} uses '{type_node.value}', which vanilla only ever "
|
|
f"uses with IncludeInPhysical = false and which carries no signal in game",
|
|
)
|
|
|
|
|
|
def test_vanilla_marks_those_types_blueprint_only():
|
|
"""The premise of the guard above, re-checked against the installed game."""
|
|
pattern = re.compile(
|
|
r"Type\s*=\s*(NetworkToggle|NetworkToggleReceiver)\b(.*?)(?=\n\s*\})",
|
|
re.DOTALL,
|
|
)
|
|
sites = 0
|
|
physical_false = 0
|
|
for root, _dirs, files in os.walk(DATA):
|
|
for filename in files:
|
|
if not filename.endswith(".rules"):
|
|
continue
|
|
with open(os.path.join(root, filename), "r", encoding="utf-8", errors="replace") as handle:
|
|
for match in pattern.finditer(handle.read()):
|
|
sites += 1
|
|
if "IncludeInPhysical = false" in match.group(2):
|
|
physical_false += 1
|
|
check(sites > 0, "expected vanilla to use NetworkToggle somewhere")
|
|
check(
|
|
sites == physical_false,
|
|
f"premise broken: {sites - physical_false} of {sites} vanilla NetworkToggle sites "
|
|
f"are physical, so the guard in test_no_blueprint_only_signal_types may be wrong",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Added components must not silently overwrite a vanilla component.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_name_collisions_with_vanilla():
|
|
for mod_file, vanilla_components in added_component_sets():
|
|
if vanilla_components is None:
|
|
continue
|
|
added = rules_parser.parse_file(mod_file)
|
|
name = os.path.basename(mod_file)
|
|
for component_name in added.order:
|
|
check(
|
|
component_name not in vanilla_components.children,
|
|
f"{name}: '{component_name}' already exists on the vanilla part and would "
|
|
f"be overwritten",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6b. Every field written on a component must exist in that component's real
|
|
# schema, read out of the game assembly's metadata.
|
|
#
|
|
# Cosmoteer ignores fields it does not recognise rather than reporting them,
|
|
# so a misspelled or invented field name produces a mod that loads perfectly
|
|
# and silently does nothing. This is the only check that catches that.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ASSEMBLY_CANDIDATES = [
|
|
os.path.join(os.path.dirname(DATA or ""), "Bin", "Cosmoteer.dll") if DATA else "",
|
|
]
|
|
|
|
|
|
def find_assembly():
|
|
override = os.environ.get("COSMOTEER_ASSEMBLY")
|
|
if override and os.path.isfile(override):
|
|
return override
|
|
for path in ASSEMBLY_CANDIDATES:
|
|
if path and os.path.isfile(path):
|
|
return path
|
|
return None
|
|
|
|
|
|
def test_component_fields_exist_in_schema():
|
|
assembly = find_assembly()
|
|
if assembly is None:
|
|
print(" (skipped: Cosmoteer.dll not found)")
|
|
return
|
|
|
|
sys.path.insert(0, os.path.join(MOD_ROOT, "tools"))
|
|
import dotnet_enum
|
|
|
|
tables = dotnet_enum.load(assembly)
|
|
|
|
for mod_file, _ in added_component_sets():
|
|
added = rules_parser.parse_file(mod_file)
|
|
name = os.path.basename(mod_file)
|
|
for component_name in added.order:
|
|
component = added.children[component_name]
|
|
if component.kind != "group":
|
|
continue
|
|
type_node = component.get("Type")
|
|
if type_node is None:
|
|
continue
|
|
schema = dotnet_enum.schema(tables, f"Part{type_node.value}Rules")
|
|
check(schema is not None, f"{name}: no schema found for Type '{type_node.value}'")
|
|
if schema is None:
|
|
continue
|
|
|
|
# Some members are complex objects the serializer accepts inline,
|
|
# so their own fields are legal at the component's top level too.
|
|
for member, inline_type in (("NetworkFilter", "PartNetworkFilter"),):
|
|
if member in schema:
|
|
nested = dotnet_enum.schema(tables, inline_type)
|
|
if nested:
|
|
schema = schema | nested
|
|
|
|
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 "
|
|
f"'{field}' -- it would be silently ignored",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6c. Every network component the mod adds must be reachable by a route.
|
|
#
|
|
# A NetworkValue or NetworkValueReceiver is only attached to the network if
|
|
# a route leads to it: the part's NetworkPort anchors on one component, and
|
|
# a NetworkRouter bridges that anchor to the rest. An unrouted network
|
|
# component is not an error -- it simply never connects and always reads as
|
|
# absent. That is exactly how v1.1.0 and v1.2.0 failed: they loaded clean,
|
|
# the components existed on the parts, and every signal read zero.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
NETWORK_COMPONENT_TYPES = {"NetworkValue", "NetworkValueReceiver", "NetworkResourceInput"}
|
|
|
|
|
|
def collect_route_names(node, found=None):
|
|
"""Every component name appearing in any Routes list under this node."""
|
|
if found is None:
|
|
found = set()
|
|
if node.kind not in ("group", "list"):
|
|
return found
|
|
for name in node.order:
|
|
child = node.children[name]
|
|
if name == "Routes" and child.kind == "list":
|
|
for key in child.order:
|
|
route = child.children[key]
|
|
if route.kind == "list":
|
|
for slot in route.order:
|
|
value = route.children[slot]
|
|
if value.kind == "scalar":
|
|
found.add(value.value.strip())
|
|
collect_route_names(child, found)
|
|
return found
|
|
|
|
|
|
def addbase_targets():
|
|
"""Yield (mod_file_path, target_part_rules_path) for each AddBase action."""
|
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
|
for action in iter_actions(mod):
|
|
kind = action.get("Action")
|
|
if not kind or kind.value != "AddBase":
|
|
continue
|
|
target = action.get("AddBaseTo")
|
|
base = action.get("BaseToAdd")
|
|
if target is None or base is None:
|
|
continue
|
|
match = re.match(r"^&?<([^>]+)>$", base.value.strip())
|
|
if match is None:
|
|
continue
|
|
part_path, _inner = split_target(target.value)
|
|
yield os.path.join(MOD_ROOT, match.group(1)), part_path
|
|
|
|
|
|
def test_network_components_are_routed():
|
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
|
|
|
# Routes the mod contributes, keyed by the part they land on.
|
|
routed = {}
|
|
|
|
for mod_file, part_path in addbase_targets():
|
|
added = rules_parser.parse_file(mod_file)
|
|
routed.setdefault(part_path, set()).update(collect_route_names(added))
|
|
|
|
for action in iter_actions(mod):
|
|
kind = action.get("Action")
|
|
add_to = action.get("AddTo")
|
|
if not kind or kind.value != "AddMany" or add_to is None:
|
|
continue
|
|
if "Routes" not in add_to.value:
|
|
continue
|
|
part_path, _inner = split_target(add_to.value)
|
|
entries = action.get("ManyToAdd")
|
|
if entries is None:
|
|
continue
|
|
for key in entries.order:
|
|
route = entries.children[key]
|
|
if route.kind != "list":
|
|
continue
|
|
for slot in route.order:
|
|
value = route.children[slot]
|
|
if value.kind == "scalar":
|
|
routed.setdefault(part_path, set()).add(value.value.strip())
|
|
|
|
for mod_file, part_path in addbase_targets():
|
|
added = rules_parser.parse_file(mod_file)
|
|
name = os.path.basename(mod_file)
|
|
reachable = routed.get(part_path, set())
|
|
for component_name in added.order:
|
|
component = added.children[component_name]
|
|
if component.kind != "group":
|
|
continue
|
|
type_node = component.get("Type")
|
|
if type_node is None or type_node.value not in NETWORK_COMPONENT_TYPES:
|
|
continue
|
|
check(
|
|
component_name in reachable,
|
|
f"{name}: {component_name} ({type_node.value}) is not named in any route, "
|
|
f"so it never attaches to the network and always reads as absent",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6d. An Overrides value that names a component must name one that exists.
|
|
#
|
|
# The whole point of the Overrides actions is to repoint vanilla components
|
|
# at components this mod adds. If such a value names something that is not
|
|
# there -- deleted, renamed, or never written -- the mod still loads and the
|
|
# gate silently reverts to doing nothing.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_override_values_name_real_components():
|
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
|
|
|
# What each part offers: its vanilla components plus what the mod adds.
|
|
available = {}
|
|
for mod_file, part_path in addbase_targets():
|
|
added = rules_parser.parse_file(mod_file)
|
|
vanilla_components = resolve(f"<{part_path}>/Part/Components")
|
|
names = set(added.order)
|
|
if vanilla_components is not None:
|
|
names |= set(vanilla_components.order)
|
|
available.setdefault(part_path, set()).update(names)
|
|
|
|
for action in iter_actions(mod):
|
|
kind = action.get("Action")
|
|
target = action.get("OverrideIn")
|
|
if not kind or kind.value != "Overrides" or target is None:
|
|
continue
|
|
part_path, _inner = split_target(target.value)
|
|
known = available.get(part_path)
|
|
if not known:
|
|
continue
|
|
overrides = action.get("Overrides")
|
|
if overrides is None:
|
|
continue
|
|
for field in overrides.order:
|
|
value = overrides.children[field]
|
|
if value.kind != "scalar":
|
|
continue
|
|
ref = value.value.strip()
|
|
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", ref):
|
|
continue
|
|
check(
|
|
ref in known,
|
|
f"Overrides sets {field} = '{ref}' on {os.path.basename(part_path)}, "
|
|
f"but no such component exists on that part",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Added indicators must bind to a real toggle and a real atlas sprite.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_added_indicators_are_valid():
|
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
|
radiator_components = resolve("<ships/terran/radiator/radiator.rules>/Part/Components")
|
|
added = rules_parser.parse_file(os.path.join(MOD_ROOT, "components", "radiator_auto.rules"))
|
|
known = set(added.order) | set(radiator_components.order if radiator_components else [])
|
|
|
|
atlas = vanilla("gui/game/indicators/indicators.rules")
|
|
check(atlas is not None, "cannot read the indicators atlas")
|
|
|
|
for action in iter_actions(mod):
|
|
kind = action.get("Action")
|
|
add_to = action.get("AddTo")
|
|
if not kind or kind.value != "AddMany" or add_to is None:
|
|
continue
|
|
if "Indicators" not in add_to.value:
|
|
continue
|
|
entries = action.get("ManyToAdd")
|
|
check(entries is not None, "indicator AddMany has no ManyToAdd list")
|
|
if entries is None:
|
|
continue
|
|
for key in entries.order:
|
|
entry = entries.children[key]
|
|
toggle = entry.get("Toggle")
|
|
check(toggle is not None, "an added indicator has no Toggle")
|
|
if toggle is not None:
|
|
check(
|
|
toggle.value.strip() in known,
|
|
f"added indicator binds to unknown component '{toggle.value.strip()}'",
|
|
)
|
|
sprite = entry.get("AtlasSprite/File")
|
|
check(sprite is not None, "an added indicator has no AtlasSprite/File")
|
|
if sprite is not None and atlas is not None:
|
|
name = sprite.value.strip().rsplit("/", 1)[-1]
|
|
check(
|
|
name in atlas.children,
|
|
f"added indicator uses unknown atlas sprite '{name}'",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The gate's semantics: radiator button ON = vanilla, always vent; button OFF
|
|
# = bank heat, and the mod vents only when the network is nearly full. So the
|
|
# gate is PowerToggle OR MustVent, and nothing else -- no ammo criterion, no
|
|
# launcher patch, no cross-part signal.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_gate_is_power_or_must_vent():
|
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
|
for action in iter_actions(mod):
|
|
for field in ("AddBaseTo", "OverrideIn", "AddTo"):
|
|
node = action.get(field)
|
|
check(
|
|
node is None or "missile_launcher" not in node.value,
|
|
f"mod.rules still patches the missile launcher via {field}",
|
|
)
|
|
check(
|
|
not os.path.exists(os.path.join(MOD_ROOT, "components", "launcher_ammo_signal.rules")),
|
|
"components/launcher_ammo_signal.rules should be gone",
|
|
)
|
|
|
|
auto = rules_parser.parse_file(os.path.join(MOD_ROOT, "components", "radiator_auto.rules"))
|
|
gate = auto.get("AutoPowerToggle")
|
|
check(gate is not None, "AutoPowerToggle missing")
|
|
if gate is None:
|
|
return
|
|
check(gate.get("Mode") is not None and gate.get("Mode").value == "Any", "AutoPowerToggle must be Mode = Any (OR)")
|
|
toggles = gate.get("Toggles")
|
|
names = [c.value for c in toggles.children.values()] if toggles is not None and toggles.kind == "list" else []
|
|
check(sorted(names) == ["AutoRad_MustVent", "PowerToggle"], f"AutoPowerToggle toggles should be exactly PowerToggle + AutoRad_MustVent, got {names}")
|
|
for name in auto.order:
|
|
check("Ammo" not in name, f"ammo-signal component still present: {name}")
|
|
|
|
|
|
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"):
|
|
check(field in mod, f"mod.rules is missing required field '{field}'")
|
|
|
|
mod_id = mod.get("ID")
|
|
check(
|
|
mod_id is not None and "." in mod_id.value and not mod_id.value.startswith("cosmoteer."),
|
|
"mod ID must be 'author.mod_name' and must not use the reserved 'cosmoteer' author",
|
|
)
|
|
|
|
|
|
def test_manifest_declares_existing_logo():
|
|
"""Steam Workshop shows the mod's Logo; the file must ship inside the mod
|
|
folder, be a real PNG, and stay under Steam's 500 KB preview limit."""
|
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
|
logo = mod.get("Logo")
|
|
check(logo is not None, "mod.rules is missing the 'Logo' field")
|
|
if logo is None:
|
|
return
|
|
path = os.path.join(MOD_ROOT, logo.value.strip('"'))
|
|
check(os.path.isfile(path), f"Logo file does not exist: {path}")
|
|
if not os.path.isfile(path):
|
|
return
|
|
with open(path, "rb") as f:
|
|
magic = f.read(8)
|
|
check(magic == b"\x89PNG\r\n\x1a\n", f"Logo is not a PNG: {path}")
|
|
check(os.path.getsize(path) < 500_000, "Logo must be under 500 KB for Workshop upload")
|
|
|
|
|
|
def main():
|
|
if DATA is None or not os.path.isdir(DATA):
|
|
print("SKIP: Cosmoteer Data folder not found. Set COSMOTEER_DATA to run these tests.")
|
|
return 0
|
|
|
|
print(f"Cosmoteer data: {DATA}\n")
|
|
for name, test in sorted(globals().items()):
|
|
if name.startswith("test_") and callable(test):
|
|
before = len(_failures)
|
|
test()
|
|
status = "PASS" if len(_failures) == before else "FAIL"
|
|
print(f" [{status}] {name}")
|
|
|
|
print(f"\n{_checks} checks run.")
|
|
if _failures:
|
|
print(f"{len(_failures)} FAILED:\n")
|
|
for failure in _failures:
|
|
print(f" - {failure}")
|
|
return 1
|
|
print("All checks passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|