Files
openttd-client/docs/TIMETABLES.md
kovagoadi 2eea541158
All checks were successful
Continuous Integration / lint-and-security (pull_request) Successful in 22s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
Add scheduled dispatch support (edit + authoritative view)
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 <noreply@anthropic.com>
2026-07-24 22:56:37 +02:00

410 lines
20 KiB
Markdown

# 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 **and** the patched JGRPP
build with the `GSOrder` timetable getters (both included in this repo's `docker/` setup — see
`docker/patches/README.md`). The Gamescript update-frequency subscription it needs is set up
automatically on first call.
## 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.