Files
kovagoadiandClaude f7ca395a4f
Continuous Integration / lint-and-security (pull_request) Failing after 19s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
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]>
2026-08-31 19:06:33 +02:00

723 lines
29 KiB
Plaintext

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 }] });
}
}
}