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>
410 lines
20 KiB
Markdown
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.
|