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
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from openttd import OpenTTDAdminClient
|
||||
from openttd.protocol import GameEventType, PacketAdminType
|
||||
|
||||
|
||||
class MockTransport:
|
||||
def is_closing(self):
|
||||
return False
|
||||
def close(self):
|
||||
pass
|
||||
def write(self, data):
|
||||
return len(data)
|
||||
|
||||
class MockProtocol:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
async def send_packet(self, data):
|
||||
self.sent.append(data)
|
||||
return len(data)
|
||||
|
||||
def decode_gamescript_payload(packet):
|
||||
"""Decode the JSON payload of an AdminGamescript packet (2-byte length + 1-byte type + string)."""
|
||||
return json.loads(packet[3:].split(b"\x00")[0])
|
||||
|
||||
def make_admin(subscribed=True, **kwargs):
|
||||
"""An admin client wired to mock transports, with the Gamescript auto-subscribe already done
|
||||
unless a test wants to observe it."""
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin", **kwargs)
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
client._gs_subscribed = subscribed
|
||||
return client
|
||||
|
||||
def events_packet(*events):
|
||||
"""The envelope the AdminBridge GameScript pushes event batches in."""
|
||||
return {"command": "events", "events": list(events)}
|
||||
|
||||
def arrival(vehicle_id=7, station_id=3, tick=100):
|
||||
return {"event": "vehicle_arrive", "tick": tick, "vehicle_id": vehicle_id,
|
||||
"station_id": station_id, "owner": 0, "vehicle_type": 0, "order_position": 0,
|
||||
"cargo": [{"cargo_id": 0, "load": 12}]}
|
||||
|
||||
|
||||
# --- Event kinds ---
|
||||
|
||||
def test_event_type_values_are_the_wire_strings():
|
||||
assert GameEventType.VehicleArrive == "vehicle_arrive"
|
||||
assert GameEventType.CargoWaiting == "cargo_waiting"
|
||||
# Members are plain strings, so they can be used interchangeably with literals.
|
||||
assert str(GameEventType.VehicleDepart) == "vehicle_depart"
|
||||
|
||||
|
||||
# --- Subscribing ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_events_defaults_send_only_the_command():
|
||||
client = make_admin(subscribed=False)
|
||||
|
||||
task = asyncio.ensure_future(client.subscribe_events())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# First use auto-subscribes to Gamescript updates, then sends the request.
|
||||
assert len(client._protocol.sent) == 2
|
||||
assert client._protocol.sent[0][2] == PacketAdminType.AdminUpdateFrequency
|
||||
assert decode_gamescript_payload(client._protocol.sent[1]) == {
|
||||
"command": "subscribe_events", "request_id": 1,
|
||||
}
|
||||
|
||||
reply = {"command": "subscribe_events", "request_id": 1,
|
||||
"events": ["vehicle_arrive", "vehicle_depart"], "interval": 10}
|
||||
await client.receive_ServerGamescript(None, data=reply)
|
||||
assert await task == reply
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_events_encodes_every_filter():
|
||||
client = make_admin()
|
||||
|
||||
task = asyncio.ensure_future(client.subscribe_events(
|
||||
events=[GameEventType.VehicleArrive, "cargo_waiting"], interval=25, company_id=0,
|
||||
vehicles=(7, 9), stations=(3,), cargo=[0, 1], min_cargo_delta=5, include_cargo=False))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert decode_gamescript_payload(client._protocol.sent[0]) == {
|
||||
"command": "subscribe_events", "request_id": 1,
|
||||
"events": ["vehicle_arrive", "cargo_waiting"], "interval": 25, "company_id": 0,
|
||||
"vehicles": [7, 9], "stations": [3], "cargo": [0, 1],
|
||||
"min_cargo_delta": 5, "include_cargo": False,
|
||||
}
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"request_id": 1, "events": ["vehicle_arrive", "cargo_waiting"]})
|
||||
assert (await task)["events"] == ["vehicle_arrive", "cargo_waiting"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_events_rejected_request_raises():
|
||||
client = make_admin()
|
||||
|
||||
task = asyncio.ensure_future(client.subscribe_events(events=["not_an_event"]))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "subscribe_events", "request_id": 1,
|
||||
"error": "unknown_event", "event": "not_an_event"})
|
||||
with pytest.raises(ValueError, match="unknown_event"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_events_timeout():
|
||||
client = make_admin()
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await client.subscribe_events(timeout=0.05)
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_events():
|
||||
client = make_admin()
|
||||
|
||||
task = asyncio.ensure_future(client.unsubscribe_events())
|
||||
await asyncio.sleep(0)
|
||||
assert decode_gamescript_payload(client._protocol.sent[0]) == {
|
||||
"command": "unsubscribe_events", "request_id": 1,
|
||||
}
|
||||
|
||||
reply = {"command": "unsubscribe_events", "request_id": 1, "events": []}
|
||||
await client.receive_ServerGamescript(None, data=reply)
|
||||
assert await task == reply
|
||||
|
||||
|
||||
# --- Receiving events ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_batch_reaches_on_event_and_bypasses_on_gamescript():
|
||||
client = make_admin()
|
||||
seen, other = [], []
|
||||
client.on_event = seen.append
|
||||
client.on_gamescript = other.append
|
||||
|
||||
depart = {"event": "vehicle_depart", "tick": 120, "vehicle_id": 7,
|
||||
"station_id": 3, "dwell": 74}
|
||||
await client.receive_ServerGamescript(None, data=events_packet(arrival(), depart))
|
||||
|
||||
assert [event["event"] for event in seen] == ["vehicle_arrive", "vehicle_depart"]
|
||||
assert other == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_event_gamescript_payloads_still_reach_on_gamescript():
|
||||
client = make_admin()
|
||||
seen, other = [], []
|
||||
client.on_event = seen.append
|
||||
client.on_gamescript = other.append
|
||||
|
||||
# A reply that merely mentions events, and a malformed batch, are not event batches.
|
||||
await client.receive_ServerGamescript(None, data={"vehicles": []})
|
||||
await client.receive_ServerGamescript(None, data={"command": "events", "events": "nope"})
|
||||
|
||||
assert seen == []
|
||||
assert other == [{"vehicles": []}, {"command": "events", "events": "nope"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_are_buffered_until_awaited():
|
||||
client = make_admin()
|
||||
await client.receive_ServerGamescript(None, data=events_packet(arrival(station_id=3),
|
||||
arrival(station_id=4)))
|
||||
|
||||
# The oldest matching event is handed out first, and only once.
|
||||
first = await client.wait_for_event("vehicle_arrive")
|
||||
second = await client.wait_for_event(GameEventType.VehicleArrive)
|
||||
assert (first["station_id"], second["station_id"]) == (3, 4)
|
||||
assert len(client._event_buffer) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_event_resolves_on_arrival():
|
||||
client = make_admin()
|
||||
task = asyncio.ensure_future(client.wait_for_event("cargo_waiting", timeout=5.0))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
cargo = {"event": "cargo_waiting", "tick": 300, "station_id": 3,
|
||||
"cargo_id": 0, "waiting": 25, "delta": 15}
|
||||
await client.receive_ServerGamescript(None, data=events_packet(arrival(), cargo))
|
||||
|
||||
assert await task == cargo
|
||||
# The event that did not match the waiter is still buffered for the next caller.
|
||||
assert await client.wait_for_event() == arrival()
|
||||
assert client._event_waiters == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_event_accepts_several_kinds():
|
||||
client = make_admin()
|
||||
task = asyncio.ensure_future(
|
||||
client.wait_for_event({GameEventType.VehicleArrive, GameEventType.VehicleDepart}))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await client.receive_ServerGamescript(
|
||||
None, data=events_packet({"event": "town_founded", "tick": 5, "town_id": 2}, arrival()))
|
||||
assert (await task)["event"] == "vehicle_arrive"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_event_ignores_non_dict_events_when_filtering():
|
||||
client = make_admin()
|
||||
task = asyncio.ensure_future(client.wait_for_event("vehicle_arrive", timeout=0.05))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# A malformed entry inside a batch must not satisfy a filtered waiter.
|
||||
await client.receive_ServerGamescript(None, data=events_packet("garbage"))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await task
|
||||
assert list(client._event_buffer) == ["garbage"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_event_timeout_deregisters_the_waiter():
|
||||
client = make_admin()
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await client.wait_for_event("vehicle_arrive", timeout=0.05)
|
||||
assert client._event_waiters == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_resolved_waiters_are_skipped():
|
||||
client = make_admin()
|
||||
# A waiter whose future completed but that has not been cleaned up yet must not swallow
|
||||
# the event; it goes to the next live waiter instead.
|
||||
stale = asyncio.get_running_loop().create_future()
|
||||
stale.set_result("already done")
|
||||
live = asyncio.get_running_loop().create_future()
|
||||
client._event_waiters = [(None, stale), (None, live)]
|
||||
|
||||
await client.receive_ServerGamescript(None, data=events_packet(arrival()))
|
||||
assert live.result() == arrival()
|
||||
assert len(client._event_buffer) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_buffer_is_bounded():
|
||||
client = make_admin(event_buffer_size=2)
|
||||
await client.receive_ServerGamescript(None, data=events_packet(
|
||||
arrival(station_id=1), arrival(station_id=2), arrival(station_id=3)))
|
||||
|
||||
# The oldest event is dropped rather than growing the buffer without bound.
|
||||
assert [event["station_id"] for event in client._event_buffer] == [2, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_fails_pending_event_waiters():
|
||||
client = make_admin()
|
||||
task = asyncio.ensure_future(client.wait_for_event())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
client.disconnect(None)
|
||||
with pytest.raises(ConnectionError):
|
||||
await task
|
||||
assert client._event_waiters == []
|
||||
Reference in New Issue
Block a user