fix(bridge): refuse world commands until the world has loaded
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m29s

The link starts in Init, before the save loads. The phase 13a walk restarted
the rig mid-run: the website saw the new boot id at once, 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.

world.owned, world.revert, world.monuments and every world write now answer
`not-ready` until OnServerInitialized, zones are not re-created before it,
and server.hello carries `worldReady` so the website can hold its reconcile
until the world it would be asking about exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-24 02:16:03 -05:00
parent bbaa7f346e
commit 0a7ad570f7

View File

@@ -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<string, object> frame = Frame("server.initialized", "event");
frame["initial"] = initial;
EmitFrame(frame);
@@ -5977,6 +5987,22 @@ namespace Oxide.Plugins
/// <summary>A counter for zone ids, so two zones one run opens in one tick never collide.</summary>
private int _zoneSerial;
/// <summary>
/// Whether the world is loaded for THIS load of the plugin — set at
/// <c>OnServerInitialized</c>, which fires at the end of a real start and at once on a
/// hotload. Until then every world command is refused <c>not-ready</c>.
///
/// <para>
/// Found by the phase 13a walk, and it is the worst kind of wrong: the link starts in
/// <c>Init</c>, BEFORE the save loads, so a website that saw the new boot id asked
/// <c>world.owned</c> 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.
/// </para>
/// </summary>
private bool _worldReady;
private string _lastWorldSummary = "none yet";
private Timer _worldTimer;
@@ -6179,8 +6205,22 @@ namespace Oxide.Plugins
return true;
}
/// <summary>Main thread. Refuses a world command until the world is loaded (see <c>_worldReady</c>).</summary>
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<object>();
foreach (KeyValuePair<string, List<MonumentInfo>> kv in MonumentGroups())
@@ -6414,6 +6457,11 @@ namespace Oxide.Plugins
/// </summary>
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
/// </summary>
private void HandleWorldRevert(Dictionary<string, object> 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<string>(Strings(cmd, "ids"));
@@ -6693,6 +6746,9 @@ namespace Oxide.Plugins
/// </summary>
private void HandleWorldOwned(Dictionary<string, object> cmd, string reqId)
{
if (!WorldReady(reqId))
return;
string runId = StringField(cmd, "runId");
RecreateZones("owned");