Add scheduled dispatch support (edit + authoritative view)
All checks were successful
Continuous Integration / lint-and-security (pull_request) Successful in 22s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s

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:
2026-07-24 22:56:37 +02:00
parent 81a4d9333d
commit 2eea541158
11 changed files with 822 additions and 6 deletions

View File

@@ -383,6 +383,47 @@ async def test_admin_get_station_cargo_error_response():
await task
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_dispatch_request_and_response():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
proto = MockProtocol()
client._protocol = proto
client._transport = MockTransport()
task = asyncio.ensure_future(client.get_dispatch(7))
await asyncio.sleep(0) # let the task send the request
# First use auto-subscribes to Gamescript updates, then sends the query.
assert len(proto.sent) == 2
assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency
assert decode_gamescript_payload(proto.sent[1]) == {
"command": "get_dispatch", "vehicle_id": 7, "request_id": 1,
}
response = {"command": "get_dispatch", "vehicle_id": 7, "request_id": 1,
"enabled": 1,
"schedules": [{"index": 0, "duration": 3000, "start_tick": 0, "delay": 0,
"reuse_slots": 0,
"slots": [{"offset": 500, "flags": 0}, {"offset": 1500, "flags": 0}]}]}
await client.receive_ServerGamescript(None, data=response)
assert await task == response
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_get_dispatch_error_response():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
client._protocol = MockProtocol()
client._transport = MockTransport()
task = asyncio.ensure_future(client.get_dispatch(65535))
await asyncio.sleep(0)
await client.receive_ServerGamescript(
None, data={"command": "get_dispatch", "vehicle_id": 65535,
"request_id": 1, "error": "invalid_vehicle"})
with pytest.raises(ValueError, match="invalid_vehicle"):
await task
assert client._gs_futures == {}
@pytest.mark.asyncio
async def test_admin_gamescript_passthrough_unmatched():
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")

View File

