From f7ca395a4fea854992f6bdf65dffa1305226ed28 Mon Sep 17 00:00:00 2001 From: kovagoadi Date: Mon, 31 Aug 2026 19:06:33 +0200 Subject: [PATCH] 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 --- .gitignore | 4 +- README.md | 3 + docker/README.md | 13 + docker/docker-compose.yml | 4 + docs/ARCHITECTURE.md | 1 + docs/EVENTS.md | 4 +- docs/PROTOCOL.md | 30 ++ docs/TESTING.md | 1 + docs/TIMETABLES.md | 9 +- gamescript/AdminBridge/README.md | 88 ++++ gamescript/AdminBridge/info.nut | 19 + gamescript/AdminBridge/main.nut | 722 +++++++++++++++++++++++++++++++ lib/openttd/client.py | 31 ++ lib/openttd/protocol.py | 7 + tests/test_admin.py | 59 ++- tests/test_e2e.py | 33 ++ tests/test_gamescript.py | 89 ++++ 17 files changed, 1110 insertions(+), 7 deletions(-) create mode 100644 gamescript/AdminBridge/README.md create mode 100644 gamescript/AdminBridge/info.nut create mode 100644 gamescript/AdminBridge/main.nut create mode 100644 tests/test_gamescript.py diff --git a/.gitignore b/.gitignore index 27c8a8e..7bf4675 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ venv __pycache__ -docker/config \ No newline at end of file +docker/config +.coverage +.pytest_cache \ No newline at end of file diff --git a/README.md b/README.md index 4ad00d8..95c9192 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica - **Station Listing:** Enumerate stations via the Admin GameScript channel with `list_stations()`. - **Station Cargo Snapshots:** `OpenTTDAdminClient.get_station()` returns a station's live per-cargo state from the running game — both the **real-time** amount waiting and the **planned** flow through the cargodist link graph — over the AdminBridge GS (stock GameScript API, no server patch needed). - **Cargo Flow Breakdown:** `OpenTTDAdminClient.get_station_cargo()` breaks one cargo type down by **source station** and **next hop** (routing destination) for both waiting (real-time) and planned amounts, with optional `from_station`/`via_station` filters. +- **Version-checked Server Bridge:** the server-side AdminBridge GameScript that answers all of the above lives in [`gamescript/AdminBridge/`](gamescript/AdminBridge/README.md) and is mounted into the Docker server automatically. `get_bridge_version()` verifies at startup that the running bridge is new enough, so an outdated one fails by name instead of hanging every query. - **Game Events:** react to the game instead of polling it — `subscribe_events()` streams events over the AdminBridge GS as they happen: a vehicle reaching or leaving a stop (`vehicle_arrive`/`vehicle_depart`, with dwell time and cargo aboard), a station's waiting cargo changing (`cargo_waiting`), plus crashes, industries opening/closing, towns, companies and subsidies. Consume them with `await wait_for_event()` or an `on_event` callback; filter by kind, company, vehicle, station or cargo. ## 🛠 Setup @@ -68,6 +69,8 @@ await client.joined.wait() - `main.py`: Main entry point and usage example. - `lib/openttd/`: Core package containing the protocol and client logic. +- `gamescript/AdminBridge/`: The server-side GameScript the admin client's GameScript channel talks to. +- `docker/`: Dedicated JGRPP server (Dockerfile, compose, and the local server patches in `docker/patches/`). - `docs/`: Extensive documentation on architecture, protocol, and contributing. - `tests/`: Comprehensive test suite (Logic, Protocol, E2E). diff --git a/docker/README.md b/docker/README.md index fff7938..018e880 100644 --- a/docker/README.md +++ b/docker/README.md @@ -24,6 +24,19 @@ This setup builds OpenTTD with the JGR Patch Pack (JGRPP) from source and runs i 4. **Save Games:** Save games are stored in `config/save/`. +## AdminBridge GameScript +`config/` is gitignored (it holds savegames, downloaded content and generated config), so the +AdminBridge GameScript — the server-side half of the Python client's admin GameScript channel — +is kept in [gamescript/AdminBridge/](../gamescript/AdminBridge/README.md) instead and bind-mounted +read-only over the container's `game/AdminBridge` by `docker-compose.yml`. Nothing to install: +`docker-compose up` serves the tracked copy. Edit it there, not under `config/game/`, which the +mount shadows. A GameScript is loaded at game start, so restart the server to pick up a change. + +Running the server outside this compose file? Copy the directory in by hand instead: +```bash +cp -r ../gamescript/AdminBridge ~/.local/share/openttd/game/ +``` + ## JGRPP Source The source code is cloned from the `jgrpp` branch of `https://github.com/JGRennison/OpenTTD-patches` (currently at tag `jgrpp-0.71.1`) and carries local patches from `patches/` — see diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5ee2ab0..3d94154 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,6 +9,10 @@ services: - "3977:3977/tcp" volumes: - ./config:/home/openttd/.local/share/openttd + # The AdminBridge GameScript is served from its tracked source rather than from the + # (gitignored) config directory, so the server always runs the reviewed copy. Nested + # inside the mount above, and read-only: edit gamescript/AdminBridge/, not the container. + - ../gamescript/AdminBridge:/home/openttd/.local/share/openttd/game/AdminBridge:ro environment: - PUID=1000 - PGID=1000 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 48e6210..1f0f45f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,6 +19,7 @@ The primary API for developers. ### 3. `OpenTTDAdminClient` (Admin Network) Talks to the Admin port (TCP 3977) and, through the AdminBridge GameScript's JSON channel, to the running game itself. - **Correlated Queries:** `get_timetable()`, `get_station()`, `get_dispatch()` and friends all funnel through `_gs_query()`, which tags each request with a monotonic `request_id`, parks a future, and lets `receive_ServerGamescript` resolve it when the matching reply arrives. +- **Two Halves:** the queries above only work because a matching GameScript is running on the server. That script is part of this project, in [`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md), and the client's `get_bridge_version()` checks the running copy is new enough for what it is about to send. - **Push Events:** `subscribe_events()` opens the one stream that flows the other way. Event batches carry no `request_id`, so `receive_ServerGamescript` routes them to the event consumers instead: each event goes to the longest-waiting matching `wait_for_event()` caller, or into a bounded buffer if nobody is waiting, and to the `on_event` observer either way. See [EVENTS.md](EVENTS.md). ## Handshake Flow diff --git a/docs/EVENTS.md b/docs/EVENTS.md index b5f9a5b..e569515 100644 --- a/docs/EVENTS.md +++ b/docs/EVENTS.md @@ -221,7 +221,9 @@ asyncio.run(main()) ## Requirements -The server must run the bundled AdminBridge GameScript (version 3 or newer). Events need **no** +The server must run the bundled AdminBridge GameScript (version 3 or newer) from +[`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md) — `get_bridge_version()` checks +that it is new enough, and it is worth calling once at startup. Events need **no** server patch — unlike `get_timetable()` and `get_dispatch()`, every getter involved is part of the stock GameScript API. Since GameScripts do not tick while the game is paused, no events are produced on a paused server. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1ebd993..08d7932 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -26,6 +26,36 @@ Similar to the Game Port, the Admin Network uses X25519 PAKE for secure authenti ### Update Frequencies Admins can subscribe to various updates (Date, Client Info, Company Info, etc.) at different frequencies (Poll, Daily, Weekly, Monthly, Quarterly, Annually, Automatic). +### The AdminBridge GameScript +Everything in this section rides on a companion GameScript running on the server — the other half +of the protocol, kept in [`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md). The +Admin Network itself offers no way to ask the game about individual vehicles, stations or orders; +what it does offer is an opaque JSON channel to whatever GameScript is loaded (`AdminGamescript` +out, `ServerGamescript` back), and this bridge is what gives that channel meaning. + +Note what the channel does **not** give you: a bridge that does not recognise a command drops it +silently. There is no "unknown command" reply, so a client talking to a bridge older than itself +sees nothing but timeouts. + +### Bridge Version +`get_bridge_version()` exists to turn that silence into an error. The bridge reports the version +of the JSON protocol it implements, and the client compares it against `GS_BRIDGE_VERSION` (the +version it was written against, in `protocol.py`). + +- **Request:** `{"command": "get_version", "request_id": X}`. +- **Reply:** `{"command": "get_version", "request_id": X, "version": N, "commands": [...], "events": [...]}` — + `commands` is every command name the bridge answers (sorted) and `events` every event kind it + can push, so a client can feature-detect a single command instead of comparing version numbers. +- **No reply at all** is the answer from a bridge older than version 4, which is when + `get_version` was added; `get_bridge_version()` surfaces that as `asyncio.TimeoutError`, the + same as a paused game or a server with no bridge loaded. + +The version covers the shape of the JSON protocol, not the implementation, and is declared in +three places that [`tests/test_gamescript.py`](../tests/test_gamescript.py) keeps in step: +`info.nut`'s `GetVersion()`, `main.nut`'s `BRIDGE_VERSION` and `protocol.py`'s +`GS_BRIDGE_VERSION`. The same test checks the bridge's event catalogue against `GameEventType` +and its command table against the commands the client actually sends. + ### Vehicle Listing The Admin Network has no native packet or `AdminUpdateType` for listing individual vehicles — `ServerCompanyStats` only reports aggregate per-company vehicle counts (trains/lorries/buses/planes/ships). To retrieve an actual vehicle list, this client sends a `list_vehicles` command over the GameScript JSON channel (`AdminGamescript`/`ServerGamescript`) via `list_vehicles()`. This requires a companion GameScript running server-side that understands the `list_vehicles` command and replies with vehicle data through `ServerGamescript`. diff --git a/docs/TESTING.md b/docs/TESTING.md index b1bd2d5..11382ea 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -15,6 +15,7 @@ The tests are located in the `tests/` directory: | [`test_logic.py`](file:///home/kovagoadi/openttd-client/tests/test_logic.py) | `OpenTTDClient` | Tests client connection lifecycle, company joining flow, authentication, and state management. | | [`test_events.py`](file:///home/kovagoadi/openttd-client/tests/test_events.py) | Game Events | Tests event subscription encoding, the push/pull consumption paths (`on_event`, `wait_for_event()`), buffering and waiter lifecycle. | | [`test_coverage.py`](file:///home/kovagoadi/openttd-client/tests/test_coverage.py) | Coverage Helpers | Auxiliary unit tests targeting connection errors, fallback packet handlers, and missing passwords to ensure high test coverage. | +| [`test_gamescript.py`](file:///home/kovagoadi/openttd-client/tests/test_gamescript.py) | AdminBridge GameScript | Reads `gamescript/AdminBridge/*.nut` and checks it against the client: protocol version, event catalogue, command table, and that the docker setup serves the tracked copy. No Squirrel toolchain needed — see [`gamescript/AdminBridge/README.md`](file:///home/kovagoadi/openttd-client/gamescript/AdminBridge/README.md). | | [`test_e2e.py`](file:///home/kovagoadi/openttd-client/tests/test_e2e.py) | Integration / E2E | Connects to a running local OpenTTD server (e.g., in Docker) to verify full socket interactions, stream cryptography, and keep-alive frames. | --- diff --git a/docs/TIMETABLES.md b/docs/TIMETABLES.md index 6adea3e..3bbfc0d 100644 --- a/docs/TIMETABLES.md +++ b/docs/TIMETABLES.md @@ -65,10 +65,11 @@ vehicle id; `response_too_large` if a very long order list overflows the admin p don't run while the game is **paused**, so a paused server always times out), and `ConnectionError` if the admin connection drops mid-query. -Requirements: the server must run the bundled AdminBridge GameScript **and** the patched JGRPP -build with the `GSOrder` timetable getters (both included in this repo's `docker/` setup — see -`docker/patches/README.md`). The Gamescript update-frequency subscription it needs is set up -automatically on first call. +Requirements: the server must run the bundled AdminBridge GameScript (from +`gamescript/AdminBridge/`, mounted into the container by this repo's `docker/` setup — see +[`gamescript/AdminBridge/README.md`](../gamescript/AdminBridge/README.md)) **and** the patched +JGRPP build with the `GSOrder` timetable getters (`docker/patches/README.md`). The Gamescript +update-frequency subscription it needs is set up automatically on first call. ## Writing (and the legacy observer): things you must know diff --git a/gamescript/AdminBridge/README.md b/gamescript/AdminBridge/README.md new file mode 100644 index 0000000..a558b36 --- /dev/null +++ b/gamescript/AdminBridge/README.md @@ -0,0 +1,88 @@ +# AdminBridge GameScript + +The server-side half of this project. Every `OpenTTDAdminClient` feature that goes over the +GameScript JSON channel — `list_vehicles`, `list_stations`, `list_cargo`, `get_timetable`, +`get_station`, `get_station_cargo`, `get_dispatch` and the `subscribe_events` push stream — is a +command this script answers. The Python client is only half the protocol; this is the other half, +and the two are documented together in [docs/PROTOCOL.md](../../docs/PROTOCOL.md). + +It is a **deity** GameScript: it joins no company, builds nothing, and only reads game state and +replies on the admin port via `GSAdmin.Send()`. + +- `info.nut` — the manifest OpenTTD's script scanner reads (name, version, API version). +- `main.nut` — the bridge itself: a command dispatch table, one handler per command, and the + event poller. + +## Why it lives here + +`docker/config/` is gitignored — it also holds savegames, downloaded content and generated +config — so a GameScript kept there is invisible to review and to CI, and a fresh clone cannot +reproduce the server side at all. This directory is the source of truth, the same arrangement +[docker/patches/](../../docker/patches/README.md) uses for the local JGRPP patches. + +`docker-compose.yml` bind-mounts this directory read-only over the container's +`game/AdminBridge`, so the server runs the tracked copy and nothing else: + +```yaml +- ../gamescript/AdminBridge:/home/openttd/.local/share/openttd/game/AdminBridge:ro +``` + +Editing the copy under `docker/config/game/AdminBridge/` therefore has no effect; that path is +shadowed by the mount. On a server not started through this compose file, copy the directory +into the OpenTTD data dir instead: + +```bash +cp -r gamescript/AdminBridge ~/.local/share/openttd/game/ +``` + +A GameScript is loaded when the game starts, so a change needs the server restarted (or the +game reloaded) before it takes effect — the running instance keeps the old code. + +## Protocol version + +`info.nut`'s `GetVersion()` is the version of the **JSON protocol**, not of the implementation: +bump it when a command, field or event kind changes shape, not for a refactor. Three places +carry it and all three must move together: + +| Where | What | +| :--- | :--- | +| `info.nut` → `GetVersion()` | what OpenTTD records in the savegame | +| `main.nut` → `BRIDGE_VERSION` | what the `get_version` command reports | +| `lib/openttd/protocol.py` → `GS_BRIDGE_VERSION` | what the client requires | + +A GameScript cannot read its own `info.nut` at runtime — `GSController.GetVersion()` returns the +*OpenTTD* version — hence the duplication. [`tests/test_gamescript.py`](../../tests/test_gamescript.py) +pins the three together, along with the event catalogue and the command list, so a half-finished +bump fails in CI rather than against a live server. + +`OpenTTDAdminClient.get_bridge_version()` is the client end of this: it asks the running bridge +and raises if it is too old, which is worth doing once at startup. Without it a stale bridge +gives no error at all — it does not recognise the command, so the client just waits out its +timeout. A bridge older than version 4 predates `get_version` itself and can only fail that way. + +## Requirements + +- **JGRPP**, patched with `docker/patches/` — `get_timetable` and `get_dispatch` call `GSOrder` + getters those patches add. Every other command uses stock GameScript API, so an unpatched + server still answers them. +- The admin client must subscribe with `update_frequency(Gamescript, Automatic)` or the server + drops every reply. The client does this automatically on first use. +- GameScripts do not tick while the game is **paused**, so a paused server answers nothing and + every query times out. + +## Which GameScript actually runs + +OpenTTD runs exactly **one** GameScript, and a savegame remembers the one it was played with by +name: loading it overrides `[game_scripts]` in `openttd.cfg`. So a savegame that pins some other +script leaves this bridge unloaded, and every command times out no matter what is mounted where. +The symptom to recognise is commands timing out *uniformly* — as opposed to a stale bridge, where +the older commands still answer and only newer ones hang. + +## Verifying a change + +`docker/config` being untracked used to mean this file could only be tested against a running +server. It still needs one for the real check — `pytest -m e2e` exercises every command end to +end, and [docs/TESTING.md](../../docs/TESTING.md) covers starting the server. Before that, +`pytest -m "not e2e" tests/test_gamescript.py` catches the drift a server would only reveal as a +timeout. Note that a syntax error anywhere in `main.nut` stops the whole bridge from loading, so +every command fails at once — check the server log (`docker compose logs`) for the compile error. diff --git a/gamescript/AdminBridge/info.nut b/gamescript/AdminBridge/info.nut new file mode 100644 index 0000000..451eef3 --- /dev/null +++ b/gamescript/AdminBridge/info.nut @@ -0,0 +1,19 @@ +class AdminBridge extends GSInfo { + function GetAuthor() { return "Gemini"; } + function GetName() { return "AdminBridge"; } + function GetShortName() { return "ADMB"; } + function GetDescription() { return "Answers JSON commands from the admin port on behalf of an admin client."; } + /* Bridge protocol version. Must equal BRIDGE_VERSION in main.nut, which is what the + * get_version command reports; tests/test_gamescript.py pins the two together and to + * openttd.protocol.GS_BRIDGE_VERSION. Bump it whenever the JSON protocol changes. */ + function GetVersion() { return 4; } + /* Any version of this script can take over from any older one: the bridge keeps no + * savegame state of its own (it has no Save()), so there is nothing to migrate. Without + * this, the default is GetVersion(), and loading a savegame that pinned an older version + * makes the engine fall back with a "no longer available" warning. */ + function MinVersionToLoad() { return 1; } + function GetAPIVersion() { return "1.10"; } + function GetDate() { return "2026-08-31"; } + function CreateInstance() { return "AdminBridge"; } +} +RegisterGS(AdminBridge()); diff --git a/gamescript/AdminBridge/main.nut b/gamescript/AdminBridge/main.nut new file mode 100644 index 0000000..945dfcb --- /dev/null +++ b/gamescript/AdminBridge/main.nut @@ -0,0 +1,722 @@ +class AdminBridge extends GSController { + /* --- Event subscription state (see HandleSubscribeEvents) --- */ + event_kinds = null; // table of subscribed kind -> true; null when not subscribed + event_order = null; // canonical kind ordering, so subscribe replies are stable + event_catalog = null; // table of every supported kind -> true, for validation + interval = 10; // ticks between state polls + sleep_ticks = 10; // ticks slept per main-loop iteration + last_poll = 0; // tick of the last state poll + seeded = false; // false until the first poll has recorded a state baseline + company_id = null; // optional owner filter for the polled vehicle/station events + watch_vehicles = null; // explicit vehicle ids to poll; null = every vehicle + watch_stations = null; // explicit station ids to poll; null = every station + cargo_ids = null; // cargo ids to inspect; set at subscribe time + min_cargo_delta = 1; // smallest waiting-amount change worth a cargo_waiting event + include_cargo = true; // attach each vehicle's load to arrive/depart events + vehicle_at = null; // vehicle id -> { station = id or -1, since = tick } + cargo_prev = null; // station id -> table of cargo id -> last seen waiting amount + + /* Cap on how many events one poll may emit. A first poll over a busy map can otherwise + * produce thousands at once; past the cap the rest are dropped and reported as such. */ + MAX_EVENTS_PER_POLL = 200; + /* Events per admin packet. Kept well under the packet size limit so a batch always fits. */ + EVENT_BATCH_SIZE = 24; + + /* Version of the JSON protocol spoken over the admin port, reported by get_version. + * A GameScript cannot read its own info.nut at runtime (GSController.GetVersion() returns + * the *OpenTTD* version), so this duplicates info.nut's GetVersion() and the two are pinned + * together — along with the client's openttd.protocol.GS_BRIDGE_VERSION — by + * tests/test_gamescript.py. Bump all three whenever the protocol changes. */ + BRIDGE_VERSION = 4; + + /* Every command the bridge answers: name -> handler method plus the request fields that + * handler needs. HandleCommand dispatches through this and get_version reports it, so the + * catalogue a client feature-detects against cannot drift from what is implemented. */ + COMMANDS = { + get_version = { handler = "HandleGetVersion", requires = [] }, + list_vehicles = { handler = "HandleListVehicles", requires = [] }, + list_stations = { handler = "HandleListStations", requires = [] }, + list_cargo = { handler = "HandleListCargo", requires = [] }, + get_timetable = { handler = "HandleGetTimetable", requires = ["vehicle_id"] }, + get_station = { handler = "HandleGetStation", requires = ["station_id"] }, + get_station_cargo = { handler = "HandleGetStationCargo", requires = ["station_id", "cargo_id"] }, + get_dispatch = { handler = "HandleGetDispatch", requires = ["vehicle_id"] }, + subscribe_events = { handler = "HandleSubscribeEvents", requires = [] }, + unsubscribe_events = { handler = "HandleUnsubscribeEvents", requires = [] }, + }; + + function InitEvents() { + this.ResetEventState(); + this.event_order = [ + /* Synthesised by polling game state (PollState). */ + "vehicle_arrive", "vehicle_depart", "cargo_waiting", + /* Native GameScript events, forwarded as they arrive (HandleNativeEvent). */ + "vehicle_crashed", "station_first_vehicle", + "industry_open", "industry_close", "town_founded", + "company_new", "company_in_trouble", "company_bankrupt", + "subsidy_offer", "subsidy_offer_expired", "subsidy_awarded", "subsidy_expired" + ]; + this.event_catalog = {}; + foreach (kind in this.event_order) this.event_catalog[kind] <- true; + } + + function Start() { + GSLog.Info("AdminBridge started."); + this.InitEvents(); + while (true) { + this.Sleep(this.sleep_ticks); + local pending = []; + local event = GSEventController.GetNextEvent(); + while (event != null) { + if (event.GetEventType() == GSEvent.ET_ADMIN_PORT) { + local admin_event = GSEventAdminPort.Convert(event); + local request = admin_event.GetObject(); + if (request != null && "command" in request) this.HandleCommand(request); + } else if (this.event_kinds != null) { + this.HandleNativeEvent(event, pending); + } + event = GSEventController.GetNextEvent(); + } + if (this.event_kinds != null) this.PollState(pending); + this.SendEventBatch(pending); + } + } + + function HandleCommand(request) { + if (!(request.command in this.COMMANDS)) return; + local spec = this.COMMANDS[request.command]; + /* A command missing the fields its handler needs is dropped rather than answered: + * there is nothing to answer *about*, and no request_id is guaranteed either. */ + foreach (field in spec.requires) { + if (!(field in request)) return; + } + this[spec.handler](request); + } + + /* Version handshake. A client expecting a newer bridge than the server runs would otherwise + * just time out on the first command this bridge does not know; asking here turns that into + * an answer. The two catalogues let a client feature-detect, which survives a bridge that + * gained commands out of version order better than comparing numbers does. */ + function HandleGetVersion(request) { + local commands = []; + foreach (name, _ in this.COMMANDS) commands.append(name); + commands.sort(); // table iteration order is arbitrary; keep the reply stable + local events = []; + foreach (kind in this.event_order) events.append(kind); + local reply = { command = "get_version", version = this.BRIDGE_VERSION, + commands = commands, events = events }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + /* Cargo id -> label ("PASS", "COAL", ...) so callers can name what a vehicle carries. + * Labels come from the NewGRF cargo table, so ids are not stable across games. */ + function HandleListCargo(request) { + local cargo = []; + foreach (c, _ in GSCargoList()) { + local label = ""; + try { label = GSCargo.GetCargoLabel(c); } catch (e) { label = ""; } + local freight = 0; + try { freight = GSCargo.IsFreight(c) ? 1 : 0; } catch (e) { freight = 0; } + cargo.append({ cargo_id = c, label = label, freight = freight }); + } + local reply = { command = "list_cargo", cargo = cargo }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + function HandleListVehicles(request) { + local vehicle_list = GSVehicleList(); + local vehicles = []; + foreach (v, _ in vehicle_list) { + if (!GSVehicle.IsValidVehicle(v)) continue; + local next_stop = "None"; + if (GSOrder.GetOrderCount(v) > 0) { + local dest_tile = GSOrder.GetOrderDestination(v, GSOrder.ORDER_CURRENT); + local station_id = GSStation.GetStationID(dest_tile); + if (GSStation.IsValidStation(station_id)) next_stop = GSStation.GetName(station_id); + } + /* Which cargoes this vehicle can actually carry. A vehicle (an articulated + * train especially) can have capacity for several cargo types, so report + * every one with a non-zero capacity rather than a single "cargo type". */ + local cargo = []; + foreach (c, _ in GSCargoList()) { + local cap = GSVehicle.GetCapacity(v, c); + if (cap > 0) cargo.append({ cargo_id = c, capacity = cap }); + } + vehicles.append({ + id = v, + age = GSVehicle.GetAge(v), + max_age = GSVehicle.GetMaxAge(v), + next_stop = next_stop, + type = GSVehicle.GetVehicleType(v), + owner = GSVehicle.GetOwner(v), + order_count = GSOrder.GetOrderCount(v), + cargo = cargo + }); + } + local reply = { vehicles = vehicles }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + function HandleGetTimetable(request) { + local v = request.vehicle_id; + local reply = { command = "get_timetable", vehicle_id = v }; + if ("request_id" in request) reply.request_id <- request.request_id; + if (!GSVehicle.IsValidVehicle(v)) { + reply.error <- "invalid_vehicle"; + GSAdmin.Send(reply); + return; + } + reply.lateness <- GSOrder.GetTimetableLateness(v); + reply.start_tick <- GSOrder.GetTimetableStartTick(v); + reply.current_order_time <- GSOrder.GetCurrentOrderTime(v); + reply.total_duration <- GSOrder.GetTimetableTotalDuration(v); + local orders = []; + local count = GSOrder.GetOrderCount(v); + for (local i = 0; i < count; i++) { + /* Resolve the order's destination station so callers can build a station + * graph (vertices = stations, edges = segments). Non-station orders + * (depots, waypoints, conditional) have no station: report -1. */ + local station_id = -1; + if (GSOrder.IsValidVehicleOrder(v, i) && GSOrder.IsGotoStationOrder(v, i)) { + local sid = GSStation.GetStationID(GSOrder.GetOrderDestination(v, i)); + if (GSStation.IsValidStation(sid)) station_id = sid; + } + orders.append({ + position = i, + station_id = station_id, + wait_time = GSOrder.GetTimetableWaitTime(v, i), + travel_time = GSOrder.GetTimetableTravelTime(v, i), + wait_timetabled = GSOrder.IsWaitTimetabled(v, i) ? 1 : 0, + travel_timetabled = GSOrder.IsTravelTimetabled(v, i) ? 1 : 0, + wait_fixed = GSOrder.IsWaitFixed(v, i) ? 1 : 0, + travel_fixed = GSOrder.IsTravelFixed(v, i) ? 1 : 0, + leave_type = GSOrder.GetLeaveType(v, i), + max_speed = GSOrder.GetTimetableMaxSpeed(v, i) + }); + } + reply.orders <- orders; + if (!GSAdmin.Send(reply)) { + /* Response exceeded the admin packet size limit; send a small error + * instead so the waiting client fails fast rather than timing out. */ + local fallback = { command = "get_timetable", vehicle_id = v, error = "response_too_large" }; + if ("request_id" in request) fallback.request_id <- request.request_id; + GSAdmin.Send(fallback); + } + } + + function HandleListStations(request) { + /* GameScripts run as a deity, so GSStationList lists every company's stations; + * an optional company_id filters to a single owner (the deity list ignores it). */ + local filter_owner = ("company_id" in request) ? request.company_id : null; + local station_list = GSStationList(GSStation.STATION_ANY); + local stations = []; + foreach (s, _ in station_list) { + if (!GSStation.IsValidStation(s)) continue; + local owner = GSStation.GetOwner(s); + if (filter_owner != null && owner != filter_owner) continue; + stations.append({ + id = s, + name = GSStation.GetName(s), + location = GSStation.GetLocation(s), + owner = owner + }); + } + local reply = { command = "list_stations", stations = stations }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + function HandleGetStation(request) { + local sid = request.station_id; + local reply = { command = "get_station", station_id = sid }; + if ("request_id" in request) reply.request_id <- request.request_id; + if (!GSStation.IsValidStation(sid)) { + reply.error <- "invalid_station"; + GSAdmin.Send(reply); + return; + } + reply.name <- GSStation.GetName(sid); + reply.location <- GSStation.GetLocation(sid); + reply.owner <- GSStation.GetOwner(sid); + local cargo = []; + local cargo_list = GSCargoList(); + foreach (c, _ in cargo_list) { + local waiting = GSStation.GetCargoWaiting(sid, c); + local planned = GSStation.GetCargoPlanned(sid, c); + local has_rating = GSStation.HasCargoRating(sid, c); + /* Skip cargo the station has never handled to keep the reply compact. */ + if (waiting <= 0 && planned <= 0 && !has_rating) continue; + cargo.append({ + cargo_id = c, + waiting = waiting, // real-time: units currently waiting + planned = planned, // planned: cargodist link-graph flow + rating = has_rating ? GSStation.GetCargoRating(sid, c) : null + }); + } + reply.cargo <- cargo; + if (!GSAdmin.Send(reply)) { + /* Response exceeded the admin packet size limit; send a small error + * instead so the waiting client fails fast rather than timing out. */ + local fallback = { command = "get_station", station_id = sid, error = "response_too_large" }; + if ("request_id" in request) fallback.request_id <- request.request_id; + GSAdmin.Send(fallback); + } + } + + /* Convert a GSList of station_id -> amount into an array of {station, amount}, + * dropping zero entries to keep the reply compact. */ + function CargoListToPairs(list) { + local pairs = []; + foreach (station, _ in list) { + local amount = list.GetValue(station); + if (amount <= 0) continue; + pairs.append({ station = station, amount = amount }); + } + return pairs; + } + + function HandleGetStationCargo(request) { + local sid = request.station_id; + local cargo = request.cargo_id; + local reply = { command = "get_station_cargo", station_id = sid, cargo_id = cargo }; + if ("request_id" in request) reply.request_id <- request.request_id; + if (!GSStation.IsValidStation(sid)) { + reply.error <- "invalid_station"; + GSAdmin.Send(reply); + return; + } + if (!GSCargo.IsValidCargo(cargo)) { + reply.error <- "invalid_cargo"; + GSAdmin.Send(reply); + return; + } + + /* Optional source (from) and next-hop (via) filters. STATION_INVALID is a legal value + * (deleted source / "via any"); any other non-station value is rejected. */ + local from = null; + local via = null; + if ("from_station" in request) { + from = request.from_station; + if (from != GSStation.STATION_INVALID && !GSStation.IsValidStation(from)) { + reply.error <- "invalid_from_station"; + GSAdmin.Send(reply); + return; + } + reply.from_station <- from; + } + if ("via_station" in request) { + via = request.via_station; + if (via != GSStation.STATION_INVALID && !GSStation.IsValidStation(via)) { + reply.error <- "invalid_via_station"; + GSAdmin.Send(reply); + return; + } + reply.via_station <- via; + } + + /* Totals, honouring whichever filters were supplied. */ + if (from != null && via != null) { + reply.waiting <- GSStation.GetCargoWaitingFromVia(sid, from, via, cargo); + reply.planned <- GSStation.GetCargoPlannedFromVia(sid, from, via, cargo); + } else if (from != null) { + reply.waiting <- GSStation.GetCargoWaitingFrom(sid, from, cargo); + reply.planned <- GSStation.GetCargoPlannedFrom(sid, from, cargo); + } else if (via != null) { + reply.waiting <- GSStation.GetCargoWaitingVia(sid, via, cargo); + reply.planned <- GSStation.GetCargoPlannedVia(sid, via, cargo); + } else { + reply.waiting <- GSStation.GetCargoWaiting(sid, cargo); + reply.planned <- GSStation.GetCargoPlanned(sid, cargo); + } + + /* Breakdown grouped by source station (restricted to the via filter if given). */ + local w_by_from = (via != null) + ? GSStationList_CargoWaitingViaByFrom(sid, cargo, via) + : GSStationList_CargoWaitingByFrom(sid, cargo); + local p_by_from = (via != null) + ? GSStationList_CargoPlannedViaByFrom(sid, cargo, via) + : GSStationList_CargoPlannedByFrom(sid, cargo); + reply.waiting_by_from <- this.CargoListToPairs(w_by_from); + reply.planned_by_from <- this.CargoListToPairs(p_by_from); + + /* Breakdown grouped by next hop (restricted to the from filter if given). */ + local w_by_via = (from != null) + ? GSStationList_CargoWaitingFromByVia(sid, cargo, from) + : GSStationList_CargoWaitingByVia(sid, cargo); + local p_by_via = (from != null) + ? GSStationList_CargoPlannedFromByVia(sid, cargo, from) + : GSStationList_CargoPlannedByVia(sid, cargo); + reply.waiting_by_via <- this.CargoListToPairs(w_by_via); + reply.planned_by_via <- this.CargoListToPairs(p_by_via); + + if (!GSAdmin.Send(reply)) { + /* Response exceeded the admin packet size limit; send a small error + * instead so the waiting client fails fast rather than timing out. */ + local fallback = { command = "get_station_cargo", station_id = sid, cargo_id = cargo, error = "response_too_large" }; + if ("request_id" in request) fallback.request_id <- request.request_id; + GSAdmin.Send(fallback); + } + } + + function HandleGetDispatch(request) { + local v = request.vehicle_id; + local reply = { command = "get_dispatch", vehicle_id = v }; + if ("request_id" in request) reply.request_id <- request.request_id; + + local count = GSOrder.GetScheduledDispatchScheduleCount(v); + if (count < 0) { + reply.error <- "invalid_vehicle"; + GSAdmin.Send(reply); + return; + } + reply.enabled <- GSOrder.IsScheduledDispatchEnabled(v); + local schedules = []; + for (local s = 0; s < count; s++) { + local slots = []; + local slot_count = GSOrder.GetScheduledDispatchSlotCount(v, s); + for (local k = 0; k < slot_count; k++) { + slots.append({ + offset = GSOrder.GetScheduledDispatchSlotOffset(v, s, k), + flags = GSOrder.GetScheduledDispatchSlotFlags(v, s, k) + }); + } + schedules.append({ + index = s, + duration = GSOrder.GetScheduledDispatchDuration(v, s), + start_tick = GSOrder.GetScheduledDispatchStartTick(v, s), + delay = GSOrder.GetScheduledDispatchDelay(v, s), + reuse_slots = GSOrder.GetScheduledDispatchReuseSlots(v, s), + slots = slots + }); + } + reply.schedules <- schedules; + if (!GSAdmin.Send(reply)) { + /* Response exceeded the admin packet size limit; send a small error + * instead so the waiting client fails fast rather than timing out. */ + local fallback = { command = "get_dispatch", vehicle_id = v, error = "response_too_large" }; + if ("request_id" in request) fallback.request_id <- request.request_id; + GSAdmin.Send(fallback); + } + } + + /* --- Events --- + * + * The engine has no GameScript event for "a vehicle reached a stop" or "cargo arrived", + * so those are synthesised here: every `interval` ticks the bridge samples the watched + * vehicles and stations and emits an event for each change against the previous sample. + * The handful of events the engine *does* raise for a deity script (crashes, industries, + * companies, ...) are forwarded straight through. Everything is pushed to the admin port + * unsolicited, batched as { command = "events", events = [...] }. */ + + function ResetEventState() { + this.event_kinds = null; + this.interval = 10; + this.sleep_ticks = 10; + this.last_poll = 0; + this.seeded = false; + this.company_id = null; + this.watch_vehicles = null; + this.watch_stations = null; + this.cargo_ids = null; + this.min_cargo_delta = 1; + this.include_cargo = true; + this.vehicle_at = {}; + this.cargo_prev = {}; + } + + /* Copy a request field that must be an array of integers, or null when absent. */ + function ReadIdList(request, key) { + if (!(key in request) || request[key] == null) return null; + local out = []; + foreach (id in request[key]) out.append(id); + return out; + } + + function HandleSubscribeEvents(request) { + local reply = { command = "subscribe_events" }; + if ("request_id" in request) reply.request_id <- request.request_id; + + /* Validate everything before touching the live subscription, so a rejected request + * leaves whatever was subscribed before running untouched. */ + local kinds = {}; + if ("events" in request && request.events != null) { + foreach (kind in request.events) { + if (!(kind in this.event_catalog)) { + reply.error <- "unknown_event"; + reply.event <- kind; + GSAdmin.Send(reply); + return; + } + kinds[kind] <- true; + } + } else { + foreach (kind in this.event_order) kinds[kind] <- true; + } + + local new_interval = ("interval" in request) ? request.interval : 10; + if (typeof new_interval != "integer" || new_interval < 1) { + reply.error <- "invalid_interval"; + GSAdmin.Send(reply); + return; + } + local new_min_delta = ("min_cargo_delta" in request) ? request.min_cargo_delta : 1; + if (typeof new_min_delta != "integer" || new_min_delta < 1) { + reply.error <- "invalid_min_cargo_delta"; + GSAdmin.Send(reply); + return; + } + + /* Cargo ids are validated up front because the amount getters below take them as a + * precondition; station and vehicle ids are not, since they can vanish mid-subscription + * anyway and are re-checked on every poll. */ + local new_cargo = this.ReadIdList(request, "cargo"); + if (new_cargo != null) { + foreach (c in new_cargo) { + if (!GSCargo.IsValidCargo(c)) { + reply.error <- "invalid_cargo"; + reply.cargo_id <- c; + GSAdmin.Send(reply); + return; + } + } + } else { + new_cargo = []; + foreach (c, _ in GSCargoList()) new_cargo.append(c); + } + + this.ResetEventState(); + this.event_kinds = kinds; + this.interval = new_interval; + this.sleep_ticks = new_interval < 10 ? new_interval : 10; + this.min_cargo_delta = new_min_delta; + this.cargo_ids = new_cargo; + this.watch_vehicles = this.ReadIdList(request, "vehicles"); + this.watch_stations = this.ReadIdList(request, "stations"); + if ("company_id" in request && request.company_id != null) this.company_id = request.company_id; + if ("include_cargo" in request) this.include_cargo = request.include_cargo ? true : false; + + local accepted = []; + foreach (kind in this.event_order) { + if (kind in this.event_kinds) accepted.append(kind); + } + reply.events <- accepted; + reply.interval <- this.interval; + GSAdmin.Send(reply); + } + + function HandleUnsubscribeEvents(request) { + this.ResetEventState(); + local reply = { command = "unsubscribe_events", events = [] }; + if ("request_id" in request) reply.request_id <- request.request_id; + GSAdmin.Send(reply); + } + + function Wants(kind) { + return this.event_kinds != null && (kind in this.event_kinds); + } + + /* Forward the events the engine raises for a deity GameScript. Vehicle "lost", "waiting in + * depot" and "unprofitable" are deliberately absent: the engine only ever raises those for + * AI companies, so a GameScript can never observe them. */ + function HandleNativeEvent(event, out) { + local type = event.GetEventType(); + local tick = this.GetTick(); + if (type == GSEvent.ET_VEHICLE_CRASHED) { + if (!this.Wants("vehicle_crashed")) return; + local e = GSEventVehicleCrashed.Convert(event); + out.append({ event = "vehicle_crashed", tick = tick, vehicle_id = e.GetVehicleID(), + tile = e.GetCrashSite(), reason = e.GetCrashReason(), + victims = e.GetVictims(), owner = e.GetVehicleOwner() }); + } else if (type == GSEvent.ET_STATION_FIRST_VEHICLE) { + if (!this.Wants("station_first_vehicle")) return; + local e = GSEventStationFirstVehicle.Convert(event); + out.append({ event = "station_first_vehicle", tick = tick, + station_id = e.GetStationID(), vehicle_id = e.GetVehicleID() }); + } else if (type == GSEvent.ET_INDUSTRY_OPEN) { + if (!this.Wants("industry_open")) return; + out.append({ event = "industry_open", tick = tick, + industry_id = GSEventIndustryOpen.Convert(event).GetIndustryID() }); + } else if (type == GSEvent.ET_INDUSTRY_CLOSE) { + if (!this.Wants("industry_close")) return; + out.append({ event = "industry_close", tick = tick, + industry_id = GSEventIndustryClose.Convert(event).GetIndustryID() }); + } else if (type == GSEvent.ET_TOWN_FOUNDED) { + if (!this.Wants("town_founded")) return; + out.append({ event = "town_founded", tick = tick, + town_id = GSEventTownFounded.Convert(event).GetTownID() }); + } else if (type == GSEvent.ET_COMPANY_NEW) { + if (!this.Wants("company_new")) return; + out.append({ event = "company_new", tick = tick, + company_id = GSEventCompanyNew.Convert(event).GetCompanyID() }); + } else if (type == GSEvent.ET_COMPANY_IN_TROUBLE) { + if (!this.Wants("company_in_trouble")) return; + out.append({ event = "company_in_trouble", tick = tick, + company_id = GSEventCompanyInTrouble.Convert(event).GetCompanyID() }); + } else if (type == GSEvent.ET_COMPANY_BANKRUPT) { + if (!this.Wants("company_bankrupt")) return; + out.append({ event = "company_bankrupt", tick = tick, + company_id = GSEventCompanyBankrupt.Convert(event).GetCompanyID() }); + } else if (type == GSEvent.ET_SUBSIDY_OFFER) { + if (!this.Wants("subsidy_offer")) return; + out.append({ event = "subsidy_offer", tick = tick, + subsidy_id = GSEventSubsidyOffer.Convert(event).GetSubsidyID() }); + } else if (type == GSEvent.ET_SUBSIDY_OFFER_EXPIRED) { + if (!this.Wants("subsidy_offer_expired")) return; + out.append({ event = "subsidy_offer_expired", tick = tick, + subsidy_id = GSEventSubsidyOfferExpired.Convert(event).GetSubsidyID() }); + } else if (type == GSEvent.ET_SUBSIDY_AWARDED) { + if (!this.Wants("subsidy_awarded")) return; + out.append({ event = "subsidy_awarded", tick = tick, + subsidy_id = GSEventSubsidyAwarded.Convert(event).GetSubsidyID() }); + } else if (type == GSEvent.ET_SUBSIDY_EXPIRED) { + if (!this.Wants("subsidy_expired")) return; + out.append({ event = "subsidy_expired", tick = tick, + subsidy_id = GSEventSubsidyExpired.Convert(event).GetSubsidyID() }); + } + } + + function PollState(out) { + local now = this.GetTick(); + if (this.seeded && now - this.last_poll < this.interval) return; + this.last_poll = now; + this.PollVehicles(now, out); + this.PollCargo(now, out); + /* The first poll only records where everything already is: a vehicle that was sitting + * at a station when the subscription started did not just arrive. */ + this.seeded = true; + } + + function WatchedVehicles() { + if (this.watch_vehicles != null) return this.watch_vehicles; + local out = []; + foreach (v, _ in GSVehicleList()) out.append(v); + return out; + } + + function WatchedStations() { + if (this.watch_stations != null) return this.watch_stations; + local out = []; + foreach (s, _ in GSStationList(GSStation.STATION_ANY)) out.append(s); + return out; + } + + /* Which station a vehicle is stopped at, or -1 when it is not loading at one. Note that a + * vehicle stopped by hand or broken down at a platform reports its own state instead, so it + * reads here as having left the station. */ + function VehicleStation(v) { + if (GSVehicle.GetState(v) != GSVehicle.VS_AT_STATION) return -1; + local sid = GSStation.GetStationID(GSVehicle.GetLocation(v)); + return GSStation.IsValidStation(sid) ? sid : -1; + } + + function PollVehicles(now, out) { + local want_arrive = this.Wants("vehicle_arrive"); + local want_depart = this.Wants("vehicle_depart"); + if (!want_arrive && !want_depart) return; + local live = {}; + foreach (v in this.WatchedVehicles()) { + if (!GSVehicle.IsValidVehicle(v)) continue; + if (this.company_id != null && GSVehicle.GetOwner(v) != this.company_id) continue; + live[v] <- true; + local at = this.VehicleStation(v); + local known = (v in this.vehicle_at) ? this.vehicle_at[v] : null; + local was = (known == null) ? -1 : known.station; + if (was == at) continue; + if (this.seeded) { + /* A vehicle that moves from one station straight to another in a single + * sampling window yields both a depart and an arrive, in that order. */ + if (was != -1 && want_depart) out.append(this.VehicleEvent("vehicle_depart", now, v, was, now - known.since)); + if (at != -1 && want_arrive) out.append(this.VehicleEvent("vehicle_arrive", now, v, at, 0)); + } + this.vehicle_at[v] <- { station = at, since = now }; + } + /* Forget vehicles that were sold or fell out of the filter, so the table cannot grow + * without bound over a long subscription. */ + local stale = []; + foreach (v, _ in this.vehicle_at) { + if (!(v in live)) stale.append(v); + } + foreach (v in stale) delete this.vehicle_at[v]; + } + + function VehicleEvent(kind, tick, v, sid, dwell) { + local ev = { + event = kind, + tick = tick, + vehicle_id = v, + station_id = sid, + owner = GSVehicle.GetOwner(v), + vehicle_type = GSVehicle.GetVehicleType(v), + order_position = GSOrder.ResolveOrderPosition(v, GSOrder.ORDER_CURRENT) + }; + /* How long the vehicle had been loading, in ticks. For a vehicle that was already at a + * station when the subscription started this counts from the first poll, not from the + * real arrival. */ + if (kind == "vehicle_depart") ev.dwell <- dwell; + if (this.include_cargo) ev.cargo <- this.VehicleCargo(v); + return ev; + } + + function VehicleCargo(v) { + local out = []; + foreach (c in this.cargo_ids) { + local load = GSVehicle.GetCargoLoad(v, c); + if (load > 0) out.append({ cargo_id = c, load = load }); + } + return out; + } + + function PollCargo(now, out) { + if (!this.Wants("cargo_waiting")) return; + local live = {}; + foreach (sid in this.WatchedStations()) { + if (!GSStation.IsValidStation(sid)) continue; + if (this.company_id != null && GSStation.GetOwner(sid) != this.company_id) continue; + live[sid] <- true; + if (!(sid in this.cargo_prev)) this.cargo_prev[sid] <- {}; + local prev = this.cargo_prev[sid]; + foreach (c in this.cargo_ids) { + local waiting = GSStation.GetCargoWaiting(sid, c); + local before = (c in prev) ? prev[c] : 0; + if (waiting == before) continue; + prev[c] <- waiting; + if (!this.seeded) continue; + local delta = waiting - before; + local magnitude = delta < 0 ? -delta : delta; + if (magnitude < this.min_cargo_delta) continue; + out.append({ event = "cargo_waiting", tick = now, station_id = sid, + cargo_id = c, waiting = waiting, delta = delta }); + } + } + local stale = []; + foreach (sid, _ in this.cargo_prev) { + if (!(sid in live)) stale.append(sid); + } + foreach (sid in stale) delete this.cargo_prev[sid]; + } + + function SendEventBatch(events) { + if (events.len() == 0) return; + local dropped = 0; + if (events.len() > this.MAX_EVENTS_PER_POLL) { + dropped = events.len() - this.MAX_EVENTS_PER_POLL; + events = events.slice(0, this.MAX_EVENTS_PER_POLL); + } + local sent = 0; + while (sent < events.len()) { + local end = sent + this.EVENT_BATCH_SIZE; + if (end > events.len()) end = events.len(); + GSAdmin.Send({ command = "events", events = events.slice(sent, end) }); + sent = end; + } + /* Tell the client its view has a hole in it rather than letting it silently miss + * transitions it is counting on. */ + if (dropped > 0) { + GSAdmin.Send({ command = "events", + events = [{ event = "events_dropped", tick = this.GetTick(), count = dropped }] }); + } + } +} diff --git a/lib/openttd/client.py b/lib/openttd/client.py index 4e09140..dea9abb 100644 --- a/lib/openttd/client.py +++ b/lib/openttd/client.py @@ -657,6 +657,37 @@ class OpenTTDAdminClient: raise ValueError(f"{context}: {data['error']}") return data + async def get_bridge_version(self, minimum=None, timeout=5.0): + """Ask the AdminBridge GameScript which protocol version it speaks, and check it is new enough. + + Every other GameScript method here needs a bridge that understands the command it sends. + Against an older bridge those commands are simply ignored and the caller waits out its + timeout with no explanation, so call this once after connecting to turn that into an + immediate, named failure. + + Returns a dict with "version" (the bridge's protocol version), "commands" (the command + names it answers) and "events" (the event kinds it can push) — the two catalogues allow + feature-detecting a single command instead of comparing version numbers. + + `minimum` defaults to GS_BRIDGE_VERSION, the version this client is written against; + pass 0 to read the version without requiring anything of it. + + Raises ValueError if the bridge is older than `minimum`, and asyncio.TimeoutError if it + does not answer at all — note that a bridge predating get_version itself (version 3 and + earlier) can only fail that second way, as can a paused game or a server with no bridge + loaded. ConnectionError if the admin connection drops while waiting. + """ + from .protocol import GS_BRIDGE_VERSION + if minimum is None: + minimum = GS_BRIDGE_VERSION + data = await self._gs_query({"command": "get_version"}, timeout, "get_version") + version = data.get("version", 0) + if version < minimum: + raise ValueError( + f"AdminBridge GameScript is version {version}, but at least {minimum} is " + f"required; update the server's copy from gamescript/AdminBridge/") + return data + async def get_timetable(self, vehicle_id, timeout=5.0): """Fetch an authoritative timetable snapshot for a vehicle via the AdminBridge GameScript. diff --git a/lib/openttd/protocol.py b/lib/openttd/protocol.py index 79c1b0b..b5a3579 100644 --- a/lib/openttd/protocol.py +++ b/lib/openttd/protocol.py @@ -220,6 +220,13 @@ class NetworkAuthenticationMethod(IntEnum): X25519_PAKE = 1 X25519_AuthorizedKey = 2 +# Version of the AdminBridge GameScript's JSON protocol this client is written against. The +# bridge reports its own version via get_version (OpenTTDAdminClient.get_bridge_version()), and +# an older one will not understand everything sent here. The bridge source lives in +# gamescript/AdminBridge/; tests/test_gamescript.py keeps this in step with the version declared +# there, so bumping one without the other fails in CI rather than at runtime. +GS_BRIDGE_VERSION = 4 + class GameEventType(StrEnum): """Event kinds the AdminBridge GameScript can push over the Admin Network. diff --git a/tests/test_admin.py b/tests/test_admin.py index 5d35cb9..6d118a0 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -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") diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 437ae04..eae3fe1 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -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): diff --git a/tests/test_gamescript.py b/tests/test_gamescript.py new file mode 100644 index 0000000..22ad1e3 --- /dev/null +++ b/tests/test_gamescript.py @@ -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