Add game event support to the admin client
Everything on the admin GameScript channel so far has been request/reply. This adds the other direction: subscribe_events() opens a push stream so a bot can react to the game instead of polling it, consumed either by awaiting wait_for_event() or via an on_event callback. Both see every event; an event goes to at most one waiter, and unclaimed ones sit in a bounded buffer. Sixteen kinds, from two sources. The engine raises no GameScript event for a vehicle reaching a stop or cargo arriving, so vehicle_arrive, vehicle_depart and cargo_waiting are synthesised by the bridge sampling state every `interval` ticks and diffing against the previous sample -- which means a stop shorter than the interval is never reported, and the first sample only establishes a baseline. The rest (crashes, industries, towns, companies, subsidies) are engine events forwarded verbatim. vehicle_lost, vehicle_waiting_in_depot and vehicle_unprofitable are deliberately absent: the engine raises those only for AI companies, so a GameScript can never observe them. The server-side half lives in the AdminBridge GameScript, which is not in this repo -- docker/config is gitignored -- so it has to be updated separately for any of this to work. Also repoints the scheduled-dispatch E2E test at a dedicated vehicle (DISPATCH_VEHICLE_ID). It had been silently skipping because vehicle 7 carries a hand-built annual dispatch schedule, which left eight dispatch methods unverified end to end while check_public_calls.py reported them green off static analysis of the call sites. Co-Authored-By: Claude <[email protected]>
This commit is contained in:
+96
-3
@@ -13,6 +13,7 @@ from openttd import OpenTTDAdminClient, OpenTTDClient
|
||||
from openttd.protocol import (
|
||||
AdminUpdateFrequency,
|
||||
AdminUpdateType,
|
||||
GameEventType,
|
||||
ModifyTimetableFlags,
|
||||
OpenTTDAdminProtocol,
|
||||
OpenTTDProtocol,
|
||||
@@ -27,6 +28,12 @@ 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
|
||||
# A second vehicle of the same company, dedicated to the scheduled-dispatch test, which owns and
|
||||
# overwrites this vehicle's dispatch state. It must have its own order list -- dispatch schedules
|
||||
# live on the order list, so pointing this at a vehicle that *shares* orders with another would
|
||||
# silently rewrite that other vehicle's schedules too. (Cloning a vehicle without sharing orders
|
||||
# gives an independent list, but copies the source's schedules along with it.)
|
||||
DISPATCH_VEHICLE_ID = 14
|
||||
|
||||
|
||||
# --- Pytest Fixtures ---
|
||||
@@ -303,17 +310,17 @@ async def test_e2e_client_scheduled_dispatch_edit_and_view(connected_owner_clien
|
||||
# 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
|
||||
veh = DISPATCH_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)
|
||||
|
||||
# The assertions below address schedules by absolute index, so DISPATCH_VEHICLE_ID must start
|
||||
# with none of its own; the test restores that state on the way out.
|
||||
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)
|
||||
@@ -716,6 +723,92 @@ async def test_e2e_admin_get_station_cargo_invalid_cargo(connected_admin):
|
||||
await connected_admin.get_station_cargo(stations[0]["id"], 250, timeout=10.0)
|
||||
|
||||
|
||||
# --- Game Events ---
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_subscribe_events_all_kinds(connected_admin):
|
||||
# Public functions: subscribe_events(), unsubscribe_events()
|
||||
# Input 1: no arguments -> every event kind, default interval
|
||||
data = await connected_admin.subscribe_events(timeout=10.0)
|
||||
assert isinstance(data["events"], list)
|
||||
assert "vehicle_arrive" in data["events"] and "cargo_waiting" in data["events"]
|
||||
assert data["interval"] == 10
|
||||
|
||||
stopped = await connected_admin.unsubscribe_events()
|
||||
assert stopped["events"] == []
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_subscribe_events_filtered(connected_admin):
|
||||
# Public functions: subscribe_events(), unsubscribe_events()
|
||||
# Input 2: a narrowed subscription -> only the requested kinds come back
|
||||
data = await connected_admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.CargoWaiting],
|
||||
interval=5, company_id=0, min_cargo_delta=2, include_cargo=False, timeout=10.0)
|
||||
assert sorted(data["events"]) == ["cargo_waiting", "vehicle_arrive"]
|
||||
assert data["interval"] == 5
|
||||
|
||||
await connected_admin.unsubscribe_events(timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_subscribe_events_unknown_kind(connected_admin):
|
||||
# Public function: subscribe_events()
|
||||
# A kind the GameScript does not know -> it reports unknown_event
|
||||
with pytest.raises(ValueError, match="unknown_event"):
|
||||
await connected_admin.subscribe_events(events=["definitely_not_an_event"], timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_wait_for_event_vehicle_reaches_a_stop(connected_admin):
|
||||
# Public function: wait_for_event()
|
||||
# Input 1: filtered by kind. Needs traffic on the server, so a quiet map skips.
|
||||
await connected_admin.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
|
||||
interval=2, timeout=10.0)
|
||||
try:
|
||||
event = await connected_admin.wait_for_event(
|
||||
{GameEventType.VehicleArrive, GameEventType.VehicleDepart}, timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.skip("No vehicle reached or left a stop on the test server within the timeout.")
|
||||
finally:
|
||||
await connected_admin.unsubscribe_events()
|
||||
|
||||
assert event["event"] in ("vehicle_arrive", "vehicle_depart")
|
||||
for key in ("tick", "vehicle_id", "station_id", "owner", "vehicle_type", "order_position"):
|
||||
assert key in event
|
||||
if event["event"] == "vehicle_depart":
|
||||
assert event["dwell"] >= 0
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_wait_for_event_any_kind(connected_admin):
|
||||
# Public function: wait_for_event()
|
||||
# Input 2: no kind filter -> whatever the game produces first
|
||||
await connected_admin.subscribe_events(interval=2, timeout=10.0)
|
||||
try:
|
||||
event = await connected_admin.wait_for_event(timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.skip("Nothing happened on the test server within the timeout.")
|
||||
finally:
|
||||
await connected_admin.unsubscribe_events()
|
||||
|
||||
assert "event" in event and "tick" in event
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_unsubscribe_events_stops_the_stream(connected_admin):
|
||||
# Public function: unsubscribe_events()
|
||||
# After unsubscribing the bridge must go quiet, so a fresh wait times out.
|
||||
await connected_admin.subscribe_events(interval=2, timeout=10.0)
|
||||
await connected_admin.unsubscribe_events(timeout=10.0)
|
||||
|
||||
connected_admin._event_buffer.clear() # drop anything delivered before we unsubscribed
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await connected_admin.wait_for_event(timeout=5.0)
|
||||
|
||||
|
||||
# --- Protocol Public Functions ---
|
||||
|
||||
@pytest.mark.e2e
|
||||
|
||||
Reference in New Issue
Block a user