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

@@ -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):