diff --git a/dCommon/dEnums/MessageType/Master.h b/dCommon/dEnums/MessageType/Master.h index 1529ca511..6dd03420e 100644 --- a/dCommon/dEnums/MessageType/Master.h +++ b/dCommon/dEnums/MessageType/Master.h @@ -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, }; } diff --git a/dNet/InstanceMigration.h b/dNet/InstanceMigration.h new file mode 100644 index 000000000..d858f1312 --- /dev/null +++ b/dNet/InstanceMigration.h @@ -0,0 +1,395 @@ +#ifndef __INSTANCEMIGRATION__H__ +#define __INSTANCEMIGRATION__H__ + +#include +#include +#include +#include +#include +#include + +#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(std::min(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 PickMergeTarget(const std::vector& 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 SuggestMerges(const std::vector& instances) { + std::map> byZone; + for (const auto& instance : instances) { + if (CheckSource(instance, false) != eRefusal::NONE) continue; + byZone[instance.zoneId].push_back(instance); + } + + std::vector 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 mergedAway(list.size(), false); + std::vector 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(kind)); + stream.Write(zoneId); + stream.Write(sourceInstance); + stream.Write(targetInstance); + stream.Write(std::min(warnSeconds, MAX_WARN_SECONDS)); + stream.Write(shutdownSource ? 1 : 0); + stream.Write(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(InstanceMigration::eKind::MERGE) || warnSeconds > MAX_WARN_SECONDS) return false; + kind = static_cast(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(mythranShift ? 1 : 0); + stream.Write(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(state)); + stream.Write(static_cast(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(InstanceMigration::eState::FAILED) || kindValue > static_cast(InstanceMigration::eKind::MERGE)) return false; + state = static_cast(stateValue); + kind = static_cast(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(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__ diff --git a/tests/dCommonTests/CMakeLists.txt b/tests/dCommonTests/CMakeLists.txt index d5ecacfb0..debf52854 100644 --- a/tests/dCommonTests/CMakeLists.txt +++ b/tests/dCommonTests/CMakeLists.txt @@ -2,6 +2,7 @@ set(DCOMMONTEST_SOURCES "AMFDeserializeTests.cpp" "Amf3Tests.cpp" "ToUnderlyingTests.cpp" + "InstanceMigrationTests.cpp" "HeaderSkipTest.cpp" "TestCDFeatureGatingTable.cpp" "TestLDFFormat.cpp" diff --git a/tests/dCommonTests/InstanceMigrationTests.cpp b/tests/dCommonTests/InstanceMigrationTests.cpp new file mode 100644 index 000000000..f5fe7b956 --- /dev/null +++ b/tests/dCommonTests/InstanceMigrationTests.cpp @@ -0,0 +1,251 @@ +#include +#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 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 crowded = { source, Instance(1100, 4, 9), Instance(1100, 6, 10) }; + EXPECT_EQ(PickMergeTarget(crowded, source), 6u); + + // Nobody has room + const std::vector full = { source, Instance(1100, 7, 11) }; + EXPECT_FALSE(PickMergeTarget(full, source).has_value()); + + // Equal loads: the older instance + const std::vector tie = { source, Instance(1100, 9, 3), Instance(1100, 8, 3) }; + EXPECT_EQ(PickMergeTarget(tie, source), 8u); +} + +TEST(InstanceMigrationTest, SuggestMergesPairsQuietInstances) { + const std::vector 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(1); + stream.Write(7); // no such kind + stream.Write(1100); + stream.Write(1); + stream.Write(0); + stream.Write(10); + stream.Write(1); + stream.Write(0); + stream.Write(0); + stream.Write(0); + InstanceMigrationRequest read; + EXPECT_FALSE(read.Deserialize(stream)); + + RakNet::BitStream truncated; + truncated.Write(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); +}