Add station listing and cargo queries to admin client
Extends the AdminBridge GameScript JSON channel (the same relay used by list_vehicles/get_timetable) with station support: - list_stations(): enumerate stations, fire-and-forget like list_vehicles(). - get_station(): authoritative per-cargo snapshot of a station's live state, with both the real-time waiting amount (GSStation.GetCargoWaiting) and the planned cargodist link-graph flow (GetCargoPlanned), plus rating. - get_station_cargo(): break one cargo type down by source station and by next hop (the cargodist routing destination) for both waiting and planned amounts, with optional from_station/via_station filters. All three use stock GameScript API (no server patch, unlike timetables). Refactors the shared GS request/reply correlation out of get_timetable and get_station into a _gs_query() helper. Companion handlers must be added to the server-side AdminBridge GameScript (not tracked in this repo). Includes unit + e2e tests, a worked demo in main_admin.py, and protocol docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,20 @@ async def test_admin_list_vehicles():
|
||||
assert decode_gamescript_payload(proto.sent[0]) == {"command": "list_vehicles"}
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {"command": "list_vehicles", "company_id": 2}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_list_stations():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
await client.list_stations()
|
||||
await client.list_stations(company_id=2)
|
||||
|
||||
assert len(proto.sent) == 2
|
||||
assert decode_gamescript_payload(proto.sent[0]) == {"command": "list_stations"}
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {"command": "list_stations", "company_id": 2}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_client_connect_and_actions(monkeypatch):
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
@@ -258,6 +272,117 @@ async def test_admin_get_timetable_disconnect_fails_pending():
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_request_and_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_station(3))
|
||||
await asyncio.sleep(0) # let the task send the request
|
||||
|
||||
# First use auto-subscribes to Gamescript updates, then sends the query.
|
||||
assert len(proto.sent) == 2
|
||||
assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {
|
||||
"command": "get_station", "station_id": 3, "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_station", "station_id": 3, "request_id": 1,
|
||||
"name": "Test Central", "location": 12345, "owner": 0,
|
||||
"cargo": [{"cargo_id": 0, "waiting": 42, "planned": 17, "rating": 71}]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_error_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_station(65535))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_station", "station_id": 65535,
|
||||
"request_id": 1, "error": "invalid_station"})
|
||||
with pytest.raises(ValueError, match="invalid_station"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_timeout():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await client.get_station(3, timeout=0.05)
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_cargo_request_and_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_station_cargo(3, 0))
|
||||
await asyncio.sleep(0) # let the task send the request
|
||||
|
||||
# First use auto-subscribes to Gamescript updates, then sends the query.
|
||||
assert len(proto.sent) == 2
|
||||
assert proto.sent[0][2] == PacketAdminType.AdminUpdateFrequency
|
||||
assert decode_gamescript_payload(proto.sent[1]) == {
|
||||
"command": "get_station_cargo", "station_id": 3, "cargo_id": 0, "request_id": 1,
|
||||
}
|
||||
|
||||
response = {"command": "get_station_cargo", "station_id": 3, "cargo_id": 0, "request_id": 1,
|
||||
"waiting": 60, "planned": 40,
|
||||
"waiting_by_from": [{"station": 5, "amount": 25}, {"station": 6, "amount": 35}],
|
||||
"planned_by_from": [{"station": 5, "amount": 40}],
|
||||
"waiting_by_via": [{"station": 7, "amount": 60}],
|
||||
"planned_by_via": [{"station": 7, "amount": 40}]}
|
||||
await client.receive_ServerGamescript(None, data=response)
|
||||
assert await task == response
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_cargo_with_filters_encoding():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
proto = MockProtocol()
|
||||
client._protocol = proto
|
||||
client._transport = MockTransport()
|
||||
client._gs_subscribed = True # skip the auto-subscribe so only the query is sent
|
||||
|
||||
task = asyncio.ensure_future(client.get_station_cargo(3, 0, from_station=6, via_station=7))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(proto.sent) == 1
|
||||
assert decode_gamescript_payload(proto.sent[0]) == {
|
||||
"command": "get_station_cargo", "station_id": 3, "cargo_id": 0,
|
||||
"from_station": 6, "via_station": 7, "request_id": 1,
|
||||
}
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"request_id": 1, "waiting": 12, "planned": 8})
|
||||
assert (await task)["waiting"] == 12
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_get_station_cargo_error_response():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
client._protocol = MockProtocol()
|
||||
client._transport = MockTransport()
|
||||
|
||||
task = asyncio.ensure_future(client.get_station_cargo(3, 999))
|
||||
await asyncio.sleep(0)
|
||||
await client.receive_ServerGamescript(
|
||||
None, data={"command": "get_station_cargo", "station_id": 3, "cargo_id": 999,
|
||||
"request_id": 1, "error": "invalid_cargo"})
|
||||
with pytest.raises(ValueError, match="invalid_cargo"):
|
||||
await task
|
||||
assert client._gs_futures == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_gamescript_passthrough_unmatched():
|
||||
client = OpenTTDAdminClient("127.0.0.1", port=3977, admin_name="TestAdmin")
|
||||
|
||||
@@ -464,6 +464,133 @@ 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_list_stations_all_companies(connected_admin):
|
||||
# Public function: list_stations()
|
||||
# Input 1: all companies (no company_id)
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert not connected_admin.shutdown_event.is_set()
|
||||
assert len(responses) >= 1
|
||||
assert "stations" in responses[-1]
|
||||
assert isinstance(responses[-1]["stations"], list)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_list_stations_specific_company(connected_admin):
|
||||
# Public function: list_stations()
|
||||
# Input 2: specific company_id
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
|
||||
await connected_admin.list_stations(company_id=0)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert not connected_admin.shutdown_event.is_set()
|
||||
assert len(responses) >= 1
|
||||
assert "stations" in responses[-1]
|
||||
assert isinstance(responses[-1]["stations"], list)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_valid_station(connected_admin):
|
||||
# Public function: get_station()
|
||||
# Input 1: a real station id discovered via list_stations
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert len(responses) >= 1 and "stations" in responses[-1]
|
||||
stations = responses[-1]["stations"]
|
||||
if not stations:
|
||||
pytest.skip("No stations on the test server to query.")
|
||||
sid = stations[0]["id"]
|
||||
|
||||
data = await connected_admin.get_station(sid, timeout=10.0)
|
||||
assert data["station_id"] == sid
|
||||
assert "cargo" in data
|
||||
assert isinstance(data["cargo"], list)
|
||||
for cargo in data["cargo"]:
|
||||
for key in ("cargo_id", "waiting", "planned", "rating"):
|
||||
assert key in cargo
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_invalid_station(connected_admin):
|
||||
# Public function: get_station()
|
||||
# Input 2: an id no station can have -> GameScript reports invalid_station
|
||||
with pytest.raises(ValueError, match="invalid_station"):
|
||||
await connected_admin.get_station(65535, timeout=10.0)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_cargo_breakdown(connected_admin):
|
||||
# Public function: get_station_cargo()
|
||||
# Input 1: a real station + a cargo it has handled, discovered via list_stations/get_station
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert responses and "stations" in responses[-1]
|
||||
stations = responses[-1]["stations"]
|
||||
if not stations:
|
||||
pytest.skip("No stations on the test server to query.")
|
||||
|
||||
# Find a station/cargo pair that actually has cargo data.
|
||||
target = None
|
||||
for st in stations:
|
||||
detail = await connected_admin.get_station(st["id"], timeout=10.0)
|
||||
if detail["cargo"]:
|
||||
target = (st["id"], detail["cargo"][0]["cargo_id"])
|
||||
break
|
||||
if target is None:
|
||||
pytest.skip("No station with handled cargo to break down.")
|
||||
sid, cid = target
|
||||
|
||||
data = await connected_admin.get_station_cargo(sid, cid, timeout=10.0)
|
||||
assert data["station_id"] == sid and data["cargo_id"] == cid
|
||||
for key in ("waiting", "planned",
|
||||
"waiting_by_from", "planned_by_from", "waiting_by_via", "planned_by_via"):
|
||||
assert key in data
|
||||
for key in ("waiting_by_from", "planned_by_from", "waiting_by_via", "planned_by_via"):
|
||||
assert isinstance(data[key], list)
|
||||
for entry in data[key]:
|
||||
assert "station" in entry and "amount" in entry
|
||||
|
||||
# Input 2: the same query narrowed by a next-hop (via) filter is accepted and echoes it back.
|
||||
filtered = await connected_admin.get_station_cargo(sid, cid, via_station=sid, timeout=10.0)
|
||||
assert filtered["via_station"] == sid
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_admin_get_station_cargo_invalid_cargo(connected_admin):
|
||||
# Public function: get_station_cargo()
|
||||
# A cargo id no cargo can have -> GameScript reports invalid_cargo. Needs a valid station.
|
||||
responses = []
|
||||
connected_admin.on_gamescript = lambda data: responses.append(data)
|
||||
await connected_admin.update_frequency(AdminUpdateType.Gamescript, AdminUpdateFrequency.Automatic)
|
||||
await connected_admin.list_stations()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert responses and "stations" in responses[-1]
|
||||
stations = responses[-1]["stations"]
|
||||
if not stations:
|
||||
pytest.skip("No stations on the test server to query.")
|
||||
|
||||
with pytest.raises(ValueError, match="invalid_cargo"):
|
||||
await connected_admin.get_station_cargo(stations[0]["id"], 250, timeout=10.0)
|
||||
|
||||
|
||||
# --- Protocol Public Functions ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user