"""Checks the checked-in AdminBridge GameScript against the client that talks to it. The bridge is Squirrel, and CI has no Squirrel toolchain, so these are not a substitute for running it (see gamescript/AdminBridge/README.md). They cover the one class of breakage that is invisible until a server is in front of you: the two halves of the protocol drifting apart -- a version bumped on one side only, an event kind or a command the client uses that the bridge does not implement, or the docker setup no longer serving the tracked copy. """ import re from pathlib import Path import pytest from openttd.protocol import GS_BRIDGE_VERSION, GameEventType ROOT = Path(__file__).resolve().parents[1] GS_DIR = ROOT / "gamescript" / "AdminBridge" INFO_NUT = (GS_DIR / "info.nut").read_text() MAIN_NUT = (GS_DIR / "main.nut").read_text() def _block(source, opening, closing): """Return the text between `opening` and the next `closing`, e.g. an array or table body.""" start = source.index(opening) + len(opening) return source[start:source.index(closing, start)] @pytest.mark.unit def test_gamescript_version_matches_client(): """info.nut, main.nut and the client must all name the same protocol version. A GameScript cannot read its own info.nut at runtime, so main.nut duplicates the version; this is what keeps the copy honest, and what makes a bump that misses a file fail here. """ info_version = int(re.search(r"function GetVersion\(\)\s*{\s*return (\d+);", INFO_NUT).group(1)) main_version = int(re.search(r"BRIDGE_VERSION = (\d+);", MAIN_NUT).group(1)) assert info_version == main_version == GS_BRIDGE_VERSION @pytest.mark.unit def test_gamescript_can_load_older_savegames(): """The bridge must stay loadable by savegames that pinned an older version of it. OpenTTD defaults MinVersionToLoad() to GetVersion(), so without an explicit override every version bump orphans existing savegames: the engine finds no compatible script and falls back with a warning. The bridge keeps no savegame state, so any version can take over. """ min_version = int(re.search(r"function MinVersionToLoad\(\)\s*{\s*return (\d+);", INFO_NUT).group(1)) assert min_version <= GS_BRIDGE_VERSION @pytest.mark.unit def test_gamescript_event_catalogue_matches_client_enum(): """Every kind the bridge can push has a GameEventType, and vice versa. GameEventType is what subscribe_events() validates against, so a kind in one list and not the other is either an event nobody can subscribe to or a subscription the bridge rejects. """ catalogue = set(re.findall(r'"(\w+)"', _block(MAIN_NUT, "this.event_order = [", "];"))) # EventsDropped is emitted by the bridge itself and never subscribed to, so it is # deliberately absent from the subscribable catalogue. assert catalogue == {e.value for e in GameEventType} - {GameEventType.EventsDropped} @pytest.mark.unit def test_gamescript_implements_every_command_the_client_sends(): """Each command in a client payload must have a handler in the bridge's dispatch table. A command the bridge does not know is silently dropped, so the caller only sees a timeout. Checked one way round only: the bridge is allowed to implement more than the client wraps, which is how list_cargo sat there answering nobody until a method was written for it. """ commands = set(re.findall(r"^\s*(\w+)\s*=\s*{ handler", _block(MAIN_NUT, "COMMANDS = {", "\n\t};"), re.MULTILINE)) client_source = (ROOT / "lib" / "openttd" / "client.py").read_text() sent = set(re.findall(r'{"command": "(\w+)"', client_source)) assert sent, "no GameScript commands found in the client -- has the payload spelling changed?" assert sent <= commands @pytest.mark.unit def test_docker_serves_the_tracked_gamescript(): """The container must run the copy in git, not an untracked one under docker/config/. That mount is the whole reason the tracked copy stays honest: without it the server reads a file nobody reviews, which is how the bridge went unversioned in the first place. """ compose = (ROOT / "docker" / "docker-compose.yml").read_text() assert "../gamescript/AdminBridge:" in compose