@@ -23,6 +23,8 @@ from openttd.protocol import (
TIMETABLE_COMPANY_ID = 0
TIMETABLE_VEHICLE_ID = 7
TIMETABLE_ORDER_POSITION = 0
# A station TIMETABLE_VEHICLE_ID can legally serve, used for add_order/remove_order tests.
ORDER_STATION_ID = 6
# --- Pytest Fixtures ---
@@ -257,6 +259,118 @@ async def test_e2e_client_get_vehicle_timetable_unknown_vehicle(connected_owner_
# Input 2: a vehicle id with no observed state
assert connected_owner_client.get_vehicle_timetable(999999) is None
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_add_and_remove_order(connected_owner_client, connected_admin):
# Public functions: add_order(), remove_order()
# Verified authoritatively via the admin get_timetable() order count. The test appends and
# inserts an order, then removes both, leaving the vehicle's order list as it started.
async def order_count():
data = await connected_admin.get_timetable(TIMETABLE_VEHICLE_ID, timeout=10.0)
return len(data["orders"])
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
before = await order_count()
# add_order input 1: append a goto-station order to the end of the list.
await connected_owner_client.add_order(TIMETABLE_VEHICLE_ID, ORDER_STATION_ID)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
assert await order_count() == before + 1
# add_order input 2: insert another before position 0.
await connected_owner_client.add_order(TIMETABLE_VEHICLE_ID, ORDER_STATION_ID, before_position=0)
await asyncio.sleep(1.0)
assert await order_count() == before + 2
# remove_order input 1: delete the one just inserted at the front.
await connected_owner_client.remove_order(TIMETABLE_VEHICLE_ID, 0)
await asyncio.sleep(1.0)
assert await order_count() == before + 1
# remove_order input 2: delete the appended order (now the last one) to restore the list.
await connected_owner_client.remove_order(TIMETABLE_VEHICLE_ID, before)
await asyncio.sleep(1.0)
assert not connected_owner_client.shutdown_event.is_set()
assert await order_count() == before
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_client_scheduled_dispatch_edit_and_view(connected_owner_client, connected_admin):
# Public functions: set_scheduled_dispatch(), add_dispatch_schedule(), remove_dispatch_schedule(),
# add_dispatch_slot(), remove_dispatch_slot(), clear_dispatch_schedule(), set_dispatch_duration(),
# set_dispatch_start_date(), and get_dispatch(). Edits go over the game port and are read back
# authoritatively via the admin get_dispatch(). The test leaves the vehicle with no schedules.
veh = TIMETABLE_VEHICLE_ID
owner = connected_owner_client
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
async def dispatch():
return await connected_admin.get_dispatch(veh, timeout=10.0)
start = await dispatch() # get_dispatch input 1: a valid vehicle
assert "schedules" in start and isinstance(start["schedules"], list)
if start["schedules"]:
pytest.skip("Test vehicle already has dispatch schedules; expected a clean vehicle.")
# add_dispatch_schedule: two schedules (indices 0 and 1) with different start ticks/durations.
await owner.add_dispatch_schedule(veh, 0, 3000)
await asyncio.sleep(0.5)
await owner.add_dispatch_schedule(veh, 1000, 2000)
await asyncio.sleep(0.5)
assert not owner.shutdown_event.is_set()
data = await dispatch()
assert len(data["schedules"]) == 2
assert data["schedules"][0]["duration"] == 3000
assert data["schedules"][1]["duration"] == 2000
# set_dispatch_duration / set_dispatch_start_date: two inputs each (schedule 0 and 1).
await owner.set_dispatch_duration(veh, 0, 4000)
await owner.set_dispatch_duration(veh, 1, 2500)
await owner.set_dispatch_start_date(veh, 0, 1_000_000)
await owner.set_dispatch_start_date(veh, 1, 2_000_000)
await asyncio.sleep(0.5)
# add_dispatch_slot: two departure slots in schedule 0.
await owner.add_dispatch_slot(veh, 0, 500)
await owner.add_dispatch_slot(veh, 0, 1500)
await asyncio.sleep(0.5)
data = await dispatch()
sched0 = data["schedules"][0]
assert sched0["duration"] == 4000
# The engine normalises the start tick relative to current game time (advancing it by whole
# durations to sit near "now"), so it won't equal the requested value verbatim; just confirm
# a start date was accepted and is reported as an integer.
assert isinstance(sched0["start_tick"], int)
assert {s["offset"] for s in sched0["slots"]} == {500, 1500}
# remove_dispatch_slot: two inputs (both slots of schedule 0).
await owner.remove_dispatch_slot(veh, 0, 1500)
await owner.remove_dispatch_slot(veh, 0, 500)
await asyncio.sleep(0.5)
assert (await dispatch())["schedules"][0]["slots"] == []
# set_scheduled_dispatch: enable then disable (two inputs), reading the flag back in between.
await owner.set_scheduled_dispatch(veh, True)
await asyncio.sleep(0.5)
assert (await dispatch())["enabled"] == 1
await owner.set_scheduled_dispatch(veh, False)
await asyncio.sleep(0.5)
assert (await dispatch())["enabled"] == 0
# clear_dispatch_schedule: two inputs (schedule 0 and 1).
await owner.clear_dispatch_schedule(veh, 0)
await owner.clear_dispatch_schedule(veh, 1)
await asyncio.sleep(0.5)
# remove_dispatch_schedule: remove both (higher index first) to restore the vehicle.
await owner.remove_dispatch_schedule(veh, 1)
await asyncio.sleep(0.5)
await owner.remove_dispatch_schedule(veh, 0)
await asyncio.sleep(0.5)
assert not owner.shutdown_event.is_set()
assert (await dispatch())["schedules"] == [] # get_dispatch input 1 (restored state)
# --- Admin Client Public Functions ---
@@ -464,6 +578,14 @@ async def test_e2e_admin_get_timetable_invalid_vehicle(connected_admin):
with pytest.raises(ValueError, match="invalid_vehicle"):
await connected_admin.get_timetable(65535, timeout=10.0)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_get_dispatch_invalid_vehicle(connected_admin):
# Public function: get_dispatch()
# Input 2: an id no vehicle can have -> GameScript reports invalid_vehicle
with pytest.raises(ValueError, match="invalid_vehicle"):
await connected_admin.get_dispatch(65535, timeout=10.0)
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e_admin_list_stations_all_companies(connected_admin):

View File

@@ -2,6 +2,7 @@ import pytest
from openttd import OpenTTDClient
from openttd.protocol import (
OpenTTDProtocol, GameCommand, ModifyTimetableFlags, ModifyTimetableCtrlFlag,
OrderType, OrderNonStopFlags, OrderStopLocation, INVALID_VEH_ORDER_ID,
write_varuint, read_varuint, write_varuint_signed, read_varuint_signed
)
from openttd_protocol.wire.read import read_uint8, read_uint16
@@ -180,6 +181,155 @@ async def test_client_set_vehicle_on_time_sends_expected_payload():
assert (vehicle_id, apply_to_group) == (7, 1)
# --- OpenTTDClient order add/remove commands ---
def _decode_insert_order_payload(payload):
vehicle_id, rest = read_varuint(payload)
sel_ord, rest = read_uint16(rest)
order_type, rest = read_uint8(rest)
order_flags, rest = read_uint16(rest)
station, _ = read_uint16(rest)
return vehicle_id, sel_ord, order_type, order_flags, station
@pytest.mark.asyncio
async def test_client_add_order_appends_by_default():
client = new_client()
await client.add_order(7, 6)
assert len(client._protocol.sent) == 1
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.InsertOrder
assert parsed["company"] == 0
vehicle_id, sel_ord, order_type, order_flags, station = _decode_insert_order_payload(parsed["payload"])
assert vehicle_id == 7
assert sel_ord == INVALID_VEH_ORDER_ID # append to the end
# OT_GOTO_STATION in bits 0-3, far-end stop location in bits 4-5, stop-everywhere non-stop in 6-7
assert order_type == (OrderType.GotoStation | (OrderStopLocation.PlatformFarEnd << 4))
assert order_flags == 0
assert station == 6
@pytest.mark.asyncio
async def test_client_add_order_insert_position_and_nonstop():
client = new_client()
await client.add_order(7, 6, before_position=1, non_stop=OrderNonStopFlags.NoStopAtIntermediate)
parsed = decode_sent_command(client._protocol.sent[0])
vehicle_id, sel_ord, order_type, order_flags, station = _decode_insert_order_payload(parsed["payload"])
assert sel_ord == 1 # insert before order position 1
assert order_type == (OrderType.GotoStation
| (OrderStopLocation.PlatformFarEnd << 4)
| (OrderNonStopFlags.NoStopAtIntermediate << 6))
@pytest.mark.asyncio
async def test_client_remove_order_sends_expected_payload():
client = new_client()
await client.remove_order(7, 2)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.DeleteOrder
vehicle_id, rest = read_varuint(parsed["payload"])
order_position, _ = read_uint16(rest)
assert (vehicle_id, order_position) == (7, 2)
# --- OpenTTDClient scheduled dispatch edit commands ---
@pytest.mark.asyncio
async def test_client_set_scheduled_dispatch_payload():
client = new_client()
await client.set_scheduled_dispatch(7, True)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatch
vehicle_id, rest = read_varuint(parsed["payload"])
enabled, _ = read_uint8(rest)
assert (vehicle_id, enabled) == (7, 1)
@pytest.mark.asyncio
async def test_client_add_dispatch_schedule_payload():
client = new_client()
await client.add_dispatch_schedule(7, -1234, 3000)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchAddNewSchedule
vehicle_id, rest = read_varuint(parsed["payload"])
start_tick, rest = read_varuint_signed(rest)
duration, _ = read_varuint(rest)
assert (vehicle_id, start_tick, duration) == (7, -1234, 3000)
@pytest.mark.asyncio
async def test_client_remove_dispatch_schedule_payload():
client = new_client()
await client.remove_dispatch_schedule(7, 2)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchRemoveSchedule
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, _ = read_varuint(rest)
assert (vehicle_id, schedule_index) == (7, 2)
@pytest.mark.asyncio
async def test_client_add_dispatch_slot_payload_defaults_and_extras():
client = new_client()
# Defaults: single slot, no interval/extra/flags/route.
await client.add_dispatch_slot(7, 1, 500)
# Bulk: three extra slots spaced 250 ticks apart, with flags and route id.
await client.add_dispatch_slot(7, 1, 500, interval=250, extra_slots=3, slot_flags=5, route_id=2)
def decode(payload):
vehicle_id, rest = read_varuint(payload)
schedule_index, rest = read_varuint(rest)
offset, rest = read_varuint(rest)
interval, rest = read_varuint(rest)
extra_slots, rest = read_varuint(rest)
slot_flags, rest = read_uint16(rest)
route_id, _ = read_uint8(rest)
return (vehicle_id, schedule_index, offset, interval, extra_slots, slot_flags, route_id)
p0 = decode_sent_command(client._protocol.sent[0])
p1 = decode_sent_command(client._protocol.sent[1])
assert p0["cmd"] == GameCommand.SchDispatchAdd
assert decode(p0["payload"]) == (7, 1, 500, 0, 0, 0, 0)
assert decode(p1["payload"]) == (7, 1, 500, 250, 3, 5, 2)
@pytest.mark.asyncio
async def test_client_remove_dispatch_slot_payload():
client = new_client()
await client.remove_dispatch_slot(7, 1, 500)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchRemove
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, rest = read_varuint(rest)
offset, _ = read_varuint(rest)
assert (vehicle_id, schedule_index, offset) == (7, 1, 500)
@pytest.mark.asyncio
async def test_client_clear_dispatch_schedule_payload():
client = new_client()
await client.clear_dispatch_schedule(7, 1)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchClear
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, _ = read_varuint(rest)
assert (vehicle_id, schedule_index) == (7, 1)
@pytest.mark.asyncio
async def test_client_set_dispatch_duration_payload():
client = new_client()
await client.set_dispatch_duration(7, 1, 4000)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchSetDuration
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, rest = read_varuint(rest)
duration, _ = read_varuint(rest)
assert (vehicle_id, schedule_index, duration) == (7, 1, 4000)
@pytest.mark.asyncio
async def test_client_set_dispatch_start_date_payload():
client = new_client()
await client.set_dispatch_start_date(7, 1, 1_000_000)
parsed = decode_sent_command(client._protocol.sent[0])
assert parsed["cmd"] == GameCommand.SchDispatchSetStartDate
vehicle_id, rest = read_varuint(parsed["payload"])
schedule_index, rest = read_varuint(rest)
start_tick, _ = read_varuint_signed(rest)
assert (vehicle_id, schedule_index, start_tick) == (7, 1, 1_000_000)
# --- OpenTTDClient.receive_ServerCommand dispatch ---
async def feed_command(client, cmd, payload):