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

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