feat(world-reload): keep properties until empty

Busy property instances on changed files are kept (KEEP_UNTIL_EMPTY) instead
of saved, frozen and moved. Every instance acted on is marked outdated; the
files status carries the flag (bit 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 04:03:19 -05:00
parent 891f21daa9
commit d71e44f3e1
5 changed files with 28 additions and 10 deletions

View File

@@ -34,7 +34,7 @@ namespace {
nlohmann::json instances = nlohmann::json::array();
for (const auto& instance : zone.instances) {
instances.push_back({ {"instance", instance.instanceId}, {"clone", instance.cloneId}, {"players", instance.players},
{"stale", instance.stale}, {"reloading", instance.reloading} });
{"stale", instance.stale}, {"reloading", instance.reloading}, {"outdated", instance.outdated} });
}
zones.push_back({ {"zone", zone.zoneId}, {"zoneName", GameText::ZoneName(zone.zoneId)}, {"changed", changed},
{"files", files}, {"instances", instances}, {"message", zone.message} });

View File

@@ -216,6 +216,9 @@ namespace WorldFileWatch {
START_THEN_STOP, // nobody there, but the zone always has an instance: start a new one, stop this one
STOP, // nobody there: stop it (a new instance starts when someone goes there)
SKIP,
// A property with players: never moved (building in progress isn't saved). It takes nobody new, its players
// are told an update is waiting, and it stops once everyone left (OutdatedInstances.h)
KEEP_UNTIL_EMPTY,
};
inline const char* ActionName(eAction action) {
@@ -224,6 +227,7 @@ namespace WorldFileWatch {
case eAction::START_THEN_STOP: return "start a new one, then stop it";
case eAction::STOP: return "stop";
case eAction::SKIP: return "skip";
case eAction::KEEP_UNTIL_EMPTY: return "keep until everyone left";
}
return "skip";
}
@@ -235,8 +239,8 @@ namespace WorldFileWatch {
};
/**
* What to do with each instance wanted() picks: replace it when players are there, stop it when it is empty (a zone
* in keepZones gets one new public instance first). Character selection loads no zone; instances still starting
* What to do with each instance wanted() picks: replace it when players are there (a property is kept until they
* all left instead), stop it when it is empty (a zone in keepZones gets one new public instance first). Character selection loads no zone; instances still starting
* load the files on disk now (and report them); instances shutting down or being emptied already are left alone.
*/
inline std::vector<Choice> Choose(const std::vector<InstanceMigration::InstanceView>& instances,
@@ -250,7 +254,7 @@ namespace WorldFileWatch {
else if (view.shuttingDown) choice.reason = "Already shutting down";
else if (view.draining) choice.reason = "Its players are already being moved";
else if (!view.ready) choice.reason = "Still starting; it loads the files on disk now";
else if (view.players > 0) choice.action = eAction::REPLACE;
else if (view.players > 0) choice.action = view.cloneId != 0 ? eAction::KEEP_UNTIL_EMPTY : eAction::REPLACE;
else choice.action = eAction::STOP;
choices.push_back(std::move(choice));
}

View File

@@ -105,11 +105,23 @@ namespace {
for (const auto& choice : choices) {
const auto& view = choice.view;
auto& counts = perZone[view.zoneId];
if (choice.action != eAction::SKIP) {
// On the old files: nobody new goes there (a request starts an instance on the files on disk now)
Game::im->MarkOutdated([&view](const Instance& instance) {
return instance.GetMapID() == view.zoneId && instance.GetInstanceID() == view.instanceId;
});
}
switch (choice.action) {
case eAction::SKIP:
LOG("World reload (%s): zone %u instance %u left alone: %s", by.c_str(), view.zoneId, view.instanceId, choice.reason.c_str());
counts.second++;
continue;
case eAction::KEEP_UNTIL_EMPTY:
LOG("World reload (%s): property zone %u clone %u instance %u keeps its %d player(s); it takes nobody new and stops once empty",
by.c_str(), view.zoneId, view.cloneId, view.instanceId, view.players);
counts.first++;
acted++;
continue;
case eAction::STOP:
case eAction::START_THEN_STOP: {
const auto& instance = Game::im->FindInstanceWithPrivate(static_cast<LWOMAPID>(view.zoneId), static_cast<LWOINSTANCEID>(view.instanceId));
@@ -141,9 +153,8 @@ namespace {
request.requesterId = requesterId;
request.requestedBy = ("world reload (" + by + ")").substr(0, InstanceMigrationRequest::MAX_BY);
MigrationCoordinator::Options options;
// Properties, private instances and activity zones are moved too; a property is saved and frozen first
// Private instances and activity zones are moved too (properties never get here: KEEP_UNTIL_EMPTY)
options.liveUpdate = true;
options.prepare = view.cloneId != 0;
g_Migrations[request.requestId] = { view.zoneId, view.instanceId };
const auto refusal = MigrationCoordinator::Start(request, options);
if (refusal != InstanceMigration::eRefusal::NONE) {
@@ -159,7 +170,7 @@ namespace {
}
}
for (const auto& [zone, counts] : perZone) {
std::string message = "Reload (" + by + "): " + std::to_string(counts.first) + " instance(s) replaced or stopped";
std::string message = "Reload (" + by + "): " + std::to_string(counts.first) + " instance(s) replaced, stopped or left to empty";
if (counts.second) message += ", " + std::to_string(counts.second) + " left alone";
SetZoneMessage(zone, message);
}
@@ -224,6 +235,7 @@ namespace {
row.players = instance->GetCurrentClientCount();
row.stale = g_Tracker.IsStale({ zoneId, row.instanceId });
row.reloading = instance->GetIsShuttingDown() || instance->GetIsDraining();
row.outdated = instance->GetIsOutdated();
zone.instances.push_back(row);
}
if (const auto message = g_ZoneMessages.find(zoneId); message != g_ZoneMessages.end()) zone.message = message->second;

View File

@@ -127,6 +127,7 @@ struct WorldFilesStatus : public LUBitStream {
int32_t players{};
bool stale{}; // loaded a version of a file that isn't on disk any more
bool reloading{}; // being replaced, or shutting down
bool outdated{}; // takes nobody new and stops once empty (a property kept until its players left)
bool operator==(const Instance& other) const = default;
};
@@ -168,7 +169,7 @@ struct WorldFilesStatus : public LUBitStream {
stream.Write(instance.instanceId);
stream.Write(instance.cloneId);
stream.Write(instance.players);
stream.Write<uint8_t>((instance.stale ? 1 : 0) | (instance.reloading ? 2 : 0));
stream.Write<uint8_t>((instance.stale ? 1 : 0) | (instance.reloading ? 2 : 0) | (instance.outdated ? 4 : 0));
}
InstanceMigration::WriteText(stream, zone.message, WorldFiles::MAX_MESSAGE);
}
@@ -201,6 +202,7 @@ struct WorldFilesStatus : public LUBitStream {
if (!stream.Read(instance.instanceId) || !stream.Read(instance.cloneId) || !stream.Read(instance.players) || !stream.Read(flags)) return false;
instance.stale = flags & 1;
instance.reloading = flags & 2;
instance.outdated = flags & 4;
}
if (!InstanceMigration::ReadText(stream, zone.message, WorldFiles::MAX_MESSAGE)) return false;
}

View File

@@ -193,7 +193,7 @@ TEST(WorldFileWatchTests, ChooseReplacesBusyAndStopsEmptyInstances) {
View(1100, 1, 5),
View(1100, 2, 0),
View(1200, 3, 2),
View(1150, 4, 1, 77), // a property keeps its clone: replaced like any other
View(1150, 4, 1, 77), // a property with players: never moved, kept until they left
View(0, 5, 3), // character selection
};
auto starting = View(1100, 6, 0);
@@ -212,7 +212,7 @@ TEST(WorldFileWatchTests, ChooseReplacesBusyAndStopsEmptyInstances) {
EXPECT_EQ(byInstance.size(), 7u); // 1200 wasn't wanted
EXPECT_EQ(byInstance[1], eAction::REPLACE);
EXPECT_EQ(byInstance[2], eAction::STOP);
EXPECT_EQ(byInstance[4], eAction::REPLACE);
EXPECT_EQ(byInstance[4], eAction::KEEP_UNTIL_EMPTY);
EXPECT_EQ(byInstance[5], eAction::SKIP);
EXPECT_EQ(byInstance[6], eAction::SKIP);
EXPECT_EQ(byInstance[7], eAction::SKIP);