Merge pull request 'Put the AdminBridge GameScript under version control' (#30) from claude/silly-lederberg-231f6c into main
Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
||||
venv
|
||||
__pycache__
|
||||
docker/config
|
||||
docker/config
|
||||
.coverage
|
||||
.pytest_cache
|
||||
@@ -19,6 +19,7 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica
|
||||
- **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.
|
||||
- **Cargo Table:** `OpenTTDAdminClient.list_cargo()` names the bare `cargo_id`s the station queries and cargo events return — every cargo type in the running game with its label (`PASS`, `COAL`, …) and freight flag, read live so NewGRF-specific ids resolve correctly.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## 🛠 Setup
|
||||
|
||||
@@ -69,6 +70,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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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());
|
||||
@@ -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 }] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+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):
|
||||
@@ -463,6 +468,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.
|
||||
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
|
||||
Reference in New Issue
Block a user