Files
kovagoadiandClaude f7ca395a4f
Continuous Integration / lint-and-security (pull_request) Failing after 19s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
Put the AdminBridge GameScript under version control
The last commit noted in passing that the server-side half of the admin
GameScript channel "is not in this repo -- docker/config is gitignored --
so it has to be updated separately for any of this to work." That was true
of all nine features the README documents: list_vehicles, list_stations,
list_cargo, get_timetable, get_station, get_station_cargo, get_dispatch and
the event stream all answer from 705 lines of Squirrel that no clone could
reproduce, no reviewer could see, and CI never touched.

The bridge now lives in gamescript/AdminBridge/ with its own README, the
same arrangement docker/patches/ uses for the local JGRPP patches, and
docker-compose.yml bind-mounts it read-only over the container's
game/AdminBridge. docker/config stays ignored -- it also holds savegames,
downloaded content and generated config -- so the copy under it is now
shadowed and can be deleted. main.nut is byte-identical to what was running,
apart from the version work below.

Adds a version handshake, because the channel gives no way to tell a stale
bridge from a hung one: a bridge that does not recognise a command drops it
silently, so a client ahead of the server sees nothing but timeouts. The
bridge now answers get_version with its protocol version plus its command
and event catalogues, and get_bridge_version() raises when that is below
GS_BRIDGE_VERSION. It is opt-in rather than checked on connect: GameScripts
do not tick while the game is paused, so an automatic check would refuse to
connect to a paused server. A bridge older than 4 predates get_version
itself and can only fail by timing out, so the E2E test catches that and
reports it by name instead.

tests/test_gamescript.py gives CI a foothold on the GameScript without a
Squirrel toolchain: it parses the .nut files and pins the protocol version
across info.nut, main.nut and protocol.py, the event catalogue against
GameEventType, and the command table against the commands client.py sends.
The version has to be declared three times because a GameScript cannot read
its own info.nut at runtime -- GSController.GetVersion() returns the OpenTTD
version, not the script's.

info.nut also gains MinVersionToLoad() { return 1; }. The engine defaults it
to GetVersion(), so without it this bump would orphan every savegame pinned
to version 3: the scanner finds no compatible script and falls back with a
warning. The bridge keeps no savegame state, so any version can take over.

HandleCommand now dispatches through the same table get_version reports,
rather than an if/else chain, so the catalogue a client feature-detects
against cannot drift from what is implemented.

Co-Authored-By: Claude <[email protected]>
2026-08-31 19:06:33 +02:00

89 lines
4.7 KiB
Markdown

# 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.