Put the AdminBridge GameScript under version control
The last commit noted in passing that the server-side half of the admin
GameScript channel "is not in this repo -- docker/config is gitignored --
so it has to be updated separately for any of this to work." That was true
of all nine features the README documents: list_vehicles, list_stations,
list_cargo, get_timetable, get_station, get_station_cargo, get_dispatch and
the event stream all answer from 705 lines of Squirrel that no clone could
reproduce, no reviewer could see, and CI never touched.
The bridge now lives in gamescript/AdminBridge/ with its own README, the
same arrangement docker/patches/ uses for the local JGRPP patches, and
docker-compose.yml bind-mounts it read-only over the container's
game/AdminBridge. docker/config stays ignored -- it also holds savegames,
downloaded content and generated config -- so the copy under it is now
shadowed and can be deleted. main.nut is byte-identical to what was running,
apart from the version work below.
Adds a version handshake, because the channel gives no way to tell a stale
bridge from a hung one: a bridge that does not recognise a command drops it
silently, so a client ahead of the server sees nothing but timeouts. The
bridge now answers get_version with its protocol version plus its command
and event catalogues, and get_bridge_version() raises when that is below
GS_BRIDGE_VERSION. It is opt-in rather than checked on connect: GameScripts
do not tick while the game is paused, so an automatic check would refuse to
connect to a paused server. A bridge older than 4 predates get_version
itself and can only fail by timing out, so the E2E test catches that and
reports it by name instead.
tests/test_gamescript.py gives CI a foothold on the GameScript without a
Squirrel toolchain: it parses the .nut files and pins the protocol version
across info.nut, main.nut and protocol.py, the event catalogue against
GameEventType, and the command table against the commands client.py sends.
The version has to be declared three times because a GameScript cannot read
its own info.nut at runtime -- GSController.GetVersion() returns the OpenTTD
version, not the script's.
info.nut also gains MinVersionToLoad() { return 1; }. The engine defaults it
to GetVersion(), so without it this bump would orphan every savegame pinned
to version 3: the scanner finds no compatible script and falls back with a
warning. The bridge keeps no savegame state, so any version can take over.
HandleCommand now dispatches through the same table get_version reports,
rather than an if/else chain, so the catalogue a client feature-detects
against cannot drift from what is implemented.
Co-Authored-By: Claude <[email protected]>
This commit is contained in:
+58
-1
@@ -5,7 +5,12 @@ import os
|
||||
import monocypher
|
||||
import pytest
|
||||
from openttd import OpenTTDAdminClient
|
||||
from openttd.protocol import AdminUpdateFrequency, AdminUpdateType, PacketAdminType
|
||||
from openttd.protocol import (
|
||||
GS_BRIDGE_VERSION,
|
||||
AdminUpdateFrequency,
|
||||
AdminUpdateType,
|
||||
PacketAdminType,
|
||||
)
|
||||
|
||||
|
||||
class FakeNetworkError(OSError):
|
||||
@@ -429,6 +434,58 @@ async def test_admin_get_dispatch_error_response():
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_bridge_version_request_and_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_bridge_version())
|
||||
await asyncio.sleep(0) # let the task send the request
|
||||
|
||||
# First use auto-subscribes to Gamescript updates, then sends the query.
|
||||
assert len(proto.sent) == 2
|
||||
assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {
|
||||
"command": "get_version", "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_version", "request_id": 1, "version": GS_BRIDGE_VERSION,
|
||||
"commands": ["get_timetable", "get_version"],
|
||||
"events": ["vehicle_arrive", "vehicle_depart"]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_bridge_version_rejects_older_bridge():
|
||||
"""A bridge that answers but is too old fails by name, not by timeout."""
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_bridge_version(minimum=GS_BRIDGE_VERSION + 1))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_version", "request_id": 1, "version": GS_BRIDGE_VERSION})
|
||||
with pytest.raises(ValueError, match=f"version {GS_BRIDGE_VERSION}"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_bridge_version_minimum_zero_accepts_anything():
|
||||
"""minimum=0 reads the version without requiring anything, even if the reply omits it."""
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_bridge_version(minimum=0))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_version", "request_id": 1, "commands": []})
|
||||
assert await task == {"command": "get_version", "request_id": 1, "commands": []}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_gamescript_passthrough_unmatched():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
|
||||
@@ -11,6 +11,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'lib'))
|
||||
|
||||
from openttd import OpenTTDAdminClient, OpenTTDClient
|
||||
from openttd.protocol import (
|
||||
GS_BRIDGE_VERSION,
|
||||
AdminUpdateFrequency,
|
||||
AdminUpdateType,
|
||||
GameEventType,
|
||||
@@ -518,6 +519,38 @@ async def test_e2e_admin_send_gamescript_multiple_inputs(connected_admin):
|
||||
await connected_admin.send_gamescript({"command": "ping", "sequence": 1})
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_bridge_version_supported(connected_admin):
|
||||
# Public function: get_bridge_version()
|
||||
# Input 1: the default minimum, i.e. the version this client is written against.
|
||||
#
|
||||
# Every GameScript test below this one fails as an unexplained timeout if the server runs a
|
||||
# stale AdminBridge (or none at all), because a bridge that does not know a command simply
|
||||
# drops it. This test is the one that says so out loud, so run it first when the GS tests
|
||||
# start hanging.
|
||||
try:
|
||||
data = await connected_admin.get_bridge_version(timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail(
|
||||
"The server's AdminBridge GameScript did not answer get_version. It is older than "
|
||||
f"version {GS_BRIDGE_VERSION} (which introduced the command), not loaded at all, or "
|
||||
"the game is paused. See gamescript/AdminBridge/README.md.")
|
||||
|
||||
assert data["version"] >= GS_BRIDGE_VERSION
|
||||
# The catalogues let a client feature-detect one command rather than compare versions.
|
||||
assert "get_version" in data["commands"]
|
||||
assert set(data["events"]) == {e.value for e in GameEventType} - {GameEventType.EventsDropped}
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_bridge_version_minimum_zero(connected_admin):
|
||||
# Public function: get_bridge_version()
|
||||
# Input 2: minimum=0 -> read the version without requiring anything of it.
|
||||
data = await connected_admin.get_bridge_version(minimum=0, timeout=10.0)
|
||||
assert isinstance(data["version"], int)
|
||||
assert "get_timetable" in data["commands"]
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_vehicles_all_companies(connected_admin):
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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.
|
||||
The bridge may implement more than the client wraps (list_cargo currently has no method).
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user