Add vehicle timetable get/set support
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 <noreply@anthropic.com>
This commit is contained in:
275
docs/TIMETABLES.md
Normal file
275
docs/TIMETABLES.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Vehicle Timetables: Usage Guide
|
||||
|
||||
This guide covers `OpenTTDClient`'s timetable API: `change_timetable()`, `autofill_timetable()`,
|
||||
`set_timetable_start()`, `set_vehicle_on_time()`, and `get_vehicle_timetable()`. 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.
|
||||
|
||||
## The two things you must know before using this API
|
||||
|
||||
1. **You must join the vehicle's own company.** These are real game commands (`DoCommand`s), 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. **There is no "get timetable" query.** OpenTTD's protocol has no command that returns a
|
||||
vehicle's current timetable. `get_vehicle_timetable()` works by passively watching the
|
||||
`ServerCommand` broadcasts the server sends to every joined client whenever *anyone* changes a
|
||||
timetable. This means:
|
||||
- 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.
|
||||
Reference in New Issue
Block a user