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