Put the AdminBridge GameScript under version control
The last commit noted in passing that the server-side half of the admin
GameScript channel "is not in this repo -- docker/config is gitignored --
so it has to be updated separately for any of this to work." That was true
of all nine features the README documents: list_vehicles, list_stations,
list_cargo, get_timetable, get_station, get_station_cargo, get_dispatch and
the event stream all answer from 705 lines of Squirrel that no clone could
reproduce, no reviewer could see, and CI never touched.
The bridge now lives in gamescript/AdminBridge/ with its own README, the
same arrangement docker/patches/ uses for the local JGRPP patches, and
docker-compose.yml bind-mounts it read-only over the container's
game/AdminBridge. docker/config stays ignored -- it also holds savegames,
downloaded content and generated config -- so the copy under it is now
shadowed and can be deleted. main.nut is byte-identical to what was running,
apart from the version work below.
Adds a version handshake, because the channel gives no way to tell a stale
bridge from a hung one: a bridge that does not recognise a command drops it
silently, so a client ahead of the server sees nothing but timeouts. The
bridge now answers get_version with its protocol version plus its command
and event catalogues, and get_bridge_version() raises when that is below
GS_BRIDGE_VERSION. It is opt-in rather than checked on connect: GameScripts
do not tick while the game is paused, so an automatic check would refuse to
connect to a paused server. A bridge older than 4 predates get_version
itself and can only fail by timing out, so the E2E test catches that and
reports it by name instead.
tests/test_gamescript.py gives CI a foothold on the GameScript without a
Squirrel toolchain: it parses the .nut files and pins the protocol version
across info.nut, main.nut and protocol.py, the event catalogue against
GameEventType, and the command table against the commands client.py sends.
The version has to be declared three times because a GameScript cannot read
its own info.nut at runtime -- GSController.GetVersion() returns the OpenTTD
version, not the script's.
info.nut also gains MinVersionToLoad() { return 1; }. The engine defaults it
to GetVersion(), so without it this bump would orphan every savegame pinned
to version 3: the scanner finds no compatible script and falls back with a
warning. The bridge keeps no savegame state, so any version can take over.
HandleCommand now dispatches through the same table get_version reports,
rather than an if/else chain, so the catalogue a client feature-detects
against cannot drift from what is implemented.
Co-Authored-By: Claude <[email protected]>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# AdminBridge GameScript
|
||||
|
||||
The server-side half of this project. Every `OpenTTDAdminClient` feature that goes over the
|
||||
GameScript JSON channel — `list_vehicles`, `list_stations`, `list_cargo`, `get_timetable`,
|
||||
`get_station`, `get_station_cargo`, `get_dispatch` and the `subscribe_events` push stream — is a
|
||||
command this script answers. The Python client is only half the protocol; this is the other half,
|
||||
and the two are documented together in [docs/PROTOCOL.md](../../docs/PROTOCOL.md).
|
||||
|
||||
It is a **deity** GameScript: it joins no company, builds nothing, and only reads game state and
|
||||
replies on the admin port via `GSAdmin.Send()`.
|
||||
|
||||
- `info.nut` — the manifest OpenTTD's script scanner reads (name, version, API version).
|
||||
- `main.nut` — the bridge itself: a command dispatch table, one handler per command, and the
|
||||
event poller.
|
||||
|
||||
## Why it lives here
|
||||
|
||||
`docker/config/` is gitignored — it also holds savegames, downloaded content and generated
|
||||
config — so a GameScript kept there is invisible to review and to CI, and a fresh clone cannot
|
||||
reproduce the server side at all. This directory is the source of truth, the same arrangement
|
||||
[docker/patches/](../../docker/patches/README.md) uses for the local JGRPP patches.
|
||||
|
||||
`docker-compose.yml` bind-mounts this directory read-only over the container's
|
||||
`game/AdminBridge`, so the server runs the tracked copy and nothing else:
|
||||
|
||||
```yaml
|
||||
- ../gamescript/AdminBridge:/home/openttd/.local/share/openttd/game/AdminBridge:ro
|
||||
```
|
||||
|
||||
Editing the copy under `docker/config/game/AdminBridge/` therefore has no effect; that path is
|
||||
shadowed by the mount. On a server not started through this compose file, copy the directory
|
||||
into the OpenTTD data dir instead:
|
||||
|
||||
```bash
|
||||
cp -r gamescript/AdminBridge ~/.local/share/openttd/game/
|
||||
```
|
||||
|
||||
A GameScript is loaded when the game starts, so a change needs the server restarted (or the
|
||||
game reloaded) before it takes effect — the running instance keeps the old code.
|
||||
|
||||
## Protocol version
|
||||
|
||||
`info.nut`'s `GetVersion()` is the version of the **JSON protocol**, not of the implementation:
|
||||
bump it when a command, field or event kind changes shape, not for a refactor. Three places
|
||||
carry it and all three must move together:
|
||||
|
||||
| Where | What |
|
||||
| :--- | :--- |
|
||||
| `info.nut` → `GetVersion()` | what OpenTTD records in the savegame |
|
||||
| `main.nut` → `BRIDGE_VERSION` | what the `get_version` command reports |
|
||||
| `lib/openttd/protocol.py` → `GS_BRIDGE_VERSION` | what the client requires |
|
||||
|
||||
A GameScript cannot read its own `info.nut` at runtime — `GSController.GetVersion()` returns the
|
||||
*OpenTTD* version — hence the duplication. [`tests/test_gamescript.py`](../../tests/test_gamescript.py)
|
||||
pins the three together, along with the event catalogue and the command list, so a half-finished
|
||||
bump fails in CI rather than against a live server.
|
||||
|
||||
`OpenTTDAdminClient.get_bridge_version()` is the client end of this: it asks the running bridge
|
||||
and raises if it is too old, which is worth doing once at startup. Without it a stale bridge
|
||||
gives no error at all — it does not recognise the command, so the client just waits out its
|
||||
timeout. A bridge older than version 4 predates `get_version` itself and can only fail that way.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **JGRPP**, patched with `docker/patches/` — `get_timetable` and `get_dispatch` call `GSOrder`
|
||||
getters those patches add. Every other command uses stock GameScript API, so an unpatched
|
||||
server still answers them.
|
||||
- The admin client must subscribe with `update_frequency(Gamescript, Automatic)` or the server
|
||||
drops every reply. The client does this automatically on first use.
|
||||
- GameScripts do not tick while the game is **paused**, so a paused server answers nothing and
|
||||
every query times out.
|
||||
|
||||
## Which GameScript actually runs
|
||||
|
||||
OpenTTD runs exactly **one** GameScript, and a savegame remembers the one it was played with by
|
||||
name: loading it overrides `[game_scripts]` in `openttd.cfg`. So a savegame that pins some other
|
||||
script leaves this bridge unloaded, and every command times out no matter what is mounted where.
|
||||
The symptom to recognise is commands timing out *uniformly* — as opposed to a stale bridge, where
|
||||
the older commands still answer and only newer ones hang.
|
||||
|
||||
## Verifying a change
|
||||
|
||||
`docker/config` being untracked used to mean this file could only be tested against a running
|
||||
server. It still needs one for the real check — `pytest -m e2e` exercises every command end to
|
||||
end, and [docs/TESTING.md](../../docs/TESTING.md) covers starting the server. Before that,
|
||||
`pytest -m "not e2e" tests/test_gamescript.py` catches the drift a server would only reveal as a
|
||||
timeout. Note that a syntax error anywhere in `main.nut` stops the whole bridge from loading, so
|
||||
every command fails at once — check the server log (`docker compose logs`) for the compile error.
|
||||
@@ -0,0 +1,19 @@
|
||||
class AdminBridge extends GSInfo {
|
||||
function GetAuthor() { return "Gemini"; }
|
||||
function GetName() { return "AdminBridge"; }
|
||||
function GetShortName() { return "ADMB"; }
|
||||
function GetDescription() { return "Answers JSON commands from the admin port on behalf of an admin client."; }
|
||||
/* Bridge protocol version. Must equal BRIDGE_VERSION in main.nut, which is what the
|
||||
* get_version command reports; tests/test_gamescript.py pins the two together and to
|
||||
* openttd.protocol.GS_BRIDGE_VERSION. Bump it whenever the JSON protocol changes. */
|
||||
function GetVersion() { return 4; }
|
||||
/* Any version of this script can take over from any older one: the bridge keeps no
|
||||
* savegame state of its own (it has no Save()), so there is nothing to migrate. Without
|
||||
* this, the default is GetVersion(), and loading a savegame that pinned an older version
|
||||
* makes the engine fall back with a "no longer available" warning. */
|
||||
function MinVersionToLoad() { return 1; }
|
||||
function GetAPIVersion() { return "1.10"; }
|
||||
function GetDate() { return "2026-08-31"; }
|
||||
function CreateInstance() { return "AdminBridge"; }
|
||||
}
|
||||
RegisterGS(AdminBridge());
|
||||
@@ -0,0 +1,722 @@
|
||||
class AdminBridge extends GSController {
|
||||
/* --- Event subscription state (see HandleSubscribeEvents) --- */
|
||||
event_kinds = null; // table of subscribed kind -> true; null when not subscribed
|
||||
event_order = null; // canonical kind ordering, so subscribe replies are stable
|
||||
event_catalog = null; // table of every supported kind -> true, for validation
|
||||
interval = 10; // ticks between state polls
|
||||
sleep_ticks = 10; // ticks slept per main-loop iteration
|
||||
last_poll = 0; // tick of the last state poll
|
||||
seeded = false; // false until the first poll has recorded a state baseline
|
||||
company_id = null; // optional owner filter for the polled vehicle/station events
|
||||
watch_vehicles = null; // explicit vehicle ids to poll; null = every vehicle
|
||||
watch_stations = null; // explicit station ids to poll; null = every station
|
||||
cargo_ids = null; // cargo ids to inspect; set at subscribe time
|
||||
min_cargo_delta = 1; // smallest waiting-amount change worth a cargo_waiting event
|
||||
include_cargo = true; // attach each vehicle's load to arrive/depart events
|
||||
vehicle_at = null; // vehicle id -> { station = id or -1, since = tick }
|
||||
cargo_prev = null; // station id -> table of cargo id -> last seen waiting amount
|
||||
|
||||
/* Cap on how many events one poll may emit. A first poll over a busy map can otherwise
|
||||
* produce thousands at once; past the cap the rest are dropped and reported as such. */
|
||||
MAX_EVENTS_PER_POLL = 200;
|
||||
/* Events per admin packet. Kept well under the packet size limit so a batch always fits. */
|
||||
EVENT_BATCH_SIZE = 24;
|
||||
|
||||
/* Version of the JSON protocol spoken over the admin port, reported by get_version.
|
||||
* A GameScript cannot read its own info.nut at runtime (GSController.GetVersion() returns
|
||||
* the *OpenTTD* version), so this duplicates info.nut's GetVersion() and the two are pinned
|
||||
* together — along with the client's openttd.protocol.GS_BRIDGE_VERSION — by
|
||||
* tests/test_gamescript.py. Bump all three whenever the protocol changes. */
|
||||
BRIDGE_VERSION = 4;
|
||||
|
||||
/* Every command the bridge answers: name -> handler method plus the request fields that
|
||||
* handler needs. HandleCommand dispatches through this and get_version reports it, so the
|
||||
* catalogue a client feature-detects against cannot drift from what is implemented. */
|
||||
COMMANDS = {
|
||||
get_version = { handler = "HandleGetVersion", requires = [] },
|
||||
list_vehicles = { handler = "HandleListVehicles", requires = [] },
|
||||
list_stations = { handler = "HandleListStations", requires = [] },
|
||||
list_cargo = { handler = "HandleListCargo", requires = [] },
|
||||
get_timetable = { handler = "HandleGetTimetable", requires = ["vehicle_id"] },
|
||||
get_station = { handler = "HandleGetStation", requires = ["station_id"] },
|
||||
get_station_cargo = { handler = "HandleGetStationCargo", requires = ["station_id", "cargo_id"] },
|
||||
get_dispatch = { handler = "HandleGetDispatch", requires = ["vehicle_id"] },
|
||||
subscribe_events = { handler = "HandleSubscribeEvents", requires = [] },
|
||||
unsubscribe_events = { handler = "HandleUnsubscribeEvents", requires = [] },
|
||||
};
|
||||
|
||||
function InitEvents() {
|
||||
this.ResetEventState();
|
||||
this.event_order = [
|
||||
/* Synthesised by polling game state (PollState). */
|
||||
"vehicle_arrive", "vehicle_depart", "cargo_waiting",
|
||||
/* Native GameScript events, forwarded as they arrive (HandleNativeEvent). */
|
||||
"vehicle_crashed", "station_first_vehicle",
|
||||
"industry_open", "industry_close", "town_founded",
|
||||
"company_new", "company_in_trouble", "company_bankrupt",
|
||||
"subsidy_offer", "subsidy_offer_expired", "subsidy_awarded", "subsidy_expired"
|
||||
];
|
||||
this.event_catalog = {};
|
||||
foreach (kind in this.event_order) this.event_catalog[kind] <- true;
|
||||
}
|
||||
|
||||
function Start() {
|
||||
GSLog.Info("AdminBridge started.");
|
||||
this.InitEvents();
|
||||
while (true) {
|
||||
this.Sleep(this.sleep_ticks);
|
||||
local pending = [];
|
||||
local event = GSEventController.GetNextEvent();
|
||||
while (event != null) {
|
||||
if (event.GetEventType() == GSEvent.ET_ADMIN_PORT) {
|
||||
local admin_event = GSEventAdminPort.Convert(event);
|
||||
local request = admin_event.GetObject();
|
||||
if (request != null && "command" in request) this.HandleCommand(request);
|
||||
} else if (this.event_kinds != null) {
|
||||
this.HandleNativeEvent(event, pending);
|
||||
}
|
||||
event = GSEventController.GetNextEvent();
|
||||
}
|
||||
if (this.event_kinds != null) this.PollState(pending);
|
||||
this.SendEventBatch(pending);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleCommand(request) {
|
||||
if (!(request.command in this.COMMANDS)) return;
|
||||
local spec = this.COMMANDS[request.command];
|
||||
/* A command missing the fields its handler needs is dropped rather than answered:
|
||||
* there is nothing to answer *about*, and no request_id is guaranteed either. */
|
||||
foreach (field in spec.requires) {
|
||||
if (!(field in request)) return;
|
||||
}
|
||||
this[spec.handler](request);
|
||||
}
|
||||
|
||||
/* Version handshake. A client expecting a newer bridge than the server runs would otherwise
|
||||
* just time out on the first command this bridge does not know; asking here turns that into
|
||||
* an answer. The two catalogues let a client feature-detect, which survives a bridge that
|
||||
* gained commands out of version order better than comparing numbers does. */
|
||||
function HandleGetVersion(request) {
|
||||
local commands = [];
|
||||
foreach (name, _ in this.COMMANDS) commands.append(name);
|
||||
commands.sort(); // table iteration order is arbitrary; keep the reply stable
|
||||
local events = [];
|
||||
foreach (kind in this.event_order) events.append(kind);
|
||||
local reply = { command = "get_version", version = this.BRIDGE_VERSION,
|
||||
commands = commands, events = events };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
/* Cargo id -> label ("PASS", "COAL", ...) so callers can name what a vehicle carries.
|
||||
* Labels come from the NewGRF cargo table, so ids are not stable across games. */
|
||||
function HandleListCargo(request) {
|
||||
local cargo = [];
|
||||
foreach (c, _ in GSCargoList()) {
|
||||
local label = "";
|
||||
try { label = GSCargo.GetCargoLabel(c); } catch (e) { label = ""; }
|
||||
local freight = 0;
|
||||
try { freight = GSCargo.IsFreight(c) ? 1 : 0; } catch (e) { freight = 0; }
|
||||
cargo.append({ cargo_id = c, label = label, freight = freight });
|
||||
}
|
||||
local reply = { command = "list_cargo", cargo = cargo };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function HandleListVehicles(request) {
|
||||
local vehicle_list = GSVehicleList();
|
||||
local vehicles = [];
|
||||
foreach (v, _ in vehicle_list) {
|
||||
if (!GSVehicle.IsValidVehicle(v)) continue;
|
||||
local next_stop = "None";
|
||||
if (GSOrder.GetOrderCount(v) > 0) {
|
||||
local dest_tile = GSOrder.GetOrderDestination(v, GSOrder.ORDER_CURRENT);
|
||||
local station_id = GSStation.GetStationID(dest_tile);
|
||||
if (GSStation.IsValidStation(station_id)) next_stop = GSStation.GetName(station_id);
|
||||
}
|
||||
/* Which cargoes this vehicle can actually carry. A vehicle (an articulated
|
||||
* train especially) can have capacity for several cargo types, so report
|
||||
* every one with a non-zero capacity rather than a single "cargo type". */
|
||||
local cargo = [];
|
||||
foreach (c, _ in GSCargoList()) {
|
||||
local cap = GSVehicle.GetCapacity(v, c);
|
||||
if (cap > 0) cargo.append({ cargo_id = c, capacity = cap });
|
||||
}
|
||||
vehicles.append({
|
||||
id = v,
|
||||
age = GSVehicle.GetAge(v),
|
||||
max_age = GSVehicle.GetMaxAge(v),
|
||||
next_stop = next_stop,
|
||||
type = GSVehicle.GetVehicleType(v),
|
||||
owner = GSVehicle.GetOwner(v),
|
||||
order_count = GSOrder.GetOrderCount(v),
|
||||
cargo = cargo
|
||||
});
|
||||
}
|
||||
local reply = { vehicles = vehicles };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function HandleGetTimetable(request) {
|
||||
local v = request.vehicle_id;
|
||||
local reply = { command = "get_timetable", vehicle_id = v };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
if (!GSVehicle.IsValidVehicle(v)) {
|
||||
reply.error <- "invalid_vehicle";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.lateness <- GSOrder.GetTimetableLateness(v);
|
||||
reply.start_tick <- GSOrder.GetTimetableStartTick(v);
|
||||
reply.current_order_time <- GSOrder.GetCurrentOrderTime(v);
|
||||
reply.total_duration <- GSOrder.GetTimetableTotalDuration(v);
|
||||
local orders = [];
|
||||
local count = GSOrder.GetOrderCount(v);
|
||||
for (local i = 0; i < count; i++) {
|
||||
/* Resolve the order's destination station so callers can build a station
|
||||
* graph (vertices = stations, edges = segments). Non-station orders
|
||||
* (depots, waypoints, conditional) have no station: report -1. */
|
||||
local station_id = -1;
|
||||
if (GSOrder.IsValidVehicleOrder(v, i) && GSOrder.IsGotoStationOrder(v, i)) {
|
||||
local sid = GSStation.GetStationID(GSOrder.GetOrderDestination(v, i));
|
||||
if (GSStation.IsValidStation(sid)) station_id = sid;
|
||||
}
|
||||
orders.append({
|
||||
position = i,
|
||||
station_id = station_id,
|
||||
wait_time = GSOrder.GetTimetableWaitTime(v, i),
|
||||
travel_time = GSOrder.GetTimetableTravelTime(v, i),
|
||||
wait_timetabled = GSOrder.IsWaitTimetabled(v, i) ? 1 : 0,
|
||||
travel_timetabled = GSOrder.IsTravelTimetabled(v, i) ? 1 : 0,
|
||||
wait_fixed = GSOrder.IsWaitFixed(v, i) ? 1 : 0,
|
||||
travel_fixed = GSOrder.IsTravelFixed(v, i) ? 1 : 0,
|
||||
leave_type = GSOrder.GetLeaveType(v, i),
|
||||
max_speed = GSOrder.GetTimetableMaxSpeed(v, i)
|
||||
});
|
||||
}
|
||||
reply.orders <- orders;
|
||||
if (!GSAdmin.Send(reply)) {
|
||||
/* Response exceeded the admin packet size limit; send a small error
|
||||
* instead so the waiting client fails fast rather than timing out. */
|
||||
local fallback = { command = "get_timetable", vehicle_id = v, error = "response_too_large" };
|
||||
if ("request_id" in request) fallback.request_id <- request.request_id;
|
||||
GSAdmin.Send(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleListStations(request) {
|
||||
/* GameScripts run as a deity, so GSStationList lists every company's stations;
|
||||
* an optional company_id filters to a single owner (the deity list ignores it). */
|
||||
local filter_owner = ("company_id" in request) ? request.company_id : null;
|
||||
local station_list = GSStationList(GSStation.STATION_ANY);
|
||||
local stations = [];
|
||||
foreach (s, _ in station_list) {
|
||||
if (!GSStation.IsValidStation(s)) continue;
|
||||
local owner = GSStation.GetOwner(s);
|
||||
if (filter_owner != null && owner != filter_owner) continue;
|
||||
stations.append({
|
||||
id = s,
|
||||
name = GSStation.GetName(s),
|
||||
location = GSStation.GetLocation(s),
|
||||
owner = owner
|
||||
});
|
||||
}
|
||||
local reply = { command = "list_stations", stations = stations };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function HandleGetStation(request) {
|
||||
local sid = request.station_id;
|
||||
local reply = { command = "get_station", station_id = sid };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
if (!GSStation.IsValidStation(sid)) {
|
||||
reply.error <- "invalid_station";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.name <- GSStation.GetName(sid);
|
||||
reply.location <- GSStation.GetLocation(sid);
|
||||
reply.owner <- GSStation.GetOwner(sid);
|
||||
local cargo = [];
|
||||
local cargo_list = GSCargoList();
|
||||
foreach (c, _ in cargo_list) {
|
||||
local waiting = GSStation.GetCargoWaiting(sid, c);
|
||||
local planned = GSStation.GetCargoPlanned(sid, c);
|
||||
local has_rating = GSStation.HasCargoRating(sid, c);
|
||||
/* Skip cargo the station has never handled to keep the reply compact. */
|
||||
if (waiting <= 0 && planned <= 0 && !has_rating) continue;
|
||||
cargo.append({
|
||||
cargo_id = c,
|
||||
waiting = waiting, // real-time: units currently waiting
|
||||
planned = planned, // planned: cargodist link-graph flow
|
||||
rating = has_rating ? GSStation.GetCargoRating(sid, c) : null
|
||||
});
|
||||
}
|
||||
reply.cargo <- cargo;
|
||||
if (!GSAdmin.Send(reply)) {
|
||||
/* Response exceeded the admin packet size limit; send a small error
|
||||
* instead so the waiting client fails fast rather than timing out. */
|
||||
local fallback = { command = "get_station", station_id = sid, error = "response_too_large" };
|
||||
if ("request_id" in request) fallback.request_id <- request.request_id;
|
||||
GSAdmin.Send(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
/* Convert a GSList of station_id -> amount into an array of {station, amount},
|
||||
* dropping zero entries to keep the reply compact. */
|
||||
function CargoListToPairs(list) {
|
||||
local pairs = [];
|
||||
foreach (station, _ in list) {
|
||||
local amount = list.GetValue(station);
|
||||
if (amount <= 0) continue;
|
||||
pairs.append({ station = station, amount = amount });
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function HandleGetStationCargo(request) {
|
||||
local sid = request.station_id;
|
||||
local cargo = request.cargo_id;
|
||||
local reply = { command = "get_station_cargo", station_id = sid, cargo_id = cargo };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
if (!GSStation.IsValidStation(sid)) {
|
||||
reply.error <- "invalid_station";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
if (!GSCargo.IsValidCargo(cargo)) {
|
||||
reply.error <- "invalid_cargo";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Optional source (from) and next-hop (via) filters. STATION_INVALID is a legal value
|
||||
* (deleted source / "via any"); any other non-station value is rejected. */
|
||||
local from = null;
|
||||
local via = null;
|
||||
if ("from_station" in request) {
|
||||
from = request.from_station;
|
||||
if (from != GSStation.STATION_INVALID && !GSStation.IsValidStation(from)) {
|
||||
reply.error <- "invalid_from_station";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.from_station <- from;
|
||||
}
|
||||
if ("via_station" in request) {
|
||||
via = request.via_station;
|
||||
if (via != GSStation.STATION_INVALID && !GSStation.IsValidStation(via)) {
|
||||
reply.error <- "invalid_via_station";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.via_station <- via;
|
||||
}
|
||||
|
||||
/* Totals, honouring whichever filters were supplied. */
|
||||
if (from != null && via != null) {
|
||||
reply.waiting <- GSStation.GetCargoWaitingFromVia(sid, from, via, cargo);
|
||||
reply.planned <- GSStation.GetCargoPlannedFromVia(sid, from, via, cargo);
|
||||
} else if (from != null) {
|
||||
reply.waiting <- GSStation.GetCargoWaitingFrom(sid, from, cargo);
|
||||
reply.planned <- GSStation.GetCargoPlannedFrom(sid, from, cargo);
|
||||
} else if (via != null) {
|
||||
reply.waiting <- GSStation.GetCargoWaitingVia(sid, via, cargo);
|
||||
reply.planned <- GSStation.GetCargoPlannedVia(sid, via, cargo);
|
||||
} else {
|
||||
reply.waiting <- GSStation.GetCargoWaiting(sid, cargo);
|
||||
reply.planned <- GSStation.GetCargoPlanned(sid, cargo);
|
||||
}
|
||||
|
||||
/* Breakdown grouped by source station (restricted to the via filter if given). */
|
||||
local w_by_from = (via != null)
|
||||
? GSStationList_CargoWaitingViaByFrom(sid, cargo, via)
|
||||
: GSStationList_CargoWaitingByFrom(sid, cargo);
|
||||
local p_by_from = (via != null)
|
||||
? GSStationList_CargoPlannedViaByFrom(sid, cargo, via)
|
||||
: GSStationList_CargoPlannedByFrom(sid, cargo);
|
||||
reply.waiting_by_from <- this.CargoListToPairs(w_by_from);
|
||||
reply.planned_by_from <- this.CargoListToPairs(p_by_from);
|
||||
|
||||
/* Breakdown grouped by next hop (restricted to the from filter if given). */
|
||||
local w_by_via = (from != null)
|
||||
? GSStationList_CargoWaitingFromByVia(sid, cargo, from)
|
||||
: GSStationList_CargoWaitingByVia(sid, cargo);
|
||||
local p_by_via = (from != null)
|
||||
? GSStationList_CargoPlannedFromByVia(sid, cargo, from)
|
||||
: GSStationList_CargoPlannedByVia(sid, cargo);
|
||||
reply.waiting_by_via <- this.CargoListToPairs(w_by_via);
|
||||
reply.planned_by_via <- this.CargoListToPairs(p_by_via);
|
||||
|
||||
if (!GSAdmin.Send(reply)) {
|
||||
/* Response exceeded the admin packet size limit; send a small error
|
||||
* instead so the waiting client fails fast rather than timing out. */
|
||||
local fallback = { command = "get_station_cargo", station_id = sid, cargo_id = cargo, error = "response_too_large" };
|
||||
if ("request_id" in request) fallback.request_id <- request.request_id;
|
||||
GSAdmin.Send(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleGetDispatch(request) {
|
||||
local v = request.vehicle_id;
|
||||
local reply = { command = "get_dispatch", vehicle_id = v };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
|
||||
local count = GSOrder.GetScheduledDispatchScheduleCount(v);
|
||||
if (count < 0) {
|
||||
reply.error <- "invalid_vehicle";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
reply.enabled <- GSOrder.IsScheduledDispatchEnabled(v);
|
||||
local schedules = [];
|
||||
for (local s = 0; s < count; s++) {
|
||||
local slots = [];
|
||||
local slot_count = GSOrder.GetScheduledDispatchSlotCount(v, s);
|
||||
for (local k = 0; k < slot_count; k++) {
|
||||
slots.append({
|
||||
offset = GSOrder.GetScheduledDispatchSlotOffset(v, s, k),
|
||||
flags = GSOrder.GetScheduledDispatchSlotFlags(v, s, k)
|
||||
});
|
||||
}
|
||||
schedules.append({
|
||||
index = s,
|
||||
duration = GSOrder.GetScheduledDispatchDuration(v, s),
|
||||
start_tick = GSOrder.GetScheduledDispatchStartTick(v, s),
|
||||
delay = GSOrder.GetScheduledDispatchDelay(v, s),
|
||||
reuse_slots = GSOrder.GetScheduledDispatchReuseSlots(v, s),
|
||||
slots = slots
|
||||
});
|
||||
}
|
||||
reply.schedules <- schedules;
|
||||
if (!GSAdmin.Send(reply)) {
|
||||
/* Response exceeded the admin packet size limit; send a small error
|
||||
* instead so the waiting client fails fast rather than timing out. */
|
||||
local fallback = { command = "get_dispatch", vehicle_id = v, error = "response_too_large" };
|
||||
if ("request_id" in request) fallback.request_id <- request.request_id;
|
||||
GSAdmin.Send(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Events ---
|
||||
*
|
||||
* The engine has no GameScript event for "a vehicle reached a stop" or "cargo arrived",
|
||||
* so those are synthesised here: every `interval` ticks the bridge samples the watched
|
||||
* vehicles and stations and emits an event for each change against the previous sample.
|
||||
* The handful of events the engine *does* raise for a deity script (crashes, industries,
|
||||
* companies, ...) are forwarded straight through. Everything is pushed to the admin port
|
||||
* unsolicited, batched as { command = "events", events = [...] }. */
|
||||
|
||||
function ResetEventState() {
|
||||
this.event_kinds = null;
|
||||
this.interval = 10;
|
||||
this.sleep_ticks = 10;
|
||||
this.last_poll = 0;
|
||||
this.seeded = false;
|
||||
this.company_id = null;
|
||||
this.watch_vehicles = null;
|
||||
this.watch_stations = null;
|
||||
this.cargo_ids = null;
|
||||
this.min_cargo_delta = 1;
|
||||
this.include_cargo = true;
|
||||
this.vehicle_at = {};
|
||||
this.cargo_prev = {};
|
||||
}
|
||||
|
||||
/* Copy a request field that must be an array of integers, or null when absent. */
|
||||
function ReadIdList(request, key) {
|
||||
if (!(key in request) || request[key] == null) return null;
|
||||
local out = [];
|
||||
foreach (id in request[key]) out.append(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
function HandleSubscribeEvents(request) {
|
||||
local reply = { command = "subscribe_events" };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
|
||||
/* Validate everything before touching the live subscription, so a rejected request
|
||||
* leaves whatever was subscribed before running untouched. */
|
||||
local kinds = {};
|
||||
if ("events" in request && request.events != null) {
|
||||
foreach (kind in request.events) {
|
||||
if (!(kind in this.event_catalog)) {
|
||||
reply.error <- "unknown_event";
|
||||
reply.event <- kind;
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
kinds[kind] <- true;
|
||||
}
|
||||
} else {
|
||||
foreach (kind in this.event_order) kinds[kind] <- true;
|
||||
}
|
||||
|
||||
local new_interval = ("interval" in request) ? request.interval : 10;
|
||||
if (typeof new_interval != "integer" || new_interval < 1) {
|
||||
reply.error <- "invalid_interval";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
local new_min_delta = ("min_cargo_delta" in request) ? request.min_cargo_delta : 1;
|
||||
if (typeof new_min_delta != "integer" || new_min_delta < 1) {
|
||||
reply.error <- "invalid_min_cargo_delta";
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Cargo ids are validated up front because the amount getters below take them as a
|
||||
* precondition; station and vehicle ids are not, since they can vanish mid-subscription
|
||||
* anyway and are re-checked on every poll. */
|
||||
local new_cargo = this.ReadIdList(request, "cargo");
|
||||
if (new_cargo != null) {
|
||||
foreach (c in new_cargo) {
|
||||
if (!GSCargo.IsValidCargo(c)) {
|
||||
reply.error <- "invalid_cargo";
|
||||
reply.cargo_id <- c;
|
||||
GSAdmin.Send(reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
new_cargo = [];
|
||||
foreach (c, _ in GSCargoList()) new_cargo.append(c);
|
||||
}
|
||||
|
||||
this.ResetEventState();
|
||||
this.event_kinds = kinds;
|
||||
this.interval = new_interval;
|
||||
this.sleep_ticks = new_interval < 10 ? new_interval : 10;
|
||||
this.min_cargo_delta = new_min_delta;
|
||||
this.cargo_ids = new_cargo;
|
||||
this.watch_vehicles = this.ReadIdList(request, "vehicles");
|
||||
this.watch_stations = this.ReadIdList(request, "stations");
|
||||
if ("company_id" in request && request.company_id != null) this.company_id = request.company_id;
|
||||
if ("include_cargo" in request) this.include_cargo = request.include_cargo ? true : false;
|
||||
|
||||
local accepted = [];
|
||||
foreach (kind in this.event_order) {
|
||||
if (kind in this.event_kinds) accepted.append(kind);
|
||||
}
|
||||
reply.events <- accepted;
|
||||
reply.interval <- this.interval;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function HandleUnsubscribeEvents(request) {
|
||||
this.ResetEventState();
|
||||
local reply = { command = "unsubscribe_events", events = [] };
|
||||
if ("request_id" in request) reply.request_id <- request.request_id;
|
||||
GSAdmin.Send(reply);
|
||||
}
|
||||
|
||||
function Wants(kind) {
|
||||
return this.event_kinds != null && (kind in this.event_kinds);
|
||||
}
|
||||
|
||||
/* Forward the events the engine raises for a deity GameScript. Vehicle "lost", "waiting in
|
||||
* depot" and "unprofitable" are deliberately absent: the engine only ever raises those for
|
||||
* AI companies, so a GameScript can never observe them. */
|
||||
function HandleNativeEvent(event, out) {
|
||||
local type = event.GetEventType();
|
||||
local tick = this.GetTick();
|
||||
if (type == GSEvent.ET_VEHICLE_CRASHED) {
|
||||
if (!this.Wants("vehicle_crashed")) return;
|
||||
local e = GSEventVehicleCrashed.Convert(event);
|
||||
out.append({ event = "vehicle_crashed", tick = tick, vehicle_id = e.GetVehicleID(),
|
||||
tile = e.GetCrashSite(), reason = e.GetCrashReason(),
|
||||
victims = e.GetVictims(), owner = e.GetVehicleOwner() });
|
||||
} else if (type == GSEvent.ET_STATION_FIRST_VEHICLE) {
|
||||
if (!this.Wants("station_first_vehicle")) return;
|
||||
local e = GSEventStationFirstVehicle.Convert(event);
|
||||
out.append({ event = "station_first_vehicle", tick = tick,
|
||||
station_id = e.GetStationID(), vehicle_id = e.GetVehicleID() });
|
||||
} else if (type == GSEvent.ET_INDUSTRY_OPEN) {
|
||||
if (!this.Wants("industry_open")) return;
|
||||
out.append({ event = "industry_open", tick = tick,
|
||||
industry_id = GSEventIndustryOpen.Convert(event).GetIndustryID() });
|
||||
} else if (type == GSEvent.ET_INDUSTRY_CLOSE) {
|
||||
if (!this.Wants("industry_close")) return;
|
||||
out.append({ event = "industry_close", tick = tick,
|
||||
industry_id = GSEventIndustryClose.Convert(event).GetIndustryID() });
|
||||
} else if (type == GSEvent.ET_TOWN_FOUNDED) {
|
||||
if (!this.Wants("town_founded")) return;
|
||||
out.append({ event = "town_founded", tick = tick,
|
||||
town_id = GSEventTownFounded.Convert(event).GetTownID() });
|
||||
} else if (type == GSEvent.ET_COMPANY_NEW) {
|
||||
if (!this.Wants("company_new")) return;
|
||||
out.append({ event = "company_new", tick = tick,
|
||||
company_id = GSEventCompanyNew.Convert(event).GetCompanyID() });
|
||||
} else if (type == GSEvent.ET_COMPANY_IN_TROUBLE) {
|
||||
if (!this.Wants("company_in_trouble")) return;
|
||||
out.append({ event = "company_in_trouble", tick = tick,
|
||||
company_id = GSEventCompanyInTrouble.Convert(event).GetCompanyID() });
|
||||
} else if (type == GSEvent.ET_COMPANY_BANKRUPT) {
|
||||
if (!this.Wants("company_bankrupt")) return;
|
||||
out.append({ event = "company_bankrupt", tick = tick,
|
||||
company_id = GSEventCompanyBankrupt.Convert(event).GetCompanyID() });
|
||||
} else if (type == GSEvent.ET_SUBSIDY_OFFER) {
|
||||
if (!this.Wants("subsidy_offer")) return;
|
||||
out.append({ event = "subsidy_offer", tick = tick,
|
||||
subsidy_id = GSEventSubsidyOffer.Convert(event).GetSubsidyID() });
|
||||
} else if (type == GSEvent.ET_SUBSIDY_OFFER_EXPIRED) {
|
||||
if (!this.Wants("subsidy_offer_expired")) return;
|
||||
out.append({ event = "subsidy_offer_expired", tick = tick,
|
||||
subsidy_id = GSEventSubsidyOfferExpired.Convert(event).GetSubsidyID() });
|
||||
} else if (type == GSEvent.ET_SUBSIDY_AWARDED) {
|
||||
if (!this.Wants("subsidy_awarded")) return;
|
||||
out.append({ event = "subsidy_awarded", tick = tick,
|
||||
subsidy_id = GSEventSubsidyAwarded.Convert(event).GetSubsidyID() });
|
||||
} else if (type == GSEvent.ET_SUBSIDY_EXPIRED) {
|
||||
if (!this.Wants("subsidy_expired")) return;
|
||||
out.append({ event = "subsidy_expired", tick = tick,
|
||||
subsidy_id = GSEventSubsidyExpired.Convert(event).GetSubsidyID() });
|
||||
}
|
||||
}
|
||||
|
||||
function PollState(out) {
|
||||
local now = this.GetTick();
|
||||
if (this.seeded && now - this.last_poll < this.interval) return;
|
||||
this.last_poll = now;
|
||||
this.PollVehicles(now, out);
|
||||
this.PollCargo(now, out);
|
||||
/* The first poll only records where everything already is: a vehicle that was sitting
|
||||
* at a station when the subscription started did not just arrive. */
|
||||
this.seeded = true;
|
||||
}
|
||||
|
||||
function WatchedVehicles() {
|
||||
if (this.watch_vehicles != null) return this.watch_vehicles;
|
||||
local out = [];
|
||||
foreach (v, _ in GSVehicleList()) out.append(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
function WatchedStations() {
|
||||
if (this.watch_stations != null) return this.watch_stations;
|
||||
local out = [];
|
||||
foreach (s, _ in GSStationList(GSStation.STATION_ANY)) out.append(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Which station a vehicle is stopped at, or -1 when it is not loading at one. Note that a
|
||||
* vehicle stopped by hand or broken down at a platform reports its own state instead, so it
|
||||
* reads here as having left the station. */
|
||||
function VehicleStation(v) {
|
||||
if (GSVehicle.GetState(v) != GSVehicle.VS_AT_STATION) return -1;
|
||||
local sid = GSStation.GetStationID(GSVehicle.GetLocation(v));
|
||||
return GSStation.IsValidStation(sid) ? sid : -1;
|
||||
}
|
||||
|
||||
function PollVehicles(now, out) {
|
||||
local want_arrive = this.Wants("vehicle_arrive");
|
||||
local want_depart = this.Wants("vehicle_depart");
|
||||
if (!want_arrive && !want_depart) return;
|
||||
local live = {};
|
||||
foreach (v in this.WatchedVehicles()) {
|
||||
if (!GSVehicle.IsValidVehicle(v)) continue;
|
||||
if (this.company_id != null && GSVehicle.GetOwner(v) != this.company_id) continue;
|
||||
live[v] <- true;
|
||||
local at = this.VehicleStation(v);
|
||||
local known = (v in this.vehicle_at) ? this.vehicle_at[v] : null;
|
||||
local was = (known == null) ? -1 : known.station;
|
||||
if (was == at) continue;
|
||||
if (this.seeded) {
|
||||
/* A vehicle that moves from one station straight to another in a single
|
||||
* sampling window yields both a depart and an arrive, in that order. */
|
||||
if (was != -1 && want_depart) out.append(this.VehicleEvent("vehicle_depart", now, v, was, now - known.since));
|
||||
if (at != -1 && want_arrive) out.append(this.VehicleEvent("vehicle_arrive", now, v, at, 0));
|
||||
}
|
||||
this.vehicle_at[v] <- { station = at, since = now };
|
||||
}
|
||||
/* Forget vehicles that were sold or fell out of the filter, so the table cannot grow
|
||||
* without bound over a long subscription. */
|
||||
local stale = [];
|
||||
foreach (v, _ in this.vehicle_at) {
|
||||
if (!(v in live)) stale.append(v);
|
||||
}
|
||||
foreach (v in stale) delete this.vehicle_at[v];
|
||||
}
|
||||
|
||||
function VehicleEvent(kind, tick, v, sid, dwell) {
|
||||
local ev = {
|
||||
event = kind,
|
||||
tick = tick,
|
||||
vehicle_id = v,
|
||||
station_id = sid,
|
||||
owner = GSVehicle.GetOwner(v),
|
||||
vehicle_type = GSVehicle.GetVehicleType(v),
|
||||
order_position = GSOrder.ResolveOrderPosition(v, GSOrder.ORDER_CURRENT)
|
||||
};
|
||||
/* How long the vehicle had been loading, in ticks. For a vehicle that was already at a
|
||||
* station when the subscription started this counts from the first poll, not from the
|
||||
* real arrival. */
|
||||
if (kind == "vehicle_depart") ev.dwell <- dwell;
|
||||
if (this.include_cargo) ev.cargo <- this.VehicleCargo(v);
|
||||
return ev;
|
||||
}
|
||||
|
||||
function VehicleCargo(v) {
|
||||
local out = [];
|
||||
foreach (c in this.cargo_ids) {
|
||||
local load = GSVehicle.GetCargoLoad(v, c);
|
||||
if (load > 0) out.append({ cargo_id = c, load = load });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function PollCargo(now, out) {
|
||||
if (!this.Wants("cargo_waiting")) return;
|
||||
local live = {};
|
||||
foreach (sid in this.WatchedStations()) {
|
||||
if (!GSStation.IsValidStation(sid)) continue;
|
||||
if (this.company_id != null && GSStation.GetOwner(sid) != this.company_id) continue;
|
||||
live[sid] <- true;
|
||||
if (!(sid in this.cargo_prev)) this.cargo_prev[sid] <- {};
|
||||
local prev = this.cargo_prev[sid];
|
||||
foreach (c in this.cargo_ids) {
|
||||
local waiting = GSStation.GetCargoWaiting(sid, c);
|
||||
local before = (c in prev) ? prev[c] : 0;
|
||||
if (waiting == before) continue;
|
||||
prev[c] <- waiting;
|
||||
if (!this.seeded) continue;
|
||||
local delta = waiting - before;
|
||||
local magnitude = delta < 0 ? -delta : delta;
|
||||
if (magnitude < this.min_cargo_delta) continue;
|
||||
out.append({ event = "cargo_waiting", tick = now, station_id = sid,
|
||||
cargo_id = c, waiting = waiting, delta = delta });
|
||||
}
|
||||
}
|
||||
local stale = [];
|
||||
foreach (sid, _ in this.cargo_prev) {
|
||||
if (!(sid in live)) stale.append(sid);
|
||||
}
|
||||
foreach (sid in stale) delete this.cargo_prev[sid];
|
||||
}
|
||||
|
||||
function SendEventBatch(events) {
|
||||
if (events.len() == 0) return;
|
||||
local dropped = 0;
|
||||
if (events.len() > this.MAX_EVENTS_PER_POLL) {
|
||||
dropped = events.len() - this.MAX_EVENTS_PER_POLL;
|
||||
events = events.slice(0, this.MAX_EVENTS_PER_POLL);
|
||||
}
|
||||
local sent = 0;
|
||||
while (sent < events.len()) {
|
||||
local end = sent + this.EVENT_BATCH_SIZE;
|
||||
if (end > events.len()) end = events.len();
|
||||
GSAdmin.Send({ command = "events", events = events.slice(sent, end) });
|
||||
sent = end;
|
||||
}
|
||||
/* Tell the client its view has a hole in it rather than letting it silently miss
|
||||
* transitions it is counting on. */
|
||||
if (dropped > 0) {
|
||||
GSAdmin.Send({ command = "events",
|
||||
events = [{ event = "events_dropped", tick = this.GetTick(), count = dropped }] });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user