feat: instance migration messages and planning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 08:40:52 -05:00
parent 00b7edfcf0
commit b1930ed4ed
4 changed files with 658 additions and 1 deletions

View File

@@ -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
View 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__

View File

@@ -2,6 +2,7 @@ set(DCOMMONTEST_SOURCES
"AMFDeserializeTests.cpp"
"Amf3Tests.cpp"
"ToUnderlyingTests.cpp"
"InstanceMigrationTests.cpp"
"HeaderSkipTest.cpp"
"TestCDFeatureGatingTable.cpp"
"TestLDFFormat.cpp"

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