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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user