diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 85c9a41..70b44af 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -981,6 +981,10 @@ namespace Oxide.Plugins hello["bootId"] = _bootId; hello["plugin"] = Version.ToString(); + // The website compares boot ids to decide when to reconcile the events ledger, and + // must not do it until the world it would be asking about exists (§28.6). + hello["worldReady"] = _worldReady; + EmitFrame(hello); } @@ -1300,6 +1304,7 @@ namespace Oxide.Plugins // Only now is the wipe known, so only now can what an earlier map owned be dropped; // and this hook also fires on a hotload, which is exactly when ZoneManager has just // erased every zone the previous load of this plugin owned (D92). + _worldReady = true; DropWorldFromOtherWipes(); RecreateZones("load"); @@ -1309,6 +1314,11 @@ namespace Oxide.Plugins if (_cadence == null) _cadence = timer.Every(BoardCadenceSeconds, Cadence); + // Say the world is ready now rather than at the next tick: the website holds its + // reconcile until it hears this. + if (_connected) + SendHello(); + Dictionary frame = Frame("server.initialized", "event"); frame["initial"] = initial; EmitFrame(frame); @@ -5977,6 +5987,22 @@ namespace Oxide.Plugins /// A counter for zone ids, so two zones one run opens in one tick never collide. private int _zoneSerial; + /// + /// Whether the world is loaded for THIS load of the plugin — set at + /// OnServerInitialized, which fires at the end of a real start and at once on a + /// hotload. Until then every world command is refused not-ready. + /// + /// + /// Found by the phase 13a walk, and it is the worst kind of wrong: the link starts in + /// Init, BEFORE the save loads, so a website that saw the new boot id asked + /// world.owned into an empty world — and the plugin, finding no entity behind + /// any net id, pruned three live crates from its own registry. They stayed in the + /// world owned by nothing, and a revert would have refused them. An empty world at + /// boot is not an answer; it is the question arriving too early. + /// + /// + private bool _worldReady; + private string _lastWorldSummary = "none yet"; private Timer _worldTimer; @@ -6179,8 +6205,22 @@ namespace Oxide.Plugins return true; } + /// Main thread. Refuses a world command until the world is loaded (see _worldReady). + private bool WorldReady(string reqId) + { + if (_worldReady) + return true; + + EmitWorldError(reqId, "not-ready", + "the server is still loading its world; ask again once it has started"); + return false; + } + private bool WorldWritesAllowed(string reqId) { + if (!WorldReady(reqId)) + return false; + if (_config != null && _config.EventsEnabled) return true; @@ -6219,6 +6259,9 @@ namespace Oxide.Plugins private void HandleWorldMonuments(string reqId) { + if (!WorldReady(reqId)) + return; + var rows = new List(); foreach (KeyValuePair> kv in MonumentGroups()) @@ -6414,6 +6457,11 @@ namespace Oxide.Plugins /// private void RecreateZones(string why) { + // At a real start ZoneManager loads before the world does and initialises its own + // zones at OnTerrainInitialized; ours wait for OnServerInitialized, which calls here. + if (!_worldReady) + return; + Plugin zm = ZoneManagerPlugin(); if (zm == null) @@ -6593,6 +6641,11 @@ namespace Oxide.Plugins /// private void HandleWorldRevert(Dictionary cmd, string reqId) { + // Before the world loads, every entity looks gone, and a revert would forget things + // that are about to exist again. + if (!WorldReady(reqId)) + return; + string runId = StringField(cmd, "runId"); string key = StringField(cmd, "key"); var ids = new List(Strings(cmd, "ids")); @@ -6693,6 +6746,9 @@ namespace Oxide.Plugins /// private void HandleWorldOwned(Dictionary cmd, string reqId) { + if (!WorldReady(reqId)) + return; + string runId = StringField(cmd, "runId"); RecreateZones("owned");