#include "testHarness.h" #include "vf/NetServer.h" #include #include #include #include using namespace vf; namespace { struct Lcg { uint64_t state; explicit Lcg(uint64_t seed) : state(seed | 1ull) {} uint32_t next() { state = state * 6364136223846793005ull + 1442695040888963407ull; return static_cast(state >> 32); } int range(int n) { return static_cast(next() % static_cast(n)); } }; void buildTerrain(World& world, int chunkMin, int chunkMax) { Lcg rng(0x1a2b3c4d); const Block palette[4] = {Block::Stone, Block::Dirt, Block::Grass, Block::Sand}; for (int cx = chunkMin; cx <= chunkMax; ++cx) { for (int cz = chunkMin; cz <= chunkMax; ++cz) { Chunk& chunk = world.ensureChunk(Vec3i{cx, 0, cz}); for (int x = 0; x < chunkSize; ++x) for (int z = 0; z < chunkSize; ++z) for (int y = 0; y < 6; ++y) chunk.set(x, y, z, palette[rng.range(4)]); } } } // The far end of one slot: an independent transport and session on endpoint 1 // of that slot's link, which is exactly what a real remote player would be. struct RemoteClient { World world; std::unique_ptr transport; std::unique_ptr session; RemoteClient(NetServer& server, int slot, const TransportConfig& transportConfig, const SessionConfig& sessionConfig, const std::string& name) { transport.reset(new Transport(*server.linkFor(slot), 1, transportConfig)); session.reset(new NetSession(*transport, NetSession::Role::Client, sessionConfig)); session->attachWorld(world); session->beginHandshake(name, 0x1000 + static_cast(slot), 0x2000); } void step(uint64_t tick) { session->receive(tick); session->send(tick); } }; SessionConfig clientConfigFor(const ServerConfig& config) { SessionConfig sessionConfig; sessionConfig.protocolVersion = config.protocolVersion; sessionConfig.viewRadius = config.viewRadius; sessionConfig.keepAliveIntervalTicks = config.keepAliveIntervalTicks; sessionConfig.keepAliveTimeoutTicks = config.keepAliveTimeoutTicks; return sessionConfig; } ServerConfig quietServerConfig() { ServerConfig config; config.maxClients = 4; config.viewRadius = 1; config.chunksPerClientPerTick = 4; config.keepAliveIntervalTicks = 10; config.keepAliveTimeoutTicks = 1000000; config.link.latencyTicks = 1; return config; } // Seeds a slot's view before login; the first snapshot burst then goes out // around this spot instead of around the origin. void placeAt(NetServer& server, int slot, float x, float y, float z) { server.sessionFor(slot)->view().position = Vec3{x, y, z}; } } // namespace VF_TEST(twoClientsStandingApartReceiveDisjointSetsOfChunks) { ServerConfig config = quietServerConfig(); World world; NetServer server(world, config); buildTerrain(world, -6, 6); int slotNear = server.acceptClient(); int slotFar = server.acceptClient(); CHECK_EQ(slotNear, 0); CHECK_EQ(slotFar, 1); placeAt(server, slotNear, 8.0f, 8.0f, 8.0f); placeAt(server, slotFar, 72.0f, 8.0f, 72.0f); TransportConfig transportConfig = config.transport; SessionConfig sessionConfig = clientConfigFor(config); RemoteClient near(server, slotNear, transportConfig, sessionConfig, "near"); RemoteClient far(server, slotFar, transportConfig, sessionConfig, "far"); for (uint64_t tick = 0; tick < 60; ++tick) { server.update(tick); near.step(tick); far.step(tick); } CHECK(near.session->phase() == SessionPhase::Play); CHECK(far.session->phase() == SessionPhase::Play); const ClientView& nearView = server.sessionFor(slotNear)->view(); const ClientView& farView = server.sessionFor(slotFar)->view(); CHECK_EQ(nearView.sent.size(), static_cast(5)); CHECK_EQ(farView.sent.size(), static_cast(5)); std::size_t shared = 0; for (const Vec3i& coord : nearView.sent) if (farView.sent.count(coord) != 0) ++shared; CHECK_EQ(shared, static_cast(0)); CHECK(nearView.hasChunk(Vec3i{0, 0, 0})); CHECK(farView.hasChunk(Vec3i{4, 0, 4})); CHECK(!nearView.hasChunk(Vec3i{4, 0, 4})); CHECK(!farView.hasChunk(Vec3i{0, 0, 0})); CHECK_EQ(near.world.chunkCount(), static_cast(5)); CHECK_EQ(far.world.chunkCount(), static_cast(5)); CHECK(near.world.chunkAt(Vec3i{4, 0, 4}) == nullptr); CHECK(far.world.chunkAt(Vec3i{0, 0, 0}) == nullptr); CHECK_EQ(server.stats().chunksSent, static_cast(10)); } VF_TEST(aBlockChangeInsideBothViewsReachesEachClientExactlyOnce) { ServerConfig config = quietServerConfig(); config.viewRadius = 2; World world; NetServer server(world, config); buildTerrain(world, -4, 4); int slotLeft = server.acceptClient(); int slotRight = server.acceptClient(); // Both stand two chunks apart, so chunk (1,0,0) is inside both spheres. placeAt(server, slotLeft, 8.0f, 8.0f, 8.0f); placeAt(server, slotRight, 40.0f, 8.0f, 8.0f); TransportConfig transportConfig = config.transport; SessionConfig sessionConfig = clientConfigFor(config); sessionConfig.viewRadius = 2; RemoteClient left(server, slotLeft, transportConfig, sessionConfig, "left"); RemoteClient right(server, slotRight, transportConfig, sessionConfig, "right"); for (uint64_t tick = 0; tick < 80; ++tick) { server.update(tick); left.step(tick); right.step(tick); } CHECK(server.sessionFor(slotLeft)->view().hasChunk(Vec3i{1, 0, 0})); CHECK(server.sessionFor(slotRight)->view().hasChunk(Vec3i{1, 0, 0})); server.setBlock(20, 3, 5, Block::Glowstone); for (uint64_t tick = 80; tick < 120; ++tick) { server.update(tick); left.step(tick); right.step(tick); } CHECK(left.world.blockAt(20, 3, 5) == Block::Glowstone); CHECK(right.world.blockAt(20, 3, 5) == Block::Glowstone); CHECK_EQ(left.session->stats().blockChangesApplied, static_cast(1)); CHECK_EQ(right.session->stats().blockChangesApplied, static_cast(1)); CHECK_EQ(server.stats().blockUpdatesSent, static_cast(2)); CHECK_EQ(server.stats().editsApplied, static_cast(1)); } VF_TEST(aBlockChangeIsNeverSentToAClientThatDoesNotHoldThatChunk) { ServerConfig config = quietServerConfig(); World world; NetServer server(world, config); buildTerrain(world, -6, 6); int slotNear = server.acceptClient(); int slotFar = server.acceptClient(); placeAt(server, slotNear, 8.0f, 8.0f, 8.0f); placeAt(server, slotFar, 72.0f, 8.0f, 72.0f); TransportConfig transportConfig = config.transport; SessionConfig sessionConfig = clientConfigFor(config); RemoteClient near(server, slotNear, transportConfig, sessionConfig, "near"); RemoteClient far(server, slotFar, transportConfig, sessionConfig, "far"); for (uint64_t tick = 0; tick < 60; ++tick) { server.update(tick); near.step(tick); far.step(tick); } for (int i = 0; i < 3; ++i) server.setBlock(i, 3, 0, Block::Obsidian); for (uint64_t tick = 60; tick < 100; ++tick) { server.update(tick); near.step(tick); far.step(tick); } for (int i = 0; i < 3; ++i) CHECK(near.world.blockAt(i, 3, 0) == Block::Obsidian); CHECK_EQ(near.session->stats().blockChangesApplied, static_cast(3)); CHECK_EQ(far.session->stats().blockChangesApplied, static_cast(0)); CHECK_EQ(far.session->stats().multiBlockChangesApplied, static_cast(0)); CHECK_EQ(far.world.chunkAt(Vec3i{0, 0, 0}), nullptr); CHECK_EQ(server.stats().blockUpdatesSent, static_cast(3)); } VF_TEST(anEditFromOneClientIsBroadcastToTheOtherThroughTheAuthoritativeWorld) { ServerConfig config = quietServerConfig(); World world; NetServer server(world, config); buildTerrain(world, -2, 2); int slotA = server.acceptClient(); int slotB = server.acceptClient(); placeAt(server, slotA, 8.0f, 8.0f, 8.0f); placeAt(server, slotB, 8.0f, 8.0f, 8.0f); TransportConfig transportConfig = config.transport; SessionConfig sessionConfig = clientConfigFor(config); RemoteClient builder(server, slotA, transportConfig, sessionConfig, "builder"); RemoteClient watcher(server, slotB, transportConfig, sessionConfig, "watcher"); for (uint64_t tick = 0; tick < 40; ++tick) { server.update(tick); builder.step(tick); watcher.step(tick); } builder.session->requestBlockEdit(4, 9, 4, Block::Glowstone); for (uint64_t tick = 40; tick < 80; ++tick) { server.update(tick); builder.step(tick); watcher.step(tick); } CHECK(world.blockAt(4, 9, 4) == Block::Glowstone); CHECK(builder.world.blockAt(4, 9, 4) == Block::Glowstone); CHECK(watcher.world.blockAt(4, 9, 4) == Block::Glowstone); CHECK_EQ(server.stats().editsApplied, static_cast(1)); CHECK_EQ(watcher.session->stats().blockChangesApplied, static_cast(1)); } VF_TEST(theBandwidthBudgetDelaysPacketsAcrossTicksWithoutLosingAnyOfThem) { ServerConfig config = quietServerConfig(); config.viewRadius = 1; config.chunksPerClientPerTick = 4; config.bytesPerClientPerTick = 220; World world; NetServer server(world, config); buildTerrain(world, -3, 3); int slot = server.acceptClient(); placeAt(server, slot, 8.0f, 8.0f, 8.0f); TransportConfig transportConfig = config.transport; RemoteClient only(server, slot, transportConfig, clientConfigFor(config), "budgeted"); bool everQueued = false; uint64_t peakQueuedBytes = 0; for (uint64_t tick = 0; tick < 40; ++tick) { server.update(tick); only.step(tick); if (server.stats().queuedPackets > 0) everQueued = true; if (server.stats().queuedBytes > peakQueuedBytes) peakQueuedBytes = server.stats().queuedBytes; // A single oversized packet is allowed through on its own, but the // budget must never be exceeded by a whole extra packet. CHECK(server.stats().bytesSentThisTick <= 220u + 4096u); } CHECK(everQueued); CHECK(peakQueuedBytes > 220u); for (uint64_t tick = 40; tick < 400; ++tick) { server.update(tick); only.step(tick); } CHECK_EQ(server.stats().queuedPackets, static_cast(0)); CHECK_EQ(only.world.chunkCount(), static_cast(5)); CHECK_EQ(only.session->stats().chunksApplied, static_cast(5)); // Nothing was dropped on the way: the client sees the same blocks. for (int x = 0; x < chunkSize; ++x) for (int z = 0; z < chunkSize; ++z) CHECK(only.world.blockAt(x, 3, z) == world.blockAt(x, 3, z)); } VF_TEST(anUnbudgetedServerSendsTheSameChunksInFarFewerTicks) { ServerConfig budgeted = quietServerConfig(); budgeted.viewRadius = 1; budgeted.chunksPerClientPerTick = 4; budgeted.bytesPerClientPerTick = 220; ServerConfig open = budgeted; open.bytesPerClientPerTick = 0; uint64_t ticksNeeded[2] = {0, 0}; for (int variant = 0; variant < 2; ++variant) { const ServerConfig& config = variant == 0 ? budgeted : open; World world; NetServer server(world, config); buildTerrain(world, -3, 3); int slot = server.acceptClient(); placeAt(server, slot, 8.0f, 8.0f, 8.0f); TransportConfig transportConfig = config.transport; RemoteClient only(server, slot, transportConfig, clientConfigFor(config), "probe"); uint64_t tick = 0; for (; tick < 500; ++tick) { server.update(tick); only.step(tick); if (only.world.chunkCount() == 5) break; } ticksNeeded[variant] = tick; CHECK_EQ(only.world.chunkCount(), static_cast(5)); } CHECK(ticksNeeded[0] > ticksNeeded[1]); CHECK(ticksNeeded[1] < 20u); } VF_TEST(theServerRefusesConnectionsOnceEverySlotIsTaken) { ServerConfig config = quietServerConfig(); config.maxClients = 2; World world; NetServer server(world, config); CHECK_EQ(server.slotCount(), static_cast(2)); CHECK_EQ(server.acceptClient(), 0); CHECK_EQ(server.acceptClient(), 1); CHECK_EQ(server.acceptClient(), -1); CHECK_EQ(server.acceptClient(), -1); CHECK_EQ(server.stats().rejectedConnections, static_cast(2)); CHECK_EQ(server.stats().acceptedConnections, static_cast(2)); CHECK_EQ(server.activeClients(), 2u); CHECK_EQ(server.linkFor(2), nullptr); CHECK_EQ(server.sessionFor(-1), nullptr); server.dropClient(0); CHECK_EQ(server.activeClients(), 1u); CHECK_EQ(server.acceptClient(), 0); CHECK_EQ(server.activeClients(), 2u); } VF_TEST(aSilentClientTimesOutAndItsSlotComesBackForReuse) { ServerConfig config = quietServerConfig(); config.maxClients = 1; config.keepAliveIntervalTicks = 5; config.keepAliveTimeoutTicks = 40; World world; NetServer server(world, config); buildTerrain(world, -2, 2); int slot = server.acceptClient(); TransportConfig transportConfig = config.transport; { RemoteClient leaving(server, slot, transportConfig, clientConfigFor(config), "leaving"); for (uint64_t tick = 0; tick < 30; ++tick) { server.update(tick); leaving.step(tick); } CHECK(server.sessionFor(slot) != nullptr); CHECK(server.sessionFor(slot)->phase() == SessionPhase::Play); } for (uint64_t tick = 30; tick < 200; ++tick) server.update(tick); CHECK_EQ(server.activeClients(), 0u); CHECK_EQ(server.stats().timeouts, static_cast(1)); CHECK_EQ(server.stats().connectedClients, 0u); CHECK_EQ(server.acceptClient(), 0); CHECK_EQ(server.activeClients(), 1u); } VF_TEST(entityMovesGoOnlyToTheClientsWhoseViewHoldsTheEntity) { ServerConfig config = quietServerConfig(); World world; NetServer server(world, config); buildTerrain(world, -6, 6); int slotNear = server.acceptClient(); int slotFar = server.acceptClient(); placeAt(server, slotNear, 8.0f, 8.0f, 8.0f); placeAt(server, slotFar, 72.0f, 8.0f, 72.0f); TransportConfig transportConfig = config.transport; SessionConfig sessionConfig = clientConfigFor(config); RemoteClient near(server, slotNear, transportConfig, sessionConfig, "near"); RemoteClient far(server, slotFar, transportConfig, sessionConfig, "far"); for (uint64_t tick = 0; tick < 40; ++tick) { server.update(tick); near.step(tick); far.step(tick); } for (uint64_t tick = 40; tick < 70; ++tick) { server.moveEntity(31, Vec3{9.0f, 5.0f, 9.0f}, 1.0f, 0.0f, true); server.update(tick); near.step(tick); far.step(tick); } EntityMove seen; CHECK(near.session->entityMove(31, seen)); CHECK_NEAR(seen.x, 9.0f, 0.001f); CHECK_EQ(far.session->trackedEntityCount(), static_cast(0)); CHECK(far.session->stats().entityMovesApplied == 0u); } VF_TEST(serverStatsAccountForClientsBytesChunksAndTheOutgoingQueue) { ServerConfig config = quietServerConfig(); config.viewRadius = 1; config.chunksPerClientPerTick = 1; World world; NetServer server(world, config); buildTerrain(world, -2, 2); CHECK_EQ(server.stats().connectedClients, 0u); int slot = server.acceptClient(); placeAt(server, slot, 8.0f, 8.0f, 8.0f); TransportConfig transportConfig = config.transport; RemoteClient only(server, slot, transportConfig, clientConfigFor(config), "counted"); uint64_t bytesSeenInAnyTick = 0; for (uint64_t tick = 0; tick < 40; ++tick) { server.update(tick); only.step(tick); bytesSeenInAnyTick += server.stats().bytesSentThisTick; } CHECK_EQ(server.stats().connectedClients, 1u); CHECK_EQ(server.stats().chunksSent, static_cast(5)); CHECK_EQ(server.stats().totalBytesSent, bytesSeenInAnyTick); CHECK(server.stats().totalBytesSent > 500u); CHECK_EQ(server.stats().queuedPackets, static_cast(0)); server.setBlock(1, 2, 3, Block::Ice); server.setBlock(2, 2, 3, Block::Ice); for (uint64_t tick = 40; tick < 60; ++tick) { server.update(tick); only.step(tick); } CHECK_EQ(server.stats().blockUpdatesSent, static_cast(2)); CHECK_EQ(server.stats().editsApplied, static_cast(2)); CHECK(only.world.blockAt(1, 2, 3) == Block::Ice); } VF_TEST(fourClientsSpreadAcrossTheWorldStayConsistentOverAnUnreliableLink) { ServerConfig config = quietServerConfig(); config.maxClients = 4; config.viewRadius = 1; config.chunksPerClientPerTick = 2; config.link.lossPercent = 25; config.link.duplicatePercent = 10; config.link.reorderPercent = 20; config.link.latencyTicks = 2; config.link.seed = 0xc0ffee1234ull; config.transport.retransmitTimeoutTicks = 8; World world; NetServer server(world, config); buildTerrain(world, -8, 8); const float spots[4][2] = {{8.0f, 8.0f}, {72.0f, 8.0f}, {8.0f, 72.0f}, {72.0f, 72.0f}}; std::vector> clients; TransportConfig transportConfig = config.transport; SessionConfig sessionConfig = clientConfigFor(config); for (int i = 0; i < 4; ++i) { int slot = server.acceptClient(); CHECK_EQ(slot, i); placeAt(server, slot, spots[i][0], 8.0f, spots[i][1]); clients.push_back(std::unique_ptr( new RemoteClient(server, slot, transportConfig, sessionConfig, "player"))); } Lcg rng(0x9911aabb); for (uint64_t tick = 0; tick < 500; ++tick) { if (tick > 100 && tick % 5 == 0) { int corner = rng.range(4); int baseX = static_cast(spots[corner][0]); int baseZ = static_cast(spots[corner][1]); server.setBlock(baseX + rng.range(8) - 4, rng.range(6), baseZ + rng.range(8) - 4, Block::Glowstone); } server.update(tick); for (auto& client : clients) client->step(tick); } for (uint64_t tick = 500; tick < 800; ++tick) { server.update(tick); for (auto& client : clients) client->step(tick); } CHECK_EQ(server.activeClients(), 4u); for (int i = 0; i < 4; ++i) { NetSession* session = server.sessionFor(i); CHECK(session->phase() == SessionPhase::Play); const ClientView& view = session->view(); CHECK_EQ(view.sent.size(), static_cast(5)); for (const Vec3i& coord : view.sent) { const Chunk* authoritative = world.chunkAt(coord); const Chunk* mirror = clients[static_cast(i)]->world.chunkAt(coord); CHECK(mirror != nullptr); if (mirror == nullptr) continue; CHECK(authoritative->raw() == mirror->raw()); } // Each client only ever holds the chunks it was told about. CHECK_EQ(clients[static_cast(i)]->world.chunkCount(), static_cast(5)); } CHECK(server.stats().editsApplied > 50u); }