Compare commits

..
55 Commits
Author SHA1 Message Date
kovagoadi 24a244a07e Merge pull request 'Update debian:trixie-slim Docker digest to a99cfc5' (#32) from renovate/debian-trixie-slim into main
Continuous Integration / lint-and-security (push) Successful in 48s
Continuous Integration / tests-and-coverage (push) Successful in 27s
Reviewed-on: #32
2026-09-21 14:45:49 +02:00
kovagoadi e4dd22157b Merge branch 'main' into renovate/debian-trixie-slim
Continuous Integration / lint-and-security (pull_request) Successful in 51s
Continuous Integration / tests-and-coverage (pull_request) Successful in 1m19s
2026-09-21 14:37:11 +02:00
kovagoadi 7335d9b954 Merge pull request 'Update debian:13 Docker digest to 9cc0800' (#31) from renovate/debian-13 into main
Continuous Integration / lint-and-security (push) Successful in 1m4s
Continuous Integration / tests-and-coverage (push) Successful in 1m4s
Reviewed-on: #31
2026-09-21 14:36:45 +02:00
renovate-bot 355bef9d25 Update debian:trixie-slim Docker digest to a99cfc5
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 25s
2026-09-20 02:22:56 +00:00
renovate-bot b2fa14b7ff Update debian:13 Docker digest to 9cc0800
Continuous Integration / lint-and-security (pull_request) Successful in 1m13s
Continuous Integration / tests-and-coverage (pull_request) Successful in 26s
2026-09-20 02:22:49 +00:00
kovagoadi f357653bbb Merge pull request 'Put the AdminBridge GameScript under version control' (#30) from claude/silly-lederberg-231f6c into main
Continuous Integration / lint-and-security (push) Successful in 20s
Continuous Integration / tests-and-coverage (push) Successful in 26s
Reviewed-on: #30
2026-08-31 19:19:33 +02:00
kovagoadiandClaude f9eeae39ed Merge branch 'main' into claude/silly-lederberg-231f6c
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
Conflicted only on the README feature list, where main's list_cargo()
bullet and this branch's bridge bullet were added at the same spot. Kept
both: list_cargo() with the other cargo queries, and the bridge one last,
since it is about what all of them run against.

list_cargo() landing also makes tests/test_gamescript.py's command check
meaningful in the other direction -- the bridge has answered list_cargo
all along with nothing sending it, which is exactly the asymmetry that
check tolerates on purpose.

Co-Authored-By: Claude <[email protected]>
2026-08-31 19:12:41 +02:00
kovagoadiandClaude aa9eda4b6d Fix import ordering in test_gamescript.py
ruff's isort rules want the openttd import grouped with the other
third-party imports, as the rest of the test suite has it.

Co-Authored-By: Claude <[email protected]>
2026-08-31 19:11:29 +02:00
kovagoadiandClaude f7ca395a4f Put the AdminBridge GameScript under version control
Continuous Integration / lint-and-security (pull_request) Failing after 19s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
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
kovagoadi 0641d25858 Merge pull request 'Add list_cargo() to the admin client' (#29) from claude/infallible-visvesvaraya-8d8c8a into main
Continuous Integration / lint-and-security (push) Successful in 21s
Continuous Integration / tests-and-coverage (push) Successful in 25s
Reviewed-on: #29
2026-08-31 18:35:57 +02:00
kovagoadiandClaude ba26b59c40 Add list_cargo() to the admin client
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 25s
The station queries and the cargo events name a cargo only by a bare
numeric id -- get_station()'s and get_station_cargo()'s cargo_id, the
cargo_waiting events, the per-cargo load on vehicle events. Those ids
index the cargo table the loaded NewGRFs build for the running game, so
the same id is coal in one save and grain in another and callers had no
way to resolve them. The AdminBridge GameScript has answered a list_cargo
command all along; no method on OpenTTDAdminClient sent it. This adds the
missing half, so no GameScript change is needed for it to work.

It goes through _gs_query() like get_station(), inheriting the request_id
correlation and the Gamescript auto-subscribe, with one difference worth
knowing: the GS handler defines no error reply for this command, so unlike
the other queries it can time out but can never raise ValueError.

The reply lists cargo in GSCargoList order rather than by id -- against
the dev server the ids come back 10 down to 0 -- so the docstring and
PROTOCOL.md both warn to index the list by cargo_id and not by position.

Also teaches the main_admin.py demo to resolve the labels before printing
a station's cargo, which is what the bare ids in its output were asking
for all along.

Co-Authored-By: Claude <[email protected]>
2026-08-31 18:33:05 +02:00
kovagoadi 90a07392cf Merge pull request 'Add game event support to the admin client' (#28) from claude/vehicle-cargo-stop-events-79422a into main
Continuous Integration / lint-and-security (push) Successful in 21s
Continuous Integration / tests-and-coverage (push) Successful in 25s
Reviewed-on: #28
2026-08-31 18:24:44 +02:00
kovagoadiandClaude d87c779d94 Add game event support to the admin client
Continuous Integration / lint-and-security (pull_request) Successful in 41s
Continuous Integration / tests-and-coverage (pull_request) Successful in 28s
Everything on the admin GameScript channel so far has been request/reply.
This adds the other direction: subscribe_events() opens a push stream so a
bot can react to the game instead of polling it, consumed either by awaiting
wait_for_event() or via an on_event callback. Both see every event; an event
goes to at most one waiter, and unclaimed ones sit in a bounded buffer.

Sixteen kinds, from two sources. The engine raises no GameScript event for a
vehicle reaching a stop or cargo arriving, so vehicle_arrive, vehicle_depart
and cargo_waiting are synthesised by the bridge sampling state every
`interval` ticks and diffing against the previous sample -- which means a
stop shorter than the interval is never reported, and the first sample only
establishes a baseline. The rest (crashes, industries, towns, companies,
subsidies) are engine events forwarded verbatim. vehicle_lost,
vehicle_waiting_in_depot and vehicle_unprofitable are deliberately absent:
the engine raises those only for AI companies, so a GameScript can never
observe them.

The server-side half lives in the AdminBridge GameScript, which is not in
this repo -- docker/config is gitignored -- so it has to be updated
separately for any of this to work.

Also repoints the scheduled-dispatch E2E test at a dedicated vehicle
(DISPATCH_VEHICLE_ID). It had been silently skipping because vehicle 7
carries a hand-built annual dispatch schedule, which left eight dispatch
methods unverified end to end while check_public_calls.py reported them
green off static analysis of the call sites.

Co-Authored-By: Claude <[email protected]>
2026-08-31 18:14:40 +02:00
kovagoadi 28f247799e Merge pull request 'Update debian:trixie-slim Docker digest to d7e1218' (#26) from renovate/debian-trixie-slim into main
Continuous Integration / lint-and-security (push) Successful in 19s
Continuous Integration / tests-and-coverage (push) Successful in 24s
Reviewed-on: #26
2026-08-26 21:08:13 +02:00
kovagoadi 6734c13963 Merge pull request 'Update debian:13 Docker digest to f324c7f' (#25) from renovate/debian-13 into main
Continuous Integration / lint-and-security (push) Successful in 19s
Continuous Integration / tests-and-coverage (push) Successful in 24s
Reviewed-on: #25
2026-08-26 21:07:27 +02:00
kovagoadi 54f8a06862 Merge branch 'main' into renovate/debian-trixie-slim
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 23s
2026-08-26 21:06:26 +02:00
kovagoadi 3641bf383e Merge branch 'main' into renovate/debian-13
Continuous Integration / lint-and-security (pull_request) Successful in 19s
Continuous Integration / tests-and-coverage (pull_request) Successful in 23s
2026-08-26 21:06:04 +02:00
kovagoadi db7fb8e09a Merge pull request 'Fix ruff lint findings across client, protocol, and tests' (#27) from claude/ruff-import-sorting-bb5651 into main
Continuous Integration / lint-and-security (push) Successful in 22s
Continuous Integration / tests-and-coverage (push) Successful in 26s
Reviewed-on: #27
2026-08-26 21:05:46 +02:00
kovagoadiandClaude 25953bea06 Fix ruff lint findings across client, protocol, and tests
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 25s
Resolve 51 findings from the I/RUF/BLE/TRY002/S110/PLR0402 rule set:

- Sort imports and __all__ (I001, RUF022, PLR0402). The sys.path.insert
  calls in check_public_calls.py and tests/test_e2e.py still precede the
  openttd imports that depend on them.
- Replace unused unpacked values with _ (RUF059) and annotate the two
  timetable lookup tables as ClassVar (RUF012).
- Narrow the best-effort excepts in OpenTTDClient.quit and
  OpenTTDAdminClient.quit to (OSError, SocketClosed) and log at debug
  rather than swallowing silently (BLE001, S110). The test doubles now
  raise an OSError subclass so they still exercise that branch.
- Narrow the gamescript JSON fallback to json.JSONDecodeError. The broad
  catch in receive_packet keeps a noqa: it guards untrusted wire data and
  must degrade to a no-op packet instead of killing the connection.
- Use contextlib.suppress instead of try/except/pass in tests.

ruff check . is clean, 102 tests pass, coverage stays at 100%.

Co-Authored-By: Claude <[email protected]>
2026-08-26 20:57:56 +02:00
renovate-bot c5876b270a Update debian:trixie-slim Docker digest to d7e1218
Continuous Integration / lint-and-security (pull_request) Failing after 17s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
2026-08-26 02:21:55 +00:00
renovate-bot 1a2ea538e8 Update debian:13 Docker digest to f324c7f
Continuous Integration / lint-and-security (pull_request) Failing after 4m2s
Continuous Integration / tests-and-coverage (pull_request) Successful in 37s
2026-08-26 02:21:51 +00:00
kovagoadi 8e352ba248 Merge pull request 'Update debian:13 Docker digest to 34cd9e9' (#23) from renovate/debian-13 into main
Continuous Integration / lint-and-security (push) Failing after 33s
Continuous Integration / tests-and-coverage (push) Successful in 24s
Reviewed-on: #23
2026-08-06 17:43:23 +02:00
kovagoadi 7fdd5f2fca Merge branch 'main' into renovate/debian-13
Continuous Integration / lint-and-security (pull_request) Successful in 23s
Continuous Integration / tests-and-coverage (pull_request) Successful in 27s
2026-08-06 17:38:42 +02:00
kovagoadi a9f5b2d5d9 Merge pull request 'Update debian:trixie-slim Docker digest to 3a39a05' (#24) from renovate/debian-trixie-slim into main
Continuous Integration / lint-and-security (push) Successful in 49s
Continuous Integration / tests-and-coverage (push) Successful in 30s
Reviewed-on: #24
2026-08-06 17:38:34 +02:00
renovate-bot e925659e28 Update debian:trixie-slim Docker digest to 3a39a05
Continuous Integration / lint-and-security (pull_request) Successful in 21s
Continuous Integration / tests-and-coverage (pull_request) Successful in 25s
2026-08-06 02:22:05 +00:00
renovate-bot 846d085e1a Update debian:13 Docker digest to 34cd9e9
Continuous Integration / lint-and-security (pull_request) Successful in 1m0s
Continuous Integration / tests-and-coverage (pull_request) Successful in 28s
2026-08-06 02:22:00 +00:00
kovagoadi f0ef4148b0 Merge pull request 'Add scheduled dispatch support (edit + authoritative view)' (#22) from claude/station-realtime-planned-data-0fc51a into main
Continuous Integration / lint-and-security (push) Successful in 21s
Continuous Integration / tests-and-coverage (push) Successful in 25s
Reviewed-on: #22
2026-07-24 23:00:14 +02:00
kovagoadi 67e886f8d2 Merge branch 'main' into claude/station-realtime-planned-data-0fc51a
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 25s
2026-07-24 22:58:39 +02:00
kovagoadiandClaude Opus 4.8 2eea541158 Add scheduled dispatch support (edit + authoritative view)
Continuous Integration / lint-and-security (pull_request) Successful in 22s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
Editing (game port, OpenTTDClient): a core of JGRPP's scheduled dispatch
DoCommands — set_scheduled_dispatch (enable/disable), add/remove schedule,
add/remove/clear slots, and set duration/start date. Adds the command IDs
to protocol.py.

Viewing (admin, OpenTTDAdminClient.get_dispatch): the GameScript API has no
dispatch support, so a new server patch (docker/patches/0002-*) adds
read-only GSOrder.GetScheduledDispatch* / IsScheduledDispatchEnabled
getters, an AdminBridge GameScript get_dispatch handler exposes them, and
get_dispatch() returns the live schedules and slots (mirrors get_timetable).

Note: set_dispatch_start_date values are normalised by the engine relative
to current game time, so they read back offset from the requested value.

Includes unit + e2e tests, a demo in main.py, and protocol/timetable docs.
The AdminBridge GameScript and the patched OpenTTD-patches clone live
outside this repo; the 0002 patch file is the durable source for the latter.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 22:56:37 +02:00
kovagoadi af1a865fd6 Merge pull request 'Add station listing and cargo queries to admin client' (#21) from claude/station-realtime-planned-data-0fc51a into main
Continuous Integration / lint-and-security (push) Successful in 20s
Continuous Integration / tests-and-coverage (push) Successful in 23s
Reviewed-on: #21
2026-07-23 21:52:35 +02:00
kovagoadiandClaude Opus 4.8 81a4d9333d Add station listing and cargo queries to admin client
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
Extends the AdminBridge GameScript JSON channel (the same relay used by
list_vehicles/get_timetable) with station support:

- list_stations(): enumerate stations, fire-and-forget like list_vehicles().
- get_station(): authoritative per-cargo snapshot of a station's live state,
  with both the real-time waiting amount (GSStation.GetCargoWaiting) and the
  planned cargodist link-graph flow (GetCargoPlanned), plus rating.
- get_station_cargo(): break one cargo type down by source station and by
  next hop (the cargodist routing destination) for both waiting and planned
  amounts, with optional from_station/via_station filters.

All three use stock GameScript API (no server patch, unlike timetables).
Refactors the shared GS request/reply correlation out of get_timetable and
get_station into a _gs_query() helper. Companion handlers must be added to
the server-side AdminBridge GameScript (not tracked in this repo).

Includes unit + e2e tests, a worked demo in main_admin.py, and protocol docs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 21:48:20 +02:00
kovagoadi c39f970ef9 Merge pull request 'Added real timetable support' (#20) from add-real-timetable-support into main
Continuous Integration / lint-and-security (push) Successful in 20s
Continuous Integration / tests-and-coverage (push) Successful in 24s
Reviewed-on: #20
2026-07-23 21:02:37 +02:00
kovagoadi 36fc118da3 Merge branch 'main' into add-real-timetable-support
Continuous Integration / lint-and-security (pull_request) Successful in 26s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
2026-07-23 21:00:50 +02:00
kovagoadi 3b54a722d6 Added real timetable support
Continuous Integration / lint-and-security (pull_request) Successful in 33s
Continuous Integration / tests-and-coverage (pull_request) Successful in 26s
2026-07-23 20:59:28 +02:00
kovagoadi 38cc5ae40c Merge pull request 'Update actions/setup-python action to v7' (#19) from renovate/actions-setup-python-7.x into main
Continuous Integration / lint-and-security (push) Successful in 20s
Continuous Integration / tests-and-coverage (push) Successful in 23s
Reviewed-on: #19
2026-07-21 20:09:28 +02:00
kovagoadi dfb88523f1 Merge branch 'main' into renovate/actions-setup-python-7.x
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
2026-07-21 20:07:54 +02:00
kovagoadi 5db0dab9bf Merge pull request 'Update actions/checkout digest to 3d3c42e' (#18) from renovate/actions-checkout-digest into main
Continuous Integration / lint-and-security (push) Successful in 22s
Continuous Integration / tests-and-coverage (push) Successful in 26s
Reviewed-on: #18
2026-07-21 20:07:47 +02:00
renovate-bot 71bef5a8a2 Update actions/setup-python action to v7
Continuous Integration / lint-and-security (pull_request) Successful in 1m55s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
2026-07-21 02:22:10 +00:00
renovate-bot df9c7b3f06 Update actions/checkout digest to 3d3c42e
Continuous Integration / lint-and-security (pull_request) Successful in 55s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
2026-07-21 02:22:05 +00:00
kovagoadi 954663e80c Merge pull request 'Add vehicle timetable get/set support' (#17) from claude/listing-vehicles-support-d07bf0 into main
Continuous Integration / lint-and-security (push) Successful in 21s
Continuous Integration / tests-and-coverage (push) Successful in 25s
Reviewed-on: #17
2026-07-16 23:26:14 +02:00
kovagoadi 6a28e4acff Merge branch 'main' into claude/listing-vehicles-support-d07bf0
Continuous Integration / lint-and-security (pull_request) Successful in 21s
Continuous Integration / tests-and-coverage (pull_request) Successful in 25s
2026-07-16 23:25:21 +02:00
kovagoadiandClaude Sonnet 5 b33869334a Add vehicle timetable get/set support
Continuous Integration / lint-and-security (pull_request) Successful in 22s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
Timetables have no GameScript API surface, so this implements real
DoCommands over the game port (ClientCommand/ServerCommand) instead of
the Admin GameScript relay used for list_vehicles(): change_timetable(),
autofill_timetable(), set_timetable_start(), and set_vehicle_on_time()
send commands, while get_vehicle_timetable() reconstructs state purely
by observing ServerCommand broadcasts, since no query command exists.

Includes the custom varuint wire codec these commands require, a full
usage guide (docs/TIMETABLES.md), and a worked demo in main.py.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-16 23:21:21 +02:00
kovagoadi fa1741f95b Merge pull request 'Add vehicle listing support to admin client' (#16) from claude/listing-vehicles-support-d07bf0 into main
Continuous Integration / lint-and-security (push) Successful in 22s
Continuous Integration / tests-and-coverage (push) Successful in 24s
Reviewed-on: #16
2026-07-16 20:46:58 +02:00
kovagoadiandClaude Sonnet 5 0a8271d57c Add vehicle listing support to admin client
Continuous Integration / lint-and-security (pull_request) Successful in 31s
Continuous Integration / tests-and-coverage (pull_request) Successful in 25s
The Admin Network has no native packet for listing individual vehicles,
so list_vehicles() sends a "list_vehicles" command over the existing
GameScript JSON channel and relies on a companion server-side script to
reply with vehicle data via ServerGamescript. Requires subscribing to
Gamescript updates (documented in docs/PROTOCOL.md) to receive the reply.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-16 20:44:44 +02:00
kovagoadi 309f1da762 Merge pull request 'Update debian Docker tag to trixie-20260713' (#15) from renovate/debian-13.x into main
Continuous Integration / lint-and-security (push) Successful in 20s
Continuous Integration / tests-and-coverage (push) Successful in 24s
Reviewed-on: #15
2026-07-15 09:14:53 +02:00
kovagoadi 2526b8aec4 Merge branch 'main' into renovate/debian-13.x
Continuous Integration / lint-and-security (pull_request) Successful in 21s
Continuous Integration / tests-and-coverage (pull_request) Successful in 23s
2026-07-15 09:13:54 +02:00
kovagoadi 38e0604cf3 Merge pull request 'Update debian:bookworm-slim Docker digest to 7b140f3' (#14) from renovate/debian-bookworm-slim into main
Continuous Integration / lint-and-security (push) Successful in 22s
Continuous Integration / tests-and-coverage (push) Successful in 24s
Reviewed-on: #14
2026-07-15 09:13:09 +02:00
renovate-bot d87f26a8ef Update debian Docker tag to trixie-20260713
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 23s
2026-07-15 02:22:08 +00:00
renovate-bot d0c4d9218d Update debian:bookworm-slim Docker digest to 7b140f3
Continuous Integration / lint-and-security (pull_request) Successful in 48s
Continuous Integration / tests-and-coverage (pull_request) Successful in 26s
2026-07-15 02:22:04 +00:00
kovagoadi ab8ab7390e Merge pull request 'Update debian Docker tag to v13' (#13) from renovate/debian-13.x into main
Continuous Integration / lint-and-security (push) Successful in 30s
Continuous Integration / tests-and-coverage (push) Successful in 23s
Reviewed-on: #13
2026-07-03 14:48:46 +02:00
renovate-bot eb507b9f11 Update debian Docker tag to v13
Continuous Integration / lint-and-security (pull_request) Successful in 23s
Continuous Integration / tests-and-coverage (pull_request) Successful in 22s
2026-07-02 02:22:50 +00:00
kovagoadi 2d5e57998b Merge pull request 'Update debian Docker tag to bookworm-20260623' (#12) from renovate/debian-12.x into main
Continuous Integration / lint-and-security (push) Successful in 22s
Continuous Integration / tests-and-coverage (push) Successful in 24s
Reviewed-on: #12
2026-07-01 20:31:44 +02:00
renovate-bot aea5cf124c Update debian Docker tag to bookworm-20260623
Continuous Integration / lint-and-security (pull_request) Successful in 21s
Continuous Integration / tests-and-coverage (pull_request) Successful in 23s
2026-07-01 02:22:45 +00:00
kovagoadi 5d5329d197 Merge pull request 'Pin dependencies' (#11) from renovate/pin-dependencies into main
Continuous Integration / lint-and-security (push) Successful in 21s
Continuous Integration / tests-and-coverage (push) Successful in 23s
Reviewed-on: #11
2026-06-30 23:47:15 +02:00
renovate-bot 17d68fa984 Pin dependencies
Continuous Integration / lint-and-security (pull_request) Successful in 20s
Continuous Integration / tests-and-coverage (pull_request) Successful in 23s
2026-06-30 02:22:41 +00:00
32 changed files with 4965 additions and 87 deletions
+4 -4
View File
@@ -10,9 +10,9 @@ jobs:
lint-and-security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.12'
@@ -32,10 +32,10 @@ jobs:
tests-and-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.12'
+3 -1
View File
@@ -1,3 +1,5 @@
venv
__pycache__
docker/config
docker/config
.coverage
.pytest_cache
+15
View File
@@ -9,6 +9,17 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica
- **Modular Design:** Separates low-level binary protocol handling from high-level game logic.
- **State Management:** Handles the full join sequence including Map download and synchronization.
- **Comprehensive Testing:** Robustly tested with unit, logic, and E2E tests (including 100% coverage for unit/logic tests).
- **Vehicle Listing:** Query vehicle data via the Admin GameScript channel with `list_vehicles()`.
- **Vehicle Timetables:** Read and modify a vehicle's timetable (`change_timetable()`, `autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, `get_vehicle_timetable()`) via real game-protocol commands.
- **Order Editing:** Add and remove a vehicle's orders (`add_order()` inserts a "go to station" stop, `remove_order()` deletes one) via real game-protocol commands.
- **Scheduled Dispatch (JGRPP):** Edit a vehicle's dispatch schedules and departure slots over the game port (`add_dispatch_schedule()`, `add_dispatch_slot()`, `set_dispatch_duration()`, `set_scheduled_dispatch()`, and more), and read them back authoritatively with `OpenTTDAdminClient.get_dispatch()` (via a patched GameScript API + the AdminBridge GS).
- **Authoritative Timetable Reads:** `OpenTTDAdminClient.get_timetable()` fetches the real, current timetable of any vehicle from the running game (via a patched GameScript API + the AdminBridge GS) — no company join needed, works for timetables set before connecting.
- **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.
- **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
@@ -59,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).
@@ -72,4 +85,6 @@ For detailed instructions on E2E testing and coverage reports, see the [Testing
## 📜 Documentation
- [Architecture & Design](docs/ARCHITECTURE.md)
- [Protocol Internals (PAKE/Encryption)](docs/PROTOCOL.md)
- [Vehicle Timetables Usage Guide](docs/TIMETABLES.md)
- [Game Events Usage Guide](docs/EVENTS.md)
- [Contributor Guide](docs/CONTRIBUTING.md)
+5 -4
View File
@@ -1,16 +1,17 @@
#!/usr/bin/env python3
import ast
import inspect
import sys
import os
import sys
# Add lib and tests to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib'))
sys.path.insert(0, os.path.dirname(__file__))
from openttd import OpenTTDClient, OpenTTDAdminClient
from openttd.protocol import OpenTTDProtocol, OpenTTDAdminProtocol
import tests.test_e2e as test_e2e
from openttd import OpenTTDAdminClient, OpenTTDClient
from openttd.protocol import OpenTTDAdminProtocol, OpenTTDProtocol
from tests import test_e2e
# 1. Gather public functions dynamically at runtime using reflection
classes = [OpenTTDClient, OpenTTDAdminClient, OpenTTDProtocol, OpenTTDAdminProtocol]
+7 -4
View File
@@ -1,5 +1,5 @@
# Build stage
FROM debian:bookworm AS builder
FROM debian:13@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c AS builder
RUN apt-get update && apt-get install -y \
build-essential \
@@ -26,13 +26,16 @@ RUN cmake .. \
&& make -j$(nproc) install
# Runtime stage
FROM debian:bookworm-slim
# Must track the builder's Debian release: the builder (debian:13/trixie) links
# against glibc 2.38+, so an older runtime (e.g. bookworm, glibc 2.36) cannot run
# the resulting binary. Package names use the trixie t64 spelling.
FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a
RUN apt-get update && apt-get install -y \
libcurl3-gnutls \
libcurl3t64-gnutls \
liblzma5 \
liblzo2-2 \
libpng16-16 \
libpng16-16t64 \
libzstd1 \
zlib1g \
ca-certificates \
+20 -2
View File
@@ -24,14 +24,32 @@ 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`.
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
[patches/README.md](patches/README.md). After a fresh clone of the source, apply them with
`git -C OpenTTD-patches am ../patches/*.patch` before building.
To update the server to a newer JGRPP version:
1. Update the `OpenTTD-patches` directory:
```bash
cd OpenTTD-patches && git pull && cd ..
```
2. Rebuild the image:
2. Reapply (rebase if needed) the local patches from `patches/`.
3. Rebuild the image:
```bash
docker-compose up -d --build
```
+4
View File
@@ -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
@@ -0,0 +1,257 @@
From 73f6770eb3e0ec5be8da9e74fa0874fb6ba2d36d Mon Sep 17 00:00:00 2001
From: kovagoadi <[email protected]>
Date: Sun, 19 Jul 2026 00:33:23 +0200
Subject: [PATCH] Add GameScript API timetable getters to ScriptOrder
Expose read-only timetable data to AI/GS scripts: per-order wait/travel
times, timetabled/fixed flags, leave type and max speed, plus per-vehicle
lateness, timetable start tick, current order time and total duration.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
src/script/api/script_order.cpp | 104 ++++++++++++++++++++++++++++
src/script/api/script_order.hpp | 116 ++++++++++++++++++++++++++++++++
2 files changed, 220 insertions(+)
diff --git a/src/script/api/script_order.cpp b/src/script/api/script_order.cpp
index 865df623f9..ee18f7d588 100644
--- a/src/script/api/script_order.cpp
+++ b/src/script/api/script_order.cpp
@@ -718,3 +718,107 @@ static void _DoCommandReturnSetOrderFlags(class ScriptInstance &instance)
return ScriptMap::DistanceManhattan(origin_tile, dest_tile);
}
}
+
+/* static */ SQInteger ScriptOrder::GetTimetableWaitTime(VehicleID vehicle_id, OrderPosition order_position)
+{
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1;
+
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
+ if (order == nullptr) return -1;
+ return order->GetWaitTime();
+}
+
+/* static */ SQInteger ScriptOrder::GetTimetableTravelTime(VehicleID vehicle_id, OrderPosition order_position)
+{
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1;
+
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
+ if (order == nullptr) return -1;
+ return order->GetTravelTime();
+}
+
+/* static */ bool ScriptOrder::IsWaitTimetabled(VehicleID vehicle_id, OrderPosition order_position)
+{
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false;
+
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
+ if (order == nullptr) return false;
+ return order->IsWaitTimetabled();
+}
+
+/* static */ bool ScriptOrder::IsTravelTimetabled(VehicleID vehicle_id, OrderPosition order_position)
+{
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false;
+
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
+ if (order == nullptr) return false;
+ return order->IsTravelTimetabled();
+}
+
+/* static */ bool ScriptOrder::IsWaitFixed(VehicleID vehicle_id, OrderPosition order_position)
+{
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false;
+
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
+ if (order == nullptr) return false;
+ return order->IsWaitFixed();
+}
+
+/* static */ bool ScriptOrder::IsTravelFixed(VehicleID vehicle_id, OrderPosition order_position)
+{
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return false;
+
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
+ if (order == nullptr) return false;
+ return order->IsTravelFixed();
+}
+
+/* static */ SQInteger ScriptOrder::GetLeaveType(VehicleID vehicle_id, OrderPosition order_position)
+{
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1;
+
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
+ if (order == nullptr) return -1;
+ return order->GetLeaveType();
+}
+
+/* static */ SQInteger ScriptOrder::GetTimetableMaxSpeed(VehicleID vehicle_id, OrderPosition order_position)
+{
+ if (!IsValidVehicleOrder(vehicle_id, order_position)) return -1;
+
+ const Order *order = ::ResolveOrder(vehicle_id, order_position);
+ if (order == nullptr) return -1;
+ return order->GetMaxSpeed();
+}
+
+/* static */ SQInteger ScriptOrder::GetTimetableLateness(VehicleID vehicle_id)
+{
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return 0;
+
+ return ::Vehicle::Get(vehicle_id)->lateness_counter;
+}
+
+/* static */ SQInteger ScriptOrder::GetTimetableStartTick(VehicleID vehicle_id)
+{
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
+
+ return ::Vehicle::Get(vehicle_id)->timetable_start.base();
+}
+
+/* static */ SQInteger ScriptOrder::GetCurrentOrderTime(VehicleID vehicle_id)
+{
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
+
+ return ::Vehicle::Get(vehicle_id)->current_order_time;
+}
+
+/* static */ SQInteger ScriptOrder::GetTimetableTotalDuration(VehicleID vehicle_id)
+{
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
+
+ const Vehicle *v = ::Vehicle::Get(vehicle_id);
+ if (v->orders == nullptr) return -1;
+ Ticks duration = v->orders->GetTimetableTotalDuration();
+ if (duration == INVALID_TICKS) return -1;
+ return duration;
+}
diff --git a/src/script/api/script_order.hpp b/src/script/api/script_order.hpp
index 81dc06cd7d..6c96b91b3a 100644
--- a/src/script/api/script_order.hpp
+++ b/src/script/api/script_order.hpp
@@ -604,6 +604,122 @@ public:
* @see ScriptEngine::GetMaximumOrderDistance and ScriptVehicle::GetMaximumOrderDistance
*/
static SQInteger GetOrderDistance(ScriptVehicle::VehicleType vehicle_type, TileIndex origin_tile, TileIndex dest_tile);
+
+ /**
+ * Gets the timetabled wait time of the given order for the given vehicle.
+ * @param vehicle_id The vehicle to get the timetable wait time for.
+ * @param order_position The order to get the timetable wait time for.
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
+ * @return The wait time of the order in ticks, or -1 when the order is invalid.
+ * @note The raw stored wait time is returned even if the wait time is not
+ * timetabled; use IsWaitTimetabled to check whether it is explicitly set.
+ */
+ static SQInteger GetTimetableWaitTime(VehicleID vehicle_id, OrderPosition order_position);
+
+ /**
+ * Gets the timetabled travel time of the given order for the given vehicle.
+ * @param vehicle_id The vehicle to get the timetable travel time for.
+ * @param order_position The order to get the timetable travel time for.
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
+ * @return The travel time of the order in ticks, or -1 when the order is invalid.
+ * @note The raw stored travel time is returned even if the travel time is not
+ * timetabled; use IsTravelTimetabled to check whether it is explicitly set.
+ */
+ static SQInteger GetTimetableTravelTime(VehicleID vehicle_id, OrderPosition order_position);
+
+ /**
+ * Checks whether the wait time of the given order is timetabled (explicitly set).
+ * @param vehicle_id The vehicle to check the order for.
+ * @param order_position The order to check.
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
+ * @return True if and only if the wait time is timetabled.
+ */
+ static bool IsWaitTimetabled(VehicleID vehicle_id, OrderPosition order_position);
+
+ /**
+ * Checks whether the travel time of the given order is timetabled (explicitly set).
+ * @param vehicle_id The vehicle to check the order for.
+ * @param order_position The order to check.
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
+ * @return True if and only if the travel time is timetabled.
+ */
+ static bool IsTravelTimetabled(VehicleID vehicle_id, OrderPosition order_position);
+
+ /**
+ * Checks whether the wait time of the given order is fixed (locked against autofill).
+ * @param vehicle_id The vehicle to check the order for.
+ * @param order_position The order to check.
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
+ * @return True if and only if the wait time is fixed.
+ */
+ static bool IsWaitFixed(VehicleID vehicle_id, OrderPosition order_position);
+
+ /**
+ * Checks whether the travel time of the given order is fixed (locked against autofill).
+ * @param vehicle_id The vehicle to check the order for.
+ * @param order_position The order to check.
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
+ * @return True if and only if the travel time is fixed.
+ */
+ static bool IsTravelFixed(VehicleID vehicle_id, OrderPosition order_position);
+
+ /**
+ * Gets the leave type of the given order for the given vehicle.
+ * @param vehicle_id The vehicle to get the leave type for.
+ * @param order_position The order to get the leave type for.
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
+ * @return The leave type of the order (0 = leave when timetabled, 1 = leave as
+ * soon as possible, 2 = leave early if any cargo fully loaded, 3 = leave early
+ * if all cargo fully loaded), or -1 when the order is invalid.
+ */
+ static SQInteger GetLeaveType(VehicleID vehicle_id, OrderPosition order_position);
+
+ /**
+ * Gets the timetabled maximum speed of the given order for the given vehicle.
+ * @param vehicle_id The vehicle to get the timetable max speed for.
+ * @param order_position The order to get the timetable max speed for.
+ * @pre IsValidVehicleOrder(vehicle_id, order_position).
+ * @return The maximum speed of the order (65535 when no speed cap is set),
+ * or -1 when the order is invalid.
+ */
+ static SQInteger GetTimetableMaxSpeed(VehicleID vehicle_id, OrderPosition order_position);
+
+ /**
+ * Gets the timetable lateness of the given vehicle.
+ * @param vehicle_id The vehicle to get the lateness for.
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
+ * @return How many ticks the vehicle is late; negative values mean the vehicle
+ * is running early. Returns 0 when the vehicle is invalid, which is
+ * indistinguishable from an on-time vehicle; check the vehicle validity first.
+ */
+ static SQInteger GetTimetableLateness(VehicleID vehicle_id);
+
+ /**
+ * Gets the state tick at which the timetable of the given vehicle starts.
+ * @param vehicle_id The vehicle to get the timetable start tick for.
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
+ * @return The absolute state tick the timetable starts at (0 when the
+ * timetable has not been started), or -1 when the vehicle is invalid.
+ */
+ static SQInteger GetTimetableStartTick(VehicleID vehicle_id);
+
+ /**
+ * Gets the number of ticks the given vehicle has spent on its current order.
+ * @param vehicle_id The vehicle to get the current order time for.
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
+ * @return The number of ticks spent on the current order, or -1 when the
+ * vehicle is invalid.
+ */
+ static SQInteger GetCurrentOrderTime(VehicleID vehicle_id);
+
+ /**
+ * Gets the total duration of the timetable of the given vehicle.
+ * @param vehicle_id The vehicle to get the timetable duration for.
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
+ * @return The total timetable duration in ticks, or -1 when the vehicle is
+ * invalid, has no orders, or the timetable is not complete.
+ */
+ static SQInteger GetTimetableTotalDuration(VehicleID vehicle_id);
};
DECLARE_ENUM_AS_BIT_SET(ScriptOrder::ScriptOrderFlags)
--
2.54.0
@@ -0,0 +1,195 @@
From 1e4bdcca84e956ece32d2d77dc8001bfd1d8e2f8 Mon Sep 17 00:00:00 2001
From: kovagoadi <[email protected]>
Date: Fri, 24 Jul 2026 22:32:29 +0200
Subject: [PATCH] Add GameScript API scheduled dispatch getters to ScriptOrder
Expose read-only scheduled dispatch data to AI/GS scripts: per-vehicle
schedule count and enabled flag, per-schedule duration, start tick, max
delay and slot re-use, and per-slot offset and flags. Enables the
AdminBridge GameScript's get_dispatch command and the Python client's
OpenTTDAdminClient.get_dispatch().
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
src/script/api/script_order.cpp | 81 +++++++++++++++++++++++++++++++++
src/script/api/script_order.hpp | 75 ++++++++++++++++++++++++++++++
2 files changed, 156 insertions(+)
diff --git a/src/script/api/script_order.cpp b/src/script/api/script_order.cpp
index ee18f7d588..3db4639f14 100644
--- a/src/script/api/script_order.cpp
+++ b/src/script/api/script_order.cpp
@@ -822,3 +822,84 @@ static void _DoCommandReturnSetOrderFlags(class ScriptInstance &instance)
if (duration == INVALID_TICKS) return -1;
return duration;
}
+
+/**
+ * Resolve a scheduled dispatch schedule for a vehicle, or nullptr if the vehicle/schedule is invalid.
+ */
+static const DispatchSchedule *ResolveDispatchSchedule(VehicleID vehicle_id, SQInteger schedule_index)
+{
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return nullptr;
+ const Vehicle *v = ::Vehicle::Get(vehicle_id);
+ if (v->orders == nullptr) return nullptr;
+ if (schedule_index < 0 || static_cast<uint>(schedule_index) >= v->orders->GetScheduledDispatchScheduleCount()) return nullptr;
+ return &v->orders->GetDispatchScheduleByIndex(static_cast<uint>(schedule_index));
+}
+
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchScheduleCount(VehicleID vehicle_id)
+{
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
+
+ const Vehicle *v = ::Vehicle::Get(vehicle_id);
+ if (v->orders == nullptr) return 0;
+ return v->orders->GetScheduledDispatchScheduleCount();
+}
+
+/* static */ SQInteger ScriptOrder::IsScheduledDispatchEnabled(VehicleID vehicle_id)
+{
+ if (!ScriptVehicle::IsPrimaryVehicle(vehicle_id)) return -1;
+
+ return ::Vehicle::Get(vehicle_id)->vehicle_flags.Test(VehicleFlag::ScheduledDispatch) ? 1 : 0;
+}
+
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchDuration(VehicleID vehicle_id, SQInteger schedule_index)
+{
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
+ if (ds == nullptr) return -1;
+ return ds->GetScheduledDispatchDuration();
+}
+
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchStartTick(VehicleID vehicle_id, SQInteger schedule_index)
+{
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
+ if (ds == nullptr) return -1;
+ return ds->GetScheduledDispatchStartTick().base();
+}
+
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchDelay(VehicleID vehicle_id, SQInteger schedule_index)
+{
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
+ if (ds == nullptr) return -1;
+ return ds->GetScheduledDispatchDelay();
+}
+
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchReuseSlots(VehicleID vehicle_id, SQInteger schedule_index)
+{
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
+ if (ds == nullptr) return -1;
+ return ds->GetScheduledDispatchReuseSlots() ? 1 : 0;
+}
+
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchSlotCount(VehicleID vehicle_id, SQInteger schedule_index)
+{
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
+ if (ds == nullptr) return -1;
+ return (SQInteger)ds->GetScheduledDispatch().size();
+}
+
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchSlotOffset(VehicleID vehicle_id, SQInteger schedule_index, SQInteger slot_index)
+{
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
+ if (ds == nullptr) return -1;
+ const std::vector<DispatchSlot> &slots = ds->GetScheduledDispatch();
+ if (slot_index < 0 || static_cast<size_t>(slot_index) >= slots.size()) return -1;
+ return slots[static_cast<size_t>(slot_index)].offset;
+}
+
+/* static */ SQInteger ScriptOrder::GetScheduledDispatchSlotFlags(VehicleID vehicle_id, SQInteger schedule_index, SQInteger slot_index)
+{
+ const DispatchSchedule *ds = ::ResolveDispatchSchedule(vehicle_id, schedule_index);
+ if (ds == nullptr) return -1;
+ const std::vector<DispatchSlot> &slots = ds->GetScheduledDispatch();
+ if (slot_index < 0 || static_cast<size_t>(slot_index) >= slots.size()) return -1;
+ return slots[static_cast<size_t>(slot_index)].flags;
+}
diff --git a/src/script/api/script_order.hpp b/src/script/api/script_order.hpp
index 6c96b91b3a..d0a7e41fa7 100644
--- a/src/script/api/script_order.hpp
+++ b/src/script/api/script_order.hpp
@@ -720,6 +720,81 @@ public:
* invalid, has no orders, or the timetable is not complete.
*/
static SQInteger GetTimetableTotalDuration(VehicleID vehicle_id);
+
+ /**
+ * Gets the number of scheduled dispatch schedules of the given vehicle.
+ * @param vehicle_id The vehicle to query.
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
+ * @return The number of dispatch schedules (0 when the vehicle has no order list),
+ * or -1 when the vehicle is invalid.
+ */
+ static SQInteger GetScheduledDispatchScheduleCount(VehicleID vehicle_id);
+
+ /**
+ * Gets whether scheduled dispatch is enabled for the given vehicle.
+ * @param vehicle_id The vehicle to query.
+ * @pre ScriptVehicle::IsPrimaryVehicle(vehicle_id).
+ * @return 1 if enabled, 0 if disabled, or -1 when the vehicle is invalid.
+ */
+ static SQInteger IsScheduledDispatchEnabled(VehicleID vehicle_id);
+
+ /**
+ * Gets the duration in ticks of a dispatch schedule.
+ * @param vehicle_id The vehicle to query.
+ * @param schedule_index The dispatch schedule index.
+ * @return The schedule duration in ticks, or -1 when the vehicle or schedule is invalid.
+ */
+ static SQInteger GetScheduledDispatchDuration(VehicleID vehicle_id, SQInteger schedule_index);
+
+ /**
+ * Gets the start tick of a dispatch schedule.
+ * @param vehicle_id The vehicle to query.
+ * @param schedule_index The dispatch schedule index.
+ * @return The absolute start state tick, or -1 when the vehicle or schedule is invalid.
+ */
+ static SQInteger GetScheduledDispatchStartTick(VehicleID vehicle_id, SQInteger schedule_index);
+
+ /**
+ * Gets the maximum allowed delay of a dispatch schedule.
+ * @param vehicle_id The vehicle to query.
+ * @param schedule_index The dispatch schedule index.
+ * @return The maximum delay in ticks, or -1 when the vehicle or schedule is invalid.
+ */
+ static SQInteger GetScheduledDispatchDelay(VehicleID vehicle_id, SQInteger schedule_index);
+
+ /**
+ * Gets whether a dispatch schedule re-uses its dispatch slots.
+ * @param vehicle_id The vehicle to query.
+ * @param schedule_index The dispatch schedule index.
+ * @return 1 if slots are re-used, 0 if not, or -1 when the vehicle or schedule is invalid.
+ */
+ static SQInteger GetScheduledDispatchReuseSlots(VehicleID vehicle_id, SQInteger schedule_index);
+
+ /**
+ * Gets the number of departure slots in a dispatch schedule.
+ * @param vehicle_id The vehicle to query.
+ * @param schedule_index The dispatch schedule index.
+ * @return The number of slots, or -1 when the vehicle or schedule is invalid.
+ */
+ static SQInteger GetScheduledDispatchSlotCount(VehicleID vehicle_id, SQInteger schedule_index);
+
+ /**
+ * Gets the departure offset (in ticks, within the schedule duration) of a dispatch slot.
+ * @param vehicle_id The vehicle to query.
+ * @param schedule_index The dispatch schedule index.
+ * @param slot_index The slot index within the schedule.
+ * @return The slot offset, or -1 when the vehicle, schedule or slot is invalid.
+ */
+ static SQInteger GetScheduledDispatchSlotOffset(VehicleID vehicle_id, SQInteger schedule_index, SQInteger slot_index);
+
+ /**
+ * Gets the flag word of a dispatch slot.
+ * @param vehicle_id The vehicle to query.
+ * @param schedule_index The dispatch schedule index.
+ * @param slot_index The slot index within the schedule.
+ * @return The slot flags, or -1 when the vehicle, schedule or slot is invalid.
+ */
+ static SQInteger GetScheduledDispatchSlotFlags(VehicleID vehicle_id, SQInteger schedule_index, SQInteger slot_index);
};
DECLARE_ENUM_AS_BIT_SET(ScriptOrder::ScriptOrderFlags)
--
2.54.0
+36
View File
@@ -0,0 +1,36 @@
# Local JGRPP patches
The `docker/OpenTTD-patches/` directory is an **untracked** clone of
[JGRennison/OpenTTD-patches](https://github.com/JGRennison/OpenTTD-patches) checked out at tag
`jgrpp-0.71.1`. The patches in this directory are the local modifications this project needs on
top of that tag; they are the durable source of truth (the clone itself is not committed).
Current patches:
- `0001-Add-GameScript-API-timetable-getters-to-ScriptOrder.patch` — adds read-only timetable
getters (`GetTimetableWaitTime`, `GetTimetableTravelTime`, `IsWaitTimetabled`,
`IsTravelTimetabled`, `IsWaitFixed`, `IsTravelFixed`, `GetLeaveType`, `GetTimetableMaxSpeed`,
`GetTimetableLateness`, `GetTimetableStartTick`, `GetCurrentOrderTime`,
`GetTimetableTotalDuration`) to the `GSOrder` GameScript class. Required by the AdminBridge
GameScript's `get_timetable` command and the Python client's
`OpenTTDAdminClient.get_timetable()`.
- `0002-Add-GameScript-API-scheduled-dispatch-getters-to-Scr.patch` — adds read-only scheduled
dispatch getters (`GetScheduledDispatchScheduleCount`, `IsScheduledDispatchEnabled`,
`GetScheduledDispatchDuration`, `GetScheduledDispatchStartTick`, `GetScheduledDispatchDelay`,
`GetScheduledDispatchReuseSlots`, `GetScheduledDispatchSlotCount`,
`GetScheduledDispatchSlotOffset`, `GetScheduledDispatchSlotFlags`) to the `GSOrder` GameScript
class. Required by the AdminBridge GameScript's `get_dispatch` command and the Python client's
`OpenTTDAdminClient.get_dispatch()`.
## Applying after a fresh clone
```bash
git clone --branch jgrpp-0.71.1 https://github.com/JGRennison/OpenTTD-patches docker/OpenTTD-patches
git -C docker/OpenTTD-patches am ../patches/*.patch
```
Then build the image as usual (`docker-compose up -d --build` from `docker/`).
If the clone is updated past `jgrpp-0.71.1`, `git am` may conflict — the patches were generated
against that tag and need rebasing in that case.
+6
View File
@@ -16,6 +16,12 @@ The primary API for developers.
- **Event-Driven:** Uses `asyncio.Event` (like `self.joined`) to signal state changes to the calling code.
- **Callback System:** Provides hooks like `on_chat` to allow users to react to game events without modifying the core library.
### 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
1. **Connection:** TCP connection established to Port 3979.
+234
View File
@@ -0,0 +1,234 @@
# Game Events: Usage Guide
Everything else in this library is a **request**: you ask the server something and it answers.
Events are the other direction — the server tells you when something happens, so a bot can react
to the game instead of polling it in a loop. This guide covers `OpenTTDAdminClient`'s
`subscribe_events()`, `unsubscribe_events()`, `wait_for_event()` and the `on_event` callback.
For the JSON that goes over the wire, see [PROTOCOL.md](PROTOCOL.md#game-events).
## Quick start
```python
import asyncio
from openttd import OpenTTDAdminClient
from openttd.protocol import GameEventType
async def main():
admin = OpenTTDAdminClient("127.0.0.1", admin_name="EventWatcher")
await admin.connect(admin_password="asd", secure=True)
await admin.joined.wait()
await admin.subscribe_events(
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
)
for _ in range(10):
event = await admin.wait_for_event(timeout=60.0)
print(f"vehicle {event['vehicle_id']} {event['event']} "
f"at station {event['station_id']}")
await admin.unsubscribe_events()
await admin.quit()
asyncio.run(main())
```
## Two ways to consume events
Both see **every** event, so you can use either or mix them.
**Pull — `await admin.wait_for_event(kind=None, timeout=5.0)`.** Returns the next matching event.
`kind` is a `GameEventType` (or plain string), an iterable of them, or `None` for "anything".
Events that arrive while you are not waiting are buffered, so nothing is lost between two calls,
and each event goes to at most one waiter. Raises `asyncio.TimeoutError` if nothing matching
arrives in time, `ConnectionError` if the admin connection drops mid-wait.
```python
# The next arrival or departure, whichever comes first.
event = await admin.wait_for_event(
{GameEventType.VehicleArrive, GameEventType.VehicleDepart}, timeout=30.0)
```
**Push — `admin.on_event = handler`.** A plain callback (like `on_chat` / `on_console`), invoked
with each event dict as it arrives. Use it for a long-running loop that reacts to everything:
```python
def handle(event):
if event["event"] == "cargo_waiting" and event["delta"] > 0:
print(f"{event['delta']} units of cargo {event['cargo_id']} "
f"arrived at station {event['station_id']}")
admin.on_event = handle
await admin.subscribe_events(events=[GameEventType.CargoWaiting])
await asyncio.sleep(600) # events arrive on the connection's own task
```
The buffer behind `wait_for_event()` holds the last 256 unclaimed events by default
(`OpenTTDAdminClient(..., event_buffer_size=N)`); past that the oldest are dropped, so a
subscription nobody drains cannot grow without bound.
## Event kinds
Import them from `openttd.protocol` as `GameEventType`, or just use the string. They come in two
flavours, which behave differently in ways worth knowing.
### Polled events — sampled, not instantaneous
The engine raises **no** event when a vehicle reaches a stop or when cargo shows up, so the
AdminBridge GameScript synthesises these by sampling game state every `interval` ticks and
reporting what changed since the previous sample.
| Kind | Fires when | Fields |
|---|---|---|
| `vehicle_arrive` | a vehicle starts loading/unloading at a station | `vehicle_id`, `station_id`, `owner`, `vehicle_type`, `order_position`, `cargo` |
| `vehicle_depart` | a vehicle stops loading/unloading at a station | the same, plus `dwell` (ticks it stayed) |
| `cargo_waiting` | a station's waiting amount of a cargo changes | `station_id`, `cargo_id`, `waiting` (new total), `delta` (signed change) |
`cargo` is a list of `{"cargo_id": n, "load": units}` for the cargo actually aboard (omit it with
`include_cargo=False`). `vehicle_type` is `0` rail, `1` road, `2` water, `3` air.
`order_position` is the index of the vehicle's current order, or `-1` if it has none.
`delta` is negative when cargo was picked up rather than delivered.
Three consequences of sampling that you should design around:
- **Short stops can be missed entirely.** If a vehicle arrives and leaves between two samples,
neither event exists. Lower `interval` to narrow the window; you cannot close it.
- **The first sample only establishes a baseline.** A vehicle already sitting at a station when
you subscribe did not just arrive and gets no `vehicle_arrive`. Its eventual `dwell` is counted
from that first sample, not from its real arrival.
- **`delta` is measured against the previous sample, not the previous event.** With a
`min_cargo_delta` filter, suppressed changes still move the baseline, so the next reported
`delta` covers everything since the last *sample*.
Two more edge cases in how "at a stop" is defined: it means *loading or unloading* at a station,
so a vehicle passing through a station non-stop never registers, and a vehicle that is stopped by
hand or breaks down at a platform reads as having left (and as arriving again when it resumes).
### Forwarded engine events — exact, as they happen
These the engine does raise, so they are reported the moment they occur, with no sampling
involved and no `interval` dependence.
| Kind | Fields |
|---|---|
| `vehicle_crashed` | `vehicle_id`, `tile`, `reason`, `victims`, `owner` |
| `station_first_vehicle` | `station_id`, `vehicle_id` |
| `industry_open`, `industry_close` | `industry_id` |
| `town_founded` | `town_id` |
| `company_new`, `company_in_trouble`, `company_bankrupt` | `company_id` |
| `subsidy_offer`, `subsidy_offer_expired`, `subsidy_awarded`, `subsidy_expired` | `subsidy_id` |
Every event of either flavour also carries `event` (its kind) and `tick` (the game tick it was
observed at).
There is deliberately no `vehicle_lost`, `vehicle_waiting_in_depot` or `vehicle_unprofitable`:
the engine raises those only for AI companies, never for a GameScript, so no bridge could
forward them. Watch `vehicle_arrive`/`vehicle_depart` or poll `get_timetable()` instead.
### `events_dropped`
Not something you subscribe to — the bridge emits it when a single poll produced more events than
its per-poll cap (200) and discarded `count` of them. Treat it as "your view has a hole in it":
if you see it, your subscription is too broad or your interval too coarse for what the map is
doing. Re-read state with `get_station()` / `get_timetable()` rather than trusting your
event-derived view.
## `subscribe_events(...)`
Awaitable; returns the bridge's confirmation `{"events": [accepted kinds], "interval": N}`. Every
argument narrows what you get, and all are optional.
| Parameter | Type | Meaning |
|---|---|---|
| `events` | iterable | Which kinds to receive. Default: all of them. |
| `interval` | `int` | Ticks between state samples for the polled kinds. Default `10`. This is their resolution, not a delay. |
| `company_id` | `int` | Only report vehicles and stations owned by this company. |
| `vehicles` | iterable | Only sample these vehicle ids instead of every vehicle. |
| `stations` | iterable | Only sample these station ids instead of every station. |
| `cargo` | iterable | Only inspect these cargo types (for `cargo_waiting`, and for the load on vehicle events). |
| `min_cargo_delta` | `int` | Suppress `cargo_waiting` events smaller than this many units. Default `1` (report everything). |
| `include_cargo` | `bool` | `False` leaves the per-cargo load off vehicle events. Default `True`. |
| `timeout` | `float` | Seconds to wait for the confirmation. Default `5.0`. |
Subscribing **replaces** any previous subscription and resets the baseline. `unsubscribe_events()`
stops the stream and drops the bridge's sampling state.
Errors: `ValueError` when the GameScript rejects the request (`unknown_event`,
`invalid_interval`, `invalid_min_cargo_delta`, `invalid_cargo`), `asyncio.TimeoutError` when it
does not answer (GameScripts don't run while the game is **paused**, so a paused server always
times out), `ConnectionError` if the connection drops. The Gamescript update-frequency
subscription the stream needs is set up automatically.
## Cost: subscribe narrowly
The bridge samples every watched vehicle and every watched station × cargo pair on each interval,
inside a GameScript's limited per-tick opcode budget. The defaults — every kind, every vehicle,
every station, every cargo, every 10 ticks — are fine on a small map and wasteful on a large busy
one, where the poll can take longer than the interval it is trying to keep.
Narrow it to what you actually need:
```python
# Watch two specific vehicles closely.
await admin.subscribe_events(
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
vehicles=[7, 9], interval=2)
# Watch coal piling up at one station, ignoring changes under 10 units.
await admin.subscribe_events(
events=[GameEventType.CargoWaiting],
stations=[3], cargo=[0], min_cargo_delta=10, interval=30)
# Only one company's traffic, without the per-vehicle cargo detail.
await admin.subscribe_events(company_id=0, include_cargo=False)
```
## Putting it together
```python
import asyncio
from openttd import OpenTTDAdminClient
from openttd.protocol import GameEventType
async def watch_route(admin, vehicle_id):
"""Report how long a vehicle spends at each stop on its route."""
await admin.subscribe_events(
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
vehicles=[vehicle_id], interval=2)
try:
while True:
event = await admin.wait_for_event(timeout=300.0)
if event["event"] == "vehicle_arrive":
print(f"arrived at station {event['station_id']} (order {event['order_position']})")
else:
loaded = sum(c["load"] for c in event["cargo"])
print(f"left station {event['station_id']} after {event['dwell']} ticks "
f"carrying {loaded} units")
except asyncio.TimeoutError:
print("vehicle went quiet")
finally:
await admin.unsubscribe_events()
async def main():
admin = OpenTTDAdminClient("127.0.0.1", admin_name="RouteWatcher")
await admin.connect(admin_password="asd", secure=True)
await admin.joined.wait()
await watch_route(admin, vehicle_id=7)
await admin.quit()
asyncio.run(main())
```
## Requirements
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.
## See also
- [PROTOCOL.md — Game Events](PROTOCOL.md#game-events) for the request/reply JSON.
- [TIMETABLES.md](TIMETABLES.md) for reading and editing what the events tell you changed.
- [ARCHITECTURE.md](ARCHITECTURE.md) for how the admin client fits together.
+131
View File
@@ -26,6 +26,137 @@ 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`.
**Important:** the server only forwards `ServerGamescript` packets to admins that have subscribed with `update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)` (enforced server-side in `NetworkAdminGameScript`, which checks `update_frequency[ADMIN_UPDATE_GAMESCRIPT]`). Call `update_frequency()` for `Gamescript` before `list_vehicles()`, or the response is silently dropped.
When a `list_vehicles` request carries a `request_id` field, the AdminBridge GameScript echoes it back in the reply (backward compatible: absent otherwise).
### Timetable Query
The stock GameScript API has no timetable getters, so this project patches the server (see `docker/patches/`) to add read-only getters to `GSOrder` (`GetTimetableWaitTime`, `GetTimetableTravelTime`, `IsWaitTimetabled`, `IsTravelTimetabled`, `IsWaitFixed`, `IsTravelFixed`, `GetLeaveType`, `GetTimetableMaxSpeed`, `GetTimetableLateness`, `GetTimetableStartTick`, `GetCurrentOrderTime`, `GetTimetableTotalDuration`). On top of that, `get_timetable()` sends a request over the same GameScript JSON channel as vehicle listing and awaits the correlated reply — an **authoritative snapshot** of the live game state, unlike the passive observer on the game port (see below).
- **Request:** `{"command": "get_timetable", "vehicle_id": N, "request_id": X}``request_id` is a client-side monotonic counter used to match the reply to the awaiting caller.
- **Reply (success):** `{"command": "get_timetable", "vehicle_id": N, "request_id": X, "lateness": ..., "start_tick": ..., "current_order_time": ..., "total_duration": ..., "orders": [{"position", "wait_time", "travel_time", "wait_timetabled", "travel_timetabled", "wait_fixed", "travel_fixed", "leave_type", "max_speed"}, ...]}` (booleans encoded as 0/1).
- **Reply (error):** same envelope with an `"error"` field instead of the data: `"invalid_vehicle"` (no such vehicle) or `"response_too_large"` (the reply exceeded the admin packet size limit, possible with very many orders). `get_timetable()` raises `ValueError` for these.
Replies carrying a `request_id` that matches a pending request resolve that request and are **not** delivered to the `on_gamescript` callback; all other `ServerGamescript` traffic reaches the callback unchanged. The same `update_frequency` subscription requirement applies (`get_timetable()` subscribes automatically on first use). Since GameScripts do not tick while the game is paused, a query against a paused server times out (`asyncio.TimeoutError`).
### Station Listing
Like vehicles, the Admin Network has no native packet for enumerating individual stations (`ServerCompanyStats` only reports an aggregate per-company station count). `list_stations()` sends a `list_stations` command over the same GameScript JSON channel and the companion AdminBridge GameScript replies with station data through `ServerGamescript` (`{"command": "list_stations", "stations": [{"id", "name", ...}, ...]}`). It is fire-and-forget, so the reply is delivered to the `on_gamescript` callback — subscribe to `Gamescript` updates first, exactly as for `list_vehicles()`. An optional `company_id` field scopes the list to one company.
### Station Query
`get_station()` fetches an authoritative snapshot of one station's live cargo state over the same GameScript JSON channel, awaiting the correlated reply — the station analogue of `get_timetable()`. Unlike timetables, the getters it relies on (`GSStation.GetCargoWaiting`, `GetCargoPlanned`, `GetCargoRating`) are part of the **stock** GameScript API, so this needs no server patch. The per-cargo reply exposes both the **real-time** amount currently waiting and the **planned** amount routed through the station by the cargodist link graph.
- **Request:** `{"command": "get_station", "station_id": N, "request_id": X}``request_id` is the same client-side monotonic counter used by `get_timetable()`, matching the reply to the awaiting caller.
- **Reply (success):** `{"command": "get_station", "station_id": N, "request_id": X, "name": ..., "location": <tile>, "owner": <company_id>, "cargo": [{"cargo_id", "waiting", "planned", "rating"}, ...]}``waiting` is the real-time units at the station (`GetCargoWaiting`), `planned` is the link-graph planned flow (`GetCargoPlanned`, 0 when cargo distribution is off for that cargo), and `rating` is the acceptance rating as a percentage (0-100, `GetCargoRating`) or `null` when the station has no rating for that cargo yet. Only cargo the station has handled appears.
- **Reply (error):** same envelope with an `"error"` field instead of the data: `"invalid_station"` (no such station) or `"response_too_large"`. `get_station()` raises `ValueError` for these.
Correlation, the `update_frequency` subscription requirement (auto-subscribed on first use), and the paused-game timeout behave exactly as described for the Timetable Query above.
### Station Cargo Flow Breakdown
`get_station_cargo()` drills into a single cargo type at one station and returns how its **waiting** (real-time) and **planned** amounts split across the cargo distribution (cargodist) link graph. Cargodist tags every unit with a **source** station (`from`, where it was first loaded) and a **next hop** (`via`, the next station it travels to toward its final destination). There is no per-station store of the *final* destination — the routing destination is the next hop — so the breakdown is offered along those two axes. The GS reads them with the stock `GSStation.GetCargoWaiting{From,Via,FromVia}` / `GetCargoPlanned{From,Via,FromVia}` scalars and the `GSStationList_Cargo{Waiting,Planned}By{From,Via}` (and `…ViaByFrom` / `…FromByVia`) list classes — again no server patch.
- **Request:** `{"command": "get_station_cargo", "station_id": N, "cargo_id": C, "request_id": X}`, optionally with `"from_station"` and/or `"via_station"` filters.
- **Reply (success):** `{"command": "get_station_cargo", "station_id": N, "cargo_id": C, "request_id": X, "waiting": ..., "planned": ..., "waiting_by_from": [{"station", "amount"}, ...], "planned_by_from": [...], "waiting_by_via": [...], "planned_by_via": [...]}`. `waiting`/`planned` are the (filtered) totals; each `*_by_from` list groups by source station and each `*_by_via` list groups by next hop (zero-amount entries omitted). A `station` of `65535` (`STATION_INVALID`) means the source was deleted or — as a next hop — the cargo has no onward routing / is consumed here (also the only next hop for cargo using manual, non-cargodist distribution). Any supplied `from_station`/`via_station` filter is echoed back.
- **Filters:** `via_station` restricts the query (and the `*_by_from` breakdowns) to cargo whose next hop is that station; `from_station` restricts it (and the `*_by_via` breakdowns) to cargo from that source; supplying both makes `waiting`/`planned` the exact source-and-next-hop amount. Pass `65535` to target `STATION_INVALID`.
- **Reply (error):** same envelope with an `"error"` field: `"invalid_station"`, `"invalid_cargo"`, `"invalid_from_station"`/`"invalid_via_station"` (a filter that is neither a valid station nor `STATION_INVALID`), or `"response_too_large"`. `get_station_cargo()` raises `ValueError` for these.
### Cargo Listing
Cargo appears in the replies above (and in the `cargo_waiting` / vehicle events) as a bare numeric `cargo_id`. Those ids index the cargo table the loaded NewGRFs build for the running game, so they are **not stable across games** — the same id can be coal in one save and grain in another. `list_cargo()` resolves them, sending a `list_cargo` command over the same GameScript JSON channel and awaiting the correlated reply. The GS reads the table with the stock `GSCargoList`, `GSCargo.GetCargoLabel` and `GSCargo.IsFreight`, so this needs no server patch.
- **Request:** `{"command": "list_cargo", "request_id": X}`.
- **Reply (success):** `{"command": "list_cargo", "request_id": X, "cargo": [{"cargo_id": N, "label": "COAL", "freight": 0|1}, ...]}` — one entry per cargo type in the game, in `GSCargoList` order rather than by id, so index the list by `cargo_id`. `label` is the four-character NewGRF cargo label (`GetCargoLabel`, underscore-padded: `OIL_`), or `""` if the GS could not read it; `freight` is 1 for freight cargo and 0 for the rest (passengers, mail, …).
- **Reply (error):** the GS defines no error for this command, so `list_cargo()` never raises `ValueError` — only `asyncio.TimeoutError` (paused game, GS not loaded) and `ConnectionError`.
Correlation and the `update_frequency` subscription requirement (auto-subscribed on first use) are as described for the Timetable Query above. Unlike `list_vehicles()`/`list_stations()` — which are fire-and-forget despite the similar name — this one is awaitable and its reply does **not** reach the `on_gamescript` callback.
### Dispatch Query
`get_dispatch()` fetches an authoritative snapshot of a vehicle's **scheduled dispatch** state over the GameScript JSON channel, the vehicle analogue of `get_timetable()` for JGRPP's scheduled dispatch feature. Like the timetable getters, the dispatch getters it relies on are added by a **server patch** (`docker/patches/0002-*`, adding `GSOrder.GetScheduledDispatch*` / `IsScheduledDispatchEnabled`), so it needs the patched JGRPP build. Correlation, the auto-subscribe, and the paused-game timeout behave exactly as for the Timetable Query.
- **Request:** `{"command": "get_dispatch", "vehicle_id": N, "request_id": X}`.
- **Reply (success):** `{"command": "get_dispatch", "vehicle_id": N, "request_id": X, "enabled": 0|1, "schedules": [{"index", "duration", "start_tick", "delay", "reuse_slots", "slots": [{"offset", "flags"}, ...]}, ...]}`. `enabled` is whether scheduled dispatch is turned on for the vehicle; each schedule reports its `duration` (ticks), `start_tick`, `delay` (max allowed delay), `reuse_slots` (0/1) and its `slots` (each a departure `offset` within the duration plus a 16-bit `flags` word). These are the same schedules and slots edited by the game-port dispatch methods.
- **Reply (error):** same envelope with an `"error"` field: `"invalid_vehicle"` or `"response_too_large"`. `get_dispatch()` raises `ValueError` for these.
### Game Events
Every other GameScript command above is request/reply. `subscribe_events()` instead opens a **push** stream: the AdminBridge GameScript sends event batches over `ServerGamescript` as things happen, unsolicited and without a `request_id`. For the calling API and the semantics of each kind, see the [Game Events Usage Guide](EVENTS.md).
- **Subscribe request:** `{"command": "subscribe_events", "request_id": X}` plus any of the optional narrowing fields `"events"` (array of kind strings), `"interval"` (ticks between state samples, default 10), `"company_id"`, `"vehicles"`, `"stations"`, `"cargo"` (arrays of ids), `"min_cargo_delta"` (default 1) and `"include_cargo"` (bool, default true).
- **Subscribe reply:** `{"command": "subscribe_events", "request_id": X, "events": [accepted kinds in catalogue order], "interval": N}`, or the same envelope with an `"error"` field: `"unknown_event"` (plus the offending `"event"`), `"invalid_interval"`, `"invalid_min_cargo_delta"`, or `"invalid_cargo"` (plus the offending `"cargo_id"`). `subscribe_events()` raises `ValueError` for these. Subscribing replaces any previous subscription and resets the bridge's sampling baseline.
- **Unsubscribe:** `{"command": "unsubscribe_events", "request_id": X}``{"command": "unsubscribe_events", "request_id": X, "events": []}`. This also drops the sampling state.
- **Event batch (unsolicited):** `{"command": "events", "events": [{"event": <kind>, "tick": T, ...}, ...]}`. Batches carry at most 24 events per packet, so one poll can produce several; a poll that generated more than 200 events is truncated and followed by a single `{"event": "events_dropped", "count": N}` entry. Because these batches carry no `request_id`, `receive_ServerGamescript` routes them to the event consumers (`on_event` / `wait_for_event()`) instead of the generic `on_gamescript` callback; every other GameScript payload reaches `on_gamescript` unchanged. The usual `update_frequency(Gamescript, Automatic)` subscription applies and is set up automatically by `subscribe_events()`.
Event kinds come from two sources. `vehicle_arrive`, `vehicle_depart` and `cargo_waiting` are **synthesised** by the bridge, because the engine raises no GameScript event for them: every `interval` ticks it samples `GSVehicle.GetState`/`GetLocation` for the watched vehicles and `GSStation.GetCargoWaiting` for the watched station-cargo pairs, and emits an event per change against the previous sample (so a stop shorter than the interval is never reported, and the first sample after subscribing only sets a baseline). All the remaining kinds — `vehicle_crashed`, `station_first_vehicle`, `industry_open`/`industry_close`, `town_founded`, `company_new`/`company_in_trouble`/`company_bankrupt` and the four `subsidy_*` kinds — are engine events forwarded verbatim from `GSEventController`. Only those the engine actually raises for a **deity** script are available: `ET_VEHICLE_LOST`, `ET_VEHICLE_WAITING_IN_DEPOT` and `ET_VEHICLE_UNPROFITABLE` are `@api ai` only and can never reach a GameScript, so the bridge does not offer them. Unlike the timetable and dispatch queries, none of this needs a server patch — every getter used is stock GameScript API.
## Vehicle Orders & Timetables (Game Port DoCommands)
Unlike vehicle listing, a vehicle's order list, timetables and scheduled dispatch have no writable GameScript API surface (this project adds read-only timetable and dispatch getters via server patches — see "Timetable Query" and "Dispatch Query" above). Reading and modifying them requires real engine commands (`DoCommand`s) sent over the **game port** (TCP 3979) via `ClientCommand`/`ServerCommand` packets, not the Admin Network. This section covers the wire format; for how to call the methods and what each parameter means, see the [Vehicle Timetables Usage Guide](TIMETABLES.md).
### Command envelope
Both `ClientCommand` and `ServerCommand` share this body: `company (uint8)`, `cmd (uint16 LE, index into the `Commands` enum)`, `error_msg (uint16 LE, StringID, use 0)`, `tile (uint32 LE, always 0 for these commands)`, `payload_len (uint16 LE)`, `payload (payload_len bytes)`, `callback (uint8, use 0)`, `callback_param (uint32 LE, only present if callback != 0)`. `ServerCommand` additionally appends `frame (uint32 LE)` and `my_cmd (uint8 bool)`, and is a **broadcast echo of the request** (no success/failure code) sent to every joined client, not just the sender.
### Payload integer encoding
Command payload fields follow JGRPP's generic serialiser, which picks the wire width from the C++ type's **size**: types of ≤1 byte are sent as a fixed `uint8`, exactly 2 bytes as a fixed `uint16` (LE), and 4/8-byte types as a variable-length **varuint** (`write_varuint`/`read_varuint` in `protocol.py` — a UTF-8-like prefix encoding, not LEB128; signed fields use zigzag via `write_varuint_signed`/`read_varuint_signed`). This is why `VehicleID` (a 4-byte pool id) is a varuint while `VehicleOrderID` (a `uint16`) is a fixed `uint16`.
### Command IDs and payload tuples
| Method | `cmd` | payload |
|---|---|---|
| `add_order()` | 52 (`InsertOrder`) | `VehicleID (varuint), sel_ord (uint16), order_type (uint8), order_flags (uint16), DestinationID (uint16)` |
| `remove_order()` | 51 (`DeleteOrder`) | `VehicleID (varuint), VehicleOrderID (uint16)` |
| `change_timetable()` | 174 (`ChangeTimetable`) | `VehicleID (varuint), VehicleOrderID (uint16), ModifyTimetableFlags (uint8), value (varuint), ModifyTimetableCtrlFlags (uint8)` |
| `set_vehicle_on_time()` | 176 (`SetVehicleOnTime`) | `VehicleID (varuint), apply_to_group (uint8 bool)` |
| `autofill_timetable()` | 177 (`AutofillTimetable`) | `VehicleID (varuint), bool (uint8), bool (uint8)` |
| `set_timetable_start()` | 180 (`SetTimetableStart`) | `VehicleID (varuint), bool (uint8), StateTicks (signed varuint)` |
| `set_scheduled_dispatch()` | 205 (`SchDispatch`) | `VehicleID (varuint), enabled (uint8 bool)` |
| `add_dispatch_slot()` | 206 (`SchDispatchAdd`) | `VehicleID (varuint), schedule_index (varuint), offset (varuint), interval (varuint), extra_slots (varuint), slot_flags (uint16), route_id (uint8)` |
| `remove_dispatch_slot()` | 207 (`SchDispatchRemove`) | `VehicleID (varuint), schedule_index (varuint), offset (varuint)` |
| `set_dispatch_duration()` | 208 (`SchDispatchSetDuration`) | `VehicleID (varuint), schedule_index (varuint), duration (varuint)` |
| `set_dispatch_start_date()` | 209 (`SchDispatchSetStartDate`) | `VehicleID (varuint), schedule_index (varuint), StateTicks (signed varuint)` |
| `clear_dispatch_schedule()` | 213 (`SchDispatchClear`) | `VehicleID (varuint), schedule_index (varuint)` |
| `add_dispatch_schedule()` | 214 (`SchDispatchAddNewSchedule`) | `VehicleID (varuint), StateTicks (signed varuint), duration (varuint)` |
| `remove_dispatch_schedule()` | 215 (`SchDispatchRemoveSchedule`) | `VehicleID (varuint), schedule_index (varuint)` |
### Scheduled dispatch (JGRPP)
A vehicle's order list can carry several **dispatch schedules**, each with a duration, a start tick and a set of departure **slots** (offsets within the duration). The methods above edit them over the game port (`add_dispatch_schedule()`/`remove_dispatch_schedule()` create and delete schedules; `add_dispatch_slot()`/`remove_dispatch_slot()`/`clear_dispatch_schedule()` manage a schedule's slots; `set_dispatch_duration()`/`set_dispatch_start_date()` adjust a schedule; `set_scheduled_dispatch()` toggles the feature for the vehicle). `add_dispatch_slot()` can add several evenly spaced slots at once via its `interval`/`extra_slots` parameters. The stock JGRPP command set covers ~22 dispatch commands (routes, departure tags, per-slot flags, adjust/swap/duplicate, …); the client implements this common core. There is no game-port read; for an authoritative view of the resulting schedules use the Admin Network's `get_dispatch()` (see "Dispatch Query" below).
### Adding & removing orders
`add_order()` issues `CMD_INSERT_ORDER`, which appends a new order before `sel_ord` (pass `0xFFFF`/`INVALID_VEH_ORDER_ID` to append to the end). The client currently builds "go to station" orders only. The `order_type` byte is bit-packed: **bits 0-3** hold the `OrderType` (`1` = `OT_GOTO_STATION`), **bits 4-5** the `OrderStopLocation`, and **bits 6-7** the `OrderNonStopFlags`. The stop location defaults to `PlatformFarEnd` (`2`) because near-end/middle/through are **train-only** and the server rejects (`CMD_ERROR`, no state change) any other value for road vehicles, ships, or aircraft. `order_flags` is the 16-bit load/unload word (`0` = load-if-possible + unload-if-possible). `DestinationID` is the target `StationID`. `remove_order()` issues `CMD_DELETE_ORDER` for the order at a given position. Both are **broadcast** back as `ServerCommand` like any DoCommand; the client does not currently decode those echoes into observed order state, so verify results via the admin `get_timetable()` order count.
### Ownership requirement
A command is rejected unless it's issued by the company that owns the target vehicle — join that company via `join_company()` with a real company id (not 255/spectator) before calling any order or timetable method. A malformed or wrong-company packet is treated as illegal and the client is kicked; a well-formed command that merely fails validation (e.g. an order the vehicle can't serve) is silently dropped with no state change and no kick.
### Reading timetables — no query command exists on the game port
There is no getter `DoCommand` for orders/timetables anywhere in the protocol. `get_vehicle_timetable()` works by passively decoding `ServerCommand` broadcasts (including the sender's own) as they arrive — it only reflects **changes made after the client joined**. A vehicle's pre-existing timetable (set before this client connected) is invisible until something changes it again; seeing it upfront would require parsing the `ORDR`/`VEHS` chunks of the initial savegame transfer (`ServerMapData`), which this client does not implement. For an authoritative read, use the Admin Network's `get_timetable()` instead (see "Timetable Query" above).
## Stream Encryption (AEAD)
Once `ServerEnableEncryption` is received, all subsequent packets use **XChaCha20-Poly1305** (Authenticated Encryption with Associated Data).
+2
View File
@@ -13,7 +13,9 @@ The tests are located in the `tests/` directory:
| [`test_admin.py`](file:///home/kovagoadi/openttd-client/tests/test_admin.py) | `OpenTTDAdminClient` | Tests admin client initialization, admin packet types, and basic protocol constants. |
| [`test_protocol.py`](file:///home/kovagoadi/openttd-client/tests/test_protocol.py) | `OpenTTDProtocol` | Tests binary serialization, custom parsers, and stream encryption/decryption (XChaCha20-Poly1305). |
| [`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. |
---
+410
View File
@@ -0,0 +1,410 @@
# Vehicle Timetables: Usage Guide
This guide covers the timetable API: writing via `OpenTTDClient`'s `change_timetable()`,
`autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, and reading via
`OpenTTDAdminClient.get_timetable()` (authoritative, recommended) or `OpenTTDClient`'s
`get_vehicle_timetable()` (passive change observer). For wire-format internals (packet layout,
varuint encoding, command IDs), see [PROTOCOL.md](PROTOCOL.md#vehicle-timetables-game-port-docommands).
This guide is about *how to call these methods and what their parameters mean*, with worked examples.
## Reading: `get_timetable()` — authoritative snapshot (recommended)
An **awaitable** method on `OpenTTDAdminClient` (admin port, TCP 3977) that queries the real
timetable state from the running game via the AdminBridge GameScript. Unlike the observer
approach below, it works for timetables set **before** you connected and returns the game's
**actual** state, not the last requested change. No `join_company` needed — it's read-only and
sees every company's vehicles.
```python
import asyncio
from openttd import OpenTTDAdminClient
async def main():
admin = OpenTTDAdminClient("127.0.0.1", admin_name="TimetableReader")
await admin.connect(admin_password="asd")
await admin.joined.wait()
data = await admin.get_timetable(7)
print(data)
await admin.quit()
asyncio.run(main())
```
Returns a `dict` shaped like:
```python
{
"command": "get_timetable",
"vehicle_id": 7,
"lateness": 0, # ticks late; negative = running early
"start_tick": 1000000, # absolute StateTicks the timetable starts at; 0 = not started
"current_order_time": 42, # ticks spent on the current order so far
"total_duration": 5400, # full timetable round-trip in ticks; -1 = timetable incomplete
"orders": [
{
"position": 0,
"wait_time": 120, # ticks (raw stored value)
"travel_time": 300, # ticks (raw stored value)
"wait_timetabled": 1, # 1 = wait time explicitly set, 0 = not timetabled
"travel_timetabled": 1,
"wait_fixed": 0, # 1 = locked against autofill
"travel_fixed": 0,
"leave_type": 0, # 0 normal, 1 leave early, 2 early if any cargo full, 3 early if all full
"max_speed": 65535, # order speed cap; 65535 = no cap
},
# ... one entry per order position
],
}
```
Errors: raises `ValueError` when the GameScript reports one (`invalid_vehicle` for a nonexistent
vehicle id; `response_too_large` if a very long order list overflows the admin packet limit),
`asyncio.TimeoutError` when no reply arrives within `timeout` (default 5.0s — note GameScripts
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 (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
1. **You must join the vehicle's own company to write.** The `change_timetable()` family are real
game commands (`DoCommand`s) on the game port, not admin-network calls. Join with
`client.join_company(company_id=<id>, company_password=...)` using the id of the company that
owns the vehicle — spectators (`company_id=255`, the default) are rejected. Sending a command
for a vehicle you don't own also fails.
2. **`get_vehicle_timetable()` is a passive observer, not a query.** It watches the
`ServerCommand` broadcasts the server sends to every joined client whenever *anyone* changes a
timetable. Use it for live change monitoring on the game port; prefer `get_timetable()` above
for reading actual state. Its limitations:
- It only reflects changes made **after your client joined**. A vehicle's pre-existing
timetable (set before you connected) is invisible until something changes it again.
- It reflects what was **requested**, not a confirmed result — the wire protocol has no
success/failure code, so a command that the server silently rejects (wrong owner, invalid
order, etc.) still updates your local view as if it succeeded.
## Quick start
```python
import asyncio
from openttd import OpenTTDClient
from openttd.protocol import ModifyTimetableFlags
async def main():
client = OpenTTDClient(host="127.0.0.1", username="TimetableBot")
await client.connect(server_password="asd")
# Must be a real company you own vehicles in -- not 255 (spectator).
await client.join_company(company_id=0, company_password="")
await client.joined.wait()
# Set order 0's wait time to 120 ticks for vehicle 7.
await client.change_timetable(7, 0, ModifyTimetableFlags.WaitTime, 120)
# Give the broadcast a moment to round-trip back to us.
await asyncio.sleep(1.0)
print(client.get_vehicle_timetable(7))
# -> {'orders': {0: {'wait_time': 120}}}
await client.quit()
asyncio.run(main())
```
## `change_timetable(vehicle_id, order_position, flag, value, clear_field=False)`
Changes one field of one order's timetable entry. This is the general-purpose "edit a cell in the
Timetable window" command — every other kind of edit (wait time, travel time, max speed, fixed
flags, leave type, dispatch schedule assignment) goes through this one method, distinguished by
`flag`.
| Parameter | Type | Meaning |
|---|---|---|
| `vehicle_id` | `int` | The `VehicleID` whose order list you're editing. You must own the company this vehicle belongs to. |
| `order_position` | `int` | Zero-based index into the vehicle's order list (order 0, order 1, ...). Must be a valid, existing order — you can't create orders with this method, only edit existing ones. |
| `flag` | `ModifyTimetableFlags` | Which field of the order to change (see table below). Import from `openttd.protocol`. |
| `value` | `int` | The new value. **Its meaning depends entirely on `flag`** — see below. |
| `clear_field` | `bool` | Only meaningful when `flag` is `WaitTime` or `TravelTime`. See "Clearing a field" below. Default `False`. |
### `ModifyTimetableFlags` values and what `value` means for each
| Flag | What it changes | `value` meaning |
|---|---|---|
| `ModifyTimetableFlags.WaitTime` | How long the vehicle waits at this order (e.g. at a station) | Wait time **in game ticks** |
| `ModifyTimetableFlags.TravelTime` | How long the vehicle takes to travel to this order | Travel time **in game ticks** |
| `ModifyTimetableFlags.TravelSpeed` | The order's max speed cap | Max speed in the order's internal speed unit (the same number shown in the Timetable window's speed column). Pass `0` to **remove** the speed cap entirely (no clamp) |
| `ModifyTimetableFlags.SetWaitFixed` | Whether the wait time is "fixed" (locked, so autofill won't overwrite it) | `1` to fix, `0` to unfix |
| `ModifyTimetableFlags.SetTravelFixed` | Whether the travel time is "fixed" (locked) | `1` to fix, `0` to unfix |
| `ModifyTimetableFlags.SetLeaveType` | When the vehicle is allowed to leave this order early | `0` = normal (leave when timetabled), `1` = leave as soon as possible, `2` = leave early if any cargo is fully loaded, `3` = leave early if all cargo is fully loaded |
| `ModifyTimetableFlags.AssignSchedule` | Which scheduled-dispatch schedule this order is tied to | A schedule index (`0`, `1`, ...), or `0xFFFFFFFF` (4294967295) to unassign (no schedule) |
A "tick" is the game's base simulation unit; how much real time it represents depends on the
server's day-length setting, so there's no fixed ticks-per-second conversion you can rely on
across servers.
### Clearing a field
`clear_field=True` only makes sense with `flag=WaitTime` or `flag=TravelTime`, and **you must also
pass `value=0`** — the server rejects the command (silently, as always — you'll only notice because
`get_vehicle_timetable()` won't show the change you expected) if `clear_field=True` and `value != 0`.
Clearing is different from just setting the time to `0`:
- `change_timetable(v, 0, ModifyTimetableFlags.WaitTime, 0)` — sets wait time to exactly 0 ticks,
but the order is still considered "timetabled" (has an explicit time).
- `change_timetable(v, 0, ModifyTimetableFlags.WaitTime, 0, clear_field=True)` — removes the
timetabled wait time entirely (back to "no time set").
### Examples
```python
from openttd.protocol import ModifyTimetableFlags
# Set order 0's wait time to 120 ticks.
await client.change_timetable(7, 0, ModifyTimetableFlags.WaitTime, 120)
# Set order 1's travel time to 300 ticks.
await client.change_timetable(7, 1, ModifyTimetableFlags.TravelTime, 300)
# Cap order 0's speed at 80 (speed units), then remove the cap again.
await client.change_timetable(7, 0, ModifyTimetableFlags.TravelSpeed, 80)
await client.change_timetable(7, 0, ModifyTimetableFlags.TravelSpeed, 0) # 0 = no cap
# Lock order 0's wait time so autofill won't touch it.
await client.change_timetable(7, 0, ModifyTimetableFlags.SetWaitFixed, 1)
# Let the vehicle leave order 2 as soon as it's loaded, instead of waiting for the timetabled time.
await client.change_timetable(7, 2, ModifyTimetableFlags.SetLeaveType, 1) # OLT_LEAVE_EARLY
# Assign order 0 to scheduled-dispatch schedule 0, then unassign it.
await client.change_timetable(7, 0, ModifyTimetableFlags.AssignSchedule, 0)
await client.change_timetable(7, 0, ModifyTimetableFlags.AssignSchedule, 0xFFFFFFFF)
# Clear order 0's wait time back to "not timetabled".
await client.change_timetable(7, 0, ModifyTimetableFlags.WaitTime, 0, clear_field=True)
```
## `autofill_timetable(vehicle_id, autofill=True, preserve_wait_time=False)`
Turns the "Autofill timetable" feature on or off for a vehicle. While autofill is active, the game
fills in wait/travel times automatically as the vehicle completes each order, instead of you
setting them manually with `change_timetable()`.
| Parameter | Type | Meaning |
|---|---|---|
| `vehicle_id` | `int` | The vehicle to enable/disable autofill for. |
| `autofill` | `bool` | `True` to start autofilling (also clears the "timetable has started" state — enabling autofill is how you (re)start building a timetable from scratch). `False` to stop. Default `True`. |
| `preserve_wait_time` | `bool` | Only relevant when `autofill=True`. If `True`, autofill only *increases* existing wait times, never shortens them, instead of overwriting them outright. Default `False`. |
### Examples
```python
# Start autofilling vehicle 7's timetable from scratch.
await client.autofill_timetable(7, autofill=True, preserve_wait_time=False)
# Start autofilling, but never shrink wait times the vehicle already has set.
await client.autofill_timetable(7, autofill=True, preserve_wait_time=True)
# Stop autofilling once you're happy with the result.
await client.autofill_timetable(7, autofill=False)
```
## `set_timetable_start(vehicle_id, timetable_all, start_date)`
Sets when a vehicle's timetable begins running.
| Parameter | Type | Meaning |
|---|---|---|
| `vehicle_id` | `int` | The vehicle whose timetable start to set. |
| `timetable_all` | `bool` | `True` to apply this start date to every vehicle that shares this vehicle's order list (a "vehicle group" running the same route); `False` to affect only this one vehicle. |
| `start_date` | `int` | An **absolute `StateTicks` value** — OpenTTD's internal tick counter that always advances at the same rate regardless of day-length settings. It is *not* a calendar date and *not* relative to "now". |
**About `start_date`:** this library doesn't currently expose "what is the current `StateTicks`
value" anywhere (the admin `ServerDate` packet reports a calendar date, which is a different,
day-length-dependent counter). In practice you'll usually either: read a `timetable_start` value
already observed via `get_vehicle_timetable()` on another vehicle in the same group and reuse it,
or coordinate the value out-of-band (e.g. from an in-game GameScript, or a known baseline) rather
than computing "now" purely from this client.
### Examples
```python
# Start vehicle 7's own timetable at StateTicks 1_000_000.
await client.set_timetable_start(7, timetable_all=False, start_date=1_000_000)
# Start the timetable for every vehicle sharing vehicle 7's orders, all at the same tick.
await client.set_timetable_start(7, timetable_all=True, start_date=1_000_000)
```
## `set_vehicle_on_time(vehicle_id, apply_to_group=False)`
Resets a vehicle's **lateness counter to zero** (marks it on-time). This command can only reduce
lateness to zero — there is no way to use it to mark a vehicle as *late*.
| Parameter | Type | Meaning |
|---|---|---|
| `vehicle_id` | `int` | The vehicle to reset lateness for. |
| `apply_to_group` | `bool` | `False` (default): reset only this vehicle. `True`: reset lateness for every vehicle sharing this vehicle's order list, by the same amount (so their relative spacing is preserved), instead of just this one. |
Note: if `apply_to_group=False` and the vehicle's timetable hasn't been started yet (see
`set_timetable_start()`), the server rejects the command — but since there's no success/failure
signal on the wire, you won't see an error, `get_vehicle_timetable()` will just show the request
was made without the underlying lateness actually having changed.
### Examples
```python
# Reset lateness for just this vehicle.
await client.set_vehicle_on_time(7, apply_to_group=False)
# Reset lateness for the whole group of vehicles sharing vehicle 7's orders.
await client.set_vehicle_on_time(7, apply_to_group=True)
```
## `get_vehicle_timetable(vehicle_id)`
A **synchronous** method (no `await`, no network round-trip) that returns whatever this client has
locally observed about a vehicle's timetable so far, or `None` if nothing has been observed for
that vehicle id yet.
```python
entry = client.get_vehicle_timetable(7)
```
Returns either `None`, or a `dict` shaped like:
```python
{
"orders": {
0: {"wait_time": 120, "wait_time_fixed": True},
2: {"travel_time": 300, "leave_type": 1},
# only order positions that have been touched by an observed change_timetable() appear here
},
"autofill": True, # present after an observed autofill_timetable()
"autofill_preserve_wait_time": False,
"timetable_start": 1000000, # present after an observed set_timetable_start()
"timetable_all": False,
"on_time_apply_to_group": False, # present after an observed set_vehicle_on_time()
}
```
Every top-level key is optional and only appears once the corresponding change has actually been
observed — a freshly-joined client that hasn't seen any broadcasts yet for a vehicle returns `None`
for it, and a vehicle that's only had its wait time changed won't have an `"autofill"` key at all.
Per-order fields inside `"orders"` follow the same rule: only fields that have been explicitly set
via `change_timetable()` appear; a cleared field (`clear_field=True`) is stored as `None` rather
than being removed, so you can distinguish "never touched" (key absent) from "explicitly cleared"
(key present, value `None`).
## Putting it together
```python
import asyncio
from openttd import OpenTTDClient
from openttd.protocol import ModifyTimetableFlags
async def build_timetable(client, vehicle_id):
# 1. Let autofill do a first pass, preserving anything already set.
await client.autofill_timetable(vehicle_id, autofill=True, preserve_wait_time=True)
await asyncio.sleep(1.0)
# 2. Manually lock in the wait time for a specific order once you're happy with it.
await client.change_timetable(vehicle_id, 0, ModifyTimetableFlags.WaitTime, 90)
await client.change_timetable(vehicle_id, 0, ModifyTimetableFlags.SetWaitFixed, 1)
await asyncio.sleep(1.0)
# 3. Turn autofill off and start the timetable running for the whole group.
await client.autofill_timetable(vehicle_id, autofill=False)
await client.set_timetable_start(vehicle_id, timetable_all=True, start_date=1_000_000)
await asyncio.sleep(1.0)
print(client.get_vehicle_timetable(vehicle_id))
async def main():
client = OpenTTDClient(host="127.0.0.1", username="TimetableBot")
await client.connect(server_password="asd")
await client.join_company(company_id=0, company_password="")
await client.joined.wait()
await build_timetable(client, vehicle_id=7)
await client.quit()
asyncio.run(main())
```
## Adding and removing orders
Beyond editing an order's timetable fields, you can change the order list itself. Both commands go
over the game port and require being joined to the company that owns the vehicle (like the timetable
methods above).
```python
# Append a "go to station" order (station id 6) to the end of vehicle 7's order list.
await client.add_order(7, 6)
# Insert one before position 0 instead of appending.
await client.add_order(7, 6, before_position=0)
# Non-stop / stop-location can be customised (defaults suit every vehicle type).
from openttd.protocol import OrderNonStopFlags
await client.add_order(7, 6, non_stop=OrderNonStopFlags.NoStopAtIntermediate)
# Delete the order at a given position.
await client.remove_order(7, 0)
```
`add_order()` builds "go to station" orders. `stop_location` defaults to `PlatformFarEnd` because the
other stop locations are train-only and rejected for road vehicles, ships and aircraft. There is no
game-port query for the resulting order list; confirm changes with the admin `get_timetable()` order
count (see [PROTOCOL.md](PROTOCOL.md#adding--removing-orders)).
## Scheduled dispatch (JGRPP)
Scheduled dispatch lets a vehicle depart on a fixed schedule of slots rather than purely by
timetable. A vehicle's order list can hold several dispatch schedules, each with a duration, a start
tick and a set of departure slots. The edit commands go over the game port and require being joined
to the owning company; the authoritative read is on the admin client.
```python
# Create a schedule (start tick 0, duration 3000 ticks) — it becomes the next schedule index.
await client.add_dispatch_schedule(7, 0, 3000)
# Add departure slots at offsets 500 and 1500 within schedule 0's duration.
await client.add_dispatch_slot(7, 0, 500)
await client.add_dispatch_slot(7, 0, 1500)
# Add several evenly spaced slots at once: offset 0, then +250 three more times.
await client.add_dispatch_slot(7, 0, 0, interval=250, extra_slots=3)
# Adjust the schedule, then turn scheduled dispatch on for the vehicle.
await client.set_dispatch_duration(7, 0, 4000)
await client.set_dispatch_start_date(7, 0, 1_000_000)
await client.set_scheduled_dispatch(7, True)
# Remove a slot, clear a schedule's slots, or remove the whole schedule.
await client.remove_dispatch_slot(7, 0, 1500)
await client.clear_dispatch_schedule(7, 0)
await client.remove_dispatch_schedule(7, 0)
```
Read the live state back over the admin connection (requires the patched JGRPP build, see
[docker/patches/README.md](../docker/patches/README.md)):
```python
data = await admin.get_dispatch(7)
# {"enabled": 1, "schedules": [{"index": 0, "duration": 4000, "start_tick": 1000000,
# "delay": 0, "reuse_slots": 0, "slots": [{"offset": 500, "flags": 0}, ...]}]}
```
The client implements a common core of the ~22 JGRPP dispatch commands; advanced operations
(departure routes/tags, per-slot flags, adjust/swap/duplicate) are not wrapped yet.
## See also
- [PROTOCOL.md — Vehicle Orders & Timetables](PROTOCOL.md#vehicle-orders--timetables-game-port-docommands) for the underlying wire format.
- [PROTOCOL.md — Dispatch Query](PROTOCOL.md#dispatch-query) for the admin `get_dispatch()` read.
- [ARCHITECTURE.md](ARCHITECTURE.md) for how `OpenTTDClient` fits into the rest of the library.
+88
View File
@@ -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.
+19
View File
@@ -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());
+722
View File
@@ -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 }] });
}
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
from .client import OpenTTDAdminClient, OpenTTDClient
from .decorators import exclude_call_check
from .client import OpenTTDClient, OpenTTDAdminClient
__all__ = ['OpenTTDClient', 'OpenTTDAdminClient', 'exclude_call_check']
__all__ = ['OpenTTDAdminClient', 'OpenTTDClient', 'exclude_call_check']
+651 -18
View File
@@ -1,12 +1,43 @@
import asyncio
import logging
import uuid
import monocypher
import os
import hashlib
from openttd_protocol.wire.write import write_init, write_string, write_uint8, write_uint16, write_uint32, write_presend, SEND_TCP_MTU
from .protocol import PacketGameType, OpenTTDProtocol, PacketAdminType, OpenTTDAdminProtocol, NetworkAuthenticationMethod
import logging
import os
import uuid
from collections import deque
from typing import ClassVar
import monocypher
from openttd_protocol.wire.exceptions import SocketClosed
from openttd_protocol.wire.read import read_uint8, read_uint16
from openttd_protocol.wire.write import (
SEND_TCP_MTU,
write_init,
write_presend,
write_string,
write_uint8,
write_uint16,
write_uint32,
)
from .decorators import exclude_call_check
from .protocol import (
INVALID_VEH_ORDER_ID,
GameCommand,
ModifyTimetableCtrlFlag,
ModifyTimetableFlags,
NetworkAuthenticationMethod,
OpenTTDAdminProtocol,
OpenTTDProtocol,
OrderStopLocation,
OrderType,
PacketAdminType,
PacketGameType,
read_varuint,
read_varuint_signed,
write_varuint,
write_varuint_signed,
)
class OpenTTDClient:
"""High-level OpenTTD client for easy integration."""
@@ -22,7 +53,8 @@ class OpenTTDClient:
self.joined = asyncio.Event()
self.shutdown_event = asyncio.Event()
self.client_id = None
self.vehicle_timetables = {}
# Internal crypto
self._server_password = ""
self._company_password = ""
@@ -62,6 +94,186 @@ class OpenTTDClient:
else:
self.log.warning("Already joined.")
async def _send_command(self, cmd, payload, tile=0, error_msg=0, callback=0):
"""Send a DoCommand over the game protocol (ClientCommand packet)."""
d = write_init(PacketGameType.ClientCommand)
write_uint8(d, self._target_company)
write_uint16(d, cmd)
write_uint16(d, error_msg)
write_uint32(d, tile)
write_uint16(d, len(payload))
d.extend(payload)
write_uint8(d, callback)
if callback != 0:
write_uint32(d, 0)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def change_timetable(self, vehicle_id, order_position, flag, value, clear_field=False):
"""Change a single order's timetable field (wait/travel time, fixed flags, leave type, ...)."""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_uint16(payload, order_position)
write_uint8(payload, flag)
write_varuint(payload, value)
write_uint8(payload, ModifyTimetableCtrlFlag.ClearField if clear_field else 0)
await self._send_command(GameCommand.ChangeTimetable, payload)
async def autofill_timetable(self, vehicle_id, autofill=True, preserve_wait_time=False):
"""Enable or disable timetable autofill for a vehicle."""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_uint8(payload, 1 if autofill else 0)
write_uint8(payload, 1 if preserve_wait_time else 0)
await self._send_command(GameCommand.AutofillTimetable, payload)
async def set_timetable_start(self, vehicle_id, timetable_all, start_date):
"""Set the timetable start date for a vehicle (or all vehicles sharing its orders)."""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_uint8(payload, 1 if timetable_all else 0)
write_varuint_signed(payload, start_date)
await self._send_command(GameCommand.SetTimetableStart, payload)
async def set_vehicle_on_time(self, vehicle_id, apply_to_group=False):
"""Reset a vehicle's lateness counter to make it on-time.
This command can only reset lateness to zero; there is no way to mark a vehicle as
late. If apply_to_group is True, every vehicle sharing this vehicle's order list has
its lateness reduced by the same amount instead of just this one vehicle. The vehicle's
timetable must already be running (see set_timetable_start()) or the server rejects
the command when apply_to_group is False.
"""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_uint8(payload, 1 if apply_to_group else 0)
await self._send_command(GameCommand.SetVehicleOnTime, payload)
def get_vehicle_timetable(self, vehicle_id):
"""Return the locally observed timetable state for a vehicle, or None if nothing has been observed.
This is a local read with no network round-trip: there is no query command for timetable data in
the OpenTTD protocol, so this only reflects ServerCommand broadcasts seen since the client joined.
"""
return self.vehicle_timetables.get(vehicle_id)
async def add_order(self, vehicle_id, station_id, before_position=None, non_stop=0,
stop_location=OrderStopLocation.PlatformFarEnd, order_flags=0):
"""Insert a 'go to station' order into a vehicle's order list.
By default the new order is appended to the end of the list; pass before_position to insert it
before an existing order at that index instead. non_stop is an OrderNonStopFlags value
(0 = stop everywhere) and stop_location an OrderStopLocation value, both packed into the
order's type byte; stop_location defaults to PlatformFarEnd because the near-end/middle/through
values are train-only and the server rejects them for other vehicle types. order_flags is the
16-bit load/unload flag word (0 = the game's defaults: load if possible, unload if possible).
Sent over the game port as a real DoCommand: it only succeeds when this client is joined to
the company that owns the vehicle (see join_company()); a spectator is rejected and kicked.
"""
order_type = OrderType.GotoStation | ((stop_location & 0x3) << 4) | ((non_stop & 0x3) << 6)
payload = bytearray()
write_varuint(payload, vehicle_id)
write_uint16(payload, INVALID_VEH_ORDER_ID if before_position is None else before_position)
write_uint8(payload, order_type)
write_uint16(payload, order_flags)
write_uint16(payload, station_id)
await self._send_command(GameCommand.InsertOrder, payload)
async def remove_order(self, vehicle_id, order_position):
"""Delete the order at order_position from a vehicle's order list.
Sent over the game port as a real DoCommand: like add_order(), it only succeeds when this
client is joined to the company that owns the vehicle.
"""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_uint16(payload, order_position)
await self._send_command(GameCommand.DeleteOrder, payload)
# --- Scheduled dispatch (JGRPP) ---
#
# A vehicle's order list can hold several dispatch schedules, each with a duration, a start
# tick and a set of departure slots (offsets within the duration). All of these are edited over
# the game port and require being joined to the owning company. For an authoritative read of the
# resulting schedules, use OpenTTDAdminClient.get_dispatch().
async def set_scheduled_dispatch(self, vehicle_id, enabled):
"""Enable or disable scheduled dispatch for a vehicle (and every vehicle sharing its orders)."""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_uint8(payload, 1 if enabled else 0)
await self._send_command(GameCommand.SchDispatch, payload)
async def add_dispatch_schedule(self, vehicle_id, start_tick, duration):
"""Create a new dispatch schedule with the given start tick and duration (in ticks).
The schedule is appended to the vehicle's schedule set; its index is the previous schedule
count (read it back with OpenTTDAdminClient.get_dispatch()). duration must be non-zero.
"""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_varuint_signed(payload, start_tick)
write_varuint(payload, duration)
await self._send_command(GameCommand.SchDispatchAddNewSchedule, payload)
async def remove_dispatch_schedule(self, vehicle_id, schedule_index):
"""Remove the dispatch schedule at schedule_index from a vehicle's schedule set."""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_varuint(payload, schedule_index)
await self._send_command(GameCommand.SchDispatchRemoveSchedule, payload)
async def add_dispatch_slot(self, vehicle_id, schedule_index, offset, interval=0, extra_slots=0,
slot_flags=0, route_id=0):
"""Add one or more departure slots to a dispatch schedule.
offset is the slot's departure time as an offset (in ticks) within the schedule's duration.
To add several evenly spaced slots in one command, pass extra_slots > 0 together with a
non-zero interval: each extra slot is placed interval ticks after the previous one (wrapping
around the duration). slot_flags is the 16-bit slot flag word and route_id an optional
departure route id (both default to 0).
"""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_varuint(payload, schedule_index)
write_varuint(payload, offset)
write_varuint(payload, interval)
write_varuint(payload, extra_slots)
write_uint16(payload, slot_flags)
write_uint8(payload, route_id)
await self._send_command(GameCommand.SchDispatchAdd, payload)
async def remove_dispatch_slot(self, vehicle_id, schedule_index, offset):
"""Remove the departure slot at the given offset from a dispatch schedule."""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_varuint(payload, schedule_index)
write_varuint(payload, offset)
await self._send_command(GameCommand.SchDispatchRemove, payload)
async def clear_dispatch_schedule(self, vehicle_id, schedule_index):
"""Remove every departure slot from a dispatch schedule (leaving the schedule itself)."""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_varuint(payload, schedule_index)
await self._send_command(GameCommand.SchDispatchClear, payload)
async def set_dispatch_duration(self, vehicle_id, schedule_index, duration):
"""Set the total duration (in ticks) of a dispatch schedule."""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_varuint(payload, schedule_index)
write_varuint(payload, duration)
await self._send_command(GameCommand.SchDispatchSetDuration, payload)
async def set_dispatch_start_date(self, vehicle_id, schedule_index, start_tick):
"""Set the start tick of a dispatch schedule."""
payload = bytearray()
write_varuint(payload, vehicle_id)
write_varuint(payload, schedule_index)
write_varuint_signed(payload, start_tick)
await self._send_command(GameCommand.SchDispatchSetStartDate, payload)
def disconnect(self, source):
"""Library callback for when connection is lost."""
self.log.info("Disconnected.")
@@ -74,8 +286,9 @@ class OpenTTDClient:
try:
d = write_init(PacketGameType.ClientQuit)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
except Exception:
pass
except (OSError, SocketClosed) as e:
# Best-effort courtesy packet: the socket may already be gone.
self.log.debug(f"Could not send quit packet: {e}")
self._transport.close()
self.shutdown_event.set()
@@ -199,7 +412,58 @@ class OpenTTDClient:
async def receive_ServerMapData(self, source, **kwargs): pass
async def receive_ServerConfigurationUpdate(self, source, **kwargs): pass
async def receive_ServerExternalChat(self, source, **kwargs): pass
async def receive_ServerCommand(self, source, **kwargs): pass
_TIMETABLE_FIELD_BY_FLAG: ClassVar[dict[ModifyTimetableFlags, str]] = {
ModifyTimetableFlags.WaitTime: "wait_time",
ModifyTimetableFlags.TravelTime: "travel_time",
ModifyTimetableFlags.TravelSpeed: "travel_speed",
ModifyTimetableFlags.SetWaitFixed: "wait_time_fixed",
ModifyTimetableFlags.SetTravelFixed: "travel_time_fixed",
ModifyTimetableFlags.SetLeaveType: "leave_type",
ModifyTimetableFlags.AssignSchedule: "assigned_schedule",
}
_TIMETABLE_BOOL_FLAGS: ClassVar[set[ModifyTimetableFlags]] = {
ModifyTimetableFlags.SetWaitFixed,
ModifyTimetableFlags.SetTravelFixed,
}
async def receive_ServerCommand(self, source, cmd, payload, **kwargs):
if cmd == GameCommand.ChangeTimetable:
vehicle_id, rest = read_varuint(payload)
order_position, rest = read_uint16(rest)
flag, rest = read_uint8(rest)
value, rest = read_varuint(rest)
ctrl_flags, _ = read_uint8(rest)
entry = self.vehicle_timetables.setdefault(vehicle_id, {"orders": {}})
order = entry["orders"].setdefault(order_position, {})
field = self._TIMETABLE_FIELD_BY_FLAG.get(flag)
if field:
cleared = bool(ctrl_flags & ModifyTimetableCtrlFlag.ClearField)
if cleared:
order[field] = None
elif flag in self._TIMETABLE_BOOL_FLAGS:
order[field] = bool(value)
else:
order[field] = value
elif cmd == GameCommand.AutofillTimetable:
vehicle_id, rest = read_varuint(payload)
autofill, rest = read_uint8(rest)
preserve_wait_time, _ = read_uint8(rest)
entry = self.vehicle_timetables.setdefault(vehicle_id, {"orders": {}})
entry["autofill"] = bool(autofill)
entry["autofill_preserve_wait_time"] = bool(preserve_wait_time)
elif cmd == GameCommand.SetTimetableStart:
vehicle_id, rest = read_varuint(payload)
timetable_all, rest = read_uint8(rest)
start_date, _ = read_varuint_signed(rest)
entry = self.vehicle_timetables.setdefault(vehicle_id, {"orders": {}})
entry["timetable_all"] = bool(timetable_all)
entry["timetable_start"] = start_date
elif cmd == GameCommand.SetVehicleOnTime:
vehicle_id, rest = read_varuint(payload)
apply_to_group, _ = read_uint8(rest)
entry = self.vehicle_timetables.setdefault(vehicle_id, {"orders": {}})
entry["on_time_apply_to_group"] = bool(apply_to_group)
async def receive_ServerFull(self, source, **kwargs): pass
async def receive_ServerBanned(self, source, **kwargs): pass
async def receive_ClientAck(self, source, **kwargs): pass
@@ -207,17 +471,17 @@ class OpenTTDClient:
class OpenTTDAdminClient:
"""High-level OpenTTD Admin client."""
def __init__(self, host, port=3977, admin_name="GeminiAdmin"):
def __init__(self, host, port=3977, admin_name="GeminiAdmin", event_buffer_size=256):
self.host = host
self.port = port
self.admin_name = admin_name
self.log = logging.getLogger(f"OTTDA-{admin_name}")
# State
self.encryption_enabled = False
self.joined = asyncio.Event()
self.shutdown_event = asyncio.Event()
# Internal crypto
self._admin_password = ""
self._session_key_send = None
@@ -225,11 +489,23 @@ class OpenTTDAdminClient:
self._encryption_nonce = None
self._send_aead = None
self._recv_aead = None
# Callbacks
self.on_chat = None
self.on_console = None
self.on_gamescript = None
self.on_event = None
# GameScript request/response correlation
self._gs_request_id = 0
self._gs_futures = {}
self._gs_subscribed = False
# Game events pushed by the AdminBridge GameScript. Events nobody is waiting for are
# kept here so a wait_for_event() call can still pick up something that arrived just
# before it; the deque bounds the memory a subscription nobody drains can cost.
self._event_buffer = deque(maxlen=event_buffer_size)
self._event_waiters = []
async def connect(self, admin_password="", secure=False):
"""Connect to the admin port and initiate handshake."""
@@ -260,6 +536,14 @@ class OpenTTDAdminClient:
def disconnect(self, source):
"""Library callback for when connection is lost."""
self.log.info("Admin disconnected.")
for fut in self._gs_futures.values():
if not fut.done():
fut.set_exception(ConnectionError("admin disconnected"))
self._gs_futures.clear()
for _, fut in self._event_waiters:
if not fut.done():
fut.set_exception(ConnectionError("admin disconnected"))
self._event_waiters.clear()
self.shutdown_event.set()
async def quit(self):
@@ -268,8 +552,9 @@ class OpenTTDAdminClient:
try:
d = write_init(PacketAdminType.AdminQuit)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
except Exception:
pass
except (OSError, SocketClosed) as e:
# Best-effort courtesy packet: the socket may already be gone.
self.log.debug(f"Could not send admin quit packet: {e}")
self._transport.close()
self.shutdown_event.set()
@@ -322,6 +607,341 @@ class OpenTTDAdminClient:
from .protocol import AdminUpdateType
await self.poll(AdminUpdateType.CompanyStats, company_id)
async def list_vehicles(self, company_id=None):
"""Request a list of vehicles via GameScript. company_id=None for all companies."""
payload = {"command": "list_vehicles"}
if company_id is not None:
payload["company_id"] = company_id
await self.send_gamescript(payload)
async def list_stations(self, company_id=None):
"""Request a list of stations via GameScript. company_id=None for all companies.
Like list_vehicles(), this is fire-and-forget: the AdminBridge GameScript replies with a
{"stations": [...]} envelope delivered to the on_gamescript callback, so subscribe to
Gamescript updates first (update_frequency(Gamescript, Automatic)) or the reply is dropped.
For a station's live cargo detail (waiting vs planned), use get_station().
"""
payload = {"command": "list_stations"}
if company_id is not None:
payload["company_id"] = company_id
await self.send_gamescript(payload)
async def _gs_query(self, payload, timeout, context):
"""Send a GameScript request and await its correlated reply.
Assigns a fresh request_id, registers a future the ServerGamescript handler resolves when
the matching reply arrives, and (on first use) subscribes to Gamescript updates so the
server actually forwards the reply. `payload` is the request dict without request_id;
`context` is a label used in the ValueError raised on a GameScript-reported error.
Raises asyncio.TimeoutError if no reply arrives within `timeout`, ValueError on an error
reply, and ConnectionError if the admin connection drops while waiting.
"""
from .protocol import AdminUpdateFrequency, AdminUpdateType
if not self._gs_subscribed:
await self.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
self._gs_subscribed = True
self._gs_request_id += 1
rid = self._gs_request_id
fut = asyncio.get_running_loop().create_future()
self._gs_futures[rid] = fut
request = dict(payload)
request["request_id"] = rid
try:
await self.send_gamescript(request)
data = await asyncio.wait_for(fut, timeout)
finally:
self._gs_futures.pop(rid, None)
if "error" in data:
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.
Unlike the game client's passive observer, this queries the real game state: it works for
timetables set before this client connected and reflects the actual (not requested) values.
Auto-subscribes to Gamescript updates on first use; if you manage update frequencies
yourself, ensure update_frequency(Gamescript, Automatic) is active before calling.
Returns a dict with vehicle-level keys (lateness, start_tick, current_order_time,
total_duration) and an "orders" list of per-order dicts (position, wait_time, travel_time,
wait_timetabled, travel_timetabled, wait_fixed, travel_fixed, leave_type, max_speed).
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
ValueError on a GameScript-reported error (invalid_vehicle, response_too_large), and
ConnectionError if the admin connection drops while waiting.
"""
return await self._gs_query(
{"command": "get_timetable", "vehicle_id": vehicle_id}, timeout,
f"get_timetable({vehicle_id})")
async def get_station(self, station_id, timeout=5.0):
"""Fetch an authoritative snapshot of a station's live cargo state via the AdminBridge GameScript.
This queries the real game state (like get_timetable() does for vehicles): it works for any
existing station regardless of when it was built or when this client connected. Auto-subscribes
to Gamescript updates on first use; if you manage update frequencies yourself, ensure
update_frequency(Gamescript, Automatic) is active before calling.
Returns a dict with station-level keys (name, location, owner) and a "cargo" list of per-cargo
dicts. Each cargo dict carries both the real-time and the planned amounts:
- "waiting": units currently sitting at the station (real-time, GSStation.GetCargoWaiting)
- "planned": units planned to move through it per the cargodist link graph
(GSStation.GetCargoPlanned); 0 when cargo distribution is not enabled for that cargo
- "rating": the station's acceptance rating for the cargo as a percentage (0-100),
or None if the station has no rating for that cargo yet
Only cargo types the station has ever handled appear in the list.
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
ValueError on a GameScript-reported error (invalid_station, response_too_large), and
ConnectionError if the admin connection drops while waiting.
"""
return await self._gs_query(
{"command": "get_station", "station_id": station_id}, timeout,
f"get_station({station_id})")
async def get_station_cargo(self, station_id, cargo_id, from_station=None, via_station=None, timeout=5.0):
"""Fetch a per-source / per-next-hop breakdown of one cargo at a station via the AdminBridge GS.
Where get_station() reports each cargo's totals, this drills into a single cargo type and
shows how the waiting (real-time) and planned amounts split across the cargo distribution
(cargodist) link graph. Cargodist tracks every unit by its source station (where it was
first loaded) and its next hop (the next station it heads to on the way to its final
destination); there is no separate "final destination" store, so the routing destination is
the next hop ("via").
Returns a dict with the (optionally filtered) totals "waiting" and "planned", plus four
breakdown lists, each a list of {"station": id, "amount": n} entries (zero amounts omitted):
- "waiting_by_from" / "planned_by_from": grouped by source station
- "waiting_by_via" / "planned_by_via": grouped by next hop (routing destination)
A station id of 65535 (STATION_INVALID) marks cargo whose source was deleted or, as a next
hop, cargo with no onward routing / to be consumed at this station (also the sole next hop
for cargo types using manual, non-cargodist distribution).
Optional filters narrow the query:
- from_station: only cargo originating at this source station.
- via_station: only cargo whose next hop is this station.
Passing from_station restricts the by_via breakdown to that source (and the totals to it);
passing via_station restricts the by_from breakdown to that next hop; passing both makes the
totals the exact source+next-hop amount. Pass 65535 for either to target STATION_INVALID.
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded),
ValueError on a GameScript-reported error (invalid_station, invalid_cargo,
response_too_large), and ConnectionError if the admin connection drops while waiting.
"""
payload = {"command": "get_station_cargo", "station_id": station_id, "cargo_id": cargo_id}
if from_station is not None:
payload["from_station"] = from_station
if via_station is not None:
payload["via_station"] = via_station
return await self._gs_query(payload, timeout, f"get_station_cargo({station_id}, {cargo_id})")
async def list_cargo(self, timeout=5.0):
"""Fetch the running game's cargo table via the AdminBridge GameScript, id to label.
Every other reply names a cargo by its bare numeric id get_station()'s and
get_station_cargo()'s cargo_id, the cargo_waiting events, the per-cargo load on vehicle
events. Those ids index the cargo table the loaded NewGRFs build, so they mean different
things in different games and are not worth hardcoding; resolve them against this list
instead. Auto-subscribes to Gamescript updates on first use; if you manage update
frequencies yourself, ensure update_frequency(Gamescript, Automatic) is active before
calling.
Returns a dict with a "cargo" list holding every cargo type in the game, in no particular
order (index it by "cargo_id"; a cargo's position in the list is not its id). Each entry:
- "cargo_id": the id used by all the replies above
- "label": the cargo label ("PASS", "COAL", ...), or "" if the GameScript could not
read it
- "freight": 1 for freight cargo, 0 for the rest (passengers, mail, ...)
Raises asyncio.TimeoutError if no reply arrives (e.g. game paused, GS not loaded) and
ConnectionError if the admin connection drops while waiting. The GameScript reports no
error for this command, so unlike get_station() it never raises ValueError.
"""
return await self._gs_query({"command": "list_cargo"}, timeout, "list_cargo")
async def get_dispatch(self, vehicle_id, timeout=5.0):
"""Fetch an authoritative snapshot of a vehicle's scheduled dispatch state via the AdminBridge GS.
Scheduled dispatch (a JGRPP feature) lets a vehicle depart on a fixed schedule of slots rather
than purely by timetable. This reads the live state (like get_timetable() does), so it works for
schedules created before this client connected and reflects the real values. Auto-subscribes to
Gamescript updates on first use; if you manage update frequencies yourself, ensure
update_frequency(Gamescript, Automatic) is active before calling.
Returns a dict with:
- "enabled": 1 if scheduled dispatch is turned on for the vehicle, else 0
- "schedules": a list of per-schedule dicts, each with "index", "duration" (ticks),
"start_tick", "delay" (max allowed delay), "reuse_slots" (0/1), and "slots" a list of
{"offset", "flags"} departure slots (offset is ticks within the schedule duration).
These are the same schedules and slots edited by the game-port methods on OpenTTDClient
(add_dispatch_schedule/add_dispatch_slot/...). Raises asyncio.TimeoutError if no reply arrives
(e.g. game paused, GS not loaded), ValueError on a GameScript-reported error (invalid_vehicle,
response_too_large), and ConnectionError if the admin connection drops while waiting.
"""
return await self._gs_query(
{"command": "get_dispatch", "vehicle_id": vehicle_id}, timeout,
f"get_dispatch({vehicle_id})")
# --- Game events ---
#
# Everything above is a request the caller makes; this is the other direction. The
# AdminBridge GameScript pushes events as they happen, so a bot can react to the game
# instead of polling it. Two kinds of thing arrive on the same channel: transitions the
# bridge synthesises by sampling game state on an interval (a vehicle reaching or leaving
# a stop, a station's waiting cargo changing), and the events the engine itself raises for
# a GameScript (crashes, industries opening, companies going bankrupt, ...). See
# GameEventType for the full catalogue.
#
# Consume them either by setting on_event (push) or by awaiting wait_for_event() (pull);
# both see every event, so the two can be mixed.
async def subscribe_events(self, events=None, interval=None, company_id=None, vehicles=None,
stations=None, cargo=None, min_cargo_delta=None,
include_cargo=None, timeout=5.0):
"""Ask the AdminBridge GameScript to start pushing game events, and wait for it to confirm.
Every argument narrows what gets sent; the defaults subscribe to every event kind for
every vehicle, station and cargo, which is the right starting point on a small map and
the wrong one on a large busy map (see the cost note below).
- events: which GameEventType kinds to receive (default: all of them).
- interval: ticks between state samples for the polled kinds (default 10). This is
the resolution of those events, not a delay: a stop shorter than `interval` can
begin and end between two samples and is then never reported at all.
- company_id: only report vehicles and stations owned by this company.
- vehicles / stations: only sample these ids, instead of every vehicle / station.
- cargo: only inspect these cargo types (for cargo_waiting and for the load reported
on vehicle events).
- min_cargo_delta: suppress cargo_waiting events whose amount moved by less than this
many units since the previous sample (default 1, i.e. report every change).
- include_cargo: set False to leave the per-cargo load off vehicle events.
Subscribing replaces any previous subscription and resets the bridge's baseline, so the
first sample after this call only records where everything already is a vehicle that
was sitting at a station when you subscribed did not just arrive, and gets no event.
Cost: the bridge samples every watched vehicle and every watched station-cargo pair on
each interval, inside a GameScript's limited per-tick budget. On a large map prefer a
coarser interval and explicit vehicles/stations/cargo lists over the defaults.
Returns the confirmation dict: {"events": [accepted kinds], "interval": N}. Raises
asyncio.TimeoutError if the GameScript does not answer (e.g. game paused, GS not
loaded), ValueError on a rejected request (unknown_event, invalid_interval,
invalid_min_cargo_delta, invalid_cargo), and ConnectionError if the admin connection
drops while waiting.
"""
payload = {"command": "subscribe_events"}
if events is not None:
payload["events"] = [str(event) for event in events]
if interval is not None:
payload["interval"] = interval
if company_id is not None:
payload["company_id"] = company_id
if vehicles is not None:
payload["vehicles"] = list(vehicles)
if stations is not None:
payload["stations"] = list(stations)
if cargo is not None:
payload["cargo"] = list(cargo)
if min_cargo_delta is not None:
payload["min_cargo_delta"] = min_cargo_delta
if include_cargo is not None:
payload["include_cargo"] = bool(include_cargo)
return await self._gs_query(payload, timeout, "subscribe_events")
async def unsubscribe_events(self, timeout=5.0):
"""Stop the event stream and wait for the GameScript to confirm.
This also drops the bridge's sampling state, so a later subscribe_events() starts from
a fresh baseline. Events already delivered stay in this client's buffer; drain or ignore
them as you like.
"""
return await self._gs_query({"command": "unsubscribe_events"}, timeout,
"unsubscribe_events")
async def wait_for_event(self, kind=None, timeout=5.0):
"""Await the next game event, optionally of a specific kind (or any of several kinds).
`kind` is a GameEventType (or plain string), an iterable of them, or None for "any
event". Events that arrived earlier and were not taken by another waiter are buffered,
so this returns immediately when a matching one is already in hand; the oldest matching
event wins. An event is handed to at most one waiter, but the on_event callback (if set)
still sees every event regardless.
Returns the event dict, which always carries "event" (its GameEventType) and "tick"
(the game tick it was observed at) plus per-kind fields see docs/EVENTS.md. Raises
asyncio.TimeoutError if nothing matching arrives in time (note that a subscription is
needed first see subscribe_events()) and ConnectionError if the admin connection drops
while waiting.
"""
kinds = None
if kind is not None:
kinds = {str(kind)} if isinstance(kind, str) else {str(k) for k in kind}
for buffered in list(self._event_buffer):
if self._event_matches(buffered, kinds):
self._event_buffer.remove(buffered)
return buffered
fut = asyncio.get_running_loop().create_future()
waiter = (kinds, fut)
self._event_waiters.append(waiter)
try:
return await asyncio.wait_for(fut, timeout)
finally:
if waiter in self._event_waiters:
self._event_waiters.remove(waiter)
@staticmethod
def _event_matches(event, kinds):
return kinds is None or (isinstance(event, dict) and event.get("event") in kinds)
def _dispatch_event(self, event):
"""Hand one event to the longest-waiting matching waiter, else buffer it; then observe."""
for waiter in self._event_waiters:
kinds, fut = waiter
if not fut.done() and self._event_matches(event, kinds):
fut.set_result(event)
self._event_waiters.remove(waiter)
break
else:
self._event_buffer.append(event)
if self.on_event:
self.on_event(event)
async def send_gamescript(self, json_data):
"""Send a JSON string to the GameScript."""
import json
@@ -417,10 +1037,23 @@ class OpenTTDAdminClient:
self.log.info(f"Admin: Company {kwargs.get('company_id')} Stats: Vehicles={kwargs.get('vehicles')}, Stations={kwargs.get('stations')}")
async def receive_ServerGamescript(self, source, **kwargs):
data = kwargs.get('data')
if isinstance(data, dict):
fut = self._gs_futures.get(data.get('request_id'))
if fut is not None:
if not fut.done():
fut.set_result(data)
return
# Unsolicited event batch from the AdminBridge GameScript: fan it out to the
# event consumers rather than the generic GameScript callback.
if data.get('command') == 'events' and isinstance(data.get('events'), list):
for event in data['events']:
self._dispatch_event(event)
return
if self.on_gamescript:
self.on_gamescript(kwargs.get('data'))
self.on_gamescript(data)
else:
self.log.info(f"GAMESCRIPT: {kwargs.get('data')}")
self.log.info(f"GAMESCRIPT: {data}")
async def receive_ServerDate(self, source, **kwargs): pass
async def receive_ServerFull(self, source, **kwargs): await self.quit()
+165 -9
View File
@@ -1,9 +1,106 @@
import struct
from enum import IntEnum, StrEnum
import monocypher
from enum import IntEnum
from openttd_protocol.wire.tcp import TCPProtocol
from openttd_protocol.wire.read import read_uint8, read_string, read_uint16, read_uint32
from openttd_protocol.wire.exceptions import SocketClosed
from openttd_protocol.wire.read import read_string, read_uint8, read_uint16, read_uint32
from openttd_protocol.wire.tcp import TCPProtocol
def write_varuint(buffer, value):
"""Encode a non-negative integer using OpenTTD's UTF-8-like varuint scheme."""
if value < 0:
raise ValueError("write_varuint requires a non-negative value")
thresholds = [1 << 7, 1 << 14, 1 << 21, 1 << 28, 1 << 35, 1 << 42, 1 << 49, 1 << 56]
for extra, limit in enumerate(thresholds):
if value < limit:
header_ones = (0xFF << (8 - extra)) & 0xFF
header = header_ones | (value >> (extra * 8))
buffer.append(header)
for i in range(extra - 1, -1, -1):
buffer.append((value >> (i * 8)) & 0xFF)
return
buffer.append(0xFF)
for i in range(7, -1, -1):
buffer.append((value >> (i * 8)) & 0xFF)
def read_varuint(data):
"""Decode a varuint written by write_varuint. Returns (value, rest)."""
header = data[0]
mask = 0x80
extra = 0
while header & mask:
extra += 1
mask >>= 1
value = header & (0x7F >> extra)
rest = data[1:]
for i in range(extra):
value = (value << 8) | rest[i]
return value, rest[extra:]
def write_varuint_signed(buffer, value):
"""Encode a signed integer using zigzag + write_varuint."""
zigzag = (value << 1) ^ (-1 if value < 0 else 0)
write_varuint(buffer, zigzag)
def read_varuint_signed(data):
"""Decode a signed varuint written by write_varuint_signed. Returns (value, rest)."""
zigzag, rest = read_varuint(data)
value = (zigzag >> 1) ^ -(zigzag & 1)
return value, rest
class GameCommand(IntEnum):
DeleteOrder = 51
InsertOrder = 52
ChangeTimetable = 174
SetVehicleOnTime = 176
AutofillTimetable = 177
SetTimetableStart = 180
# Scheduled dispatch (JGRPP)
SchDispatch = 205
SchDispatchAdd = 206
SchDispatchRemove = 207
SchDispatchSetDuration = 208
SchDispatchSetStartDate = 209
SchDispatchClear = 213
SchDispatchAddNewSchedule = 214
SchDispatchRemoveSchedule = 215
# Sentinel VehicleOrderID meaning "append to the end of the order list" for InsertOrder.
INVALID_VEH_ORDER_ID = 0xFFFF
class OrderType(IntEnum):
"""OrderType occupies bits 0-3 of an order's `type` byte (bits 6-7 hold OrderNonStopFlags)."""
GotoStation = 1
GotoDepot = 2
GotoWaypoint = 6
class OrderNonStopFlags(IntEnum):
"""Packed into bits 6-7 of an order's `type` byte."""
StopEverywhere = 0
NoStopAtIntermediate = 1
NoStopAtDestination = 2
NoStopAtAny = 3
class OrderStopLocation(IntEnum):
"""Packed into bits 4-5 of an order's `type` byte. Near-end/middle/through are train-only;
FarEnd is the only value the server accepts for every vehicle type, so it is the safe default."""
PlatformNearEnd = 0
PlatformMiddle = 1
PlatformFarEnd = 2
PlatformThrough = 3
class ModifyTimetableFlags(IntEnum):
WaitTime = 0
TravelTime = 1
TravelSpeed = 2
SetWaitFixed = 3
SetTravelFixed = 4
SetLeaveType = 5
AssignSchedule = 6
class ModifyTimetableCtrlFlag(IntEnum):
ClearField = 1 << 0
class PacketGameType(IntEnum):
ServerFull = 0
@@ -123,6 +220,47 @@ 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.
These are the values of the "event" field of each event dict, and what
OpenTTDAdminClient.subscribe_events() and wait_for_event() take. They are plain strings,
so a bare "vehicle_arrive" works everywhere a member does.
The first three are synthesised by the GameScript sampling game state on an interval,
because the engine raises no event for them; the rest are engine events forwarded as they
happen. VehicleLost, VehicleWaitingInDepot and VehicleUnprofitable have deliberately no
entry here: the engine only ever raises those for AI companies, never for a GameScript.
"""
# Polled: derived by diffing successive samples of the game state.
VehicleArrive = "vehicle_arrive"
VehicleDepart = "vehicle_depart"
CargoWaiting = "cargo_waiting"
# Forwarded straight from the engine's own GameScript events.
VehicleCrashed = "vehicle_crashed"
StationFirstVehicle = "station_first_vehicle"
IndustryOpen = "industry_open"
IndustryClose = "industry_close"
TownFounded = "town_founded"
CompanyNew = "company_new"
CompanyInTrouble = "company_in_trouble"
CompanyBankrupt = "company_bankrupt"
SubsidyOffer = "subsidy_offer"
SubsidyOfferExpired = "subsidy_offer_expired"
SubsidyAwarded = "subsidy_awarded"
SubsidyExpired = "subsidy_expired"
# Emitted by the bridge itself, never subscribed to: one poll produced more events than
# fit in the per-poll cap and `count` of them were discarded.
EventsDropped = "events_dropped"
class OpenTTDProtocol(TCPProtocol):
"""Low-level OpenTTD TCP protocol handler with encryption support."""
PacketType = PacketGameType
@@ -137,21 +275,21 @@ class OpenTTDProtocol(TCPProtocol):
if self.handler.encryption_enabled:
if not self.handler._recv_aead:
self.handler._recv_aead = monocypher.IncrementalAuthenticatedEncryption(self.handler._session_key_recv, self.handler._encryption_nonce)
length, rest = read_uint16(data)
_, rest = read_uint16(data)
payload = self.handler._recv_aead.unlock(bytes(rest[:16]), bytes(rest[16:]))
if payload is None:
raise SocketClosed("Decryption failed")
data = memoryview(struct.pack("<H", len(payload) + 2) + payload)
return super().receive_packet(source, data)
except Exception:
except Exception: # noqa: BLE001 - untrusted wire data: any decode failure must degrade to a no-op packet rather than kill the connection
return PacketGameType.ServerUnused, {}
async def send_packet(self, data):
if self.handler.encryption_enabled:
if not self.handler._send_aead:
self.handler._send_aead = monocypher.IncrementalAuthenticatedEncryption(self.handler._session_key_send, self.handler._encryption_nonce)
length, payload = read_uint16(memoryview(data))
_, payload = read_uint16(memoryview(data))
mac, ciphertext = self.handler._send_aead.lock(payload.tobytes())
data = struct.pack("<H", 18 + len(ciphertext)) + mac + ciphertext
@@ -192,7 +330,7 @@ class OpenTTDProtocol(TCPProtocol):
@staticmethod
def receive_ServerFrame(source, data):
f, data = read_uint32(data)
max_f, data = read_uint32(data)
_, data = read_uint32(data)
token = 0
if len(data) > 0:
if len(data) >= 13:
@@ -229,7 +367,25 @@ class OpenTTDProtocol(TCPProtocol):
@staticmethod
def receive_ServerExternalChat(source, data): return {}
@staticmethod
def receive_ServerCommand(source, data): return {}
def receive_ServerCommand(source, data):
company, data = read_uint8(data)
cmd, data = read_uint16(data)
error_msg, data = read_uint16(data)
tile, data = read_uint32(data)
payload_len, data = read_uint16(data)
payload = data[:payload_len]
data = data[payload_len:]
callback, data = read_uint8(data)
callback_param = 0
if callback != 0:
callback_param, data = read_uint32(data)
frame, data = read_uint32(data)
my_cmd, _ = read_uint8(data)
return {
"company": company, "cmd": cmd, "error_msg": error_msg, "tile": tile,
"payload": payload, "callback": callback, "callback_param": callback_param,
"frame": frame, "my_cmd": bool(my_cmd)
}
@staticmethod
def receive_ServerFull(source, data): return {}
@staticmethod
@@ -462,7 +618,7 @@ class OpenTTDAdminProtocol(OpenTTDProtocol):
import json
try:
return {"data": json.loads(json_str)}
except Exception:
except json.JSONDecodeError:
return {"raw_data": json_str}
@staticmethod
+125 -3
View File
@@ -1,12 +1,13 @@
import asyncio
import logging
import sys
import os
import sys
# Add the lib directory to sys.path so we can import the openttd package
sys.path.append(os.path.join(os.path.dirname(__file__), 'lib'))
from openttd import OpenTTDClient
from openttd.protocol import ModifyTimetableFlags
# Configuration
SERVER_HOST = "127.0.0.1"
@@ -17,6 +18,123 @@ SERVER_PASSWORD = "asd"
COMPANY_ID = 0 # "Én transport"
COMPANY_PASSWORD = "asd123"
# A vehicle owned by COMPANY_ID, used to demonstrate timetable get/set below. Set to a real
# vehicle id to see it in action; leave as None to skip the demonstration.
DEMO_VEHICLE_ID = 7
# A station DEMO_VEHICLE_ID can legally serve, used to demonstrate add_order/remove_order.
DEMO_STATION_ID = 6
async def demo_timetable_workflow(client, vehicle_id):
"""A deliberately thorough walk-through of the timetable API: every ModifyTimetableFlags
variant, clear_field, autofill, timetable start, and lateness reset, on a vehicle assumed
to have at least two orders (positions 0 and 1)."""
ORDER_A, ORDER_B = 0, 1
def show(label):
print(f" -> [{label}] {client.get_vehicle_timetable(vehicle_id)}")
print(f"=== Timetable demo starting for vehicle {vehicle_id} ===")
# 1. Wait/travel times, in game ticks.
print("--- Step 1: set wait/travel times ---")
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.WaitTime, 90)
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.TravelTime, 240)
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.WaitTime, 45)
await asyncio.sleep(0.5)
show("wait/travel times set")
# 2. Lock order A's wait time so autofill won't overwrite it later.
print("--- Step 2: fix order A's wait time ---")
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.SetWaitFixed, 1)
await asyncio.sleep(0.5)
show("order A wait time fixed")
# 3. Cap order B's speed, then remove the cap again (0 = uncapped).
print("--- Step 3: cap and uncap order B's speed ---")
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.TravelSpeed, 80)
await asyncio.sleep(0.5)
show("order B speed capped at 80")
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.TravelSpeed, 0)
await asyncio.sleep(0.5)
show("order B speed cap removed")
# 4. Let the vehicle leave order B early once any cargo is fully loaded.
print("--- Step 4: change order B's leave type ---")
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.SetLeaveType, 2)
await asyncio.sleep(0.5)
show("order B leave type: leave early if any cargo full")
# 5. Assign order A to scheduled-dispatch schedule 0, then unassign it again.
print("--- Step 5: assign and unassign a dispatch schedule ---")
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.AssignSchedule, 0)
await asyncio.sleep(0.5)
show("order A assigned to dispatch schedule 0")
await client.change_timetable(vehicle_id, ORDER_A, ModifyTimetableFlags.AssignSchedule, 0xFFFFFFFF)
await asyncio.sleep(0.5)
show("order A unassigned from dispatch schedule")
# 6. Clear order B's wait time entirely (distinct from setting it to 0).
print("--- Step 6: clear order B's wait time ---")
await client.change_timetable(vehicle_id, ORDER_B, ModifyTimetableFlags.WaitTime, 0, clear_field=True)
await asyncio.sleep(0.5)
show("order B wait time cleared")
# 7. Autofill: start it preserving existing (fixed) wait times, then turn it off again.
print("--- Step 7: toggle autofill ---")
await client.autofill_timetable(vehicle_id, autofill=True, preserve_wait_time=True)
await asyncio.sleep(0.5)
show("autofill enabled (preserving wait times)")
await client.autofill_timetable(vehicle_id, autofill=False)
await asyncio.sleep(0.5)
show("autofill disabled")
# 8. Start the timetable for this vehicle only, then restart it for the whole group.
print("--- Step 8: set timetable start ---")
await client.set_timetable_start(vehicle_id, timetable_all=False, start_date=1_000_000)
await asyncio.sleep(0.5)
show("timetable started (this vehicle only)")
await client.set_timetable_start(vehicle_id, timetable_all=True, start_date=1_500_000)
await asyncio.sleep(0.5)
show("timetable restarted (whole group)")
# 9. Reset lateness for this vehicle, then for the whole group sharing its orders.
print("--- Step 9: reset lateness ---")
await client.set_vehicle_on_time(vehicle_id, apply_to_group=False)
await asyncio.sleep(0.5)
show("lateness reset (this vehicle only)")
await client.set_vehicle_on_time(vehicle_id, apply_to_group=True)
await asyncio.sleep(0.5)
show("lateness reset (whole group)")
# 10. Add an order to the front of the list, then remove it again (net-zero, so the
# vehicle's route is left unchanged). Inserting before position 0 and deleting
# position 0 needs no knowledge of the existing order count.
print(f"--- Step 10: add then remove a 'go to station {DEMO_STATION_ID}' order ---")
await client.add_order(vehicle_id, DEMO_STATION_ID, before_position=0)
await asyncio.sleep(0.5)
print(" -> inserted a goto-station order at position 0")
await client.remove_order(vehicle_id, 0)
await asyncio.sleep(0.5)
print(" -> removed it again (route restored)")
# 11. Scheduled dispatch: create a schedule with two departure slots, enable it, then tear it
# all down again so the vehicle is left as it started. Read it back with
# OpenTTDAdminClient.get_dispatch() (see main_admin.py); the game port has no dispatch read.
print("--- Step 11: scheduled dispatch create/enable, then clean up ---")
await client.add_dispatch_schedule(vehicle_id, start_tick=0, duration=3000)
await client.add_dispatch_slot(vehicle_id, 0, 500)
await client.add_dispatch_slot(vehicle_id, 0, 1500)
await client.set_scheduled_dispatch(vehicle_id, True)
await asyncio.sleep(0.5)
print(" -> created schedule 0 with 2 slots and enabled scheduled dispatch")
await client.set_scheduled_dispatch(vehicle_id, False)
await client.remove_dispatch_schedule(vehicle_id, 0)
await asyncio.sleep(0.5)
print(" -> disabled and removed the schedule (restored)")
print(f"=== Timetable demo finished. Final state for vehicle {vehicle_id}: ===")
print(f" {client.get_vehicle_timetable(vehicle_id)}")
async def run_client():
# 1. Initialize high-level client
username = sys.argv[1] if len(sys.argv) > 1 else "Modular_Joiner"
@@ -41,7 +159,11 @@ async def run_client():
await client.joined.wait()
print(f"--- Successfully joined! Client ID: {client.client_id} ---")
# 6. Lifecycle management
# 6. Timetable demonstration (requires DEMO_VEHICLE_ID to be owned by COMPANY_ID)
if DEMO_VEHICLE_ID is not None:
await demo_timetable_workflow(client, DEMO_VEHICLE_ID)
# 7. Lifecycle management
# We wait for either a manual shutdown signal or a 10s timeout
try:
await asyncio.wait_for(client.shutdown_event.wait(), timeout=10.0)
@@ -49,7 +171,7 @@ async def run_client():
print("--- Finished 10s stay, exiting gracefully ---")
await client.quit()
except Exception as e:
except Exception as e: # noqa: BLE001 - top-level demo handler: report any failure instead of dumping a traceback
print(f"!!! Error: {e}")
if __name__ == "__main__":
+66 -6
View File
@@ -1,13 +1,13 @@
import asyncio
import logging
import sys
import os
import sys
# Add the lib directory to sys.path so we can import the openttd package
sys.path.append(os.path.join(os.path.dirname(__file__), 'lib'))
from openttd import OpenTTDAdminClient
from openttd.protocol import AdminUpdateType, AdminUpdateFrequency
from openttd.protocol import AdminUpdateFrequency, AdminUpdateType, GameEventType
# Configuration
SERVER_HOST = "127.0.0.1"
@@ -48,13 +48,73 @@ async def run_admin():
await admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
print("--- Requesting vehicle info via GameScript ---")
await admin.send_gamescript({"command": "list_vehicles"})
await asyncio.sleep(5)
await admin.list_vehicles()
# Capture station-list replies (delivered to on_gamescript, like list_vehicles) while
# still logging every other GameScript message.
stations = []
def gamescript_capture(data):
if isinstance(data, dict) and "stations" in data:
stations.append(data["stations"])
gamescript_logger(data)
admin.on_gamescript = gamescript_capture
print("--- Requesting station info via GameScript ---")
await admin.list_stations()
await asyncio.sleep(1)
# Fetch one station's authoritative live cargo (real-time waiting + planned).
if stations and stations[-1]:
sid = stations[-1][0]["id"]
try:
# Cargo ids come from the loaded NewGRFs, so resolve them to labels to print.
labels = {c["cargo_id"]: c["label"]
for c in (await admin.list_cargo(timeout=10.0))["cargo"]}
data = await admin.get_station(sid, timeout=10.0)
print(f"--- Station {sid} ({data.get('name')}) cargo: real-time waiting vs planned ---")
for cargo in data.get("cargo", []):
print(f" {labels.get(cargo['cargo_id'], cargo['cargo_id'])}: "
f"waiting={cargo['waiting']} "
f"planned={cargo['planned']} rating={cargo['rating']}")
# Break the first cargo down by source station and by next hop (routing destination).
if data.get("cargo"):
cid = data["cargo"][0]["cargo_id"]
flow = await admin.get_station_cargo(sid, cid, timeout=10.0)
print(f"--- Station {sid} cargo {labels.get(cid, cid)} flow breakdown "
f"(station 65535 = none/deleted) ---")
print(f" waiting by source: {flow['waiting_by_from']}")
print(f" waiting by next hop: {flow['waiting_by_via']}")
print(f" planned by source: {flow['planned_by_from']}")
print(f" planned by next hop: {flow['planned_by_via']}")
except Exception as e: # noqa: BLE001 - demo script: one failed station query should not abort the walk
print(f"!!! station query failed: {e}")
# Watch the game live: vehicles reaching/leaving stops and cargo piling up at stations.
print("--- Subscribing to game events ---")
try:
accepted = await admin.subscribe_events(
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart,
GameEventType.CargoWaiting],
interval=5, timeout=10.0)
print(f" subscribed to {accepted['events']} every {accepted['interval']} ticks")
for _ in range(5):
try:
event = await admin.wait_for_event(timeout=15.0)
except asyncio.TimeoutError:
print(" (nothing happened -- is the server paused or idle?)")
break
print(f">>> [EVENT] {event}")
await admin.unsubscribe_events()
except Exception as e: # noqa: BLE001 - demo script: report and carry on to a clean quit
print(f"!!! event subscription failed: {e}")
print("--- Quitting ---")
await admin.quit()
except Exception as e:
except Exception as e: # noqa: BLE001 - top-level demo handler: report any failure instead of dumping a traceback
print(f"!!! Error: {e}")
if __name__ == "__main__":
+3 -1
View File
@@ -1,6 +1,8 @@
import pytest
import os
import pytest
@pytest.fixture(scope="session")
def server_config():
"""Provides server connection parameters from environment variables with defaults."""
+370 -4
View File
@@ -1,9 +1,24 @@
import pytest
import asyncio
import json
import os
import monocypher
import pytest
from openttd import OpenTTDAdminClient
from openttd.protocol import PacketAdminType, AdminUpdateType, AdminUpdateFrequency
from openttd.protocol import (
GS_BRIDGE_VERSION,
AdminUpdateFrequency,
AdminUpdateType,
PacketAdminType,
)
class FakeNetworkError(OSError):
"""Stand-in for a socket-level failure, so it matches the client's narrowed handlers."""
def decode_gamescript_payload(packet):
"""Decode the JSON payload of an AdminGamescript packet (2-byte length + 1-byte type + string)."""
return json.loads(packet[3:].split(b"\x00")[0])
class MockTransport:
def __init__(self):
@@ -27,6 +42,34 @@ def test_admin_packet_types():
assert PacketAdminType.ServerWelcome == 104
assert PacketAdminType.ServerAuthRequest == 128
@pytest.mark.asyncio
async def test_admin_list_vehicles():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
proto = MockProtocol()
client._protocol = proto
client._transport = MockTransport()
await client.list_vehicles()
await client.list_vehicles(company_id=2)
assert len(proto.sent) == 2
assert decode_gamescript_payload(proto.sent[0]) == {"command": "list_vehicles"}
assert decode_gamescript_payload(proto.sent[1]) == {"command": "list_vehicles", "company_id": 2}
@pytest.mark.asyncio
async def test_admin_list_stations():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
proto = MockProtocol()
client._protocol = proto
client._transport = MockTransport()
await client.list_stations()
await client.list_stations(company_id=2)
assert len(proto.sent) == 2
assert decode_gamescript_payload(proto.sent[0]) == {"command": "list_stations"}
assert decode_gamescript_payload(proto.sent[1]) == {"command": "list_stations", "company_id": 2}
@pytest.mark.asyncio
async def test_admin_client_connect_and_actions(monkeypatch):
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
@@ -51,7 +94,7 @@ async def test_admin_client_connect_and_actions(monkeypatch):
# 3. Connect Exception
async def mock_fail(*args, **kwargs):
raise Exception("Connection Failed")
raise FakeNetworkError("Connection Failed")
monkeypatch.setattr(asyncio.get_running_loop(), "create_connection", mock_fail)
with pytest.raises(Exception, match="Connection Failed"):
await client.connect()
@@ -163,8 +206,331 @@ async def test_admin_client_connect_and_actions(monkeypatch):
# 8. Quit Exception
class BadProtocol:
async def send_packet(self, data):
raise Exception("Fail")
raise FakeNetworkError("Fail")
client._transport = MockTransport()
client._protocol = BadProtocol()
await client.quit()
assert client.shutdown_event.is_set()
@pytest.mark.asyncio
async def test_admin_get_timetable_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_timetable(5))
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_timetable", "vehicle_id": 5, "request_id": 1,
}
response = {"command": "get_timetable", "vehicle_id": 5, "request_id": 1,
"lateness": 0, "start_tick": 0, "current_order_time": 3,
"total_duration": -1, "orders": []}
await client.receive_ServerGamescript(None, data=response)
assert await task == response
assert client._gs_futures == {}
# Second call must not re-subscribe and must use a fresh request id.
task = asyncio.ensure_future(client.get_timetable(9))
await asyncio.sleep(0)
assert len(proto.sent) == 3
assert decode_gamescript_payload(proto.sent[2])["request_id"] == 2
await client.receive_ServerGamescript(None, data={"request_id": 2, "orders": []})
assert (await task)["orders"] == []
@pytest.mark.asyncio
async def test_admin_get_timetable_timeout():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
with pytest.raises(asyncio.TimeoutError):
await client.get_timetable(5, timeout=0.05)
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_timetable_error_response():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
task = asyncio.ensure_future(client.get_timetable(65535))
await asyncio.sleep(0)
await client.receive_ServerGamescript(
None, data={"command": "get_timetable", "vehicle_id": 65535,
"request_id": 1, "error": "invalid_vehicle"})
with pytest.raises(ValueError, match="invalid_vehicle"):
await task
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_timetable_disconnect_fails_pending():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
task = asyncio.ensure_future(client.get_timetable(5))
await asyncio.sleep(0)
client.disconnect(None)
with pytest.raises(ConnectionError):
await task
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_station_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_station(3))
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_station", "station_id": 3, "request_id": 1,
}
response = {"command": "get_station", "station_id": 3, "request_id": 1,
"name": "Test Central", "location": 12345, "owner": 0,
"cargo": [{"cargo_id": 0, "waiting": 42, "planned": 17, "rating": 71}]}
await client.receive_ServerGamescript(None, data=response)
assert await task == response
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_station_error_response():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
task = asyncio.ensure_future(client.get_station(65535))
await asyncio.sleep(0)
await client.receive_ServerGamescript(
None, data={"command": "get_station", "station_id": 65535,
"request_id": 1, "error": "invalid_station"})
with pytest.raises(ValueError, match="invalid_station"):
await task
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_station_timeout():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
with pytest.raises(asyncio.TimeoutError):
await client.get_station(3, timeout=0.05)
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_station_cargo_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_station_cargo(3, 0))
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_station_cargo", "station_id": 3, "cargo_id": 0, "request_id": 1,
}
response = {"command": "get_station_cargo", "station_id": 3, "cargo_id": 0, "request_id": 1,
"waiting": 60, "planned": 40,
"waiting_by_from": [{"station": 5, "amount": 25}, {"station": 6, "amount": 35}],
"planned_by_from": [{"station": 5, "amount": 40}],
"waiting_by_via": [{"station": 7, "amount": 60}],
"planned_by_via": [{"station": 7, "amount": 40}]}
await client.receive_ServerGamescript(None, data=response)
assert await task == response
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_station_cargo_with_filters_encoding():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
proto = MockProtocol()
client._protocol = proto
client._transport = MockTransport()
client._gs_subscribed = True # skip the auto-subscribe so only the query is sent
task = asyncio.ensure_future(client.get_station_cargo(3, 0, from_station=6, via_station=7))
await asyncio.sleep(0)
assert len(proto.sent) == 1
assert decode_gamescript_payload(proto.sent[0]) == {
"command": "get_station_cargo", "station_id": 3, "cargo_id": 0,
"from_station": 6, "via_station": 7, "request_id": 1,
}
await client.receive_ServerGamescript(
None, data={"request_id": 1, "waiting": 12, "planned": 8})
assert (await task)["waiting"] == 12
@pytest.mark.asyncio
async def test_admin_get_station_cargo_error_response():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
task = asyncio.ensure_future(client.get_station_cargo(3, 999))
await asyncio.sleep(0)
await client.receive_ServerGamescript(
None, data={"command": "get_station_cargo", "station_id": 3, "cargo_id": 999,
"request_id": 1, "error": "invalid_cargo"})
with pytest.raises(ValueError, match="invalid_cargo"):
await task
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_list_cargo_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.list_cargo())
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": "list_cargo", "request_id": 1,
}
response = {"command": "list_cargo", "request_id": 1,
"cargo": [{"cargo_id": 0, "label": "PASS", "freight": 0},
{"cargo_id": 1, "label": "COAL", "freight": 1}]}
await client.receive_ServerGamescript(None, data=response)
assert await task == response
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_list_cargo_timeout():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
with pytest.raises(asyncio.TimeoutError):
await client.list_cargo(timeout=0.05)
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_dispatch_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_dispatch(7))
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_dispatch", "vehicle_id": 7, "request_id": 1,
}
response = {"command": "get_dispatch", "vehicle_id": 7, "request_id": 1,
"enabled": 1,
"schedules": [{"index": 0, "duration": 3000, "start_tick": 0, "delay": 0,
"reuse_slots": 0,
"slots": [{"offset": 500, "flags": 0}, {"offset": 1500, "flags": 0}]}]}
await client.receive_ServerGamescript(None, data=response)
assert await task == response
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_dispatch_error_response():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
task = asyncio.ensure_future(client.get_dispatch(65535))
await asyncio.sleep(0)
await client.receive_ServerGamescript(
None, data={"command": "get_dispatch", "vehicle_id": 65535,
"request_id": 1, "error": "invalid_vehicle"})
with pytest.raises(ValueError, match="invalid_vehicle"):
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")
client._protocol = MockProtocol()
client._transport = MockTransport()
gs_events = []
client.on_gamescript = lambda data: gs_events.append(data)
# No request_id, unknown request_id, and non-dict payloads all pass through.
await client.receive_ServerGamescript(None, data={"vehicles": []})
await client.receive_ServerGamescript(None, data={"request_id": 999, "orders": []})
await client.receive_ServerGamescript(None, data="plain string")
assert gs_events == [{"vehicles": []}, {"request_id": 999, "orders": []}, "plain string"]
+9 -4
View File
@@ -1,8 +1,10 @@
import pytest
import asyncio
import struct
from openttd.protocol import OpenTTDProtocol, PacketGameType
import pytest
from openttd.client import OpenTTDClient
from openttd.protocol import OpenTTDProtocol, PacketGameType
class MockTransport:
def __init__(self): self._closing = False
@@ -10,16 +12,19 @@ class MockTransport:
def close(self): self._closing = True
def write(self, data): return len(data)
class FakeNetworkError(OSError):
"""Stand-in for a socket-level failure, so it matches the client's narrowed handlers."""
class MockProtocol:
async def send_packet(self, data):
raise Exception("Send failed")
raise FakeNetworkError("Send failed")
@pytest.mark.asyncio
async def test_client_connect_exception(monkeypatch):
# Coverage for client.py:51-53
client = OpenTTDClient(host="127.0.0.1")
async def mock_fail(*args, **kwargs):
raise Exception("Async Failure")
raise FakeNetworkError("Async Failure")
monkeypatch.setattr(asyncio.get_running_loop(), "create_connection", mock_fail)
with pytest.raises(Exception, match="Async Failure"):
await client.connect()
+622 -10
View File
@@ -1,21 +1,41 @@
import asyncio
import pytest
import pytest_asyncio
import sys
import os
import random
import sys
import pytest
import pytest_asyncio
# Add lib to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'lib'))
from openttd import OpenTTDClient, OpenTTDAdminClient
from openttd import OpenTTDAdminClient, OpenTTDClient
from openttd.protocol import (
OpenTTDProtocol,
OpenTTDAdminProtocol,
AdminUpdateType,
GS_BRIDGE_VERSION,
AdminUpdateFrequency,
PacketGameType
AdminUpdateType,
GameEventType,
ModifyTimetableFlags,
OpenTTDAdminProtocol,
OpenTTDProtocol,
PacketGameType,
)
# These identify a vehicle/order that already exists in the local dev server's persisted
# save (company 0, unprotected, owns vehicle 7 with 2 orders) -- required for the timetable
# command tests below, since DoCommands are rejected unless issued by the owning company.
TIMETABLE_COMPANY_ID = 0
TIMETABLE_VEHICLE_ID = 7
TIMETABLE_ORDER_POSITION = 0
# A station TIMETABLE_VEHICLE_ID can legally serve, used for add_order/remove_order tests.
ORDER_STATION_ID = 6
# A second vehicle of the same company, dedicated to the scheduled-dispatch test, which owns and
# overwrites this vehicle's dispatch state. It must have its own order list -- dispatch schedules
# live on the order list, so pointing this at a vehicle that *shares* orders with another would
# silently rewrite that other vehicle's schedules too. (Cloning a vehicle without sharing orders
# gives an independent list, but copies the source's schedules along with it.)
DISPATCH_VEHICLE_ID = 14
# --- Pytest Fixtures ---
@@ -52,6 +72,22 @@ async def connected_client(server_config):
if hasattr(client, '_transport') and not client.shutdown_event.is_set():
await client.quit()
@pytest_asyncio.fixture
async def connected_owner_client(server_config):
"""Fixture to yield a client joined to TIMETABLE_COMPANY_ID (owns a real vehicle for command tests)."""
client_name = f"E2E_Owner_{random.randint(1000, 9999)}"
client = OpenTTDClient(
host=server_config["host"],
port=server_config["game_port"],
username=client_name
)
await client.connect(server_password=server_config["password"])
await client.join_company(company_id=TIMETABLE_COMPANY_ID, company_password="")
await asyncio.wait_for(client.joined.wait(), timeout=15.0)
yield client
if hasattr(client, '_transport') and not client.shutdown_event.is_set():
await client.quit()
# ==============================================================================
# --- End-to-End Tests (Covering all public functions with multiple inputs) ---
@@ -129,6 +165,222 @@ async def test_e2e_client_quit_and_disconnect_multiple_inputs(server_config):
# Input 2: quit already inactive client
await client2.quit()
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_change_timetable_wait_time(connected_owner_client):
# Public function: change_timetable()
# Input 1: set wait time
await connected_owner_client.change_timetable(TIMETABLE_VEHICLE_ID, TIMETABLE_ORDER_POSITION, ModifyTimetableFlags.WaitTime, 42)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
assert entry["orders"][TIMETABLE_ORDER_POSITION]["wait_time"] == 42
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_change_timetable_travel_time(connected_owner_client):
# Public function: change_timetable()
# Input 2: set travel time
await connected_owner_client.change_timetable(TIMETABLE_VEHICLE_ID, TIMETABLE_ORDER_POSITION, ModifyTimetableFlags.TravelTime, 99)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
assert entry["orders"][TIMETABLE_ORDER_POSITION]["travel_time"] == 99
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_autofill_timetable_enable(connected_owner_client):
# Public function: autofill_timetable()
# Input 1: enable autofill
await connected_owner_client.autofill_timetable(TIMETABLE_VEHICLE_ID, autofill=True, preserve_wait_time=False)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
assert connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)["autofill"] is True
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_autofill_timetable_disable(connected_owner_client):
# Public function: autofill_timetable()
# Input 2: disable autofill, preserve wait time
await connected_owner_client.autofill_timetable(TIMETABLE_VEHICLE_ID, autofill=False, preserve_wait_time=True)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
assert entry["autofill"] is False
assert entry["autofill_preserve_wait_time"] is True
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_set_timetable_start_single_vehicle(connected_owner_client):
# Public function: set_timetable_start()
# Input 1: this vehicle only
await connected_owner_client.set_timetable_start(TIMETABLE_VEHICLE_ID, False, 500000)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
assert entry["timetable_start"] == 500000
assert entry["timetable_all"] is False
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_set_timetable_start_all_shared(connected_owner_client):
# Public function: set_timetable_start()
# Input 2: all vehicles sharing this order list
await connected_owner_client.set_timetable_start(TIMETABLE_VEHICLE_ID, True, 600000)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
entry = connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)
assert entry["timetable_start"] == 600000
assert entry["timetable_all"] is True
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_set_vehicle_on_time_single_vehicle(connected_owner_client):
# Public function: set_vehicle_on_time()
# Input 1: reset lateness for this vehicle only
await connected_owner_client.set_vehicle_on_time(TIMETABLE_VEHICLE_ID, apply_to_group=False)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
assert connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)["on_time_apply_to_group"] is False
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_set_vehicle_on_time_apply_to_group(connected_owner_client):
# Public function: set_vehicle_on_time()
# Input 2: reset lateness for every vehicle sharing these orders
await connected_owner_client.set_vehicle_on_time(TIMETABLE_VEHICLE_ID, apply_to_group=True)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
assert connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID)["on_time_apply_to_group"] is True
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_get_vehicle_timetable_after_change(connected_owner_client):
# Public function: get_vehicle_timetable()
# Input 1: a vehicle with observed state
await connected_owner_client.change_timetable(TIMETABLE_VEHICLE_ID, TIMETABLE_ORDER_POSITION, ModifyTimetableFlags.WaitTime, 15)
await asyncio.sleep(1.0)
assert connected_owner_client.get_vehicle_timetable(TIMETABLE_VEHICLE_ID) is not None
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_get_vehicle_timetable_unknown_vehicle(connected_owner_client):
# Public function: get_vehicle_timetable()
# Input 2: a vehicle id with no observed state
assert connected_owner_client.get_vehicle_timetable(999999) is None
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_add_and_remove_order(connected_owner_client, connected_admin):
# Public functions: add_order(), remove_order()
# Verified authoritatively via the admin get_timetable() order count. The test appends and
# inserts an order, then removes both, leaving the vehicle's order list as it started.
async def order_count():
data = await connected_admin.get_timetable(TIMETABLE_VEHICLE_ID, timeout=10.0)
return len(data["orders"])
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
before = await order_count()
# add_order input 1: append a goto-station order to the end of the list.
await connected_owner_client.add_order(TIMETABLE_VEHICLE_ID, ORDER_STATION_ID)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
assert await order_count() == before + 1
# add_order input 2: insert another before position 0.
await connected_owner_client.add_order(TIMETABLE_VEHICLE_ID, ORDER_STATION_ID, before_position=0)
await asyncio.sleep(1.0)
assert await order_count() == before + 2
# remove_order input 1: delete the one just inserted at the front.
await connected_owner_client.remove_order(TIMETABLE_VEHICLE_ID, 0)
await asyncio.sleep(1.0)
assert await order_count() == before + 1
# remove_order input 2: delete the appended order (now the last one) to restore the list.
await connected_owner_client.remove_order(TIMETABLE_VEHICLE_ID, before)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
assert await order_count() == before
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_scheduled_dispatch_edit_and_view(connected_owner_client, connected_admin):
# Public functions: set_scheduled_dispatch(), add_dispatch_schedule(), remove_dispatch_schedule(),
# add_dispatch_slot(), remove_dispatch_slot(), clear_dispatch_schedule(), set_dispatch_duration(),
# set_dispatch_start_date(), and get_dispatch(). Edits go over the game port and are read back
# authoritatively via the admin get_dispatch(). The test leaves the vehicle with no schedules.
veh = DISPATCH_VEHICLE_ID
owner = connected_owner_client
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
async def dispatch():
return await connected_admin.get_dispatch(veh, timeout=10.0)
# The assertions below address schedules by absolute index, so DISPATCH_VEHICLE_ID must start
# with none of its own; the test restores that state on the way out.
start = await dispatch() # get_dispatch input 1: a valid vehicle
assert "schedules" in start and isinstance(start["schedules"], list)
# add_dispatch_schedule: two schedules (indices 0 and 1) with different start ticks/durations.
await owner.add_dispatch_schedule(veh, 0, 3000)
await asyncio.sleep(0.5)
await owner.add_dispatch_schedule(veh, 1000, 2000)
await asyncio.sleep(0.5)
assert not owner.shutdown_event.is_set()
data = await dispatch()
assert len(data["schedules"]) == 2
assert data["schedules"][0]["duration"] == 3000
assert data["schedules"][1]["duration"] == 2000
# set_dispatch_duration / set_dispatch_start_date: two inputs each (schedule 0 and 1).
await owner.set_dispatch_duration(veh, 0, 4000)
await owner.set_dispatch_duration(veh, 1, 2500)
await owner.set_dispatch_start_date(veh, 0, 1_000_000)
await owner.set_dispatch_start_date(veh, 1, 2_000_000)
await asyncio.sleep(0.5)
# add_dispatch_slot: two departure slots in schedule 0.
await owner.add_dispatch_slot(veh, 0, 500)
await owner.add_dispatch_slot(veh, 0, 1500)
await asyncio.sleep(0.5)
data = await dispatch()
sched0 = data["schedules"][0]
assert sched0["duration"] == 4000
# The engine normalises the start tick relative to current game time (advancing it by whole
# durations to sit near "now"), so it won't equal the requested value verbatim; just confirm
# a start date was accepted and is reported as an integer.
assert isinstance(sched0["start_tick"], int)
assert {s["offset"] for s in sched0["slots"]} == {500, 1500}
# remove_dispatch_slot: two inputs (both slots of schedule 0).
await owner.remove_dispatch_slot(veh, 0, 1500)
await owner.remove_dispatch_slot(veh, 0, 500)
await asyncio.sleep(0.5)
assert (await dispatch())["schedules"][0]["slots"] == []
# set_scheduled_dispatch: enable then disable (two inputs), reading the flag back in between.
await owner.set_scheduled_dispatch(veh, True)
await asyncio.sleep(0.5)
assert (await dispatch())["enabled"] == 1
await owner.set_scheduled_dispatch(veh, False)
await asyncio.sleep(0.5)
assert (await dispatch())["enabled"] == 0
# clear_dispatch_schedule: two inputs (schedule 0 and 1).
await owner.clear_dispatch_schedule(veh, 0)
await owner.clear_dispatch_schedule(veh, 1)
await asyncio.sleep(0.5)
# remove_dispatch_schedule: remove both (higher index first) to restore the vehicle.
await owner.remove_dispatch_schedule(veh, 1)
await asyncio.sleep(0.5)
await owner.remove_dispatch_schedule(veh, 0)
await asyncio.sleep(0.5)
assert not owner.shutdown_event.is_set()
assert (await dispatch())["schedules"] == [] # get_dispatch input 1 (restored state)
# --- Admin Client Public Functions ---
@@ -267,6 +519,366 @@ 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):
# Public function: list_vehicles()
# Input 1: all companies (no company_id)
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_vehicles()
await asyncio.sleep(0.5)
assert not connected_admin.shutdown_event.is_set()
assert len(responses) >= 1
assert "vehicles" in responses[-1]
assert isinstance(responses[-1]["vehicles"], list)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_list_vehicles_specific_company(connected_admin):
# Public function: list_vehicles()
# Input 2: specific company_id
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_vehicles(company_id=0)
await asyncio.sleep(0.5)
assert not connected_admin.shutdown_event.is_set()
assert len(responses) >= 1
assert "vehicles" in responses[-1]
assert isinstance(responses[-1]["vehicles"], list)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_get_timetable_valid_vehicle(connected_admin):
# Public function: get_timetable()
# Input 1: a real vehicle id discovered via list_vehicles
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_vehicles()
await asyncio.sleep(0.5)
assert len(responses) >= 1 and "vehicles" in responses[-1]
vehicles = responses[-1]["vehicles"]
if not vehicles:
pytest.skip("No vehicles on the test server to query a timetable for.")
vid = vehicles[0]["id"]
data = await connected_admin.get_timetable(vid, timeout=10.0)
assert data["vehicle_id"] == vid
for key in ("lateness", "start_tick", "current_order_time", "total_duration", "orders"):
assert key in data
assert isinstance(data["orders"], list)
for order in data["orders"]:
for key in ("position", "wait_time", "travel_time", "wait_timetabled",
"travel_timetabled", "wait_fixed", "travel_fixed", "leave_type", "max_speed"):
assert key in order
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_get_timetable_invalid_vehicle(connected_admin):
# Public function: get_timetable()
# Input 2: an id no vehicle can have -> GameScript reports invalid_vehicle
with pytest.raises(ValueError, match="invalid_vehicle"):
await connected_admin.get_timetable(65535, timeout=10.0)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_get_dispatch_invalid_vehicle(connected_admin):
# Public function: get_dispatch()
# Input 2: an id no vehicle can have -> GameScript reports invalid_vehicle
with pytest.raises(ValueError, match="invalid_vehicle"):
await connected_admin.get_dispatch(65535, timeout=10.0)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_list_stations_all_companies(connected_admin):
# Public function: list_stations()
# Input 1: all companies (no company_id)
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_stations()
await asyncio.sleep(0.5)
assert not connected_admin.shutdown_event.is_set()
assert len(responses) >= 1
assert "stations" in responses[-1]
assert isinstance(responses[-1]["stations"], list)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_list_stations_specific_company(connected_admin):
# Public function: list_stations()
# Input 2: specific company_id
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_stations(company_id=0)
await asyncio.sleep(0.5)
assert not connected_admin.shutdown_event.is_set()
assert len(responses) >= 1
assert "stations" in responses[-1]
assert isinstance(responses[-1]["stations"], list)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_get_station_valid_station(connected_admin):
# Public function: get_station()
# Input 1: a real station id discovered via list_stations
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_stations()
await asyncio.sleep(0.5)
assert len(responses) >= 1 and "stations" in responses[-1]
stations = responses[-1]["stations"]
if not stations:
pytest.skip("No stations on the test server to query.")
sid = stations[0]["id"]
data = await connected_admin.get_station(sid, timeout=10.0)
assert data["station_id"] == sid
assert "cargo" in data
assert isinstance(data["cargo"], list)
for cargo in data["cargo"]:
for key in ("cargo_id", "waiting", "planned", "rating"):
assert key in cargo
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_get_station_invalid_station(connected_admin):
# Public function: get_station()
# Input 2: an id no station can have -> GameScript reports invalid_station
with pytest.raises(ValueError, match="invalid_station"):
await connected_admin.get_station(65535, timeout=10.0)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_get_station_cargo_breakdown(connected_admin):
# Public function: get_station_cargo()
# Input 1: a real station + a cargo it has handled, discovered via list_stations/get_station
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_stations()
await asyncio.sleep(0.5)
assert responses and "stations" in responses[-1]
stations = responses[-1]["stations"]
if not stations:
pytest.skip("No stations on the test server to query.")
# Find a station/cargo pair that actually has cargo data.
target = None
for st in stations:
detail = await connected_admin.get_station(st["id"], timeout=10.0)
if detail["cargo"]:
target = (st["id"], detail["cargo"][0]["cargo_id"])
break
if target is None:
pytest.skip("No station with handled cargo to break down.")
sid, cid = target
data = await connected_admin.get_station_cargo(sid, cid, timeout=10.0)
assert data["station_id"] == sid and data["cargo_id"] == cid
for key in ("waiting", "planned",
"waiting_by_from", "planned_by_from", "waiting_by_via", "planned_by_via"):
assert key in data
for key in ("waiting_by_from", "planned_by_from", "waiting_by_via", "planned_by_via"):
assert isinstance(data[key], list)
for entry in data[key]:
assert "station" in entry and "amount" in entry
# Input 2: the same query narrowed by a next-hop (via) filter is accepted and echoes it back.
filtered = await connected_admin.get_station_cargo(sid, cid, via_station=sid, timeout=10.0)
assert filtered["via_station"] == sid
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_get_station_cargo_invalid_cargo(connected_admin):
# Public function: get_station_cargo()
# A cargo id no cargo can have -> GameScript reports invalid_cargo. Needs a valid station.
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_stations()
await asyncio.sleep(0.5)
assert responses and "stations" in responses[-1]
stations = responses[-1]["stations"]
if not stations:
pytest.skip("No stations on the test server to query.")
with pytest.raises(ValueError, match="invalid_cargo"):
await connected_admin.get_station_cargo(stations[0]["id"], 250, timeout=10.0)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_list_cargo_table(connected_admin):
# Public function: list_cargo()
# Input 1: an explicit timeout. Every game has a cargo table, so an empty list would be a bug.
data = await connected_admin.list_cargo(timeout=10.0)
assert isinstance(data["cargo"], list) and data["cargo"]
for cargo in data["cargo"]:
for key in ("cargo_id", "label", "freight"):
assert key in cargo
assert cargo["freight"] in (0, 1)
ids = [cargo["cargo_id"] for cargo in data["cargo"]]
assert len(ids) == len(set(ids))
# Any cargo set carries passengers as well as freight, so both kinds must show up.
assert any(cargo["freight"] == 0 for cargo in data["cargo"])
assert any(cargo["freight"] == 1 for cargo in data["cargo"])
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_list_cargo_resolves_station_cargo_ids(connected_admin):
# Public function: list_cargo()
# Input 2: the default timeout. The point of the call: naming the bare ids get_station() returns.
responses = []
connected_admin.on_gamescript = lambda data: responses.append(data)
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
await connected_admin.list_stations()
await asyncio.sleep(0.5)
assert responses and "stations" in responses[-1]
stations = responses[-1]["stations"]
if not stations:
pytest.skip("No stations on the test server to query.")
labels = {cargo["cargo_id"]: cargo["label"] for cargo in (await connected_admin.list_cargo())["cargo"]}
detail = await connected_admin.get_station(stations[0]["id"], timeout=10.0)
for cargo in detail["cargo"]:
assert cargo["cargo_id"] in labels
# --- Game Events ---
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_subscribe_events_all_kinds(connected_admin):
# Public functions: subscribe_events(), unsubscribe_events()
# Input 1: no arguments -> every event kind, default interval
data = await connected_admin.subscribe_events(timeout=10.0)
assert isinstance(data["events"], list)
assert "vehicle_arrive" in data["events"] and "cargo_waiting" in data["events"]
assert data["interval"] == 10
stopped = await connected_admin.unsubscribe_events()
assert stopped["events"] == []
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_subscribe_events_filtered(connected_admin):
# Public functions: subscribe_events(), unsubscribe_events()
# Input 2: a narrowed subscription -> only the requested kinds come back
data = await connected_admin.subscribe_events(
events=[GameEventType.VehicleArrive, GameEventType.CargoWaiting],
interval=5, company_id=0, min_cargo_delta=2, include_cargo=False, timeout=10.0)
assert sorted(data["events"]) == ["cargo_waiting", "vehicle_arrive"]
assert data["interval"] == 5
await connected_admin.unsubscribe_events(timeout=10.0)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_subscribe_events_unknown_kind(connected_admin):
# Public function: subscribe_events()
# A kind the GameScript does not know -> it reports unknown_event
with pytest.raises(ValueError, match="unknown_event"):
await connected_admin.subscribe_events(events=["definitely_not_an_event"], timeout=10.0)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_wait_for_event_vehicle_reaches_a_stop(connected_admin):
# Public function: wait_for_event()
# Input 1: filtered by kind. Needs traffic on the server, so a quiet map skips.
await connected_admin.subscribe_events(
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
interval=2, timeout=10.0)
try:
event = await connected_admin.wait_for_event(
{GameEventType.VehicleArrive, GameEventType.VehicleDepart}, timeout=60.0)
except asyncio.TimeoutError:
pytest.skip("No vehicle reached or left a stop on the test server within the timeout.")
finally:
await connected_admin.unsubscribe_events()
assert event["event"] in ("vehicle_arrive", "vehicle_depart")
for key in ("tick", "vehicle_id", "station_id", "owner", "vehicle_type", "order_position"):
assert key in event
if event["event"] == "vehicle_depart":
assert event["dwell"] >= 0
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_wait_for_event_any_kind(connected_admin):
# Public function: wait_for_event()
# Input 2: no kind filter -> whatever the game produces first
await connected_admin.subscribe_events(interval=2, timeout=10.0)
try:
event = await connected_admin.wait_for_event(timeout=60.0)
except asyncio.TimeoutError:
pytest.skip("Nothing happened on the test server within the timeout.")
finally:
await connected_admin.unsubscribe_events()
assert "event" in event and "tick" in event
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_unsubscribe_events_stops_the_stream(connected_admin):
# Public function: unsubscribe_events()
# After unsubscribing the bridge must go quiet, so a fresh wait times out.
await connected_admin.subscribe_events(interval=2, timeout=10.0)
await connected_admin.unsubscribe_events(timeout=10.0)
connected_admin._event_buffer.clear() # drop anything delivered before we unsubscribed
with pytest.raises(asyncio.TimeoutError):
await connected_admin.wait_for_event(timeout=5.0)
# --- Protocol Public Functions ---
@@ -292,11 +904,11 @@ async def test_e2e_protocol_public_functions_multiple_inputs(server_config):
# 2. Test receive_packet() with multiple inputs
# Input 1: Valid packet structure (length >= 3)
res_type1, res_data1 = proto_game.receive_packet(None, memoryview(b"\x03\x00\x05")) # type 5 is ServerUnused
res_type1, _ = proto_game.receive_packet(None, memoryview(b"\x03\x00\x05")) # type 5 is ServerUnused
assert res_type1 == PacketGameType.ServerUnused
# Input 2: Invalid/short packet structure (length < 3)
res_type2, res_data2 = proto_game.receive_packet(None, memoryview(b"\x01"))
res_type2, _ = proto_game.receive_packet(None, memoryview(b"\x01"))
assert res_type2 == PacketGameType.ServerUnused # Falls back to ServerUnused on error
# 3. Test send_packet() with multiple inputs (using mock transports)
+251
View File
@@ -0,0 +1,251 @@
import asyncio
import json
import pytest
from openttd import OpenTTDAdminClient
from openttd.protocol import GameEventType, PacketAdminType
class MockTransport:
def is_closing(self):
return False
def close(self):
pass
def write(self, data):
return len(data)
class MockProtocol:
def __init__(self):
self.sent = []
async def send_packet(self, data):
self.sent.append(data)
return len(data)
def decode_gamescript_payload(packet):
"""Decode the JSON payload of an AdminGamescript packet (2-byte length + 1-byte type + string)."""
return json.loads(packet[3:].split(b"\x00")[0])
def make_admin(subscribed=True, **kwargs):
"""An admin client wired to mock transports, with the Gamescript auto-subscribe already done
unless a test wants to observe it."""
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin", **kwargs)
client._protocol = MockProtocol()
client._transport = MockTransport()
client._gs_subscribed = subscribed
return client
def events_packet(*events):
"""The envelope the AdminBridge GameScript pushes event batches in."""
return {"command": "events", "events": list(events)}
def arrival(vehicle_id=7, station_id=3, tick=100):
return {"event": "vehicle_arrive", "tick": tick, "vehicle_id": vehicle_id,
"station_id": station_id, "owner": 0, "vehicle_type": 0, "order_position": 0,
"cargo": [{"cargo_id": 0, "load": 12}]}
# --- Event kinds ---
def test_event_type_values_are_the_wire_strings():
assert GameEventType.VehicleArrive == "vehicle_arrive"
assert GameEventType.CargoWaiting == "cargo_waiting"
# Members are plain strings, so they can be used interchangeably with literals.
assert str(GameEventType.VehicleDepart) == "vehicle_depart"
# --- Subscribing ---
@pytest.mark.asyncio
async def test_subscribe_events_defaults_send_only_the_command():
client = make_admin(subscribed=False)
task = asyncio.ensure_future(client.subscribe_events())
await asyncio.sleep(0)
# First use auto-subscribes to Gamescript updates, then sends the request.
assert len(client._protocol.sent) == 2
assert client._protocol.sent[0][2] == PacketAdminType.AdminUpdateFrequency
assert decode_gamescript_payload(client._protocol.sent[1]) == {
"command": "subscribe_events", "request_id": 1,
}
reply = {"command": "subscribe_events", "request_id": 1,
"events": ["vehicle_arrive", "vehicle_depart"], "interval": 10}
await client.receive_ServerGamescript(None, data=reply)
assert await task == reply
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_subscribe_events_encodes_every_filter():
client = make_admin()
task = asyncio.ensure_future(client.subscribe_events(
events=[GameEventType.VehicleArrive, "cargo_waiting"], interval=25, company_id=0,
vehicles=(7, 9), stations=(3,), cargo=[0, 1], min_cargo_delta=5, include_cargo=False))
await asyncio.sleep(0)
assert decode_gamescript_payload(client._protocol.sent[0]) == {
"command": "subscribe_events", "request_id": 1,
"events": ["vehicle_arrive", "cargo_waiting"], "interval": 25, "company_id": 0,
"vehicles": [7, 9], "stations": [3], "cargo": [0, 1],
"min_cargo_delta": 5, "include_cargo": False,
}
await client.receive_ServerGamescript(
None, data={"request_id": 1, "events": ["vehicle_arrive", "cargo_waiting"]})
assert (await task)["events"] == ["vehicle_arrive", "cargo_waiting"]
@pytest.mark.asyncio
async def test_subscribe_events_rejected_request_raises():
client = make_admin()
task = asyncio.ensure_future(client.subscribe_events(events=["not_an_event"]))
await asyncio.sleep(0)
await client.receive_ServerGamescript(
None, data={"command": "subscribe_events", "request_id": 1,
"error": "unknown_event", "event": "not_an_event"})
with pytest.raises(ValueError, match="unknown_event"):
await task
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_subscribe_events_timeout():
client = make_admin()
with pytest.raises(asyncio.TimeoutError):
await client.subscribe_events(timeout=0.05)
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_unsubscribe_events():
client = make_admin()
task = asyncio.ensure_future(client.unsubscribe_events())
await asyncio.sleep(0)
assert decode_gamescript_payload(client._protocol.sent[0]) == {
"command": "unsubscribe_events", "request_id": 1,
}
reply = {"command": "unsubscribe_events", "request_id": 1, "events": []}
await client.receive_ServerGamescript(None, data=reply)
assert await task == reply
# --- Receiving events ---
@pytest.mark.asyncio
async def test_event_batch_reaches_on_event_and_bypasses_on_gamescript():
client = make_admin()
seen, other = [], []
client.on_event = seen.append
client.on_gamescript = other.append
depart = {"event": "vehicle_depart", "tick": 120, "vehicle_id": 7,
"station_id": 3, "dwell": 74}
await client.receive_ServerGamescript(None, data=events_packet(arrival(), depart))
assert [event["event"] for event in seen] == ["vehicle_arrive", "vehicle_depart"]
assert other == []
@pytest.mark.asyncio
async def test_non_event_gamescript_payloads_still_reach_on_gamescript():
client = make_admin()
seen, other = [], []
client.on_event = seen.append
client.on_gamescript = other.append
# A reply that merely mentions events, and a malformed batch, are not event batches.
await client.receive_ServerGamescript(None, data={"vehicles": []})
await client.receive_ServerGamescript(None, data={"command": "events", "events": "nope"})
assert seen == []
assert other == [{"vehicles": []}, {"command": "events", "events": "nope"}]
@pytest.mark.asyncio
async def test_events_are_buffered_until_awaited():
client = make_admin()
await client.receive_ServerGamescript(None, data=events_packet(arrival(station_id=3),
arrival(station_id=4)))
# The oldest matching event is handed out first, and only once.
first = await client.wait_for_event("vehicle_arrive")
second = await client.wait_for_event(GameEventType.VehicleArrive)
assert (first["station_id"], second["station_id"]) == (3, 4)
assert len(client._event_buffer) == 0
@pytest.mark.asyncio
async def test_wait_for_event_resolves_on_arrival():
client = make_admin()
task = asyncio.ensure_future(client.wait_for_event("cargo_waiting", timeout=5.0))
await asyncio.sleep(0)
cargo = {"event": "cargo_waiting", "tick": 300, "station_id": 3,
"cargo_id": 0, "waiting": 25, "delta": 15}
await client.receive_ServerGamescript(None, data=events_packet(arrival(), cargo))
assert await task == cargo
# The event that did not match the waiter is still buffered for the next caller.
assert await client.wait_for_event() == arrival()
assert client._event_waiters == []
@pytest.mark.asyncio
async def test_wait_for_event_accepts_several_kinds():
client = make_admin()
task = asyncio.ensure_future(
client.wait_for_event({GameEventType.VehicleArrive, GameEventType.VehicleDepart}))
await asyncio.sleep(0)
await client.receive_ServerGamescript(
None, data=events_packet({"event": "town_founded", "tick": 5, "town_id": 2}, arrival()))
assert (await task)["event"] == "vehicle_arrive"
@pytest.mark.asyncio
async def test_wait_for_event_ignores_non_dict_events_when_filtering():
client = make_admin()
task = asyncio.ensure_future(client.wait_for_event("vehicle_arrive", timeout=0.05))
await asyncio.sleep(0)
# A malformed entry inside a batch must not satisfy a filtered waiter.
await client.receive_ServerGamescript(None, data=events_packet("garbage"))
with pytest.raises(asyncio.TimeoutError):
await task
assert list(client._event_buffer) == ["garbage"]
@pytest.mark.asyncio
async def test_wait_for_event_timeout_deregisters_the_waiter():
client = make_admin()
with pytest.raises(asyncio.TimeoutError):
await client.wait_for_event("vehicle_arrive", timeout=0.05)
assert client._event_waiters == []
@pytest.mark.asyncio
async def test_already_resolved_waiters_are_skipped():
client = make_admin()
# A waiter whose future completed but that has not been cleaned up yet must not swallow
# the event; it goes to the next live waiter instead.
stale = asyncio.get_running_loop().create_future()
stale.set_result("already done")
live = asyncio.get_running_loop().create_future()
client._event_waiters = [(None, stale), (None, live)]
await client.receive_ServerGamescript(None, data=events_packet(arrival()))
assert live.result() == arrival()
assert len(client._event_buffer) == 0
@pytest.mark.asyncio
async def test_event_buffer_is_bounded():
client = make_admin(event_buffer_size=2)
await client.receive_ServerGamescript(None, data=events_packet(
arrival(station_id=1), arrival(station_id=2), arrival(station_id=3)))
# The oldest event is dropped rather than growing the buffer without bound.
assert [event["station_id"] for event in client._event_buffer] == [2, 3]
@pytest.mark.asyncio
async def test_disconnect_fails_pending_event_waiters():
client = make_admin()
task = asyncio.ensure_future(client.wait_for_event())
await asyncio.sleep(0)
client.disconnect(None)
with pytest.raises(ConnectionError):
await task
assert client._event_waiters == []
+89
View File
@@ -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
+9 -6
View File
@@ -1,8 +1,13 @@
import pytest
import asyncio
import hashlib
from openttd.protocol import PacketGameType
import pytest
from openttd.client import OpenTTDClient
from openttd.protocol import PacketGameType
class FakeNetworkError(OSError):
"""Stand-in for a socket-level failure, so it matches the client's narrowed handlers."""
def test_packet_game_type_values():
assert PacketGameType.ServerFull == 0
@@ -61,7 +66,7 @@ async def test_client_connect_success(monkeypatch):
async def test_client_connect_failure(monkeypatch):
client = OpenTTDClient(host="127.0.0.1")
async def mock_fail(*args, **kwargs):
raise Exception("Async Failure")
raise FakeNetworkError("Async Failure")
monkeypatch.setattr(asyncio.get_running_loop(), "create_connection", mock_fail)
with pytest.raises(Exception, match="Async Failure"):
await client.connect()
@@ -118,11 +123,10 @@ async def test_fallback_handlers():
await client.receive_ServerConfigurationUpdate(None)
await client.receive_ServerClientInfo(None)
await client.receive_ServerExternalChat(None)
await client.receive_ServerCommand(None)
await client.receive_ClientAck(None)
await client.receive_ClientIdentify(None)
await client.receive_ServerCompanyUpdate(None)
client.joined.set()
await client.join_company(0)
@@ -224,7 +228,6 @@ async def test_unit_client_noop_callbacks(server_config):
await client.receive_ServerMapData(None)
await client.receive_ServerConfigurationUpdate(None)
await client.receive_ServerExternalChat(None)
await client.receive_ServerCommand(None)
await client.receive_ServerFull(None)
await client.receive_ServerBanned(None)
await client.receive_ClientAck(None)
+10 -9
View File
@@ -1,9 +1,12 @@
import pytest
import contextlib
import struct
import monocypher
import pytest
from openttd.protocol import OpenTTDProtocol, PacketGameType
from openttd_protocol.wire.exceptions import SocketClosed
class MockTransport:
def __init__(self): self._closing = False
def is_closing(self): return self._closing
@@ -38,7 +41,6 @@ def test_protocol_static_parsers():
assert res["token"] == 7
assert OpenTTDProtocol.receive_ServerExternalChat(None, b"") == {}
assert OpenTTDProtocol.receive_ServerCommand(None, b"") == {}
assert OpenTTDProtocol.receive_ServerFull(None, b"") == {}
assert OpenTTDProtocol.receive_ServerBanned(None, b"") == {}
assert OpenTTDProtocol.receive_ClientIdentify(None, b"") == {}
@@ -60,10 +62,8 @@ def test_protocol_static_parsers():
assert OpenTTDProtocol.receive_ServerNeedCompanyPassword(None, memoryview(struct.pack("<I", 1234) + b"sid\x00")) == {"seed": 1234, "server_id": "sid"}
# Coverage for receive_ServerGameInfo
try:
with contextlib.suppress(Exception):
OpenTTDProtocol.receive_ServerGameInfo(None, memoryview(b"\x00" * 200))
except Exception:
pass
@pytest.mark.asyncio
async def test_protocol_exception_handling():
@@ -72,7 +72,7 @@ async def test_protocol_exception_handling():
proto.transport = MockTransport()
# Passing data that causes struct.unpack to fail (too short for uint16)
ptype, kwargs = proto.receive_packet(None, memoryview(b"\x01"))
ptype, _ = proto.receive_packet(None, memoryview(b"\x01"))
assert ptype == PacketGameType.ServerUnused
@pytest.mark.asyncio
@@ -112,8 +112,8 @@ async def test_protocol_decryption_failure():
proto.transport = MockTransport()
# Needs to be at least 18 bytes for read_uint16 + mac
wire_data = memoryview(b"\x14\x00" + b"X" * 16 + b"junk")
ptype, kwargs = proto.receive_packet(None, wire_data)
wire_data = memoryview(b"\x14\x00" + b"X" * 16 + b"junk")
ptype, _ = proto.receive_packet(None, wire_data)
assert ptype == PacketGameType.ServerUnused
@pytest.mark.asyncio
@@ -128,9 +128,10 @@ async def test_protocol_is_closing_failure():
await proto.send_packet(b"\x02\x00")
def test_admin_protocol_static_receives():
from openttd.protocol import OpenTTDAdminProtocol
import struct
from openttd.protocol import OpenTTDAdminProtocol
# 1. receive_ServerProtocol
data = memoryview(struct.pack("<B B H H B", 3, 1, 10, 100, 0))
res = OpenTTDAdminProtocol.receive_ServerProtocol(None, data)
+435
View File
@@ -0,0 +1,435 @@
import pytest
from openttd import OpenTTDClient
from openttd.protocol import (
INVALID_VEH_ORDER_ID,
GameCommand,
ModifyTimetableCtrlFlag,
ModifyTimetableFlags,
OpenTTDProtocol,
OrderNonStopFlags,
OrderStopLocation,
OrderType,
read_varuint,
read_varuint_signed,
write_varuint,
write_varuint_signed,
)
from openttd_protocol.wire.read import read_uint8, read_uint16
class MockTransport:
def is_closing(self):
return False
def write(self, data):
return len(data)
class MockProtocol:
def __init__(self):
self.sent = []
async def send_packet(self, data):
self.sent.append(data)
return len(data)
def decode_sent_command(packet):
"""Decode a ClientCommand packet by reusing the ServerCommand parser, padding on the
frame/my_cmd trailer that only ServerCommand carries on the wire (ClientCommand doesn't)."""
padded = bytes(packet)[3:] + b"\x00\x00\x00\x00\x00"
return OpenTTDProtocol.receive_ServerCommand(None, memoryview(padded))
def new_client():
client = OpenTTDClient("127.0.0.1")
client._protocol = MockProtocol()
client._transport = MockTransport()
client._target_company = 0
return client
# --- Varuint codec ---
@pytest.mark.parametrize("value", [
0, 1, 127,
128, 16383,
16384, 2097151,
2097152, 268435455,
268435456, 34359738367,
34359738368, 4398046511103,
4398046511104, 562949953421311,
562949953421312, 72057594037927935,
72057594037927936, 18446744073709551615,
])
def test_varuint_roundtrip_boundaries(value):
buf = bytearray()
write_varuint(buf, value)
decoded, rest = read_varuint(memoryview(bytes(buf)))
assert decoded == value
assert bytes(rest) == b""
def test_varuint_rejects_negative():
with pytest.raises(ValueError):
write_varuint(bytearray(), -1)
@pytest.mark.parametrize("value", [0, 1, -1, 2, -2, 1000000, -1000000, 9223372036854775807, -9223372036854775808])
def test_varuint_signed_roundtrip(value):
buf = bytearray()
write_varuint_signed(buf, value)
decoded, rest = read_varuint_signed(memoryview(bytes(buf)))
assert decoded == value
assert bytes(rest) == b""
# --- OpenTTDProtocol.receive_ServerCommand ---
def build_server_command_bytes(company, cmd, payload, callback=0, callback_param=0, frame=42, my_cmd=True):
import struct
body = bytearray()
body += struct.pack("<B", company)
body += struct.pack("<H", cmd)
body += struct.pack("<H", 0)
body += struct.pack("<I", 0)
body += struct.pack("<H", len(payload))
body += payload
body += struct.pack("<B", callback)
if callback != 0:
body += struct.pack("<I", callback_param)
body += struct.pack("<I", frame)
body += struct.pack("<B", 1 if my_cmd else 0)
return bytes(body)
def test_protocol_receive_server_command_no_callback():
payload = bytearray()
write_varuint(payload, 7)
data = build_server_command_bytes(1, GameCommand.ChangeTimetable, payload, callback=0, frame=42, my_cmd=True)
res = OpenTTDProtocol.receive_ServerCommand(None, memoryview(data))
assert res["company"] == 1
assert res["cmd"] == GameCommand.ChangeTimetable
assert res["callback"] == 0
assert res["callback_param"] == 0
assert res["frame"] == 42
assert res["my_cmd"] is True
assert bytes(res["payload"]) == bytes(payload)
def test_protocol_receive_server_command_with_callback():
payload = bytearray()
write_varuint(payload, 9)
data = build_server_command_bytes(2, GameCommand.SetVehicleOnTime, payload, callback=5, callback_param=999, frame=100, my_cmd=False)
res = OpenTTDProtocol.receive_ServerCommand(None, memoryview(data))
assert res["callback"] == 5
assert res["callback_param"] == 999
assert res["my_cmd"] is False
# --- OpenTTDClient outgoing command methods ---
@pytest.mark.asyncio
async def test_client_send_command_with_callback_includes_callback_param():
client = new_client()
await client._send_command(GameCommand.ChangeTimetable, bytearray(), callback=5)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["callback"] == 5
assert parsed["callback_param"] == 0
@pytest.mark.asyncio
async def test_client_change_timetable_sends_expected_payload():
client = new_client()
await client.change_timetable(7, 3, ModifyTimetableFlags.WaitTime, 120)
assert len(client._protocol.sent) == 1
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.ChangeTimetable
assert parsed["company"] == 0
vehicle_id, rest = read_varuint(parsed["payload"])
order_position, rest = read_uint16(rest)
flag, rest = read_uint8(rest)
value, rest = read_varuint(rest)
ctrl_flags, _ = read_uint8(rest)
assert (vehicle_id, order_position, flag, value, ctrl_flags) == (7, 3, ModifyTimetableFlags.WaitTime, 120, 0)
@pytest.mark.asyncio
async def test_client_change_timetable_clear_field_sets_ctrl_flag():
client = new_client()
await client.change_timetable(7, 3, ModifyTimetableFlags.TravelTime, 0, clear_field=True)
parsed = decode_sent_command(client._protocol.sent[0])
_, rest = read_varuint(parsed["payload"])
_, rest = read_uint16(rest)
flag, rest = read_uint8(rest)
_, rest = read_varuint(rest)
ctrl_flags, _ = read_uint8(rest)
assert flag == ModifyTimetableFlags.TravelTime
assert ctrl_flags == ModifyTimetableCtrlFlag.ClearField
@pytest.mark.asyncio
async def test_client_autofill_timetable_sends_expected_payload():
client = new_client()
await client.autofill_timetable(7, autofill=True, preserve_wait_time=False)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.AutofillTimetable
vehicle_id, rest = read_varuint(parsed["payload"])
autofill, rest = read_uint8(rest)
preserve_wait_time, _ = read_uint8(rest)
assert (vehicle_id, autofill, preserve_wait_time) == (7, 1, 0)
@pytest.mark.asyncio
async def test_client_set_timetable_start_sends_expected_payload():
client = new_client()
await client.set_timetable_start(7, True, -12345)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SetTimetableStart
vehicle_id, rest = read_varuint(parsed["payload"])
timetable_all, rest = read_uint8(rest)
start_date, _ = read_varuint_signed(rest)
assert (vehicle_id, timetable_all, start_date) == (7, 1, -12345)
@pytest.mark.asyncio
async def test_client_set_vehicle_on_time_sends_expected_payload():
client = new_client()
await client.set_vehicle_on_time(7, apply_to_group=True)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SetVehicleOnTime
vehicle_id, rest = read_varuint(parsed["payload"])
apply_to_group, _ = read_uint8(rest)
assert (vehicle_id, apply_to_group) == (7, 1)
# --- OpenTTDClient order add/remove commands ---
def _decode_insert_order_payload(payload):
vehicle_id, rest = read_varuint(payload)
sel_ord, rest = read_uint16(rest)
order_type, rest = read_uint8(rest)
order_flags, rest = read_uint16(rest)
station, _ = read_uint16(rest)
return vehicle_id, sel_ord, order_type, order_flags, station
@pytest.mark.asyncio
async def test_client_add_order_appends_by_default():
client = new_client()
await client.add_order(7, 6)
assert len(client._protocol.sent) == 1
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.InsertOrder
assert parsed["company"] == 0
vehicle_id, sel_ord, order_type, order_flags, station = _decode_insert_order_payload(parsed["payload"])
assert vehicle_id == 7
assert sel_ord == INVALID_VEH_ORDER_ID # append to the end
# OT_GOTO_STATION in bits 0-3, far-end stop location in bits 4-5, stop-everywhere non-stop in 6-7
assert order_type == (OrderType.GotoStation | (OrderStopLocation.PlatformFarEnd << 4))
assert order_flags == 0
assert station == 6
@pytest.mark.asyncio
async def test_client_add_order_insert_position_and_nonstop():
client = new_client()
await client.add_order(7, 6, before_position=1, non_stop=OrderNonStopFlags.NoStopAtIntermediate)
parsed = decode_sent_command(client._protocol.sent[0])
_, sel_ord, order_type, _, _ = _decode_insert_order_payload(parsed["payload"])
assert sel_ord == 1 # insert before order position 1
assert order_type == (OrderType.GotoStation
| (OrderStopLocation.PlatformFarEnd << 4)
| (OrderNonStopFlags.NoStopAtIntermediate << 6))
@pytest.mark.asyncio
async def test_client_remove_order_sends_expected_payload():
client = new_client()
await client.remove_order(7, 2)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.DeleteOrder
vehicle_id, rest = read_varuint(parsed["payload"])
order_position, _ = read_uint16(rest)
assert (vehicle_id, order_position) == (7, 2)
# --- OpenTTDClient scheduled dispatch edit commands ---
@pytest.mark.asyncio
async def test_client_set_scheduled_dispatch_payload():
client = new_client()
await client.set_scheduled_dispatch(7, True)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatch
vehicle_id, rest = read_varuint(parsed["payload"])
enabled, _ = read_uint8(rest)
assert (vehicle_id, enabled) == (7, 1)
@pytest.mark.asyncio
async def test_client_add_dispatch_schedule_payload():
client = new_client()
await client.add_dispatch_schedule(7, -1234, 3000)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchAddNewSchedule
vehicle_id, rest = read_varuint(parsed["payload"])
start_tick, rest = read_varuint_signed(rest)
duration, _ = read_varuint(rest)
assert (vehicle_id, start_tick, duration) == (7, -1234, 3000)
@pytest.mark.asyncio
async def test_client_remove_dispatch_schedule_payload():
client = new_client()
await client.remove_dispatch_schedule(7, 2)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchRemoveSchedule
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, _ = read_varuint(rest)
assert (vehicle_id, schedule_index) == (7, 2)
@pytest.mark.asyncio
async def test_client_add_dispatch_slot_payload_defaults_and_extras():
client = new_client()
# Defaults: single slot, no interval/extra/flags/route.
await client.add_dispatch_slot(7, 1, 500)
# Bulk: three extra slots spaced 250 ticks apart, with flags and route id.
await client.add_dispatch_slot(7, 1, 500, interval=250, extra_slots=3, slot_flags=5, route_id=2)
def decode(payload):
vehicle_id, rest = read_varuint(payload)
schedule_index, rest = read_varuint(rest)
offset, rest = read_varuint(rest)
interval, rest = read_varuint(rest)
extra_slots, rest = read_varuint(rest)
slot_flags, rest = read_uint16(rest)
route_id, _ = read_uint8(rest)
return (vehicle_id, schedule_index, offset, interval, extra_slots, slot_flags, route_id)
p0 = decode_sent_command(client._protocol.sent[0])
p1 = decode_sent_command(client._protocol.sent[1])
assert p0["cmd"] == GameCommand.SchDispatchAdd
assert decode(p0["payload"]) == (7, 1, 500, 0, 0, 0, 0)
assert decode(p1["payload"]) == (7, 1, 500, 250, 3, 5, 2)
@pytest.mark.asyncio
async def test_client_remove_dispatch_slot_payload():
client = new_client()
await client.remove_dispatch_slot(7, 1, 500)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchRemove
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, rest = read_varuint(rest)
offset, _ = read_varuint(rest)
assert (vehicle_id, schedule_index, offset) == (7, 1, 500)
@pytest.mark.asyncio
async def test_client_clear_dispatch_schedule_payload():
client = new_client()
await client.clear_dispatch_schedule(7, 1)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchClear
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, _ = read_varuint(rest)
assert (vehicle_id, schedule_index) == (7, 1)
@pytest.mark.asyncio
async def test_client_set_dispatch_duration_payload():
client = new_client()
await client.set_dispatch_duration(7, 1, 4000)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchSetDuration
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, rest = read_varuint(rest)
duration, _ = read_varuint(rest)
assert (vehicle_id, schedule_index, duration) == (7, 1, 4000)
@pytest.mark.asyncio
async def test_client_set_dispatch_start_date_payload():
client = new_client()
await client.set_dispatch_start_date(7, 1, 1_000_000)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchSetStartDate
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, rest = read_varuint(rest)
start_tick, _ = read_varuint_signed(rest)
assert (vehicle_id, schedule_index, start_tick) == (7, 1, 1_000_000)
# --- OpenTTDClient.receive_ServerCommand dispatch ---
async def feed_command(client, cmd, payload):
parsed = OpenTTDProtocol.receive_ServerCommand(None, memoryview(build_server_command_bytes(0, cmd, payload)))
await client.receive_ServerCommand(None, **parsed)
@pytest.mark.asyncio
async def test_receive_change_timetable_updates_order_state():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload += (3).to_bytes(2, "little")
payload.append(ModifyTimetableFlags.WaitTime)
write_varuint(payload, 120)
payload.append(0)
await feed_command(client, GameCommand.ChangeTimetable, payload)
assert client.get_vehicle_timetable(7) == {"orders": {3: {"wait_time": 120}}}
@pytest.mark.asyncio
async def test_receive_change_timetable_clear_field_sets_none():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload += (3).to_bytes(2, "little")
payload.append(ModifyTimetableFlags.TravelTime)
write_varuint(payload, 0)
payload.append(ModifyTimetableCtrlFlag.ClearField)
await feed_command(client, GameCommand.ChangeTimetable, payload)
assert client.get_vehicle_timetable(7)["orders"][3]["travel_time"] is None
@pytest.mark.asyncio
async def test_receive_change_timetable_wait_fixed_stores_bool():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload += (0).to_bytes(2, "little")
payload.append(ModifyTimetableFlags.SetWaitFixed)
write_varuint(payload, 1)
payload.append(0)
await feed_command(client, GameCommand.ChangeTimetable, payload)
assert client.get_vehicle_timetable(7)["orders"][0]["wait_time_fixed"] is True
@pytest.mark.asyncio
async def test_receive_autofill_timetable_updates_state():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload.append(1)
payload.append(0)
await feed_command(client, GameCommand.AutofillTimetable, payload)
entry = client.get_vehicle_timetable(7)
assert entry["autofill"] is True
assert entry["autofill_preserve_wait_time"] is False
@pytest.mark.asyncio
async def test_receive_set_timetable_start_updates_state():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload.append(1)
write_varuint_signed(payload, 555)
await feed_command(client, GameCommand.SetTimetableStart, payload)
entry = client.get_vehicle_timetable(7)
assert entry["timetable_all"] is True
assert entry["timetable_start"] == 555
@pytest.mark.asyncio
async def test_receive_set_vehicle_on_time_updates_state():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload.append(1)
await feed_command(client, GameCommand.SetVehicleOnTime, payload)
assert client.get_vehicle_timetable(7)["on_time_apply_to_group"] is True
@pytest.mark.asyncio
async def test_receive_unknown_command_is_ignored():
client = new_client()
await feed_command(client, 999, bytearray())
assert client.vehicle_timetables == {}
# --- get_vehicle_timetable ---
@pytest.mark.asyncio
async def test_get_vehicle_timetable_known_and_unknown():
client = new_client()
assert client.get_vehicle_timetable(7) is None
payload = bytearray()
write_varuint(payload, 7)
payload.append(1)
await feed_command(client, GameCommand.SetVehicleOnTime, payload)
assert client.get_vehicle_timetable(7) is not None
assert client.get_vehicle_timetable(42) is None