mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat: instance migration messages and planning
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,16 @@ namespace MessageType {
|
||||
AFFIRM_TRANSFER_REQUEST,
|
||||
AFFIRM_TRANSFER_RESPONSE,
|
||||
|
||||
NEW_SESSION_ALERT
|
||||
NEW_SESSION_ALERT,
|
||||
|
||||
// Move everyone in one instance to another (replace or merge; see InstanceMigration.h). Sent by a world
|
||||
// for a GM command; a dashboard could send it too
|
||||
INSTANCE_MIGRATE,
|
||||
// Master -> source world: send your players to this instance
|
||||
MIGRATE_PLAYERS,
|
||||
// Source world -> master -> every world: how a migration is going
|
||||
MIGRATE_STATUS,
|
||||
// Source world -> master -> target world: what a moved player had that isn't in their saved character
|
||||
MIGRATE_PLAYER_STATE,
|
||||
};
|
||||
}
|
||||
|
||||
395
dNet/InstanceMigration.h
Normal file
395
dNet/InstanceMigration.h
Normal file
@@ -0,0 +1,395 @@
|
||||
#ifndef __INSTANCEMIGRATION__H__
|
||||
#define __INSTANCEMIGRATION__H__
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "BitStream.h"
|
||||
#include "dCommonVars.h"
|
||||
|
||||
/**
|
||||
* Moving everyone in one world instance to another instance of the same zone: to replace an instance (a live update:
|
||||
* a fresh world server, possibly a newer binary, takes over) or to merge two quiet instances into one.
|
||||
*
|
||||
* Normally the client shows a loading screen (see docs/SeamlessTransfer.md): the source world saves each player,
|
||||
* locks them and sends TRANSFER_TO_WORLD with the "Mythran shift" flag, which shows the client's own "Mythran
|
||||
* Dimensional Shift Succeeded!" notice, and the target sends LOAD_STATIC_ZONE. Same zone means the player lands where
|
||||
* they stood. An experimental "seamless" mode skips LOAD_STATIC_ZONE so the client keeps its scene.
|
||||
*
|
||||
* Messages (all appended to MessageType::Master):
|
||||
* INSTANCE_MIGRATE world (a GM's /replaceinstance or /mergeinstance) or a future web dashboard -> master
|
||||
* InstanceMigrationRequest
|
||||
* MIGRATE_PLAYERS master -> source world MigratePlayersOrder
|
||||
* MIGRATE_STATUS source world -> master -> every world (for the GM who asked) MigrationStatus
|
||||
* MIGRATE_PLAYER_STATE source world -> master -> target world CarriedPlayerState
|
||||
*/
|
||||
namespace InstanceMigration {
|
||||
// Length-prefixed string, capped so a bad packet can't ask for huge allocations
|
||||
inline void WriteText(RakNet::BitStream& stream, const std::string& text, uint16_t max) {
|
||||
const auto length = static_cast<uint16_t>(std::min<size_t>(text.size(), max));
|
||||
stream.Write(length);
|
||||
stream.Write(text.data(), length);
|
||||
}
|
||||
|
||||
inline bool ReadText(RakNet::BitStream& stream, std::string& text, uint16_t max) {
|
||||
uint16_t length{};
|
||||
if (!stream.Read(length) || length > max) return false;
|
||||
text.resize(length);
|
||||
return length == 0 || stream.Read(text.data(), length);
|
||||
}
|
||||
|
||||
enum class eKind : uint8_t {
|
||||
REPLACE = 0, // start a fresh instance of the zone, move everyone there, shut the old one down
|
||||
MERGE = 1, // move everyone into another running instance of the zone, shut this one down
|
||||
};
|
||||
|
||||
enum class eState : uint8_t {
|
||||
STARTING_TARGET = 0, // waiting for the new instance to be ready
|
||||
WARNING, // players were told they'll be moved
|
||||
MOVING,
|
||||
DONE,
|
||||
FAILED,
|
||||
};
|
||||
|
||||
inline const char* KindName(eKind kind) { return kind == eKind::MERGE ? "merge" : "replace"; }
|
||||
|
||||
inline const char* StateName(eState state) {
|
||||
switch (state) {
|
||||
case eState::STARTING_TARGET: return "starting_target";
|
||||
case eState::WARNING: return "warning";
|
||||
case eState::MOVING: return "moving";
|
||||
case eState::DONE: return "done";
|
||||
case eState::FAILED: return "failed";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// What master knows about one running instance, enough to plan moves
|
||||
struct InstanceView {
|
||||
uint32_t zoneId{};
|
||||
uint32_t instanceId{};
|
||||
uint32_t cloneId{};
|
||||
int32_t players{};
|
||||
int32_t softCap{};
|
||||
int32_t hardCap{};
|
||||
int32_t reserved{}; // seats held for players being moved in
|
||||
bool ready{};
|
||||
bool isPrivate{};
|
||||
bool shuttingDown{};
|
||||
bool draining{}; // its players are being moved away; nobody new is sent there
|
||||
|
||||
int32_t Load() const { return players + reserved; }
|
||||
};
|
||||
|
||||
enum class eRefusal : uint8_t {
|
||||
NONE = 0,
|
||||
NOT_RUNNING,
|
||||
CHARACTER_SELECT,
|
||||
PRIVATE_INSTANCE,
|
||||
PROPERTY_OR_CLONE,
|
||||
ACTIVITY_ZONE,
|
||||
SHUTTING_DOWN,
|
||||
ALREADY_MIGRATING,
|
||||
NOT_READY,
|
||||
TARGET_IS_SOURCE,
|
||||
TARGET_OTHER_ZONE,
|
||||
TARGET_FULL,
|
||||
NO_TARGET,
|
||||
MASTER_SHUTTING_DOWN,
|
||||
};
|
||||
|
||||
inline const char* Describe(eRefusal refusal) {
|
||||
switch (refusal) {
|
||||
case eRefusal::NONE: return "";
|
||||
case eRefusal::NOT_RUNNING: return "That instance isn't running";
|
||||
case eRefusal::CHARACTER_SELECT: return "Character selection can't be moved";
|
||||
case eRefusal::PRIVATE_INSTANCE: return "Private instances can't be moved";
|
||||
case eRefusal::PROPERTY_OR_CLONE: return "Properties and other cloned instances can't be moved";
|
||||
case eRefusal::ACTIVITY_ZONE: return "Activity instances (races, minigames) can't be moved";
|
||||
case eRefusal::SHUTTING_DOWN: return "That instance is shutting down";
|
||||
case eRefusal::ALREADY_MIGRATING: return "That instance is already being moved or taking players in";
|
||||
case eRefusal::NOT_READY: return "That instance is still starting";
|
||||
case eRefusal::TARGET_IS_SOURCE: return "Pick a different instance to merge into";
|
||||
case eRefusal::TARGET_OTHER_ZONE: return "Instances can only be merged within the same zone";
|
||||
case eRefusal::TARGET_FULL: return "The other instance doesn't have room for everyone";
|
||||
case eRefusal::NO_TARGET: return "No other instance of that zone has room for everyone";
|
||||
case eRefusal::MASTER_SHUTTING_DOWN: return "The server is shutting down";
|
||||
}
|
||||
return "Unknown reason";
|
||||
}
|
||||
|
||||
// Whether the players of this instance may be moved at all. Clones (properties) and activity zones (races,
|
||||
// minigames) keep state that lives only in that world, and private instances belong to someone.
|
||||
inline eRefusal CheckSource(const InstanceView& source, bool activityZone) {
|
||||
if (source.zoneId == 0) return eRefusal::CHARACTER_SELECT;
|
||||
if (source.isPrivate) return eRefusal::PRIVATE_INSTANCE;
|
||||
if (source.cloneId != 0) return eRefusal::PROPERTY_OR_CLONE;
|
||||
if (activityZone) return eRefusal::ACTIVITY_ZONE;
|
||||
if (source.shuttingDown) return eRefusal::SHUTTING_DOWN;
|
||||
if (source.draining) return eRefusal::ALREADY_MIGRATING;
|
||||
if (!source.ready) return eRefusal::NOT_READY;
|
||||
return eRefusal::NONE;
|
||||
}
|
||||
|
||||
// Whether everyone in source fits into target. Merges may fill up to the hard cap: the soft cap only keeps
|
||||
// room for friends joining, and a merge is those players staying together anyway.
|
||||
inline eRefusal CheckMergeTarget(const InstanceView& target, const InstanceView& source) {
|
||||
if (target.zoneId == source.zoneId && target.instanceId == source.instanceId) return eRefusal::TARGET_IS_SOURCE;
|
||||
if (target.zoneId != source.zoneId || target.cloneId != source.cloneId) return eRefusal::TARGET_OTHER_ZONE;
|
||||
if (target.isPrivate) return eRefusal::PRIVATE_INSTANCE;
|
||||
if (target.shuttingDown) return eRefusal::SHUTTING_DOWN;
|
||||
if (target.draining) return eRefusal::ALREADY_MIGRATING;
|
||||
if (!target.ready) return eRefusal::NOT_READY;
|
||||
if (target.Load() + source.players > target.hardCap) return eRefusal::TARGET_FULL;
|
||||
return eRefusal::NONE;
|
||||
}
|
||||
|
||||
// The best instance to merge source into: one where everyone fits under the soft cap if there is one, and of
|
||||
// those the fullest (so players end up together). Ties go to the older instance.
|
||||
inline std::optional<uint32_t> PickMergeTarget(const std::vector<InstanceView>& instances, const InstanceView& source) {
|
||||
const InstanceView* best = nullptr;
|
||||
bool bestUnderSoft = false;
|
||||
for (const auto& candidate : instances) {
|
||||
if (CheckMergeTarget(candidate, source) != eRefusal::NONE) continue;
|
||||
const bool underSoft = candidate.Load() + source.players <= candidate.softCap;
|
||||
if (!best || (underSoft && !bestUnderSoft) ||
|
||||
(underSoft == bestUnderSoft && (candidate.Load() > best->Load() ||
|
||||
(candidate.Load() == best->Load() && candidate.instanceId < best->instanceId)))) {
|
||||
best = &candidate;
|
||||
bestUnderSoft = underSoft;
|
||||
}
|
||||
}
|
||||
if (!best) return std::nullopt;
|
||||
return best->instanceId;
|
||||
}
|
||||
|
||||
struct MergeSuggestion {
|
||||
uint32_t zoneId{};
|
||||
uint32_t sourceInstance{};
|
||||
uint32_t targetInstance{};
|
||||
int32_t players{}; // moved
|
||||
int32_t resulting{}; // in the target afterwards
|
||||
};
|
||||
|
||||
// Quiet instances worth merging: per zone, the emptiest instance goes into the fullest one that stays under the
|
||||
// soft cap, until nothing more fits. Empty instances aren't listed (they shut themselves down).
|
||||
inline std::vector<MergeSuggestion> SuggestMerges(const std::vector<InstanceView>& instances) {
|
||||
std::map<uint32_t, std::vector<InstanceView>> byZone;
|
||||
for (const auto& instance : instances) {
|
||||
if (CheckSource(instance, false) != eRefusal::NONE) continue;
|
||||
byZone[instance.zoneId].push_back(instance);
|
||||
}
|
||||
|
||||
std::vector<MergeSuggestion> suggestions;
|
||||
for (auto& [zone, list] : byZone) {
|
||||
if (list.size() < 2) continue;
|
||||
std::sort(list.begin(), list.end(), [](const InstanceView& a, const InstanceView& b) {
|
||||
return a.Load() != b.Load() ? a.Load() < b.Load() : a.instanceId > b.instanceId;
|
||||
});
|
||||
std::vector<bool> mergedAway(list.size(), false);
|
||||
std::vector<bool> takingIn(list.size(), false);
|
||||
for (size_t i = 0; i < list.size(); i++) {
|
||||
if (takingIn[i] || list[i].players <= 0) continue;
|
||||
// Into the fullest one it still fits in; only ones at least as full (later in the list)
|
||||
for (size_t j = list.size(); j-- > i + 1;) {
|
||||
if (mergedAway[j] || list[j].Load() + list[i].players > list[j].softCap) continue;
|
||||
suggestions.push_back({ zone, list[i].instanceId, list[j].instanceId, list[i].players, list[j].Load() + list[i].players });
|
||||
list[j].reserved += list[i].players;
|
||||
mergedAway[i] = true;
|
||||
takingIn[j] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
enum class ePlayerDecision : uint8_t {
|
||||
MOVE, // move now
|
||||
CANCEL_TRADE_MOVE, // cancel the open trade (nothing changes hands), then move
|
||||
WAIT, // try again next tick
|
||||
};
|
||||
|
||||
// Whether a player can be moved right now. Players who are dead or building wait a little (their state there
|
||||
// isn't saved), then go anyway so a migration always finishes.
|
||||
inline ePlayerDecision DecidePlayer(bool inTrade, bool buildMode, bool dead, float waitedSeconds, float maxWaitSeconds) {
|
||||
if ((buildMode || dead) && waitedSeconds < maxWaitSeconds) return ePlayerDecision::WAIT;
|
||||
if (inTrade) return ePlayerDecision::CANCEL_TRADE_MOVE;
|
||||
return ePlayerDecision::MOVE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INSTANCE_MIGRATE (a world for a GM command, or anything else connected to master): move everyone in one instance. targetInstance 0 picks a target for
|
||||
* merges (the best fit); replaces always start a fresh instance.
|
||||
*/
|
||||
struct InstanceMigrationRequest {
|
||||
static constexpr uint16_t MAX_BY = 64;
|
||||
static constexpr uint16_t MAX_WARN_SECONDS = 300;
|
||||
|
||||
uint32_t requestId{};
|
||||
InstanceMigration::eKind kind{ InstanceMigration::eKind::REPLACE };
|
||||
uint32_t zoneId{};
|
||||
uint32_t sourceInstance{};
|
||||
uint32_t targetInstance{};
|
||||
uint16_t warnSeconds{};
|
||||
bool shutdownSource{ true };
|
||||
bool seamless{}; // experimental: no loading screen (see MigratePlayersOrder::seamless)
|
||||
LWOOBJID requesterId{}; // the character who asked, told how it goes; 0 for none
|
||||
std::string requestedBy;
|
||||
|
||||
void Serialize(RakNet::BitStream& stream) const {
|
||||
stream.Write(requestId);
|
||||
stream.Write(static_cast<uint8_t>(kind));
|
||||
stream.Write(zoneId);
|
||||
stream.Write(sourceInstance);
|
||||
stream.Write(targetInstance);
|
||||
stream.Write(std::min(warnSeconds, MAX_WARN_SECONDS));
|
||||
stream.Write<uint8_t>(shutdownSource ? 1 : 0);
|
||||
stream.Write<uint8_t>(seamless ? 1 : 0);
|
||||
stream.Write(requesterId);
|
||||
InstanceMigration::WriteText(stream, requestedBy, MAX_BY);
|
||||
}
|
||||
|
||||
bool Deserialize(RakNet::BitStream& stream) {
|
||||
uint8_t kindValue{}, shutdown{}, seamlessValue{};
|
||||
if (!stream.Read(requestId) || !stream.Read(kindValue) || !stream.Read(zoneId) || !stream.Read(sourceInstance) ||
|
||||
!stream.Read(targetInstance) || !stream.Read(warnSeconds) || !stream.Read(shutdown) || !stream.Read(seamlessValue) ||
|
||||
!stream.Read(requesterId)) return false;
|
||||
if (kindValue > static_cast<uint8_t>(InstanceMigration::eKind::MERGE) || warnSeconds > MAX_WARN_SECONDS) return false;
|
||||
kind = static_cast<InstanceMigration::eKind>(kindValue);
|
||||
shutdownSource = shutdown != 0;
|
||||
seamless = seamlessValue != 0;
|
||||
return InstanceMigration::ReadText(stream, requestedBy, MAX_BY);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* MIGRATE_PLAYERS (master -> source world): send everyone to this instance. The target is already running and has
|
||||
* seats held for them.
|
||||
*/
|
||||
struct MigratePlayersOrder {
|
||||
static constexpr uint16_t MAX_IP = 255;
|
||||
|
||||
uint32_t migrationId{};
|
||||
uint32_t targetZone{};
|
||||
uint32_t targetInstance{};
|
||||
uint32_t targetClone{};
|
||||
std::string targetIp;
|
||||
uint16_t targetPort{};
|
||||
uint16_t warnSeconds{};
|
||||
uint16_t playersPerSecond{ 10 };
|
||||
bool mythranShift{ true }; // the client's own "moved to a new dimension" notice after the transfer
|
||||
/**
|
||||
* Experimental: keep the client's scene. The source destroys every object it sent the client (the client keeps
|
||||
* its own player object when its ghost goes), then transfers; the target skips LOAD_STATIC_ZONE and constructs
|
||||
* everything at once, and the client adopts its existing player object for the new ghost
|
||||
* (LwoClientGhostManager::OnReceiveConstruction). Not tested with the real client yet.
|
||||
*/
|
||||
bool seamless{};
|
||||
|
||||
void Serialize(RakNet::BitStream& stream) const {
|
||||
stream.Write(migrationId);
|
||||
stream.Write(targetZone);
|
||||
stream.Write(targetInstance);
|
||||
stream.Write(targetClone);
|
||||
InstanceMigration::WriteText(stream, targetIp, MAX_IP);
|
||||
stream.Write(targetPort);
|
||||
stream.Write(warnSeconds);
|
||||
stream.Write(playersPerSecond);
|
||||
stream.Write<uint8_t>(mythranShift ? 1 : 0);
|
||||
stream.Write<uint8_t>(seamless ? 1 : 0);
|
||||
}
|
||||
|
||||
bool Deserialize(RakNet::BitStream& stream) {
|
||||
uint8_t shift{}, seamlessValue{};
|
||||
if (!stream.Read(migrationId) || !stream.Read(targetZone) || !stream.Read(targetInstance) || !stream.Read(targetClone) ||
|
||||
!InstanceMigration::ReadText(stream, targetIp, MAX_IP) || !stream.Read(targetPort) || !stream.Read(warnSeconds) ||
|
||||
!stream.Read(playersPerSecond) || !stream.Read(shift) || !stream.Read(seamlessValue)) return false;
|
||||
mythranShift = shift != 0;
|
||||
seamless = seamlessValue != 0;
|
||||
if (playersPerSecond == 0) playersPerSecond = 1;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* MIGRATE_STATUS (source world -> master, master -> every world): progress of one migration. Master passes it on
|
||||
* with who asked for it, and sends its own (STARTING_TARGET, FAILED) the same way.
|
||||
*/
|
||||
struct MigrationStatus {
|
||||
static constexpr uint16_t MAX_MESSAGE = 300;
|
||||
|
||||
uint32_t migrationId{};
|
||||
InstanceMigration::eState state{ InstanceMigration::eState::STARTING_TARGET };
|
||||
InstanceMigration::eKind kind{ InstanceMigration::eKind::REPLACE };
|
||||
uint32_t zoneId{};
|
||||
uint32_t sourceInstance{};
|
||||
uint32_t targetInstance{};
|
||||
uint16_t moved{};
|
||||
uint16_t remaining{};
|
||||
uint16_t failed{};
|
||||
LWOOBJID requesterId{}; // filled in by master
|
||||
std::string message;
|
||||
|
||||
bool Finished() const { return state == InstanceMigration::eState::DONE || state == InstanceMigration::eState::FAILED; }
|
||||
|
||||
void Serialize(RakNet::BitStream& stream) const {
|
||||
stream.Write(migrationId);
|
||||
stream.Write(static_cast<uint8_t>(state));
|
||||
stream.Write(static_cast<uint8_t>(kind));
|
||||
stream.Write(zoneId);
|
||||
stream.Write(sourceInstance);
|
||||
stream.Write(targetInstance);
|
||||
stream.Write(moved);
|
||||
stream.Write(remaining);
|
||||
stream.Write(failed);
|
||||
stream.Write(requesterId);
|
||||
InstanceMigration::WriteText(stream, message, MAX_MESSAGE);
|
||||
}
|
||||
|
||||
bool Deserialize(RakNet::BitStream& stream) {
|
||||
uint8_t stateValue{}, kindValue{};
|
||||
if (!stream.Read(migrationId) || !stream.Read(stateValue) || !stream.Read(kindValue) || !stream.Read(zoneId) ||
|
||||
!stream.Read(sourceInstance) || !stream.Read(targetInstance) || !stream.Read(moved) || !stream.Read(remaining) ||
|
||||
!stream.Read(failed) || !stream.Read(requesterId)) return false;
|
||||
if (stateValue > static_cast<uint8_t>(InstanceMigration::eState::FAILED) || kindValue > static_cast<uint8_t>(InstanceMigration::eKind::MERGE)) return false;
|
||||
state = static_cast<InstanceMigration::eState>(stateValue);
|
||||
kind = static_cast<InstanceMigration::eKind>(kindValue);
|
||||
return InstanceMigration::ReadText(stream, message, MAX_MESSAGE);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* MIGRATE_PLAYER_STATE (source world -> master -> target world): what a moved player had that their saved
|
||||
* character doesn't keep, put back when they finish loading in the target. Everything else (position, health,
|
||||
* imagination, armor, buffs, inventory, missions) is in the character XML, saved just before the transfer.
|
||||
*/
|
||||
struct CarriedPlayerState {
|
||||
uint32_t targetZone{};
|
||||
uint32_t targetInstance{};
|
||||
LWOOBJID characterId{};
|
||||
LWOOBJID petItemId{}; // the pet that was out (its item), summoned again; 0 for none
|
||||
bool seamless{}; // the client kept its scene: skip LOAD_STATIC_ZONE and load the player at once
|
||||
|
||||
void Serialize(RakNet::BitStream& stream) const {
|
||||
stream.Write(targetZone);
|
||||
stream.Write(targetInstance);
|
||||
stream.Write(characterId);
|
||||
stream.Write(petItemId);
|
||||
stream.Write<uint8_t>(seamless ? 1 : 0);
|
||||
}
|
||||
|
||||
bool Deserialize(RakNet::BitStream& stream) {
|
||||
uint8_t seamlessValue{};
|
||||
if (!stream.Read(targetZone) || !stream.Read(targetInstance) || !stream.Read(characterId) || !stream.Read(petItemId) || !stream.Read(seamlessValue)) return false;
|
||||
seamless = seamlessValue != 0;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
#endif //!__INSTANCEMIGRATION__H__
|
||||
@@ -2,6 +2,7 @@ set(DCOMMONTEST_SOURCES
|
||||
"AMFDeserializeTests.cpp"
|
||||
"Amf3Tests.cpp"
|
||||
"ToUnderlyingTests.cpp"
|
||||
"InstanceMigrationTests.cpp"
|
||||
"HeaderSkipTest.cpp"
|
||||
"TestCDFeatureGatingTable.cpp"
|
||||
"TestLDFFormat.cpp"
|
||||
|
||||
251
tests/dCommonTests/InstanceMigrationTests.cpp
Normal file
251
tests/dCommonTests/InstanceMigrationTests.cpp
Normal file
@@ -0,0 +1,251 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include "InstanceMigration.h"
|
||||
|
||||
using namespace InstanceMigration;
|
||||
|
||||
namespace {
|
||||
InstanceView Instance(uint32_t zone, uint32_t instance, int32_t players, int32_t soft = 8, int32_t hard = 12) {
|
||||
InstanceView view;
|
||||
view.zoneId = zone;
|
||||
view.instanceId = instance;
|
||||
view.players = players;
|
||||
view.softCap = soft;
|
||||
view.hardCap = hard;
|
||||
view.ready = true;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, SourcesThatCantMove) {
|
||||
EXPECT_EQ(CheckSource(Instance(1100, 1, 3), false), eRefusal::NONE);
|
||||
EXPECT_EQ(CheckSource(Instance(0, 1, 3), false), eRefusal::CHARACTER_SELECT);
|
||||
EXPECT_EQ(CheckSource(Instance(1203, 1, 3), true), eRefusal::ACTIVITY_ZONE);
|
||||
|
||||
auto property = Instance(1150, 2, 1);
|
||||
property.cloneId = 55;
|
||||
EXPECT_EQ(CheckSource(property, false), eRefusal::PROPERTY_OR_CLONE);
|
||||
|
||||
auto privateInstance = Instance(1100, 3, 1);
|
||||
privateInstance.isPrivate = true;
|
||||
EXPECT_EQ(CheckSource(privateInstance, false), eRefusal::PRIVATE_INSTANCE);
|
||||
|
||||
auto draining = Instance(1100, 4, 1);
|
||||
draining.draining = true;
|
||||
EXPECT_EQ(CheckSource(draining, false), eRefusal::ALREADY_MIGRATING);
|
||||
|
||||
auto starting = Instance(1100, 5, 0);
|
||||
starting.ready = false;
|
||||
EXPECT_EQ(CheckSource(starting, false), eRefusal::NOT_READY);
|
||||
|
||||
auto stopping = Instance(1100, 6, 2);
|
||||
stopping.shuttingDown = true;
|
||||
EXPECT_EQ(CheckSource(stopping, false), eRefusal::SHUTTING_DOWN);
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, MergeTargetMustFitEveryone) {
|
||||
const auto source = Instance(1100, 1, 5);
|
||||
EXPECT_EQ(CheckMergeTarget(Instance(1100, 2, 7), source), eRefusal::NONE); // 12 = hard cap
|
||||
EXPECT_EQ(CheckMergeTarget(Instance(1100, 2, 8), source), eRefusal::TARGET_FULL);
|
||||
EXPECT_EQ(CheckMergeTarget(Instance(1100, 1, 5), source), eRefusal::TARGET_IS_SOURCE);
|
||||
EXPECT_EQ(CheckMergeTarget(Instance(1200, 2, 0), source), eRefusal::TARGET_OTHER_ZONE);
|
||||
|
||||
// Seats held for another move count
|
||||
auto busy = Instance(1100, 3, 4);
|
||||
busy.reserved = 4;
|
||||
EXPECT_EQ(CheckMergeTarget(busy, source), eRefusal::TARGET_FULL);
|
||||
|
||||
auto draining = Instance(1100, 4, 0);
|
||||
draining.draining = true;
|
||||
EXPECT_EQ(CheckMergeTarget(draining, source), eRefusal::ALREADY_MIGRATING);
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, PickMergeTargetPrefersFullestUnderSoftCap) {
|
||||
const auto source = Instance(1100, 1, 2);
|
||||
const std::vector<InstanceView> instances = {
|
||||
source,
|
||||
Instance(1100, 2, 3), // 5 after: fits the soft cap
|
||||
Instance(1100, 3, 6), // 8 after: fits the soft cap, fuller
|
||||
Instance(1100, 4, 9), // 11 after: only under the hard cap
|
||||
Instance(1200, 5, 1), // another zone
|
||||
};
|
||||
EXPECT_EQ(PickMergeTarget(instances, source), 3u);
|
||||
|
||||
// When nothing stays under the soft cap, the hard cap still allows it
|
||||
const std::vector<InstanceView> crowded = { source, Instance(1100, 4, 9), Instance(1100, 6, 10) };
|
||||
EXPECT_EQ(PickMergeTarget(crowded, source), 6u);
|
||||
|
||||
// Nobody has room
|
||||
const std::vector<InstanceView> full = { source, Instance(1100, 7, 11) };
|
||||
EXPECT_FALSE(PickMergeTarget(full, source).has_value());
|
||||
|
||||
// Equal loads: the older instance
|
||||
const std::vector<InstanceView> tie = { source, Instance(1100, 9, 3), Instance(1100, 8, 3) };
|
||||
EXPECT_EQ(PickMergeTarget(tie, source), 8u);
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, SuggestMergesPairsQuietInstances) {
|
||||
const std::vector<InstanceView> instances = {
|
||||
Instance(1100, 1, 1),
|
||||
Instance(1100, 2, 2),
|
||||
Instance(1100, 3, 6),
|
||||
Instance(1200, 4, 3), // alone in its zone
|
||||
Instance(1300, 5, 0), // empty: shuts itself down
|
||||
Instance(1300, 6, 2),
|
||||
};
|
||||
const auto suggestions = SuggestMerges(instances);
|
||||
// 1 goes into the fullest that fits (3: 6 + 1 = 7); 2 no longer fits there (7 + 2 > 8). Zone 1300's only
|
||||
// non-empty instance isn't moved into an empty one (that one shuts itself down anyway)
|
||||
ASSERT_EQ(suggestions.size(), 1u);
|
||||
EXPECT_EQ(suggestions[0].zoneId, 1100u);
|
||||
EXPECT_EQ(suggestions[0].sourceInstance, 1u);
|
||||
EXPECT_EQ(suggestions[0].targetInstance, 3u);
|
||||
EXPECT_EQ(suggestions[0].players, 1);
|
||||
EXPECT_EQ(suggestions[0].resulting, 7);
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, SuggestMergesSkipsWhatCantMove) {
|
||||
auto property = Instance(1150, 1, 1);
|
||||
property.cloneId = 9;
|
||||
auto otherProperty = Instance(1150, 2, 1);
|
||||
otherProperty.cloneId = 10;
|
||||
auto draining = Instance(1100, 3, 1);
|
||||
draining.draining = true;
|
||||
EXPECT_TRUE(SuggestMerges({ property, otherProperty, draining, Instance(1100, 4, 2) }).empty());
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, BusyPlayersWaitThenGo) {
|
||||
EXPECT_EQ(DecidePlayer(false, false, false, 0.0f, 15.0f), ePlayerDecision::MOVE);
|
||||
EXPECT_EQ(DecidePlayer(true, false, false, 0.0f, 15.0f), ePlayerDecision::CANCEL_TRADE_MOVE);
|
||||
EXPECT_EQ(DecidePlayer(false, true, false, 1.0f, 15.0f), ePlayerDecision::WAIT);
|
||||
EXPECT_EQ(DecidePlayer(false, false, true, 1.0f, 15.0f), ePlayerDecision::WAIT);
|
||||
EXPECT_EQ(DecidePlayer(false, false, true, 15.0f, 15.0f), ePlayerDecision::MOVE);
|
||||
EXPECT_EQ(DecidePlayer(true, true, false, 20.0f, 15.0f), ePlayerDecision::CANCEL_TRADE_MOVE);
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, RequestRoundTrip) {
|
||||
InstanceMigrationRequest request;
|
||||
request.requestId = 77;
|
||||
request.kind = eKind::MERGE;
|
||||
request.zoneId = 1100;
|
||||
request.sourceInstance = 4;
|
||||
request.targetInstance = 2;
|
||||
request.warnSeconds = 30;
|
||||
request.shutdownSource = false;
|
||||
request.seamless = true;
|
||||
request.requesterId = 1152921510436607007LL;
|
||||
request.requestedBy = "admin";
|
||||
|
||||
RakNet::BitStream stream;
|
||||
request.Serialize(stream);
|
||||
InstanceMigrationRequest read;
|
||||
ASSERT_TRUE(read.Deserialize(stream));
|
||||
EXPECT_EQ(read.requestId, 77u);
|
||||
EXPECT_EQ(read.kind, eKind::MERGE);
|
||||
EXPECT_EQ(read.zoneId, 1100u);
|
||||
EXPECT_EQ(read.sourceInstance, 4u);
|
||||
EXPECT_EQ(read.targetInstance, 2u);
|
||||
EXPECT_EQ(read.warnSeconds, 30u);
|
||||
EXPECT_FALSE(read.shutdownSource);
|
||||
EXPECT_TRUE(read.seamless);
|
||||
EXPECT_EQ(read.requesterId, 1152921510436607007LL);
|
||||
EXPECT_EQ(read.requestedBy, "admin");
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, RequestRejectsBadValues) {
|
||||
RakNet::BitStream stream;
|
||||
stream.Write<uint32_t>(1);
|
||||
stream.Write<uint8_t>(7); // no such kind
|
||||
stream.Write<uint32_t>(1100);
|
||||
stream.Write<uint32_t>(1);
|
||||
stream.Write<uint32_t>(0);
|
||||
stream.Write<uint16_t>(10);
|
||||
stream.Write<uint8_t>(1);
|
||||
stream.Write<uint8_t>(0);
|
||||
stream.Write<LWOOBJID>(0);
|
||||
stream.Write<uint16_t>(0);
|
||||
InstanceMigrationRequest read;
|
||||
EXPECT_FALSE(read.Deserialize(stream));
|
||||
|
||||
RakNet::BitStream truncated;
|
||||
truncated.Write<uint32_t>(1);
|
||||
EXPECT_FALSE(read.Deserialize(truncated));
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, OrderRoundTrip) {
|
||||
MigratePlayersOrder order;
|
||||
order.migrationId = 5;
|
||||
order.targetZone = 1200;
|
||||
order.targetInstance = 9;
|
||||
order.targetClone = 0;
|
||||
order.targetIp = "10.0.0.2";
|
||||
order.targetPort = 3003;
|
||||
order.warnSeconds = 10;
|
||||
order.playersPerSecond = 4;
|
||||
order.mythranShift = false;
|
||||
order.seamless = true;
|
||||
|
||||
RakNet::BitStream stream;
|
||||
order.Serialize(stream);
|
||||
MigratePlayersOrder read;
|
||||
ASSERT_TRUE(read.Deserialize(stream));
|
||||
EXPECT_EQ(read.migrationId, 5u);
|
||||
EXPECT_EQ(read.targetZone, 1200u);
|
||||
EXPECT_EQ(read.targetInstance, 9u);
|
||||
EXPECT_EQ(read.targetIp, "10.0.0.2");
|
||||
EXPECT_EQ(read.targetPort, 3003u);
|
||||
EXPECT_EQ(read.warnSeconds, 10u);
|
||||
EXPECT_EQ(read.playersPerSecond, 4u);
|
||||
EXPECT_FALSE(read.mythranShift);
|
||||
EXPECT_TRUE(read.seamless);
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, StatusRoundTrip) {
|
||||
MigrationStatus status;
|
||||
status.migrationId = 12;
|
||||
status.state = eState::MOVING;
|
||||
status.kind = eKind::REPLACE;
|
||||
status.zoneId = 1100;
|
||||
status.sourceInstance = 1;
|
||||
status.targetInstance = 7;
|
||||
status.moved = 3;
|
||||
status.remaining = 2;
|
||||
status.failed = 1;
|
||||
status.requesterId = 42;
|
||||
status.message = "Moving players";
|
||||
|
||||
RakNet::BitStream stream;
|
||||
status.Serialize(stream);
|
||||
MigrationStatus read;
|
||||
ASSERT_TRUE(read.Deserialize(stream));
|
||||
EXPECT_EQ(read.migrationId, 12u);
|
||||
EXPECT_EQ(read.state, eState::MOVING);
|
||||
EXPECT_EQ(read.kind, eKind::REPLACE);
|
||||
EXPECT_EQ(read.targetInstance, 7u);
|
||||
EXPECT_EQ(read.moved, 3u);
|
||||
EXPECT_EQ(read.remaining, 2u);
|
||||
EXPECT_EQ(read.failed, 1u);
|
||||
EXPECT_EQ(read.requesterId, 42);
|
||||
EXPECT_EQ(read.message, "Moving players");
|
||||
EXPECT_FALSE(read.Finished());
|
||||
read.state = eState::FAILED;
|
||||
EXPECT_TRUE(read.Finished());
|
||||
}
|
||||
|
||||
TEST(InstanceMigrationTest, CarriedStateRoundTrip) {
|
||||
CarriedPlayerState state;
|
||||
state.targetZone = 1100;
|
||||
state.targetInstance = 3;
|
||||
state.characterId = 1152921510436607007LL;
|
||||
state.petItemId = 1152921510436607100LL;
|
||||
state.seamless = true;
|
||||
|
||||
RakNet::BitStream stream;
|
||||
state.Serialize(stream);
|
||||
CarriedPlayerState read;
|
||||
ASSERT_TRUE(read.Deserialize(stream));
|
||||
EXPECT_EQ(read.targetZone, 1100u);
|
||||
EXPECT_EQ(read.targetInstance, 3u);
|
||||
EXPECT_EQ(read.characterId, 1152921510436607007LL);
|
||||
EXPECT_EQ(read.petItemId, 1152921510436607100LL);
|
||||
EXPECT_TRUE(read.seamless);
|
||||
}
|
||||
Reference in New Issue
Block a user