1261c30724
The interceptor's launch is one sequence: the cap opens, the round is thrown out unlit and slows under linear gas drag (`cold` ticks), the motor lights where it has reached, it turns with a growing rate while thrust spools up (`boost` ticks), and full guidance starts only at `cold + boost`. A rail launch lights at once but spools over the same `boost` ticks; the electric P1-SUN has no rocket spool or plume. S-400/500 now hold the unlit ejection for 20/24 ticks (1.00/1.20 s at 20 Hz); IRIS-T/Tor keep shorter 8-tick throws. Ignition, its sound, flame and recorded MotorIgnition event all use the missile's current position. The RM48U strike follows the same 20-tick choreography, counts the throw in its flight time and starts its recorded smoke at ignition. Maximum speed, turn, accel, pk and every collision rule are unchanged. Co-Authored-By: Kimi Code <noreply@kimi.com>
382 lines
18 KiB
Python
382 lines
18 KiB
Python
"""Shared content identities and item presentation used by runtime and assets."""
|
|
import json
|
|
import math
|
|
import re
|
|
from pathlib import Path
|
|
|
|
PATH = Path(__file__).resolve().parents[2] / "catalog" / "content.json"
|
|
SPECS = PATH.parent / "specs.json"
|
|
|
|
FAMILIES = {"trainable", "gun", "turret", "vertical", "static", "truck", "light"}
|
|
CLASSES = {"drone", "jet", "cruise", "supersonic", "ballistic"}
|
|
MUZZLES = {"none", "patriot", "pantsir", "grid", "rails", "cells"}
|
|
ENGINES = {"prop": {"type", "back", "size"}, "jet": {"type", "up", "back", "size"},
|
|
"rocket": {"type", "back", "size"}}
|
|
MODEL = r"airdef:[a-z0-9_]+"
|
|
|
|
|
|
def load():
|
|
data = json.loads(PATH.read_text(encoding="utf-8"))
|
|
items = data["items"]
|
|
ids = [item["id"] for item in items]
|
|
if len(ids) != len(set(ids)):
|
|
raise ValueError("duplicate item ID in content catalog")
|
|
referenced = []
|
|
for group in (data["aircraft"], data["units"]):
|
|
kinds = [entry["kind"] for entry in group]
|
|
if len(kinds) != len(set(kinds)):
|
|
raise ValueError("duplicate kind in content catalog")
|
|
referenced.extend(entry["item"] for entry in group if entry["item"] is not None)
|
|
if len(referenced) != len(set(referenced)) or not set(referenced) <= set(ids):
|
|
raise ValueError("duplicate or unregistered content item")
|
|
for item in items:
|
|
if len(item["color"]) != 3 or any(not isinstance(c, int) or not 0 <= c <= 255 for c in item["color"]):
|
|
raise ValueError(f"invalid icon color for {item['id']}")
|
|
return data
|
|
|
|
|
|
def load_sounds():
|
|
"""Load stable event IDs and engine attenuation metadata."""
|
|
entries = json.loads((PATH.parent / "sounds.json").read_text(encoding="utf-8"))
|
|
seen = set()
|
|
for entry in entries:
|
|
event, distance, far = entry["event"], entry["distance"], entry["far"]
|
|
if not re.fullmatch(r"[a-z0-9_]+\.[a-z0-9_]+", event) or event in seen:
|
|
raise ValueError(f"invalid or duplicate sound event {event!r}")
|
|
if type(distance) is not int or distance <= 0 or type(far) is not bool:
|
|
raise ValueError(f"invalid sound metadata for {event}")
|
|
seen.add(event)
|
|
names = [entry["event"].replace(".", "_") for entry in entries]
|
|
if len(names) != len(set(names)):
|
|
raise ValueError("sound event IDs collide as resource names")
|
|
registered = [name for entry in entries for name in ([entry["event"], entry["event"] + "_far"] if entry["far"] else [entry["event"]])]
|
|
if len(registered) != len(set(registered)):
|
|
raise ValueError("sound event IDs collide with far variants")
|
|
return entries
|
|
|
|
|
|
def _exact(entry, keys, where):
|
|
if not isinstance(entry, dict) or set(entry) != set(keys):
|
|
got = sorted(entry) if isinstance(entry, dict) else type(entry).__name__
|
|
raise ValueError(f"{where}: expected keys {sorted(keys)}, got {got}")
|
|
|
|
|
|
def _number(value, where, minimum=None):
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
raise ValueError(f"{where}: expected a finite number, got {value!r}")
|
|
if minimum is not None and value < minimum:
|
|
raise ValueError(f"{where}: {value!r} is below {minimum}")
|
|
return value
|
|
|
|
|
|
def _integer(value, where, minimum=0):
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
raise ValueError(f"{where}: expected an integer >= {minimum}, got {value!r}")
|
|
return value
|
|
|
|
|
|
def _text(value, where, pattern=None):
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError(f"{where}: expected a non-empty string")
|
|
if pattern and not re.fullmatch(pattern, value):
|
|
raise ValueError(f"{where}: {value!r} does not match {pattern}")
|
|
return value
|
|
|
|
|
|
def _sound(value, where, events):
|
|
_text(value, where, r"[a-z0-9_]+\.[a-z0-9_]+")
|
|
if value not in events:
|
|
raise ValueError(f"{where}: sound event {value!r} is not registered in sounds.json")
|
|
|
|
|
|
def _named_ref(value, where, named, kind):
|
|
_exact(value, {"ref"}, where)
|
|
name = value["ref"]
|
|
if name not in named or named[name]["kind"] != kind:
|
|
raise ValueError(f"{where}: unknown {kind} reference {name!r}")
|
|
return name
|
|
|
|
|
|
def _model_ref(value, where, named, kind):
|
|
return f"NAMED_{_named_ref(value, where, named, kind).upper()}"
|
|
|
|
|
|
def _parts(entry, where, named):
|
|
_exact(entry, {"body", "launcher", "pack", "dish", "fixed", "spinner"}, where)
|
|
_text(entry["body"], f"{where}.body", MODEL)
|
|
for slot in ("launcher", "pack"):
|
|
if entry[slot] is not None:
|
|
_text(entry[slot], f"{where}.{slot}", MODEL)
|
|
dish = entry["dish"]
|
|
if dish is not None:
|
|
if not isinstance(dish, list) or len(dish) != 4:
|
|
raise ValueError(f"{where}.dish: expected [model, height, forward, tilt]")
|
|
_text(dish[0], f"{where}.dish.model", MODEL)
|
|
for i, v in enumerate(dish[1:], 1):
|
|
_number(v, f"{where}.dish[{i}]")
|
|
fixed = entry["fixed"]
|
|
if isinstance(fixed, dict):
|
|
_named_ref(fixed, f"{where}.fixed", named, "fixed")
|
|
elif not isinstance(fixed, list):
|
|
raise ValueError(f"{where}.fixed: expected a list or a named reference")
|
|
else:
|
|
for i, piece in enumerate(fixed):
|
|
if not isinstance(piece, list) or len(piece) != 3:
|
|
raise ValueError(f"{where}.fixed[{i}]: expected [model, height, back]")
|
|
_text(piece[0], f"{where}.fixed[{i}].model", MODEL)
|
|
_number(piece[1], f"{where}.fixed[{i}].height")
|
|
_number(piece[2], f"{where}.fixed[{i}].back")
|
|
spinner = entry["spinner"]
|
|
if isinstance(spinner, dict):
|
|
_named_ref(spinner, f"{where}.spinner", named, "spinner")
|
|
elif spinner is not None:
|
|
if not isinstance(spinner, list) or len(spinner) != 5:
|
|
raise ValueError(f"{where}.spinner: expected [model, height, back, size, tilt]")
|
|
_text(spinner[0], f"{where}.spinner.model", MODEL)
|
|
for i, v in enumerate(spinner[1:], 1):
|
|
_number(v, f"{where}.spinner[{i}]")
|
|
|
|
|
|
def _muzzle(entry, where):
|
|
if not isinstance(entry, dict) or entry.get("type") not in MUZZLES:
|
|
raise ValueError(f"{where}: expected one of {sorted(MUZZLES)}")
|
|
kind = entry["type"]
|
|
keys = {"grid": {"type", "cols", "rows", "size"}, "rails": {"type", "xs"}}.get(kind, {"type"})
|
|
_exact(entry, keys, where)
|
|
if kind == "grid":
|
|
_integer(entry["cols"], f"{where}.cols", 1)
|
|
_integer(entry["rows"], f"{where}.rows", 1)
|
|
_number(entry["size"], f"{where}.size", 0)
|
|
elif kind == "rails":
|
|
xs = entry["xs"]
|
|
if not isinstance(xs, list) or not xs:
|
|
raise ValueError(f"{where}.xs: expected a non-empty list")
|
|
for i, v in enumerate(xs):
|
|
_number(v, f"{where}.xs[{i}]")
|
|
|
|
|
|
def _rest_pitch(entry, where):
|
|
if isinstance(entry, dict):
|
|
_exact(entry, {"half_pi"}, where)
|
|
_number(entry["half_pi"], f"{where}.half_pi")
|
|
else:
|
|
_number(entry, where)
|
|
|
|
|
|
def load_specs():
|
|
"""Load the numeric profiles shared by Rust and Python, validating every reference."""
|
|
return validate_specs(json.loads(SPECS.read_text(encoding="utf-8")), load(),
|
|
{entry["event"] for entry in load_sounds()})
|
|
|
|
|
|
def validate_specs(data, content, events):
|
|
"""Check a specs document against the content identities and registered sound events.
|
|
|
|
Nothing here names a unit or aircraft kind: a new type only needs its catalog entry.
|
|
"""
|
|
_exact(data, {"shared", "drones", "units", "missiles", "named"}, "specs.json")
|
|
|
|
shared = data["shared"]
|
|
_exact(shared, {"mobility", "flight", "logistics"}, "shared")
|
|
_exact(shared["mobility"], {"drive_scale", "drive_ramp", "trainable", "vertical", "other"}, "shared.mobility")
|
|
_number(shared["mobility"]["drive_scale"], "shared.mobility.drive_scale", 0)
|
|
_number(shared["mobility"]["drive_ramp"], "shared.mobility.drive_ramp", 0)
|
|
for family in ("trainable", "vertical", "other"):
|
|
axes = shared["mobility"][family]
|
|
if not isinstance(axes, list) or len(axes) != 2:
|
|
raise ValueError(f"shared.mobility.{family}: expected [yaw, pitch]")
|
|
for i, v in enumerate(axes):
|
|
_number(v, f"shared.mobility.{family}[{i}]")
|
|
_exact(shared["flight"], {"gravity", "block_split"}, "shared.flight")
|
|
_number(shared["flight"]["gravity"], "shared.flight.gravity", 0)
|
|
_number(shared["flight"]["block_split"], "shared.flight.block_split", 0)
|
|
_exact(shared["logistics"], {"depot_reach", "handover", "truck_load", "stock_limit"}, "shared.logistics")
|
|
_number(shared["logistics"]["depot_reach"], "shared.logistics.depot_reach", 0)
|
|
_number(shared["logistics"]["handover"], "shared.logistics.handover", 0)
|
|
_integer(shared["logistics"]["truck_load"], "shared.logistics.truck_load", 1)
|
|
_integer(shared["logistics"]["stock_limit"], "shared.logistics.stock_limit", 1)
|
|
|
|
named = data["named"]
|
|
if not isinstance(named, dict):
|
|
raise ValueError("named: expected an object")
|
|
referenced = set()
|
|
for name, entry in named.items():
|
|
where = f"named.{name}"
|
|
_exact(entry, {"kind", "value"}, where)
|
|
value = entry["value"]
|
|
if not isinstance(value, list):
|
|
raise ValueError(f"{where}.value: expected a list")
|
|
if entry["kind"] == "fixed":
|
|
for i, piece in enumerate(value):
|
|
if not isinstance(piece, list) or len(piece) != 3:
|
|
raise ValueError(f"{where}.value[{i}]: expected [model, height, back]")
|
|
_text(piece[0], f"{where}.value[{i}].model", MODEL)
|
|
_number(piece[1], f"{where}.value[{i}].height")
|
|
_number(piece[2], f"{where}.value[{i}].back")
|
|
elif entry["kind"] == "spinner":
|
|
if len(value) != 5:
|
|
raise ValueError(f"{where}: expected [model, height, back, size, tilt]")
|
|
_text(value[0], f"{where}.model", MODEL)
|
|
for i, v in enumerate(value[1:], 1):
|
|
_number(v, f"{where}[{i}]")
|
|
elif entry["kind"] == "pivot":
|
|
if len(value) != 3:
|
|
raise ValueError(f"{where}: expected [x, y, z]")
|
|
for i, v in enumerate(value):
|
|
_number(v, f"{where}[{i}]")
|
|
else:
|
|
raise ValueError(f"{where}.kind: expected fixed, spinner or pivot")
|
|
|
|
missile_kinds = set()
|
|
for i, entry in enumerate(data["missiles"]):
|
|
where = f"missiles[{i}]"
|
|
_exact(entry, {"kind", "model", "speed", "accel", "turn", "boost", "cold", "eject", "eject_drag",
|
|
"pk", "fuse", "life", "scale", "flame", "rear", "seeker", "launch_sound", "voice"}, where)
|
|
kind = _text(entry["kind"], f"{where}.kind", r"[A-Z][A-Za-z0-9]*")
|
|
if kind in missile_kinds:
|
|
raise ValueError(f"{where}: duplicate missile kind {kind}")
|
|
missile_kinds.add(kind)
|
|
_text(entry["model"], f"{where}.model", MODEL)
|
|
for field in ("speed", "accel", "turn", "pk", "fuse", "scale", "flame", "rear", "seeker"):
|
|
_number(entry[field], f"{where}.{field}", 0)
|
|
_integer(entry["boost"], f"{where}.boost")
|
|
_integer(entry["cold"], f"{where}.cold")
|
|
_number(entry["eject"], f"{where}.eject", 0)
|
|
_number(entry["eject_drag"], f"{where}.eject_drag", 0)
|
|
_integer(entry["life"], f"{where}.life", 1)
|
|
_sound(entry["launch_sound"], f"{where}.launch_sound", events)
|
|
_exact(entry["voice"], {"event", "gain", "pitch"}, f"{where}.voice")
|
|
_sound(entry["voice"]["event"], f"{where}.voice.event", events)
|
|
_number(entry["voice"]["gain"], f"{where}.voice.gain", 0)
|
|
_number(entry["voice"]["pitch"], f"{where}.voice.pitch", 0)
|
|
|
|
drone_kinds = []
|
|
keys = set()
|
|
for i, entry in enumerate(data["drones"]):
|
|
where = f"drones[{i}]"
|
|
_exact(entry, {"kind", "key", "name", "model", "speed", "turn", "cruise", "clearance", "dive",
|
|
"warhead", "scale", "engine", "decoy", "stealth", "sound", "class", "ballistic",
|
|
"rcs", "launch_sound"}, where)
|
|
kind = _text(entry["kind"], f"{where}.kind", r"[A-Z][A-Za-z0-9]*")
|
|
drone_kinds.append(kind)
|
|
key = _text(entry["key"], f"{where}.key", r"[a-z0-9_]+")
|
|
if key in keys:
|
|
raise ValueError(f"{where}: duplicate key {key}")
|
|
keys.add(key)
|
|
_text(entry["name"], f"{where}.name")
|
|
_text(entry["model"], f"{where}.model", MODEL)
|
|
for field in ("speed", "turn", "cruise", "clearance", "dive", "scale", "stealth", "rcs"):
|
|
_number(entry[field], f"{where}.{field}", 0)
|
|
_integer(entry["warhead"], f"{where}.warhead")
|
|
if not isinstance(entry["decoy"], bool):
|
|
raise ValueError(f"{where}.decoy: expected a boolean")
|
|
engine = entry["engine"]
|
|
if not isinstance(engine, dict) or engine.get("type") not in ENGINES:
|
|
raise ValueError(f"{where}.engine: expected one of {sorted(ENGINES)}")
|
|
_exact(engine, ENGINES[engine["type"]], f"{where}.engine")
|
|
for field in ENGINES[engine["type"]] - {"type"}:
|
|
_number(engine[field], f"{where}.engine.{field}")
|
|
_exact(entry["sound"], {"event", "pitch", "interval"}, f"{where}.sound")
|
|
_sound(entry["sound"]["event"], f"{where}.sound.event", events)
|
|
_number(entry["sound"]["pitch"], f"{where}.sound.pitch", 0)
|
|
_integer(entry["sound"]["interval"], f"{where}.sound.interval", 1)
|
|
if entry["class"] not in CLASSES:
|
|
raise ValueError(f"{where}.class: expected one of {sorted(CLASSES)}")
|
|
if entry["ballistic"] is not None:
|
|
_number(entry["ballistic"], f"{where}.ballistic", 0)
|
|
_sound(entry["launch_sound"], f"{where}.launch_sound", events)
|
|
|
|
unit_kinds = []
|
|
used_missiles = set()
|
|
for i, entry in enumerate(data["units"]):
|
|
where = f"units[{i}]"
|
|
_exact(entry, {"kind", "key", "name", "missile", "gun", "reload", "scale", "rest_pitch",
|
|
"roles", "family", "parts", "mount", "pivot", "muzzle", "sensor", "mast"}, where)
|
|
kind = _text(entry["kind"], f"{where}.kind", r"[A-Z][A-Za-z0-9]*")
|
|
unit_kinds.append(kind)
|
|
key = _text(entry["key"], f"{where}.key", r"[a-z0-9_]+")
|
|
if key in keys:
|
|
raise ValueError(f"{where}: duplicate key {key}")
|
|
keys.add(key)
|
|
_text(entry["name"], f"{where}.name")
|
|
_integer(entry["reload"], f"{where}.reload")
|
|
_number(entry["scale"], f"{where}.scale", 0)
|
|
_rest_pitch(entry["rest_pitch"], f"{where}.rest_pitch")
|
|
_integer(entry["roles"], f"{where}.roles")
|
|
if entry["roles"] > 0x1F:
|
|
raise ValueError(f"{where}.roles: mask out of range")
|
|
if entry["family"] not in FAMILIES:
|
|
raise ValueError(f"{where}.family: expected one of {sorted(FAMILIES)}")
|
|
_parts(entry["parts"], f"{where}.parts", named)
|
|
if not isinstance(entry["mount"], list) or len(entry["mount"]) != 2:
|
|
raise ValueError(f"{where}.mount: expected [up, forward]")
|
|
for j, v in enumerate(entry["mount"]):
|
|
_number(v, f"{where}.mount[{j}]")
|
|
pivot = entry["pivot"]
|
|
if isinstance(pivot, dict):
|
|
_model_ref(pivot, f"{where}.pivot", named, "pivot")
|
|
elif not isinstance(pivot, list) or len(pivot) != 3:
|
|
raise ValueError(f"{where}.pivot: expected [x, y, z] or a named reference")
|
|
else:
|
|
for j, v in enumerate(pivot):
|
|
_number(v, f"{where}.pivot[{j}]")
|
|
_muzzle(entry["muzzle"], f"{where}.muzzle")
|
|
_number(entry["sensor"], f"{where}.sensor", 0)
|
|
_number(entry["mast"], f"{where}.mast", 0)
|
|
missile = entry["missile"]
|
|
if missile is not None:
|
|
_exact(missile, {"kind", "magazine", "min", "max", "interval"}, f"{where}.missile")
|
|
used_missiles.add(_text(missile["kind"], f"{where}.missile.kind", r"[A-Z][A-Za-z0-9]*"))
|
|
_integer(missile["magazine"], f"{where}.missile.magazine", 1)
|
|
_number(missile["min"], f"{where}.missile.min", 0)
|
|
_number(missile["max"], f"{where}.missile.max", 0)
|
|
_integer(missile["interval"], f"{where}.missile.interval", 1)
|
|
gun = entry["gun"]
|
|
if gun is not None:
|
|
_exact(gun, {"range", "track", "p", "bursts", "shell", "slew"}, f"{where}.gun")
|
|
for field in ("range", "track", "p", "shell"):
|
|
_number(gun[field], f"{where}.gun.{field}", 0)
|
|
_integer(gun["bursts"], f"{where}.gun.bursts", 1)
|
|
if not isinstance(gun["slew"], list) or len(gun["slew"]) != 2:
|
|
raise ValueError(f"{where}.gun.slew: expected [yaw, pitch]")
|
|
for j, v in enumerate(gun["slew"]):
|
|
_number(v, f"{where}.gun.slew[{j}]", 0)
|
|
|
|
if set(drone_kinds) != {entry["kind"] for entry in content["aircraft"]}:
|
|
raise ValueError("specs.json drones and content.json aircraft disagree")
|
|
if len(drone_kinds) != len(content["aircraft"]):
|
|
raise ValueError("specs.json drones and content.json aircraft differ in count")
|
|
if set(unit_kinds) != {entry["kind"] for entry in content["units"]}:
|
|
raise ValueError("specs.json units and content.json units disagree")
|
|
if len(unit_kinds) != len(content["units"]):
|
|
raise ValueError("specs.json units and content.json units differ in count")
|
|
missing = used_missiles - missile_kinds
|
|
if missing:
|
|
raise ValueError(f"units reference missiles without a profile: {sorted(missing)}")
|
|
unused = missile_kinds - used_missiles
|
|
if unused:
|
|
raise ValueError(f"missile profiles are not carried by any unit: {sorted(unused)}")
|
|
for entry in data["units"]:
|
|
parts = entry["parts"]
|
|
for slot in ("fixed", "spinner"):
|
|
if isinstance(parts[slot], dict):
|
|
referenced.add(parts[slot]["ref"])
|
|
if isinstance(entry["pivot"], dict):
|
|
referenced.add(entry["pivot"]["ref"])
|
|
idle = set(named) - referenced
|
|
if idle:
|
|
raise ValueError(f"named part profiles are not referenced: {sorted(idle)}")
|
|
return data
|
|
|
|
|
|
def sound_events(entries):
|
|
"""Build the Minecraft sound event registry without generating audio."""
|
|
events = {}
|
|
for entry in entries:
|
|
event, distance = entry["event"], entry["distance"]
|
|
name = event.replace(".", "_")
|
|
events[event] = {"sounds": [{"name": f"airdef:{name}", "attenuation_distance": distance}]}
|
|
if entry["far"]:
|
|
events[event + "_far"] = {"sounds": [{"name": f"airdef:{name}_far", "attenuation_distance": int(distance * 1.6)}]}
|
|
return events
|