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
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
# 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())
|
||||
Reference in New Issue
Block a user