425 lines
15 KiB
C++
425 lines
15 KiB
C++
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include "procgen/Procgen.h"
|
|
#include "procgen/Semantic.h"
|
|
#include "procgen/GeneratorRegistry.h"
|
|
#include "procgen/algorithms/RoguelikeGenerator.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <queue>
|
|
#include <set>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
using namespace eve::procgen;
|
|
|
|
namespace {
|
|
|
|
struct ParamsLease {
|
|
ProcgenParamsHandleRef handle{};
|
|
ParamsLease() = default;
|
|
explicit ParamsLease(ProcgenParamsHandleRef value) : handle(value) {}
|
|
ParamsLease(const ParamsLease &) = delete;
|
|
ParamsLease &operator=(const ParamsLease &) = delete;
|
|
ParamsLease(ParamsLease &&other) noexcept : handle(other.handle) { other.handle = {}; }
|
|
ParamsLease &operator=(ParamsLease &&other) noexcept {
|
|
if (this == &other) return *this;
|
|
reset();
|
|
handle = other.handle;
|
|
other.handle = {};
|
|
return *this;
|
|
}
|
|
~ParamsLease() { reset(); }
|
|
void reset() noexcept {
|
|
if (!handle.isValid()) return;
|
|
auto result = Procgen::release(handle);
|
|
result.ignore("roguelike params cleanup");
|
|
handle = {};
|
|
}
|
|
[[nodiscard]] eve::script::Borrowed<Params> view() const noexcept { return Procgen::resolve(handle); }
|
|
};
|
|
|
|
struct GridLease {
|
|
ProcgenGridHandleRef handle{};
|
|
GridLease() = default;
|
|
explicit GridLease(ProcgenGridHandleRef value) : handle(value) {}
|
|
GridLease(const GridLease &) = delete;
|
|
GridLease &operator=(const GridLease &) = delete;
|
|
GridLease(GridLease &&other) noexcept : handle(other.handle) { other.handle = {}; }
|
|
GridLease &operator=(GridLease &&other) noexcept {
|
|
if (this == &other) return *this;
|
|
reset();
|
|
handle = other.handle;
|
|
other.handle = {};
|
|
return *this;
|
|
}
|
|
~GridLease() { reset(); }
|
|
void reset() noexcept {
|
|
if (!handle.isValid()) return;
|
|
auto result = Procgen::release(handle);
|
|
result.ignore("roguelike grid cleanup");
|
|
handle = {};
|
|
}
|
|
[[nodiscard]] eve::script::Borrowed<Grid2D> view() const noexcept { return Procgen::resolve(handle); }
|
|
};
|
|
|
|
ParamsLease requireParams() {
|
|
auto result = Procgen::newParamsHandle();
|
|
REQUIRE(result.ok());
|
|
return ParamsLease(std::move(result).takeValue());
|
|
}
|
|
|
|
GridLease requireGrid(Procgen &proc, const std::string &algorithm, ProcgenParamsHandleRef params) {
|
|
auto result = proc.generateHandle(algorithm, params);
|
|
REQUIRE(result.ok());
|
|
return GridLease(std::move(result).takeValue());
|
|
}
|
|
|
|
int countSemantic(const Grid2D &g, int semantic) {
|
|
int n = 0;
|
|
for (uint32_t c : g.cells())
|
|
if (int(c) == semantic) ++n;
|
|
return n;
|
|
}
|
|
|
|
bool isWalkableCell(const Grid2D &g, int x, int y) {
|
|
const uint32_t c = uint32_t(g.getCell(x, y));
|
|
return c == Semantic::Floor || c == Semantic::Corridor;
|
|
}
|
|
|
|
constexpr int kE = 1 << 0; // +x
|
|
constexpr int kS = 1 << 1; // +y
|
|
constexpr int kW = 1 << 2; // -x
|
|
constexpr int kN = 1 << 3; // -y
|
|
constexpr int kSE = 1 << 4;
|
|
constexpr int kSW = 1 << 5;
|
|
constexpr int kNW = 1 << 6;
|
|
constexpr int kNE = 1 << 7;
|
|
|
|
int expectedWallMask(const Grid2D &g, int x, int y) {
|
|
const int w = g.getWidth();
|
|
const int h = g.getHeight();
|
|
int mask = 0;
|
|
if (x + 1 < w && isWalkableCell(g, x + 1, y)) mask |= kE;
|
|
if (y + 1 < h && isWalkableCell(g, x, y + 1)) mask |= kS;
|
|
if (x - 1 >= 0 && isWalkableCell(g, x - 1, y)) mask |= kW;
|
|
if (y - 1 >= 0 && isWalkableCell(g, x, y - 1)) mask |= kN;
|
|
if (x + 1 < w && y + 1 < h && isWalkableCell(g, x + 1, y + 1)) mask |= kSE;
|
|
if (x - 1 >= 0 && y + 1 < h && isWalkableCell(g, x - 1, y + 1)) mask |= kSW;
|
|
if (x - 1 >= 0 && y - 1 >= 0 && isWalkableCell(g, x - 1, y - 1)) mask |= kNW;
|
|
if (x + 1 < w && y - 1 >= 0 && isWalkableCell(g, x + 1, y - 1)) mask |= kNE;
|
|
return mask;
|
|
}
|
|
|
|
// Wall detail must exactly equal the 8-bit neighbour mask (0 is fine for walls
|
|
// that touch no floor). Non-wall detail must be a floor variant (1..32) or a
|
|
// decor marker (>=100); anything else would be a mask leak into the floor.
|
|
bool wallMasksConsistent(const Grid2D &g) {
|
|
const int w = g.getWidth();
|
|
const int h = g.getHeight();
|
|
for (int y = 0; y < h; ++y) {
|
|
for (int x = 0; x < w; ++x) {
|
|
if (uint32_t(g.getCell(x, y)) == Semantic::Wall) {
|
|
if (g.getDetail(x, y) != expectedWallMask(g, x, y)) return false;
|
|
} else {
|
|
const int d = g.getDetail(x, y);
|
|
if (d > 32 && d < 100) return false; // mask leak into floor
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Spawn and stairs must land on walkable cells.
|
|
bool markersWalkable(const Grid2D &g) {
|
|
for (int i = 0; i < g.getObjectCount(); ++i) {
|
|
const std::string &t = g.getObjectType(i);
|
|
if (t != "spawn" && t != "stairs") continue;
|
|
if (!isWalkableCell(g, int(g.getObjectX(i)), int(g.getObjectY(i)))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool allWalkableConnected(const Grid2D &g) {
|
|
const int w = g.getWidth(), h = g.getHeight();
|
|
int start = -1, total = 0;
|
|
for (int y = 0; y < h; ++y)
|
|
for (int x = 0; x < w; ++x)
|
|
if (isWalkableCell(g, x, y)) { if (start < 0) start = y * w + x; ++total; }
|
|
if (start < 0) return true;
|
|
std::vector<bool> seen(size_t(w * h), false);
|
|
std::queue<int> pending;
|
|
pending.push(start); seen[size_t(start)] = true;
|
|
int reached = 0;
|
|
constexpr int dx[] = {1, -1, 0, 0};
|
|
constexpr int dy[] = {0, 0, 1, -1};
|
|
while (!pending.empty()) {
|
|
const int key = pending.front(); pending.pop(); ++reached;
|
|
const int x = key % w, y = key / w;
|
|
for (int i = 0; i < 4; ++i) {
|
|
const int nx = x + dx[i], ny = y + dy[i];
|
|
if (nx < 0 || ny < 0 || nx >= w || ny >= h || !isWalkableCell(g, nx, ny)) continue;
|
|
const int next = ny * w + nx;
|
|
if (!seen[size_t(next)]) { seen[size_t(next)] = true; pending.push(next); }
|
|
}
|
|
}
|
|
return reached == total;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("procgen.roguelike.reproducible") {
|
|
Procgen *mod = Procgen::create();
|
|
CHECK(mod->hasAlgorithm("level.roguelike"));
|
|
|
|
auto p = requireParams();
|
|
auto pView = p.view();
|
|
REQUIRE(pView.isBound());
|
|
pView->setSeed(42);
|
|
pView->setSize(48, 32);
|
|
pView->setInt("roomCount", 10);
|
|
auto aLease = requireGrid(*mod, "level.roguelike", p.handle);
|
|
auto a = aLease.view();
|
|
REQUIRE(a.isBound());
|
|
|
|
auto q = requireParams();
|
|
auto qView = q.view();
|
|
REQUIRE(qView.isBound());
|
|
qView->setSeed(42);
|
|
qView->setSize(48, 32);
|
|
qView->setInt("roomCount", 10);
|
|
auto bLease = requireGrid(*mod, "level.roguelike", q.handle);
|
|
auto b = bLease.view();
|
|
REQUIRE(b.isBound());
|
|
|
|
// Same seed -> identical cells and detail.
|
|
CHECK(a->cells() == b->cells());
|
|
CHECK(a->detail() == b->detail());
|
|
}
|
|
|
|
TEST_CASE("procgen.roguelike.structure") {
|
|
Procgen *mod = Procgen::create();
|
|
auto p = requireParams();
|
|
auto pView = p.view();
|
|
REQUIRE(pView.isBound());
|
|
pView->setSeed(7);
|
|
pView->setSize(48, 32);
|
|
pView->setInt("roomCount", 12);
|
|
pView->setString("connectionStyle", "nearest");
|
|
pView->setFloat("decorDensity", 0.06f);
|
|
pView->setString("decorSet", "mixed");
|
|
auto gLease = requireGrid(*mod, "level.roguelike", p.handle);
|
|
auto g = gLease.view();
|
|
REQUIRE(g.isBound());
|
|
|
|
const int floorCount = countSemantic(*g, Semantic::Floor) + countSemantic(*g, Semantic::Corridor);
|
|
const int wallCount = countSemantic(*g, Semantic::Wall);
|
|
CHECK_GT(floorCount, 0);
|
|
CHECK_GT(wallCount, 0);
|
|
CHECK_EQ(floorCount + wallCount, g->getWidth() * g->getHeight());
|
|
|
|
// Outer border stays solid wall (padding >= 1 by default).
|
|
CHECK_EQ(g->getCell(0, 0), int(Semantic::Wall));
|
|
CHECK_EQ(g->getCell(g->getWidth() - 1, g->getHeight() - 1), int(Semantic::Wall));
|
|
|
|
// Detail layer: wall autotile masks + floor variants are present.
|
|
CHECK(wallMasksConsistent(*g));
|
|
CHECK(markersWalkable(*g));
|
|
CHECK(allWalkableConnected(*g));
|
|
|
|
// Metadata describes the run.
|
|
CHECK_EQ(g->getMeta("algorithm", ""), "level.roguelike");
|
|
CHECK_EQ(g->getMeta("seed", ""), "7");
|
|
|
|
// Objects carry spawn/stairs plus props.
|
|
bool hasSpawn = false, hasStairs = false;
|
|
for (int i = 0; i < g->getObjectCount(); ++i) {
|
|
if (g->getObjectType(i) == "spawn") hasSpawn = true;
|
|
if (g->getObjectType(i) == "stairs") {
|
|
hasStairs = true;
|
|
CHECK((g->getObjectFlags(i) & 64) != 0);
|
|
const int x = int(g->getObjectX(i)), y = int(g->getObjectY(i));
|
|
const float rotation = g->getObjectRotation(i);
|
|
if (rotation == 180.f) CHECK_EQ(g->getCell(x, y - 1), int(Semantic::Wall));
|
|
else if (rotation == 0.f) CHECK_EQ(g->getCell(x, y + 1), int(Semantic::Wall));
|
|
else if (rotation == 270.f) CHECK_EQ(g->getCell(x - 1, y), int(Semantic::Wall));
|
|
else { CHECK_EQ(rotation, 90.f); CHECK_EQ(g->getCell(x + 1, y), int(Semantic::Wall)); }
|
|
}
|
|
}
|
|
CHECK(hasSpawn);
|
|
CHECK(hasStairs);
|
|
}
|
|
|
|
TEST_CASE("procgen.roguelike.seedVaries") {
|
|
Procgen *mod = Procgen::create();
|
|
auto run = [&](uint32_t seed) {
|
|
auto p = requireParams();
|
|
auto pView = p.view();
|
|
REQUIRE(pView.isBound());
|
|
pView->setSeed(seed);
|
|
pView->setSize(40, 28);
|
|
pView->setInt("roomCount", 9);
|
|
auto gLease = requireGrid(*mod, "level.roguelike", p.handle);
|
|
auto g = gLease.view();
|
|
REQUIRE(g.isBound());
|
|
Grid2D copy = *g;
|
|
return copy;
|
|
};
|
|
Grid2D s1 = run(1);
|
|
Grid2D s2 = run(2);
|
|
Grid2D s3 = run(1);
|
|
CHECK(s1.cells() != s2.cells());
|
|
CHECK(s1.cells() == s3.cells());
|
|
}
|
|
|
|
TEST_CASE("procgen.roguelike.rulesChangeLayout") {
|
|
Procgen *mod = Procgen::create();
|
|
auto gen = [&](int rooms, const std::string &pattern, const std::string &style,
|
|
const std::string &layout) {
|
|
auto p = requireParams();
|
|
auto pView = p.view();
|
|
REQUIRE(pView.isBound());
|
|
pView->setSeed(11);
|
|
pView->setSize(48, 32);
|
|
pView->setInt("roomCount", rooms);
|
|
pView->setString("floorPattern", pattern);
|
|
pView->setString("corridorStyle", style);
|
|
pView->setString("layoutStyle", layout);
|
|
pView->setString("connectionStyle", layout == "clustered" ? "growth" : "sequential");
|
|
auto gLease = requireGrid(*mod, "level.roguelike", p.handle);
|
|
auto g = gLease.view();
|
|
REQUIRE(g.isBound());
|
|
Grid2D copy = *g;
|
|
return copy;
|
|
};
|
|
|
|
Grid2D few = gen(5, "brick", "l", "grid");
|
|
Grid2D many = gen(20, "brick", "l", "grid");
|
|
CHECK(few.cells() != many.cells());
|
|
|
|
// Pattern change keeps same base but alters floor detail.
|
|
Grid2D brick = gen(9, "brick", "l", "grid");
|
|
Grid2D checker = gen(9, "checker", "l", "grid");
|
|
CHECK_EQ(brick.cells(), checker.cells());
|
|
CHECK(brick.detail() != checker.detail());
|
|
|
|
Grid2D clustered = gen(9, "brick", "l", "clustered");
|
|
CHECK(clustered.cells() != brick.cells());
|
|
CHECK(allWalkableConnected(clustered));
|
|
}
|
|
|
|
TEST_CASE("procgen.roguelike.configurableAssetDressing") {
|
|
Procgen *mod = Procgen::create();
|
|
auto p = requireParams();
|
|
auto pView = p.view();
|
|
REQUIRE(pView.isBound());
|
|
pView->setSeed(23);
|
|
pView->setSize(52, 38);
|
|
pView->setInt("roomCount", 14);
|
|
pView->setString("assetPack", "test-pack");
|
|
pView->setString("assets.container", "crate_a,crate_b");
|
|
pView->setString("assets.light", "sconce_custom");
|
|
pView->setString("assets.banner", "wall_hanging_custom");
|
|
pView->setString("assets.floor", "floor_a,floor_b");
|
|
pView->setFloat("propDensity", 0.6f);
|
|
auto gLease = requireGrid(*mod, "level.roguelike", p.handle);
|
|
auto g = gLease.view();
|
|
REQUIRE(g.isBound());
|
|
CHECK_EQ(g->getMeta("assetPack", ""), "test-pack");
|
|
CHECK_EQ(g->getMeta("assets.floor", ""), "floor_a,floor_b");
|
|
CHECK_GT(std::stoi(g->getMeta("placedProps", "0")), 0);
|
|
CHECK_GE(std::stoi(g->getMeta("minimumRoomProps", "0")), 8);
|
|
|
|
bool customLight = false;
|
|
bool fractionalPlacement = false;
|
|
bool roomZone = false;
|
|
for (int i = 0; i < g->getObjectCount(); ++i) {
|
|
if (g->getObjectType(i) == "room") {
|
|
roomZone = true;
|
|
const int rx = int(g->getObjectX(i)), ry = int(g->getObjectY(i));
|
|
const int rw = int(g->getObjectWidth(i)), rh = int(g->getObjectHeight(i));
|
|
for (int y = ry; y < ry + rh; ++y)
|
|
for (int x = rx; x < rx + rw; ++x)
|
|
CHECK_EQ(g->getCell(x, y), int(Semantic::Floor));
|
|
}
|
|
if (g->getObjectType(i) == "light") {
|
|
customLight = customLight || g->getObjectAsset(i) == "sconce_custom";
|
|
CHECK((g->getObjectFlags(i) & 4) != 0);
|
|
}
|
|
CHECK_GE(g->getObjectRotation(i), 0.f);
|
|
CHECK_LT(g->getObjectRotation(i), 360.f);
|
|
fractionalPlacement = fractionalPlacement ||
|
|
std::abs(g->getObjectX(i) - std::round(g->getObjectX(i))) > 0.01f ||
|
|
std::abs(g->getObjectY(i) - std::round(g->getObjectY(i))) > 0.01f;
|
|
}
|
|
CHECK(customLight);
|
|
CHECK(roomZone);
|
|
CHECK(fractionalPlacement);
|
|
CHECK(markersWalkable(*g));
|
|
}
|
|
|
|
TEST_CASE("procgen.roguelike.errors") {
|
|
Procgen *mod = Procgen::create();
|
|
auto p = requireParams();
|
|
auto pView = p.view();
|
|
REQUIRE(pView.isBound());
|
|
pView->setSeed(1);
|
|
pView->setSize(4, 4); // too small
|
|
auto failed = mod->generateHandle("level.roguelike", p.handle);
|
|
CHECK(!failed.ok());
|
|
const eve::Diagnostic *diagnostic = failed.error();
|
|
REQUIRE(diagnostic != nullptr);
|
|
CHECK_EQ(diagnostic->code(), eve::DiagnosticCode::Failed);
|
|
}
|
|
|
|
TEST_CASE("procgen.roguelike.manualFillDetail") {
|
|
Procgen *mod = Procgen::create();
|
|
auto p = requireParams();
|
|
auto pView = p.view();
|
|
REQUIRE(pView.isBound());
|
|
pView->setSeed(3);
|
|
pView->setSize(32, 24);
|
|
auto gLease = requireGrid(*mod, "level.roguelike", p.handle);
|
|
auto g = gLease.view();
|
|
REQUIRE(g.isBound());
|
|
|
|
// Ultimate control: script can override any cell / detail after generation.
|
|
g->setCell(1, 1, int(Semantic::Floor));
|
|
g->setDetail(1, 1, 5);
|
|
CHECK_EQ(g->getCell(1, 1), int(Semantic::Floor));
|
|
CHECK_EQ(g->getDetail(1, 1), 5);
|
|
CHECK_EQ(g->getDetail(-5, -5), 0); // out of bounds -> 0
|
|
g->setDetail(0, 0, 999); // clamps to 255
|
|
CHECK_EQ(g->getDetail(0, 0), 255);
|
|
}
|
|
|
|
TEST_CASE("procgen.autotileGrid.postProcess") {
|
|
Procgen *mod = Procgen::create();
|
|
auto p = requireParams();
|
|
auto pView = p.view();
|
|
REQUIRE(pView.isBound());
|
|
pView->setSeed(9);
|
|
pView->setSize(40, 30);
|
|
pView->setInt("roomCount", 8);
|
|
pView->setInt("autotile", 0); // disable built-in autotile to test the helper alone
|
|
auto gLease = requireGrid(*mod, "level.roguelike", p.handle);
|
|
auto g = gLease.view();
|
|
REQUIRE(g.isBound());
|
|
auto autotile = mod->autotileGrid(gLease.handle);
|
|
CHECK(autotile.ok());
|
|
CHECK(wallMasksConsistent(*g));
|
|
}
|
|
|
|
TEST_CASE("procgen.randomSeed.nonZeroAndVaries") {
|
|
Procgen *mod = Procgen::create();
|
|
CHECK_GT(mod->randomSeed(), 0u);
|
|
uint32_t a = mod->randomSeed();
|
|
uint32_t b = mod->randomSeed();
|
|
// Overwhelmingly likely distinct; do not require strict inequality.
|
|
CHECK_GT(a, 0u);
|
|
CHECK_GT(b, 0u);
|
|
CHECK_EQ(randomSeedValue() == 0, false);
|
|
}
|