Add vehicle timetable get/set support
All checks were successful
Continuous Integration / lint-and-security (pull_request) Successful in 22s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s

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:
2026-07-16 23:21:21 +02:00
parent 0a8271d57c
commit b33869334a
10 changed files with 998 additions and 10 deletions

276
tests/test_timetable.py Normal file
View File

@@ -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("<B", company)
body += struct.pack("<H", cmd)
body += struct.pack("<H", 0)
body += struct.pack("<I", 0)
body += struct.pack("<H", len(payload))
body += payload
body += struct.pack("<B", callback)
if callback != 0:
body += struct.pack("<I", callback_param)
body += struct.pack("<I", frame)
body += struct.pack("<B", 1 if my_cmd else 0)
return bytes(body)
def test_protocol_receive_server_command_no_callback():
payload = bytearray()
write_varuint(payload, 7)
data = build_server_command_bytes(1, GameCommand.ChangeTimetable, payload, callback=0, frame=42, my_cmd=True)
res = OpenTTDProtocol.receive_ServerCommand(None, memoryview(data))
assert res["company"] == 1
assert res["cmd"] == GameCommand.ChangeTimetable
assert res["callback"] == 0
assert res["callback_param"] == 0
assert res["frame"] == 42
assert res["my_cmd"] is True
assert bytes(res["payload"]) == bytes(payload)
def test_protocol_receive_server_command_with_callback():
payload = bytearray()
write_varuint(payload, 9)
data = build_server_command_bytes(2, GameCommand.SetVehicleOnTime, payload, callback=5, callback_param=999, frame=100, my_cmd=False)
res = OpenTTDProtocol.receive_ServerCommand(None, memoryview(data))
assert res["callback"] == 5
assert res["callback_param"] == 999
assert res["my_cmd"] is False
# --- OpenTTDClient outgoing command methods ---
@pytest.mark.asyncio
async def test_client_send_command_with_callback_includes_callback_param():
client = new_client()
await client._send_command(GameCommand.ChangeTimetable, bytearray(), callback=5)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["callback"] == 5
assert parsed["callback_param"] == 0
@pytest.mark.asyncio
async def test_client_change_timetable_sends_expected_payload():
client = new_client()
await client.change_timetable(7, 3, ModifyTimetableFlags.WaitTime, 120)
assert len(client._protocol.sent) == 1
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.ChangeTimetable
assert parsed["company"] == 0
vehicle_id, rest = read_varuint(parsed["payload"])
order_position, rest = read_uint16(rest)
flag, rest = read_uint8(rest)
value, rest = read_varuint(rest)
ctrl_flags, _ = read_uint8(rest)
assert (vehicle_id, order_position, flag, value, ctrl_flags) == (7, 3, ModifyTimetableFlags.WaitTime, 120, 0)
@pytest.mark.asyncio
async def test_client_change_timetable_clear_field_sets_ctrl_flag():
client = new_client()
await client.change_timetable(7, 3, ModifyTimetableFlags.TravelTime, 0, clear_field=True)
parsed = decode_sent_command(client._protocol.sent[0])
vehicle_id, rest = read_varuint(parsed["payload"])
order_position, rest = read_uint16(rest)
flag, rest = read_uint8(rest)
value, rest = read_varuint(rest)
ctrl_flags, _ = read_uint8(rest)
assert flag == ModifyTimetableFlags.TravelTime
assert ctrl_flags == ModifyTimetableCtrlFlag.ClearField
@pytest.mark.asyncio
async def test_client_autofill_timetable_sends_expected_payload():
client = new_client()
await client.autofill_timetable(7, autofill=True, preserve_wait_time=False)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.AutofillTimetable
vehicle_id, rest = read_varuint(parsed["payload"])
autofill, rest = read_uint8(rest)
preserve_wait_time, _ = read_uint8(rest)
assert (vehicle_id, autofill, preserve_wait_time) == (7, 1, 0)
@pytest.mark.asyncio
async def test_client_set_timetable_start_sends_expected_payload():
client = new_client()
await client.set_timetable_start(7, True, -12345)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SetTimetableStart
vehicle_id, rest = read_varuint(parsed["payload"])
timetable_all, rest = read_uint8(rest)
start_date, _ = read_varuint_signed(rest)
assert (vehicle_id, timetable_all, start_date) == (7, 1, -12345)
@pytest.mark.asyncio
async def test_client_set_vehicle_on_time_sends_expected_payload():
client = new_client()
await client.set_vehicle_on_time(7, apply_to_group=True)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SetVehicleOnTime
vehicle_id, rest = read_varuint(parsed["payload"])
apply_to_group, _ = read_uint8(rest)
assert (vehicle_id, apply_to_group) == (7, 1)
# --- OpenTTDClient.receive_ServerCommand dispatch ---
async def feed_command(client, cmd, payload):
parsed = OpenTTDProtocol.receive_ServerCommand(None, memoryview(build_server_command_bytes(0, cmd, payload)))
await client.receive_ServerCommand(None, **parsed)
@pytest.mark.asyncio
async def test_receive_change_timetable_updates_order_state():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload += (3).to_bytes(2, "little")
payload.append(ModifyTimetableFlags.WaitTime)
write_varuint(payload, 120)
payload.append(0)
await feed_command(client, GameCommand.ChangeTimetable, payload)
assert client.get_vehicle_timetable(7) == {"orders": {3: {"wait_time": 120}}}
@pytest.mark.asyncio
async def test_receive_change_timetable_clear_field_sets_none():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload += (3).to_bytes(2, "little")
payload.append(ModifyTimetableFlags.TravelTime)
write_varuint(payload, 0)
payload.append(ModifyTimetableCtrlFlag.ClearField)
await feed_command(client, GameCommand.ChangeTimetable, payload)
assert client.get_vehicle_timetable(7)["orders"][3]["travel_time"] is None
@pytest.mark.asyncio
async def test_receive_change_timetable_wait_fixed_stores_bool():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload += (0).to_bytes(2, "little")
payload.append(ModifyTimetableFlags.SetWaitFixed)
write_varuint(payload, 1)
payload.append(0)
await feed_command(client, GameCommand.ChangeTimetable, payload)
assert client.get_vehicle_timetable(7)["orders"][0]["wait_time_fixed"] is True
@pytest.mark.asyncio
async def test_receive_autofill_timetable_updates_state():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload.append(1)
payload.append(0)
await feed_command(client, GameCommand.AutofillTimetable, payload)
entry = client.get_vehicle_timetable(7)
assert entry["autofill"] is True
assert entry["autofill_preserve_wait_time"] is False
@pytest.mark.asyncio
async def test_receive_set_timetable_start_updates_state():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload.append(1)
write_varuint_signed(payload, 555)
await feed_command(client, GameCommand.SetTimetableStart, payload)
entry = client.get_vehicle_timetable(7)
assert entry["timetable_all"] is True
assert entry["timetable_start"] == 555
@pytest.mark.asyncio
async def test_receive_set_vehicle_on_time_updates_state():
client = new_client()
payload = bytearray()
write_varuint(payload, 7)
payload.append(1)
await feed_command(client, GameCommand.SetVehicleOnTime, payload)
assert client.get_vehicle_timetable(7)["on_time_apply_to_group"] is True
@pytest.mark.asyncio
async def test_receive_unknown_command_is_ignored():
client = new_client()
await feed_command(client, 999, bytearray())
assert client.vehicle_timetables == {}
# --- get_vehicle_timetable ---
@pytest.mark.asyncio
async def test_get_vehicle_timetable_known_and_unknown():
client = new_client()
assert client.get_vehicle_timetable(7) is None
payload = bytearray()
write_varuint(payload, 7)
payload.append(1)
await feed_command(client, GameCommand.SetVehicleOnTime, payload)
assert client.get_vehicle_timetable(7) is not None
assert client.get_vehicle_timetable(42) is None