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