From b33869334aea603881b6243940cb1cd3e0a24a7f Mon Sep 17 00:00:00 2001 From: kovagoadi Date: Thu, 16 Jul 2026 23:21:21 +0200 Subject: [PATCH] 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 --- README.md | 2 + docs/PROTOCOL.md | 22 ++++ docs/TIMETABLES.md | 275 +++++++++++++++++++++++++++++++++++++++ lib/openttd/client.py | 122 +++++++++++++++++- lib/openttd/protocol.py | 80 +++++++++++- main.py | 96 +++++++++++++- tests/test_e2e.py | 130 ++++++++++++++++++- tests/test_logic.py | 4 +- tests/test_protocol.py | 1 - tests/test_timetable.py | 276 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 998 insertions(+), 10 deletions(-) create mode 100644 docs/TIMETABLES.md create mode 100644 tests/test_timetable.py diff --git a/README.md b/README.md index eabf569..0929e17 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A high-performance, Object-Oriented Python client for OpenTTD servers, specifica - **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. ## 🛠 Setup @@ -73,4 +74,5 @@ 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) - [Contributor Guide](docs/CONTRIBUTING.md) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 3943681..a787473 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -31,6 +31,28 @@ The Admin Network has no native packet or `AdminUpdateType` for listing individu **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. +## Vehicle Timetables (Game Port DoCommands) +Unlike vehicle listing, timetables have no GameScript API surface at all (no getters or setters exist in `GSOrder`/`GSVehicle`). 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. + +### Timetable command IDs and payload tuples +| Method | `cmd` | payload | +|---|---|---| +| `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)` | + +`VehicleID` and other 4/8-byte fields use OpenTTD's custom varuint scheme (`write_varuint`/`read_varuint` in `protocol.py`) — a UTF-8-like prefix encoding, not LEB128; signed fields (`StateTicks`) use zigzag on top of it (`write_varuint_signed`/`read_varuint_signed`). + +### Ownership requirement +A command is rejected (and the client kicked) 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 timetable method. + +### Reading timetables — no query command exists +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. + ## Stream Encryption (AEAD) Once `ServerEnableEncryption` is received, all subsequent packets use **XChaCha20-Poly1305** (Authenticated Encryption with Associated Data). diff --git a/docs/TIMETABLES.md b/docs/TIMETABLES.md new file mode 100644 index 0000000..51aed19 --- /dev/null +++ b/docs/TIMETABLES.md @@ -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=, 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. diff --git a/lib/openttd/client.py b/lib/openttd/client.py index 70b8f9e..753bc33 100644 --- a/lib/openttd/client.py +++ b/lib/openttd/client.py @@ -5,7 +5,12 @@ 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 +from openttd_protocol.wire.read import read_uint8, read_uint16 +from .protocol import ( + PacketGameType, OpenTTDProtocol, PacketAdminType, OpenTTDAdminProtocol, NetworkAuthenticationMethod, + GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag, + write_varuint, read_varuint, write_varuint_signed, read_varuint_signed +) from .decorators import exclude_call_check class OpenTTDClient: @@ -22,7 +27,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 +68,68 @@ 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) + def disconnect(self, source): """Library callback for when connection is lost.""" self.log.info("Disconnected.") @@ -199,7 +267,55 @@ 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 = { + 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 = {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 diff --git a/lib/openttd/protocol.py b/lib/openttd/protocol.py index 2a745ff..9e0d6af 100644 --- a/lib/openttd/protocol.py +++ b/lib/openttd/protocol.py @@ -5,6 +5,66 @@ 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 +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): + ChangeTimetable = 174 + SetVehicleOnTime = 176 + AutofillTimetable = 177 + SetTimetableStart = 180 + +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 ServerBanned = 1 @@ -229,7 +289,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 diff --git a/main.py b/main.py index 3b0a256..d53302a 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ import os 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,95 @@ 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 + +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)") + + 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 +131,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) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 45ac7dd..ccd6f7f 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -13,9 +13,17 @@ from openttd.protocol import ( OpenTTDAdminProtocol, AdminUpdateType, AdminUpdateFrequency, - PacketGameType + PacketGameType, + ModifyTimetableFlags ) +# 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 + # --- Pytest Fixtures --- @@ -52,6 +60,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 +153,110 @@ 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 + # --- Admin Client Public Functions --- diff --git a/tests/test_logic.py b/tests/test_logic.py index f9f8f4d..1a89002 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -118,11 +118,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 +223,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) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 6395b10..403ada8 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -38,7 +38,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"") == {} diff --git a/tests/test_timetable.py b/tests/test_timetable.py new file mode 100644 index 0000000..7c75d99 --- /dev/null +++ b/tests/test_timetable.py @@ -0,0 +1,276 @@ +import pytest +from openttd import OpenTTDClient +from openttd.protocol import ( + OpenTTDProtocol, GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag, + write_varuint, read_varuint, write_varuint_signed, read_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("