342 lines
17 KiB
Markdown
342 lines
17 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())
|
|
```
|
|
|
|
## See also
|
|
- [PROTOCOL.md — Vehicle Timetables](PROTOCOL.md#vehicle-timetables-game-port-docommands) for the underlying wire format.
|
|
- [ARCHITECTURE.md](ARCHITECTURE.md) for how `OpenTTDClient` fits into the rest of the library.
|