Files
cosmoteer-pressure-relief-r…/tests/rules_parser.py
T
TotenKnopfandClaude Opus 5 831a628b53 feat: auto-gate radiators on heat network surplus
Radiators and Thermal Canister Missile launchers pull from the same heat
pool with identical NetworkResourceOutput settings, and priorities.rules
defines no ordering for heat. A constantly-running radiator therefore
spends missile ammunition to make steam.

Gate radiator dissipation on three network signals instead of the raw
PowerToggle: a 98%/94% hysteresis band on the thermal battery, whether
any launcher is actually set to thermal ammo, and an override driven by
the engine's own OverflowHeatStorage as a safety net.

Storage fullness causes no damage on its own -- only overflow does, and
only above 350 status per tile -- so the band sits high deliberately to
keep the network full as a missile reserve.

Signal polarity is "hold", not "vent", so any ship missing a broadcaster
falls back to vanilla behaviour.

Includes an offline validation suite that resolves every patch target,
component reference and component Type against the installed game data,
since all three otherwise fail only as a crash on game load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni
2026-08-28 23:24:32 +02:00

215 lines
6.8 KiB
Python

# ABOUT ME
# Minimal parser for Cosmoteer ".rules" files.
#
# Why this exists: a Cosmoteer mod patches the game by naming paths into vanilla
# .rules data (e.g. "<ships/terran/radiator/radiator.rules>/Part/Components/
# HeatDissipationConverter"). A single wrong path is not caught until the game
# crashes on load, with only a log file to go on. This parser resolves those
# paths offline so the test suite can prove every target exists before launch.
#
# Scope: structure only -- named groups, lists, and the raw text of scalar
# values. It does not evaluate the "&" reference expressions, arithmetic, or
# the ":" inheritance chains, because the tests only need to know that a node
# exists and which keys it declares.
import re
_TOKEN = re.compile(
r"""
(?P<string>"(?:[^"\\]|\\.)*") # quoted string
| (?P<punct>[{}\[\]=:;]) # structural punctuation
| (?P<comma>,) # separator, carries no meaning
| (?P<space>\s+)
| (?P<word>[^\s{}\[\]=:;,"]+) # bare word / expression fragment
""",
re.VERBOSE,
)
# Tokens that can only begin a new entry, never continue a scalar value.
_ENTRY_STARTERS = ("=", "{", "[", ":")
class Node:
"""A parsed .rules node: a group, a list, or a scalar."""
def __init__(self, kind, value=None):
self.kind = kind # "group" | "list" | "scalar"
self.children = {} # name (or str index) -> Node
self.order = [] # child names in declaration order
self.bases = [] # raw text of ":" inheritance sources
self.value = value # raw text, for scalars
def add(self, name, node):
if name not in self.children:
self.order.append(name)
self.children[name] = node
def get(self, path):
"""Resolve a "/"-separated path. Returns None if any step is missing."""
node = self
for part in [p for p in path.split("/") if p]:
if node is None or part not in node.children:
return None
node = node.children[part]
return node
def __contains__(self, name):
return name in self.children
def __repr__(self):
if self.kind == "scalar":
return f"Scalar({self.value!r})"
return f"{self.kind.title()}({', '.join(self.order)})"
def _strip_comments(text):
"""Remove // comments, leaving anything inside a quoted string alone."""
out = []
in_string = False
i = 0
while i < len(text):
ch = text[i]
if in_string:
if ch == "\\":
out.append(text[i : i + 2])
i += 2
continue
if ch == '"':
in_string = False
out.append(ch)
elif ch == '"':
in_string = True
out.append(ch)
elif ch == "/" and text[i : i + 2] == "//":
while i < len(text) and text[i] != "\n":
i += 1
continue
else:
out.append(ch)
i += 1
return "".join(out)
def _tokenize(text):
tokens = []
for match in _TOKEN.finditer(_strip_comments(text)):
if match.lastgroup in ("space", "comma"):
continue
tokens.append(match.group())
return tokens
class _Parser:
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
def peek(self):
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def next(self):
token = self.peek()
self.pos += 1
return token
def parse_group(self, terminator=None):
node = Node("group")
while True:
token = self.peek()
if token is None:
break
if token == terminator:
self.next()
break
if token in ("}", "]"):
self.next()
break
if token == ";":
self.next()
continue
name = self.next().strip('"')
self.parse_entry(node, name)
return node
def parse_list(self):
node = Node("list")
index = 0
while True:
token = self.peek()
if token is None or token == "]":
self.next()
break
if token == ";":
self.next()
continue
if token == "{":
self.next()
node.add(str(index), self.parse_group("}"))
elif token == "[":
self.next()
node.add(str(index), self.parse_list())
else:
node.add(str(index), Node("scalar", self.next()))
index += 1
return node
def parse_entry(self, parent, name):
"""Parse one "name ..." entry and attach it to parent."""
bases = []
while self.peek() == ":":
self.next()
bases.append(self.next())
token = self.peek()
if token == "{":
self.next()
child = self.parse_group("}")
elif token == "[":
self.next()
child = self.parse_list()
elif token == "=":
self.next()
child = self.parse_value()
else:
# A bare name with only inheritance, e.g. "Foo : Bar" with no body.
child = Node("group")
child.bases = bases
parent.add(name, child)
def parse_value(self):
token = self.peek()
if token == "{":
self.next()
return self.parse_group("}")
if token == "[":
self.next()
return self.parse_list()
# Scalar: consume tokens until a structural boundary. Expressions such as
# "(&../Foo/MAX) * 2" arrive as several bare words, so collect them all.
#
# The .rules format has no statement terminator, so the only way to know
# a value has ended is to look one token ahead: a bare word followed by
# "=", "{", "[" or ":" is the name of the *next* entry, not part of this
# value. Without this lookahead, "DescriptionKey = "x"" swallows the
# "Resources" that follows it and the rest of the file shifts.
parts = []
while True:
token = self.peek()
if token is None or token in (";", "}", "]", "{", "["):
break
if parts:
lookahead = self.tokens[self.pos + 1] if self.pos + 1 < len(self.tokens) else None
if lookahead in _ENTRY_STARTERS:
break
parts.append(self.next())
return Node("scalar", " ".join(parts))
def parse(text):
return _Parser(_tokenize(text)).parse_group()
def parse_file(path):
with open(path, "r", encoding="utf-8", errors="replace") as handle:
return parse(handle.read())