Files
EVEngine/test/procgen_runtime_generation.cpp
T
西风逍遥游 293be41d66 Merge remote-tracking branch 'origin/dev' into codex/architecture-consolidation
# Conflicts:
#	cmake/module_manifest.cmake
#	docs/usr/modules/procgen.md
#	src/engine/CMakeLists.txt
#	src/engine/cmdline/Run.cpp
#	src/engine/common/ScriptModule.h
#	src/modules/animation/Animation.cpp
#	src/modules/physics/World.cpp
#	src/modules/physics/World3D.cpp
#	src/modules/procgen/Procgen.cpp
#	src/modules/procgen/Procgen.h
#	src/modules/scene/SceneCapabilities.cpp
#	src/modules/schema/Schema.cpp
#	src/modules/schema/Schema.h
2026-08-27 14:52:28 +08:00

488 lines
19 KiB
C++

#include "procgen/RuntimeGeneration.h"
#include "procgen/Procgen.h"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include <memory>
#include <utility>
using namespace eve::procgen;
namespace {
/** @brief Owns a module-backed runtime-generation handle for one test. */
class RuntimeLease {
public:
RuntimeLease() = default;
RuntimeLease(Procgen& owner, ProcgenRuntimeGenerationHandleRef handle)
: owner_(&owner), handle_(handle) {}
RuntimeLease(const RuntimeLease&) = delete;
RuntimeLease& operator=(const RuntimeLease&) = delete;
RuntimeLease(RuntimeLease&& other) noexcept
: owner_(other.owner_), handle_(other.handle_) {
other.owner_ = nullptr;
other.handle_ = {};
}
RuntimeLease& operator=(RuntimeLease&& other) noexcept {
if (this == &other) return *this;
reset();
owner_ = other.owner_;
handle_ = other.handle_;
other.owner_ = nullptr;
other.handle_ = {};
return *this;
}
~RuntimeLease() { reset(); }
/** @brief Releases the scheduler handle and explicitly observes the Result. */
void reset() noexcept {
if (!owner_ || !handle_.isValid()) return;
auto result = owner_->release(handle_);
result.ignore("test runtime-generation cleanup");
owner_ = nullptr;
handle_ = {};
}
/** @brief Provides a short-lived borrowed view of the scheduler. */
[[nodiscard]] eve::script::Borrowed<RuntimeGeneration> view() const noexcept {
return owner_ ? owner_->resolveRuntimeGeneration(handle_)
: eve::script::Borrowed<RuntimeGeneration>();
}
/** @brief Preserves the concise `runtime->operation()` test syntax. */
[[nodiscard]] eve::script::Borrowed<RuntimeGeneration> operator->() const noexcept {
return view();
}
private:
Procgen* owner_ = nullptr;
ProcgenRuntimeGenerationHandleRef handle_{};
};
/** @brief Converts a checked scheduler allocation Result into a test lease. */
RuntimeLease requireRuntime(Procgen& proc, uint32_t worldSeed) {
auto result = proc.newRuntimeGenerationHandle(worldSeed);
const bool ok = result.ok();
if (!ok) {
const eve::Diagnostic* diagnostic = result.error();
REQUIRE(diagnostic != nullptr);
}
REQUIRE(ok);
return RuntimeLease(proc, std::move(result).takeValue());
}
/** @brief Gives an owning lifetime to a request allocated by the scheduler API. */
using RequestLease = std::unique_ptr<ProcgenCellRequest>;
[[nodiscard]] RequestLease ownRequest(ProcgenCellRequest* request) noexcept {
return RequestLease(request);
}
} // namespace
TEST_CASE("procgen.runtimeGeneration.partitionsAndPublishesCells") {
Procgen proc;
auto runtime = requireRuntime(proc, 42);
const int nearLevel = runtime->addLevel(10.f, 8.f, 1.5f);
const int farLevel = runtime->addLevel(40.f, 30.f, 2.f);
CHECK_EQ(nearLevel, 0);
CHECK_EQ(farLevel, 1);
CHECK_EQ(runtime->getLevelCount(), 2);
CHECK_EQ(runtime->getLevelCleanupRadius(0), 12.f);
runtime->setMaxGenerating(1);
runtime->setFrameTimeBudget(2.f);
CHECK_EQ(runtime->getFrameTimeBudget(), 2.f);
runtime->beginFrame();
runtime->updateSource(5.f, 5.f, 1.f, 0.f);
CHECK(runtime->getPendingGenerateCount() > 0);
auto request = ownRequest(runtime->nextGenerate());
REQUIRE(bool(request));
CHECK_EQ(runtime->getGeneratingCount(), 1);
CHECK(!runtime->nextGenerate());
CHECK_NE(request->getSeed(), uint32_t(0));
CHECK(request->getMaxX() > request->getMinX());
const int level = request->getLevel();
const int x = request->getX();
const int z = request->getZ();
PointSet output;
output.add(request->getMinX(), 0.f, request->getMinZ());
CHECK(runtime->completeGeneration(request.get(), &output));
CHECK(runtime->hasCell(level, x, z));
CHECK_EQ(runtime->getCellRevision(level, x, z), uint64_t(1));
auto stored = std::unique_ptr<PointSet>(runtime->getCellOutput(level, x, z));
REQUIRE(bool(stored));
CHECK_EQ(stored->getCount(), 1);
}
TEST_CASE("procgen.runtimeGeneration.cleansBeyondHysteresisRadius") {
Procgen proc;
auto runtime = requireRuntime(proc, 7);
runtime->addLevel(10.f, 6.f, 2.f);
runtime->updateSource(5.f, 5.f, 0.f, 1.f);
auto generated = ownRequest(runtime->nextGenerate());
REQUIRE(bool(generated));
const int cellX = generated->getX();
const int cellZ = generated->getZ();
PointSet points;
points.add(5.f, 0.f, 5.f);
CHECK(runtime->completeGeneration(generated.get(), &points));
runtime->updateSource(100.f, 100.f, 0.f, 1.f);
CHECK(runtime->getPendingCleanupCount() > 0);
bool cleanedTarget = false;
while (auto cleanup = ownRequest(runtime->nextCleanup())) {
if (cleanup->getX() == cellX && cleanup->getZ() == cellZ) cleanedTarget = true;
CHECK(runtime->completeCleanup(cleanup.get()));
}
CHECK(cleanedTarget);
CHECK(!runtime->hasCell(0, cellX, cellZ));
}
TEST_CASE("procgen.runtimeGeneration.failedWorkCanRetryWithStableSeed") {
Procgen proc;
auto runtime = requireRuntime(proc, 99);
runtime->addLevel(8.f, 5.f, 1.25f);
runtime->updateSource(4.f, 4.f, 1.f, 0.f);
auto first = ownRequest(runtime->nextGenerate());
REQUIRE(bool(first));
const uint32_t seed = first->getSeed();
CHECK(runtime->failGeneration(first.get()));
auto retry = ownRequest(runtime->nextGenerate());
REQUIRE(bool(retry));
CHECK_EQ(retry->getSeed(), seed);
CHECK_EQ(retry->getLevel(), 0);
}
TEST_CASE("procgen.runtimeGeneration.unionsNamedSourcesAndCleansAfterLastSource") {
Procgen proc;
auto runtime = requireRuntime(proc, 123);
runtime->addLevel(10.f, 16.f, 1.5f);
runtime->setMaxGenerating(100);
CHECK(runtime->setGenerationSource("player", 5.f, 5.f, 1.f, 0.f, 1.f));
CHECK(runtime->setGenerationSource("quest", 105.f, 5.f, 0.f, 0.f, 1.f));
CHECK_EQ(runtime->getGenerationSourceCount(), 2);
CHECK_EQ(runtime->getGenerationSourceId(0), std::string("player"));
CHECK_EQ(runtime->getGenerationSourceId(1), std::string("quest"));
bool generatedNearPlayer = false;
bool generatedNearQuest = false;
PointSet empty;
while (auto request = ownRequest(runtime->nextGenerate())) {
if (request->getX() >= -1 && request->getX() <= 1) generatedNearPlayer = true;
if (request->getX() >= 9 && request->getX() <= 11) generatedNearQuest = true;
CHECK(runtime->completeGeneration(request.get(), &empty));
}
CHECK(generatedNearPlayer);
CHECK(generatedNearQuest);
CHECK(runtime->removeGenerationSource("player"));
bool cleanupNearPlayer = false;
bool cleanupNearQuest = false;
while (auto request = ownRequest(runtime->nextCleanup())) {
if (request->getX() >= -1 && request->getX() <= 1) cleanupNearPlayer = true;
if (request->getX() >= 9 && request->getX() <= 11) cleanupNearQuest = true;
CHECK(runtime->completeCleanup(request.get()));
}
CHECK(cleanupNearPlayer);
CHECK(!cleanupNearQuest);
}
TEST_CASE("procgen.runtimeGeneration.frustumCullsFarBehindButKeepsNearCells") {
Procgen proc;
auto runtime = requireRuntime(proc, 9);
runtime->addLevel(10.f, 35.f, 1.25f);
runtime->setMaxGenerating(100);
runtime->setFrustumCulling(true, 45.f, 11.f);
CHECK(runtime->isFrustumCullingEnabled());
CHECK_EQ(runtime->getFrustumHalfAngle(), 45.f);
CHECK_EQ(runtime->getFrustumBehindRadius(), 11.f);
CHECK(runtime->setGenerationSource("camera", 5.f, 5.f, 1.f, 0.f, 1.f));
bool foundFarBehind = false;
bool foundNearBehind = false;
bool foundForward = false;
PointSet empty;
while (auto request = ownRequest(runtime->nextGenerate())) {
const float centerX = (request->getMinX() + request->getMaxX()) * 0.5f;
if (centerX < -6.f) foundFarBehind = true;
if (centerX == -5.f) foundNearBehind = true;
if (centerX > 5.f) foundForward = true;
CHECK(runtime->completeGeneration(request.get(), &empty));
}
CHECK(!foundFarBehind);
CHECK(foundNearBehind);
CHECK(foundForward);
}
TEST_CASE("procgen.runtimeGeneration.rejectsStaleAsyncGenerationTickets") {
Procgen proc;
auto runtime = requireRuntime(proc, 77);
runtime->addLevel(10.f, 6.f, 1.5f);
runtime->updateSource(5.f, 5.f, 1.f, 0.f);
auto stale = ownRequest(runtime->nextGenerate());
REQUIRE(bool(stale));
const uint64_t staleTicket = stale->getTicket();
runtime->updateSource(100.f, 100.f, 1.f, 0.f);
runtime->updateSource(5.f, 5.f, 1.f, 0.f);
auto current = ownRequest(runtime->nextGenerate());
REQUIRE(bool(current));
CHECK_NE(current->getTicket(), staleTicket);
PointSet output;
CHECK(!runtime->completeGeneration(stale.get(), &output));
CHECK(runtime->completeGeneration(current.get(), &output));
}
TEST_CASE("procgen.runtimeGeneration.rejectsStaleAsyncCleanupTickets") {
Procgen proc;
auto runtime = requireRuntime(proc, 78);
runtime->addLevel(10.f, 6.f, 1.5f);
runtime->updateSource(5.f, 5.f, 1.f, 0.f);
auto generated = ownRequest(runtime->nextGenerate());
REQUIRE(bool(generated));
PointSet output;
CHECK(runtime->completeGeneration(generated.get(), &output));
runtime->updateSource(100.f, 100.f, 1.f, 0.f);
auto stale = ownRequest(runtime->nextCleanup());
REQUIRE(bool(stale));
runtime->updateSource(5.f, 5.f, 1.f, 0.f);
runtime->updateSource(100.f, 100.f, 1.f, 0.f);
CHECK(!runtime->completeCleanup(stale.get()));
auto current = ownRequest(runtime->nextCleanup());
REQUIRE(bool(current));
CHECK_NE(current->getTicket(), stale->getTicket());
CHECK(runtime->completeCleanup(current.get()));
}
TEST_CASE("procgen.runtimeGeneration.enforcesResidentCellReservations") {
Procgen proc;
auto runtime = requireRuntime(proc, 91);
runtime->addLevel(10.f, 30.f, 1.5f);
runtime->setMaxGenerating(100);
runtime->setMaxActiveCells(2);
CHECK_EQ(runtime->getMaxActiveCells(), 2);
runtime->updateSource(5.f, 5.f, 1.f, 0.f);
auto first = ownRequest(runtime->nextGenerate());
auto second = ownRequest(runtime->nextGenerate());
REQUIRE(bool(first));
REQUIRE(bool(second));
CHECK(!runtime->nextGenerate());
CHECK_EQ(runtime->getGeneratingCount(), 2);
PointSet empty;
CHECK(runtime->completeGeneration(first.get(), &empty));
CHECK(runtime->completeGeneration(second.get(), &empty));
CHECK_EQ(runtime->getActiveCellCount(), 2);
CHECK(!runtime->nextGenerate());
CHECK(runtime->debugReport().find("maxActive=2") != std::string::npos);
runtime->setMaxActiveCells(0);
CHECK_EQ(runtime->getMaxActiveCells(), 0);
auto unlimited = ownRequest(runtime->nextGenerate());
REQUIRE(bool(unlimited));
}
TEST_CASE("procgen.runtimeGeneration.enforcesPointMemoryBudgets") {
Procgen proc;
auto runtime = requireRuntime(proc, 92);
runtime->addLevel(10.f, 25.f, 1.5f);
runtime->setMaxGenerating(100);
runtime->setMaxPointsPerCell(2);
runtime->setMaxResidentPoints(2);
CHECK_EQ(runtime->getMaxPointsPerCell(), 2);
CHECK_EQ(runtime->getMaxResidentPoints(), 2);
runtime->updateSource(5.f, 5.f, 1.f, 0.f);
auto first = ownRequest(runtime->nextGenerate());
REQUIRE(bool(first));
PointSet oversized;
oversized.add(0.f, 0.f, 0.f);
oversized.add(1.f, 0.f, 0.f);
oversized.add(2.f, 0.f, 0.f);
CHECK(!runtime->completeGeneration(first.get(), &oversized));
CHECK_EQ(runtime->getRejectedOutputCount(), 1);
CHECK(runtime->failGeneration(first.get()));
auto retry = ownRequest(runtime->nextGenerate());
REQUIRE(bool(retry));
PointSet accepted;
accepted.add(0.f, 0.f, 0.f);
accepted.add(1.f, 0.f, 0.f);
CHECK(runtime->completeGeneration(retry.get(), &accepted));
CHECK_EQ(runtime->getResidentPointCount(), 2);
auto second = ownRequest(runtime->nextGenerate());
REQUIRE(bool(second));
PointSet one;
one.add(0.f, 0.f, 0.f);
CHECK(!runtime->completeGeneration(second.get(), &one));
CHECK_EQ(runtime->getRejectedOutputCount(), 2);
CHECK_EQ(runtime->getPendingCleanupCount(), 1);
runtime->refreshGenerationSources();
CHECK(!runtime->completeGeneration(second.get(), &one));
CHECK_EQ(runtime->getRejectedOutputCount(), 3);
CHECK_EQ(runtime->getPendingCleanupCount(), 1);
auto cleanup = ownRequest(runtime->nextCleanup());
REQUIRE(bool(cleanup));
CHECK(runtime->completeCleanup(cleanup.get()));
CHECK_EQ(runtime->getResidentPointCount(), 0);
CHECK(runtime->completeGeneration(second.get(), &one));
CHECK_EQ(runtime->getResidentPointCount(), 1);
CHECK(runtime->debugReport().find("residentPoints=1") != std::string::npos);
}
TEST_CASE("procgen.runtimeGeneration.breaksEqualPriorityTiesDeterministically") {
Procgen proc;
auto first = requireRuntime(proc, 17);
auto second = requireRuntime(proc, 17);
for (RuntimeLease* runtime : {&first, &second}) {
(*runtime)->addLevel(10.f, 25.f, 1.5f);
(*runtime)->setMaxGenerating(100);
(*runtime)->updateSource(5.f, 5.f, 0.f, 0.f);
}
for (int index = 0; index < 12; ++index) {
auto a = ownRequest(first->nextGenerate());
auto b = ownRequest(second->nextGenerate());
REQUIRE(bool(a));
REQUIRE(bool(b));
CHECK_EQ(a->getLevel(), b->getLevel());
CHECK_EQ(a->getX(), b->getX());
CHECK_EQ(a->getZ(), b->getZ());
}
}
TEST_CASE("procgen.runtimeGeneration.trimsLowestPriorityCellsDeterministically") {
Procgen proc;
auto runtime = requireRuntime(proc, 18);
runtime->addLevel(10.f, 25.f, 1.5f);
runtime->setMaxGenerating(100);
runtime->updateSource(5.f, 5.f, 0.f, 0.f);
int farX = 0;
int farZ = 0;
PointSet one;
one.add(0.f, 0.f, 0.f);
for (int index = 0; index < 3; ++index) {
auto request = ownRequest(runtime->nextGenerate());
REQUIRE(bool(request));
farX = request->getX();
farZ = request->getZ();
CHECK(runtime->completeGeneration(request.get(), &one));
}
CHECK_EQ(runtime->getResidentPointCount(), 3);
const int trimmedCells = runtime->trimToResidentPoints(2);
CHECK_EQ(trimmedCells, 1);
CHECK_EQ(runtime->getResidentPointCount(), 3);
auto cleanup = ownRequest(runtime->nextCleanup());
REQUIRE(bool(cleanup));
CHECK_EQ(cleanup->getX(), farX);
CHECK_EQ(cleanup->getZ(), farZ);
CHECK(runtime->completeCleanup(cleanup.get()));
CHECK_EQ(runtime->getResidentPointCount(), 2);
}
TEST_CASE("procgen.runtimeGeneration.boundsRetriesAndExplicitlyRecoversFailures") {
RuntimeGeneration runtime(29);
runtime.addLevel(10.f, 6.f, 1.5f);
runtime.setMaxGenerationRetries(1);
CHECK_EQ(runtime.getMaxGenerationRetries(), 1);
runtime.updateSource(5.f, 5.f, 1.f, 0.f);
ProcgenCellRequest* initial = runtime.nextGenerate();
REQUIRE(bool(initial));
CHECK(runtime.failGeneration(initial));
delete initial;
CHECK_EQ(runtime.getFailedCellCount(), 0);
ProcgenCellRequest* retry = runtime.nextGenerate();
REQUIRE(bool(retry));
CHECK(runtime.failGeneration(retry));
delete retry;
CHECK_EQ(runtime.getFailedCellCount(), 1);
CHECK(runtime.debugReport().find("failed=1") != std::string::npos);
const int retriedCells = runtime.retryFailedCells();
CHECK_EQ(retriedCells, 1);
CHECK_EQ(runtime.getFailedCellCount(), 0);
ProcgenCellRequest* recovered = runtime.nextGenerate();
REQUIRE(bool(recovered));
PointSet empty;
CHECK(runtime.completeGeneration(recovered, &empty));
CHECK_EQ(runtime.getActiveCellCount(), 1);
delete recovered;
}
TEST_CASE("procgen.runtimeGeneration.persistsAttributedCellCachesAtomically") {
RuntimeGeneration source(404);
source.addLevel(10.f, 6.f, 1.5f);
source.updateSource(5.f, 5.f, 1.f, 0.f);
ProcgenCellRequest* generated = source.nextGenerate();
REQUIRE(bool(generated));
const int level = generated->getLevel();
const int x = generated->getX();
const int z = generated->getZ();
PointSet points;
const int point = points.add(1.25f, 2.5f, 3.75f);
points.setNormal(point, 0.f, 0.f, 1.f);
points.setYaw(point, 45.f);
points.setScale(point, 2.f, 3.f, 4.f);
points.setDensity(point, 0.75f);
points.setPointSeed(point, 1234);
points.setFloatAttribute(point, "slope", 12.5f);
points.setFloatAttribute(point, "roughness", 0.4f);
points.setStringAttribute(point, "asset", "oak \"hero\"");
points.setStringAttribute(point, "biome", "forest");
points.add(9.f, 8.f, 7.f);
CHECK(source.completeGeneration(generated, &points));
delete generated;
const std::string persisted = source.serializeCell(level, x, z);
CHECK(persisted.find("EVPCG_CELL 1 404") == 0);
RuntimeGeneration equivalent(404);
equivalent.addLevel(10.f, 6.f, 1.5f);
equivalent.updateSource(5.f, 5.f, 1.f, 0.f);
ProcgenCellRequest* equivalentRequest = equivalent.nextGenerate();
REQUIRE(bool(equivalentRequest));
PointSet equivalentPoints = points;
equivalentPoints.points()[0].floatAttributes.clear();
equivalentPoints.points()[0].stringAttributes.clear();
equivalentPoints.setFloatAttribute(0, "roughness", 0.4f);
equivalentPoints.setFloatAttribute(0, "slope", 12.5f);
equivalentPoints.setStringAttribute(0, "biome", "forest");
equivalentPoints.setStringAttribute(0, "asset", "oak \"hero\"");
CHECK(equivalent.completeGeneration(equivalentRequest, &equivalentPoints));
delete equivalentRequest;
CHECK_EQ(equivalent.serializeCell(level, x, z), persisted);
RuntimeGeneration restored(404);
restored.addLevel(10.f, 6.f, 1.5f);
CHECK(restored.deserializeCell(persisted));
CHECK(restored.hasCell(level, x, z));
CHECK_EQ(restored.getCellRevision(level, x, z), uint64_t(1));
PointSet* loaded = restored.getCellOutput(level, x, z);
REQUIRE(bool(loaded));
CHECK_EQ(loaded->getCount(), 2);
CHECK_EQ(loaded->getX(0), 1.25f);
CHECK_EQ(loaded->getNormalZ(0), 1.f);
CHECK_EQ(loaded->getScaleY(0), 3.f);
CHECK_EQ(loaded->getFloatAttribute(0, "slope", -1.f), 12.5f);
CHECK_EQ(loaded->getStringAttribute(0, "asset", ""), std::string("oak \"hero\""));
delete loaded;
CHECK(!restored.deserializeCell(persisted + "TRAILING"));
CHECK(restored.hasCell(level, x, z));
RuntimeGeneration wrongWorld(405);
wrongWorld.addLevel(10.f, 6.f, 1.5f);
CHECK(!wrongWorld.deserializeCell(persisted));
RuntimeGeneration constrained(404);
constrained.addLevel(10.f, 6.f, 1.5f);
constrained.setMaxPointsPerCell(1);
CHECK(!constrained.deserializeCell(persisted));
CHECK_EQ(constrained.getRejectedOutputCount(), 1);
}