Add scheduled dispatch support (edit + authoritative view)
Editing (game port, OpenTTDClient): a core of JGRPP's scheduled dispatch DoCommands — set_scheduled_dispatch (enable/disable), add/remove schedule, add/remove/clear slots, and set duration/start date. Adds the command IDs to protocol.py. Viewing (admin, OpenTTDAdminClient.get_dispatch): the GameScript API has no dispatch support, so a new server patch (docker/patches/0002-*) adds read-only GSOrder.GetScheduledDispatch* / IsScheduledDispatchEnabled getters, an AdminBridge GameScript get_dispatch handler exposes them, and get_dispatch() returns the live schedules and slots (mirrors get_timetable). Note: set_dispatch_start_date values are normalised by the engine relative to current game time, so they read back offset from the requested value. Includes unit + e2e tests, a demo in main.py, and protocol/timetable docs. The AdminBridge GameScript and the patched OpenTTD-patches clone live outside this repo; the 0002 patch file is the durable source for the latter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from openttd_protocol.wire.read import read_uint8, read_uint16
|
||||
from .protocol import (
|
||||
PacketGameType, OpenTTDProtocol, PacketAdminType, OpenTTDAdminProtocol, NetworkAuthenticationMethod,
|
||||
GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag,
|
||||
OrderType, OrderStopLocation, INVALID_VEH_ORDER_ID,
|
||||
write_varuint, read_varuint, write_varuint_signed, read_varuint_signed
|
||||
)
|
||||
from .decorators import exclude_call_check
|
||||
@@ -130,6 +131,124 @@ class OpenTTDClient:
|
||||
"""
|
||||
return self.vehicle_timetables.get(vehicle_id)
|
||||
|
||||
async def add_order(self, vehicle_id, station_id, before_position=None, non_stop=0,
|
||||
stop_location=OrderStopLocation.PlatformFarEnd, order_flags=0):
|
||||
"""Insert a 'go to station' order into a vehicle's order list.
|
||||
|
||||
By default the new order is appended to the end of the list; pass before_position to insert it
|
||||
before an existing order at that index instead. non_stop is an OrderNonStopFlags value
|
||||
(0 = stop everywhere) and stop_location an OrderStopLocation value, both packed into the
|
||||
order's type byte; stop_location defaults to PlatformFarEnd because the near-end/middle/through
|
||||
values are train-only and the server rejects them for other vehicle types. order_flags is the
|
||||
16-bit load/unload flag word (0 = the game's defaults: load if possible, unload if possible).
|
||||
|
||||
Sent over the game port as a real DoCommand: it only succeeds when this client is joined to
|
||||
the company that owns the vehicle (see join_company()); a spectator is rejected and kicked.
|
||||
"""
|
||||
order_type = OrderType.GotoStation | ((stop_location & 0x3) << 4) | ((non_stop & 0x3) << 6)
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint16(payload, INVALID_VEH_ORDER_ID if before_position is None else before_position)
|
||||
write_uint8(payload, order_type)
|
||||
write_uint16(payload, order_flags)
|
||||
write_uint16(payload, station_id)
|
||||
await self._send_command(GameCommand.InsertOrder, payload)
|
||||
|
||||
async def remove_order(self, vehicle_id, order_position):
|
||||
"""Delete the order at order_position from a vehicle's order list.
|
||||
|
||||
Sent over the game port as a real DoCommand: like add_order(), it only succeeds when this
|
||||
client is joined to the company that owns the vehicle.
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint16(payload, order_position)
|
||||
await self._send_command(GameCommand.DeleteOrder, payload)
|
||||
|
||||
# --- Scheduled dispatch (JGRPP) ---
|
||||
#
|
||||
# A vehicle's order list can hold several dispatch schedules, each with a duration, a start
|
||||
# tick and a set of departure slots (offsets within the duration). All of these are edited over
|
||||
# the game port and require being joined to the owning company. For an authoritative read of the
|
||||
# resulting schedules, use OpenTTDAdminClient.get_dispatch().
|
||||
|
||||
async def set_scheduled_dispatch(self, vehicle_id, enabled):
|
||||
"""Enable or disable scheduled dispatch for a vehicle (and every vehicle sharing its orders)."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_uint8(payload, 1 if enabled else 0)
|
||||
await self._send_command(GameCommand.SchDispatch, payload)
|
||||
|
||||
async def add_dispatch_schedule(self, vehicle_id, start_tick, duration):
|
||||
"""Create a new dispatch schedule with the given start tick and duration (in ticks).
|
||||
|
||||
The schedule is appended to the vehicle's schedule set; its index is the previous schedule
|
||||
count (read it back with OpenTTDAdminClient.get_dispatch()). duration must be non-zero.
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint_signed(payload, start_tick)
|
||||
write_varuint(payload, duration)
|
||||
await self._send_command(GameCommand.SchDispatchAddNewSchedule, payload)
|
||||
|
||||
async def remove_dispatch_schedule(self, vehicle_id, schedule_index):
|
||||
"""Remove the dispatch schedule at schedule_index from a vehicle's schedule set."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
await self._send_command(GameCommand.SchDispatchRemoveSchedule, payload)
|
||||
|
||||
async def add_dispatch_slot(self, vehicle_id, schedule_index, offset, interval=0, extra_slots=0,
|
||||
slot_flags=0, route_id=0):
|
||||
"""Add one or more departure slots to a dispatch schedule.
|
||||
|
||||
offset is the slot's departure time as an offset (in ticks) within the schedule's duration.
|
||||
To add several evenly spaced slots in one command, pass extra_slots > 0 together with a
|
||||
non-zero interval: each extra slot is placed interval ticks after the previous one (wrapping
|
||||
around the duration). slot_flags is the 16-bit slot flag word and route_id an optional
|
||||
departure route id (both default to 0).
|
||||
"""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint(payload, offset)
|
||||
write_varuint(payload, interval)
|
||||
write_varuint(payload, extra_slots)
|
||||
write_uint16(payload, slot_flags)
|
||||
write_uint8(payload, route_id)
|
||||
await self._send_command(GameCommand.SchDispatchAdd, payload)
|
||||
|
||||
async def remove_dispatch_slot(self, vehicle_id, schedule_index, offset):
|
||||
"""Remove the departure slot at the given offset from a dispatch schedule."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint(payload, offset)
|
||||
await self._send_command(GameCommand.SchDispatchRemove, payload)
|
||||
|
||||
async def clear_dispatch_schedule(self, vehicle_id, schedule_index):
|
||||
"""Remove every departure slot from a dispatch schedule (leaving the schedule itself)."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
await self._send_command(GameCommand.SchDispatchClear, payload)
|
||||
|
||||
async def set_dispatch_duration(self, vehicle_id, schedule_index, duration):
|
||||
"""Set the total duration (in ticks) of a dispatch schedule."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint(payload, duration)
|
||||
await self._send_command(GameCommand.SchDispatchSetDuration, payload)
|
||||
|
||||
async def set_dispatch_start_date(self, vehicle_id, schedule_index, start_tick):
|
||||
"""Set the start tick of a dispatch schedule."""
|
||||
payload = bytearray()
|
||||
write_varuint(payload, vehicle_id)
|
||||
write_varuint(payload, schedule_index)
|
||||
write_varuint_signed(payload, start_tick)
|
||||
await self._send_command(GameCommand.SchDispatchSetStartDate, payload)
|
||||
|
||||
def disconnect(self, source):
|
||||
"""Library callback for when connection is lost."""
|
||||
self.log.info("Disconnected.")
|
||||
@@ -578,6 +697,30 @@ class OpenTTDAdminClient:
|
||||
payload["via_station"] = via_station
|
||||
return await self._gs_query(payload, timeout, f"get_station_cargo({station_id}, {cargo_id})")
|
||||
|
||||
async def get_dispatch(self, vehicle_id, timeout=5.0):
|
||||
"""Fetch an authoritative snapshot of a vehicle's scheduled dispatch state via the AdminBridge GS.
|
||||
|
||||
Scheduled dispatch (a JGRPP feature) lets a vehicle depart on a fixed schedule of slots rather
|
||||
than purely by timetable. This reads the live state (like get_timetable() does), so it works for
|
||||
schedules created before this client connected and reflects the real values. Auto-subscribes to
|
||||
Gamescript updates on first use; if you manage update frequencies yourself, ensure
|
||||
update_frequency(Gamescript, Automatic) is active before calling.
|
||||
|
||||
Returns a dict with:
|
||||
- "enabled": 1 if scheduled dispatch is turned on for the vehicle, else 0
|
||||
- "schedules": a list of per-schedule dicts, each with "index", "duration" (ticks),
|
||||
"start_tick", "delay" (max allowed delay), "reuse_slots" (0/1), and "slots" — a list of
|
||||
{"offset", "flags"} departure slots (offset is ticks within the schedule duration).
|
||||
|
||||
These are the same schedules and slots edited by the game-port methods on OpenTTDClient
|
||||
(add_dispatch_schedule/add_dispatch_slot/...). Raises asyncio.TimeoutError if no reply arrives
|
||||
(e.g. game paused, GS not loaded), ValueError on a GameScript-reported error (invalid_vehicle,
|
||||
response_too_large), and ConnectionError if the admin connection drops while waiting.
|
||||
"""
|
||||
return await self._gs_query(
|
||||
{"command": "get_dispatch", "vehicle_id": vehicle_id}, timeout,
|
||||
f"get_dispatch({vehicle_id})")
|
||||
|
||||
async def send_gamescript(self, json_data):
|
||||
"""Send a JSON string to the GameScript."""
|
||||
import json
|
||||
|
||||
Reference in New Issue
Block a user