Files
EVEngine/test/hex_level_data.cpp
T

1430 lines
53 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Data-driven hex level fixtures — loads examples/hex-levels/data/*.json
// and exercises catalog / items / loot / seeds / particles / tiny map / perception.
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "PathBesideSource.h"
#include "ProcgenGridTestSupport.h"
#include "data/DataModule.h"
#include "data/JsonDocument.h"
#include "filesystem/Filesystem.h"
#include "graphics/Light.h"
#include "inventory/Bag.h"
#include "inventory/Equipment.h"
#include "inventory/Inventory.h"
#include "inventory/InventorySystem.h"
#include "inventory/Item.h"
#include "map/Fov.h"
#include "map/Map.h"
#include "map/Path.h"
#include "map/Pathfinder.h"
#include "map/TileConfig.h"
#include "map/TileLayer.h"
#include "map/TileOrientation.h"
#include "particles/ParticleEmitter.h"
#include "particles/Particles.h"
#include "procgen/GeneratorRegistry.h"
#include "procgen/Grid2D.h"
#include "procgen/Params.h"
#include "procgen/Procgen.h"
#include "procgen/Semantic.h"
#include <Poco/Dynamic/Var.h>
#include <Poco/JSON/Array.h>
#include <Poco/JSON/Object.h>
#include <algorithm>
#include <cmath>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
using namespace eve::map;
using namespace eve::procgen;
using namespace eve::inventory;
using namespace eve::particles;
using namespace eve::data;
namespace {
std::string hexDataDir() { return eve_test_path::pathBesideTestDir(__FILE__, "../examples/hex-levels/data"); }
std::string readTextFile(const std::string &path) {
std::ifstream in(path);
if (!in) return {};
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
std::unique_ptr<JsonDocument> loadJson(const std::string &relPath) {
const std::string path = hexDataDir() + "/" + relPath;
const std::string text = readTextFile(path);
REQUIRE(!text.empty());
auto *dm = DataModule::create();
std::string err;
std::unique_ptr<JsonDocument> doc(dm->decodeJson(text, &err));
REQUIRE(doc.get() != nullptr);
return doc;
}
void hideLayers() {
if (ecs::current()->getManager<TileLayer>() == nullptr) return;
auto view = ecs::View<TileLayer, TileLayer::Draw>();
for (auto it = view.begin(); it != view.end(); ++it) {
auto [draw] = *it;
draw->visible = false;
}
}
int countWalkable(const Grid2D &g) {
int n = 0;
for (uint32_t c : g.cells()) {
if (c == Semantic::Floor || c == Semantic::Corridor || c == Semantic::Door) ++n;
}
return n;
}
} // namespace
TEST_CASE("hex.data.catalog.loadsLevels") {
auto doc = loadJson("catalog.json");
REQUIRE(doc->isObject());
auto root = doc->object();
REQUIRE(root);
CHECK_EQ(root->getValue<int>("version"), 1);
auto levels = root->getArray("levels");
REQUIRE(levels);
CHECK(levels->size() >= 31);
int foundPipeline = 0;
for (size_t i = 0; i < levels->size(); ++i) {
auto lv = levels->getObject(static_cast<unsigned int>(i));
REQUIRE(lv);
CHECK(lv->has("id"));
CHECK(lv->has("key"));
CHECK(lv->has("seed"));
CHECK(lv->has("algorithm"));
CHECK(lv->has("width"));
CHECK(lv->has("height"));
CHECK(lv->has("features"));
if (lv->getValue<std::string>("key") == "pipeline_full") foundPipeline = 1;
}
CHECK_EQ(foundPipeline, 1);
auto defaults = root->getObject("defaults");
REQUIRE(defaults);
CHECK_EQ(defaults->getValue<std::string>("orientation"), std::string("hexagonal"));
CHECK_EQ(defaults->getValue<int>("wallGid"), 1);
CHECK_EQ(defaults->getValue<int>("floorGid"), 2);
}
TEST_CASE("hex.data.items.registerAll") {
auto doc = loadJson("items.json");
REQUIRE(doc->isArray());
auto arr = doc->array();
REQUIRE(arr);
CHECK(arr->size() >= 12);
ItemRegistry::clear();
InventorySystem::ensureBuiltins();
auto *inv = Inventory::create();
const std::string text = readTextFile(hexDataDir() + "/items.json");
const int n = inv->registerItemsFromJson(text);
CHECK_EQ(n, int(arr->size()));
CHECK(ItemRegistry::find("hex.potion") != nullptr);
CHECK(ItemRegistry::find("hex.relic") != nullptr);
CHECK(ItemRegistry::find("hex.coin") != nullptr);
CHECK(ItemRegistry::find("hex.boots") != nullptr);
CHECK(ItemRegistry::find("hex.scroll") != nullptr);
CHECK_EQ(ItemRegistry::find("hex.gem")->maxStack, 5);
CHECK(ItemRegistry::find("hex.potion")->hasTag("potion"));
CHECK_EQ(ItemRegistry::find("hex.relic")->getExtra("rarity"), std::string("legendary"));
Bag *bag = inv->newBag(20);
const int coinAdded = bag->addItem("hex.coin", 50);
const int potionAdded = bag->addItem("hex.potion", 3);
CHECK_EQ(coinAdded, 50);
CHECK_EQ(potionAdded, 3);
CHECK_EQ(bag->countItem("hex.coin"), 50);
CHECK_EQ(bag->countItem("hex.potion"), 3);
bag->destroy();
ItemRegistry::clear();
}
TEST_CASE("hex.data.lootTables.placeFromOffsets") {
auto lootDoc = loadJson("loot_tables.json");
auto tables = lootDoc->object()->getObject("tables");
REQUIRE(tables);
auto rich = tables->getArray("rich");
REQUIRE(rich);
CHECK(rich->size() >= 4);
auto *mapMod = Map::create();
auto *gen = Procgen::create();
hideLayers();
TileLayer *layer = mapMod->newLayer(16, 12, 64.f, 32.f);
{
auto c = layer->config();
c->orientation = MapOrientation::Hexagonal;
c->hexSideLength = 16.f;
c->staggerAxis = StaggerAxis::Y;
c->staggerIndex = StaggerIndex::Odd;
}
GeneratorRegistry::instance().registerBuiltins();
Params p;
p.setSeed(123);
p.setSize(16, 12);
Grid2D grid;
std::string err;
REQUIRE(GeneratorRegistry::instance().generate("dungeon.bsp", p, grid, err));
gen->setPaletteGid("hex_data", "wall", 1);
gen->setPaletteGid("hex_data", "floor", 2);
gen->setPaletteGid("hex_data", "corridor", 2);
gen->setPaletteGid("hex_data", "door", 3);
eve::test_support::applyGridToLayer(*gen, grid, "hex_data", *layer);
int spawnX = -1, spawnY = -1;
for (int i = 0; i < grid.getObjectCount(); ++i) {
if (grid.getObjectType(i) == "spawn") {
spawnX = int(grid.getObjectX(i));
spawnY = int(grid.getObjectY(i));
}
}
if (spawnX < 0) {
for (int y = 0; y < grid.getHeight() && spawnX < 0; ++y)
for (int x = 0; x < grid.getWidth() && spawnX < 0; ++x)
if (grid.getCell(x, y) == int(Semantic::Floor)) {
spawnX = x;
spawnY = y;
}
}
REQUIRE(spawnX >= 0);
Pathfinder *pf = mapMod->newPathfinder(layer);
pf->blockGid(1);
pf->setTopology("hex");
ItemRegistry::clear();
InventorySystem::ensureBuiltins();
auto *inv = Inventory::create();
inv->registerItemsFromJson(readTextFile(hexDataDir() + "/items.json"));
Bag *bag = inv->newBag(24);
int placed = 0;
int picked = 0;
for (size_t i = 0; i < rich->size(); ++i) {
auto entry = rich->getObject(static_cast<unsigned int>(i));
const std::string itemId = entry->getValue<std::string>("itemId");
const int qty = entry->has("qty") ? entry->getValue<int>("qty") : 1;
auto off = entry->getArray("offset");
REQUIRE(off);
const int tx = spawnX + off->getElement<int>(0);
const int ty = spawnY + off->getElement<int>(1);
if (!pf->isWalkable(tx, ty)) continue;
++placed;
const int added = bag->addItem(itemId, qty);
CHECK(added > 0);
picked += added;
}
CHECK(placed >= 1);
CHECK(picked >= 1);
CHECK(bag->getUsedSlotCount() >= 1);
bag->destroy();
ItemRegistry::clear();
delete pf;
layer->setVisible(false);
}
TEST_CASE("hex.data.seedsMatrix.smokeConnectivity") {
auto doc = loadJson("seeds_matrix.json");
auto root = doc->object();
auto seeds = root->getArray("seeds");
auto algos = root->getArray("algorithms");
auto sizes = root->getArray("sizes");
REQUIRE(seeds);
REQUIRE(algos);
REQUIRE(sizes);
CHECK(seeds->size() >= 10);
CHECK(algos->size() >= 4);
// Sample a subset: first 6 seeds × all algos × medium size — keep runtime bounded.
auto size = sizes->getObject(1); // 24×18
const int w = size->getValue<int>("width");
const int h = size->getValue<int>("height");
const int minWalkable = root->getObject("expectations")->getValue<int>("minWalkable");
GeneratorRegistry::instance().registerBuiltins();
int okRuns = 0;
const size_t seedLimit = std::min<size_t>(6, seeds->size());
for (size_t si = 0; si < seedLimit; ++si) {
const uint32_t seed = uint32_t(seeds->getElement<int>(static_cast<unsigned int>(si)));
for (size_t ai = 0; ai < algos->size(); ++ai) {
auto algoObj = algos->getObject(static_cast<unsigned int>(ai));
const std::string algo = algoObj->getValue<std::string>("id");
Params p;
p.setSeed(seed);
p.setSize(w, h);
if (auto params = algoObj->getObject("params")) {
if (params->has("loops")) p.setInt("loops", params->getValue<int>("loops"));
if (params->has("fill")) p.setFloat("fill", float(params->getValue<double>("fill")));
if (params->has("floorPct"))
p.setFloat("floorPct", float(params->getValue<double>("floorPct")));
if (params->has("preset"))
p.setString("preset", params->getValue<std::string>("preset"));
if (params->has("maxAttempts"))
p.setInt("maxAttempts", params->getValue<int>("maxAttempts"));
}
Grid2D grid;
std::string err;
const bool ok = GeneratorRegistry::instance().generate(algo, p, grid, err);
CHECK(ok);
if (!ok) continue;
// Some cellular seeds are wall-heavy on medium maps; only BSP is
// expected to reliably clear the fixture minWalkable quota.
const int walkable = countWalkable(grid);
if (algo == "dungeon.bsp") {
CHECK(walkable >= minWalkable);
} else {
CHECK(walkable >= 1);
}
++okRuns;
}
}
CHECK(okRuns >= int(seedLimit) * 3);
}
TEST_CASE("hex.data.particles.loadConfigs") {
auto *fs = eve::filesystem::Filesystem::create();
REQUIRE(fs->setIdentity("ev_ut_hex_particles", true));
REQUIRE(fs->setupWriteDirectory());
// Copy fixture JSON into VFS write dir so loadConfig can find them.
const char *names[] = {"torch_fire.json", "pickup_burst.json", "ember_trail.json",
"mist_fog.json"};
for (const char *name : names) {
const std::string src = readTextFile(hexDataDir() + "/particles/" + name);
REQUIRE(!src.empty());
fs->write(name, src.c_str(), src.size());
}
auto *parts = Particles::create();
ParticleEmitter *torch = parts->newEmitterFromFile("torch_fire.json");
REQUIRE(torch != nullptr);
CHECK(torch->getEmissionRate() > 0.f);
torch->setPosition(10.f, 20.f);
torch->start();
for (int i = 0; i < 6; ++i) parts->update(1.f / 60.f);
CHECK(torch->getCount() > 0);
torch->stop();
ParticleEmitter *burst = parts->newEmitterFromFile("pickup_burst.json");
REQUIRE(burst != nullptr);
burst->setPosition(30.f, 40.f);
burst->start();
burst->emit(20);
CHECK(burst->getCount() > 0);
burst->stop();
ParticleEmitter *ember = parts->newEmitterFromFile("ember_trail.json");
REQUIRE(ember != nullptr);
CHECK(ember->getEmissionRate() > 0.f);
ember->stop();
ParticleEmitter *mist = parts->newEmitterFromFile("mist_fog.json");
REQUIRE(mist != nullptr);
CHECK(mist->getEmissionRate() > 0.f);
mist->setPosition(50.f, 60.f);
mist->start();
for (int i = 0; i < 8; ++i) parts->update(1.f / 60.f);
CHECK(mist->getCount() > 0);
mist->stop();
}
TEST_CASE("hex.data.tinyMap.handcraftedPath") {
const std::string text = readTextFile(hexDataDir() + "/maps/tiny_hex.json");
REQUIRE(!text.empty());
hideLayers();
std::vector<MapObject> objects;
std::string err;
auto layers = loadMapText(text, &objects, &err);
REQUIRE(!layers.empty());
TileLayer *layer = layers.front();
CHECK_EQ(static_cast<int>(layer->config()->orientation),
static_cast<int>(MapOrientation::Hexagonal));
CHECK(std::fabs(layer->config()->hexSideLength - 16.f) < 1e-3f);
CHECK_EQ(layer->getMapWidth(), 8);
CHECK_EQ(layer->getMapHeight(), 6);
CHECK_EQ(layer->getTile(0, 0), 1); // wall
CHECK_EQ(layer->getTile(1, 1), 2); // floor
CHECK_EQ(layer->getTile(3, 3), 3); // door
auto *mapMod = Map::create();
mapMod->setObjects(objects);
CHECK(mapMod->getObjectCount() >= 2);
int spawnX = -1, spawnY = -1, exitX = -1, exitY = -1;
for (int i = 0; i < mapMod->getObjectCount(); ++i) {
const std::string t = mapMod->getObjectType(i);
if (t == "spawn") {
spawnX = int(mapMod->getObjectX(i));
spawnY = int(mapMod->getObjectY(i));
} else if (t == "stairs") {
exitX = int(mapMod->getObjectX(i));
exitY = int(mapMod->getObjectY(i));
}
}
CHECK_EQ(spawnX, 1);
CHECK_EQ(spawnY, 1);
CHECK_EQ(exitX, 6);
CHECK_EQ(exitY, 4);
Pathfinder *pf = mapMod->newPathfinder(layer);
pf->blockGid(1);
pf->setTopology("hex");
CHECK(pf->isWalkable(spawnX, spawnY));
CHECK(pf->isWalkable(exitX, exitY));
Path *path = pf->findPath(spawnX, spawnY, exitX, exitY);
REQUIRE(path != nullptr);
CHECK(path->getLength() > 0);
CHECK_EQ(path->getX(path->getLength() - 1), exitX);
CHECK_EQ(path->getY(path->getLength() - 1), exitY);
delete path;
delete pf;
for (TileLayer *L : layers) L->setVisible(false);
}
TEST_CASE("hex.data.ringMap.loopPathThroughDoor") {
const std::string text = readTextFile(hexDataDir() + "/maps/ring_hex.json");
REQUIRE(!text.empty());
hideLayers();
std::vector<MapObject> objects;
std::string err;
auto layers = loadMapText(text, &objects, &err);
REQUIRE(!layers.empty());
TileLayer *layer = layers.front();
CHECK_EQ(layer->getMapWidth(), 9);
CHECK_EQ(layer->getMapHeight(), 7);
CHECK_EQ(layer->getTile(4, 3), 3); // center door
CHECK_EQ(layer->getTile(4, 2), 1); // inner wall ring
auto *mapMod = Map::create();
mapMod->setObjects(objects);
int spawnX = -1, spawnY = -1, exitX = -1, exitY = -1;
for (int i = 0; i < mapMod->getObjectCount(); ++i) {
const std::string t = mapMod->getObjectType(i);
if (t == "spawn") {
spawnX = int(mapMod->getObjectX(i));
spawnY = int(mapMod->getObjectY(i));
} else if (t == "stairs") {
exitX = int(mapMod->getObjectX(i));
exitY = int(mapMod->getObjectY(i));
}
}
CHECK_EQ(spawnX, 1);
CHECK_EQ(exitX, 7);
Pathfinder *pf = mapMod->newPathfinder(layer);
pf->blockGid(1);
pf->setTopology("hex");
Path *outer = pf->findPath(spawnX, spawnY, exitX, exitY);
REQUIRE(outer != nullptr);
CHECK(outer->getLength() > 0);
// Path must stay on the outer ring (avoid blocked center) or pass the door.
bool touchedDoor = false;
for (int i = 0; i < outer->getLength(); ++i) {
if (outer->getX(i) == 4 && outer->getY(i) == 3) touchedDoor = true;
}
// Outer corridor path is valid either way; just ensure connectivity.
CHECK(pf->isWalkable(4, 3));
delete outer;
Path *viaDoor = pf->findPath(4, 1, 4, 5);
REQUIRE(viaDoor != nullptr);
CHECK(viaDoor->getLength() > 0);
delete viaDoor;
delete pf;
for (TileLayer *L : layers) L->setVisible(false);
(void)touchedDoor;
}
TEST_CASE("hex.data.perception.casesFromJson") {
auto doc = loadJson("perception_cases.json");
auto root = doc->object();
auto cases = root->getArray("cases");
auto radiusCases = root->getArray("radiusScaleCases");
REQUIRE(cases);
REQUIRE(radiusCases);
auto *mapMod = Map::create();
Fov *fov = mapMod->newFovSize(7, 7);
fov->setBlockEmpty(false);
fov->setTopology("hex");
fov->setDetectionMargin(0.f);
for (size_t i = 0; i < cases->size(); ++i) {
auto c = cases->getObject(static_cast<unsigned int>(i));
const float perception = float(c->getValue<double>("perception"));
const float stealth = float(c->getValue<double>("stealth"));
const bool expect = c->getValue<bool>("expectDetect");
fov->clearRevealers();
const int id = fov->addRevealer(3, 3, 2);
fov->setRevealerPerception(id, perception);
fov->setPerceptionRadiusScale(0.f); // keep radius fixed for stealth-only cases
fov->compute();
CHECK(fov->isVisible(3, 3));
const bool got = fov->canDetect(id, 3, 3, stealth);
CHECK_EQ(got, expect);
}
for (size_t i = 0; i < radiusCases->size(); ++i) {
auto c = radiusCases->getObject(static_cast<unsigned int>(i));
const int base = c->getValue<int>("baseRadius");
const float perception = float(c->getValue<double>("perception"));
const float scale = float(c->getValue<double>("scale"));
const int expect = c->getValue<int>("expectEffective");
fov->clearRevealers();
const int id = fov->addRevealer(3, 3, base);
fov->setRevealerPerception(id, perception);
fov->setPerceptionRadiusScale(scale);
CHECK_EQ(fov->getEffectiveRadius(id), expect);
}
delete fov;
}
TEST_CASE("hex.data.catalog.driveLevelGeneration") {
auto doc = loadJson("catalog.json");
auto levels = doc->object()->getArray("levels");
REQUIRE(levels);
auto *mapMod = Map::create();
auto *gen = Procgen::create();
GeneratorRegistry::instance().registerBuiltins();
gen->setPaletteGid("hex_catalog", "wall", 1);
gen->setPaletteGid("hex_catalog", "floor", 2);
gen->setPaletteGid("hex_catalog", "corridor", 2);
gen->setPaletteGid("hex_catalog", "door", 3);
int generated = 0;
for (size_t i = 0; i < levels->size(); ++i) {
auto lv = levels->getObject(static_cast<unsigned int>(i));
// Skip id 7 synthetic cost map (still dungeon.bsp — generate is fine).
const uint32_t seed = uint32_t(lv->getValue<int>("seed"));
const int w = lv->getValue<int>("width");
const int h = lv->getValue<int>("height");
const std::string algo = lv->getValue<std::string>("algorithm");
hideLayers();
TileLayer *layer = mapMod->newLayer(w, h, 64.f, 32.f);
{
auto c = layer->config();
c->orientation = MapOrientation::Hexagonal;
c->hexSideLength = 16.f;
c->staggerAxis = StaggerAxis::Y;
c->staggerIndex = StaggerIndex::Odd;
}
Params p;
p.setSeed(seed);
p.setSize(w, h);
if (auto params = lv->getObject("params")) {
if (params->has("loops")) p.setInt("loops", params->getValue<int>("loops"));
if (params->has("fill")) p.setFloat("fill", float(params->getValue<double>("fill")));
}
Grid2D grid;
std::string err;
REQUIRE(GeneratorRegistry::instance().generate(algo, p, grid, err));
eve::test_support::applyGridToLayer(*gen, grid, "hex_catalog", *layer);
CHECK(countWalkable(grid) >= 4);
Pathfinder *pf = mapMod->newPathfinder(layer);
pf->blockGid(1);
pf->setTopology("auto");
CHECK_EQ(pf->getTopology(), std::string("hex"));
delete pf;
layer->setVisible(false);
++generated;
}
CHECK_EQ(generated, int(levels->size()));
}
TEST_CASE("hex.data.catalog.bootEachLevelDistinctConfig") {
// Start every catalog level with its own boot config and assert the
// applied FOV / light / cell-cost / loot / algo settings actually differ
// where the catalog says they should.
auto catalog = loadJson("catalog.json");
auto lootDoc = loadJson("loot_tables.json");
auto levels = catalog->object()->getArray("levels");
auto tables = lootDoc->object()->getObject("tables");
REQUIRE(levels);
REQUIRE(tables);
auto *mapMod = Map::create();
auto *gen = Procgen::create();
GeneratorRegistry::instance().registerBuiltins();
gen->setPaletteGid("hex_boot", "wall", 1);
gen->setPaletteGid("hex_boot", "floor", 2);
gen->setPaletteGid("hex_boot", "corridor", 2);
gen->setPaletteGid("hex_boot", "door", 3);
std::vector<uint32_t> seenSeeds;
std::vector<std::string> seenKeys;
int booted = 0;
for (size_t i = 0; i < levels->size(); ++i) {
auto lv = levels->getObject(static_cast<unsigned int>(i));
REQUIRE(lv);
const int id = lv->getValue<int>("id");
const std::string key = lv->getValue<std::string>("key");
const uint32_t seed = uint32_t(lv->getValue<int>("seed"));
const int w = lv->getValue<int>("width");
const int h = lv->getValue<int>("height");
const std::string algo = lv->getValue<std::string>("algorithm");
const std::string lootTable =
lv->has("lootTable") ? lv->getValue<std::string>("lootTable") : "starter";
CHECK(!key.empty());
CHECK(tables->has(lootTable));
for (uint32_t s : seenSeeds) CHECK(s != seed);
for (const auto &k : seenKeys) CHECK(k != key);
seenSeeds.push_back(seed);
seenKeys.push_back(key);
hideLayers();
TileLayer *layer = mapMod->newLayer(w, h, 64.f, 32.f);
{
auto c = layer->config();
c->orientation = MapOrientation::Hexagonal;
c->hexSideLength = 16.f;
c->staggerAxis = StaggerAxis::Y;
c->staggerIndex = StaggerIndex::Odd;
}
Params p;
p.setSeed(seed);
p.setSize(w, h);
if (auto params = lv->getObject("params")) {
if (params->has("loops")) p.setInt("loops", params->getValue<int>("loops"));
if (params->has("fill")) p.setFloat("fill", float(params->getValue<double>("fill")));
if (params->has("floorPct"))
p.setFloat("floorPct", float(params->getValue<double>("floorPct")));
if (params->has("preset"))
p.setString("preset", params->getValue<std::string>("preset"));
if (params->has("maxAttempts"))
p.setInt("maxAttempts", params->getValue<int>("maxAttempts"));
}
Grid2D grid;
std::string err;
REQUIRE(GeneratorRegistry::instance().generate(algo, p, grid, err));
eve::test_support::applyGridToLayer(*gen, grid, "hex_boot", *layer);
CHECK(countWalkable(grid) >= 1);
Pathfinder *pf = mapMod->newPathfinder(layer);
pf->blockGid(1);
pf->setTopology("auto");
CHECK_EQ(pf->getTopology(), std::string("hex"));
// Apply cell-cost boot config when present.
if (auto cc = lv->getObject("cellCost")) {
const float cost = float(cc->getValue<double>("cost"));
const int strip = cc->getValue<int>("stripWidth");
CHECK(cost > 1.f);
CHECK(strip >= 1);
int painted = 0;
for (int y = 1; y < h - 1; ++y) {
for (int x = 2; x < 2 + strip && x < w - 1; ++x) {
if (!pf->isWalkable(x, y)) continue;
pf->setCellCost(x, y, cost);
CHECK(std::fabs(pf->getCellCost(x, y) - cost) < 1e-3f);
++painted;
}
}
CHECK(painted >= 1);
}
// Apply FOV boot config when present / enabled.
auto enable = lv->getObject("enable");
const bool fovOn = !enable || !enable->has("fov") || enable->getValue<bool>("fov");
if (fovOn) {
Fov *fov = mapMod->newFov(layer);
fov->blockOpaqueGid(1);
fov->setBlockEmpty(false);
fov->setTopology("auto");
auto fovCfg = lv->getObject("fov");
std::string fovAlgo = "shadowcast";
int radius = 6;
float perception = 0.f;
float scale = 0.f;
int torchRadius = 0;
if (fovCfg) {
if (fovCfg->has("algorithm"))
fovAlgo = fovCfg->getValue<std::string>("algorithm");
if (fovCfg->has("radius")) radius = fovCfg->getValue<int>("radius");
if (fovCfg->has("heroRadius")) radius = fovCfg->getValue<int>("heroRadius");
if (fovCfg->has("heroPerception"))
perception = float(fovCfg->getValue<double>("heroPerception"));
if (fovCfg->has("perceptionScale"))
scale = float(fovCfg->getValue<double>("perceptionScale"));
if (fovCfg->has("torchRadius")) torchRadius = fovCfg->getValue<int>("torchRadius");
if (fovCfg->has("enabled") && !fovCfg->getValue<bool>("enabled")) {
delete fov;
delete pf;
layer->setVisible(false);
++booted;
continue;
}
}
if (radius < 1) radius = 1;
fov->setAlgorithm(fovAlgo);
CHECK_EQ(fov->getAlgorithm(), fovAlgo);
fov->setPerceptionRadiusScale(scale);
const int hero = fov->addRevealer(w / 2, h / 2, radius);
fov->setRevealerPerception(hero, perception);
CHECK(std::fabs(fov->getRevealerPerception(hero) - perception) < 1e-3f);
const int expectEff = radius + int(std::floor(perception * scale));
CHECK_EQ(fov->getEffectiveRadius(hero), expectEff);
if (torchRadius > 0) {
const int torch = fov->addRevealer(1, 1, torchRadius);
CHECK_EQ(fov->getEffectiveRadius(torch), torchRadius);
CHECK_EQ(fov->getRevealerCount(), 2);
} else {
CHECK_EQ(fov->getRevealerCount(), 1);
}
fov->compute();
delete fov;
}
// Apply light boot config when present.
if (auto light = lv->getObject("light")) {
auto *lamp = eve::graphics::Light2D::createLight("point");
const float radius = light->has("radius")
? float(light->getValue<double>("radius"))
: 120.f;
lamp->setRadius(radius);
CHECK(std::fabs(lamp->getRadius() - radius) < 1e-3f);
if (light->has("count")) CHECK(light->getValue<int>("count") >= 1);
lamp->setEnabled(false);
}
// Level-specific feature tags should be non-empty and include hex.
auto features = lv->getArray("features");
REQUIRE(features);
CHECK(features->size() >= 1);
bool hasHex = false;
for (size_t fi = 0; fi < features->size(); ++fi) {
if (features->getElement<std::string>(static_cast<unsigned int>(fi)) == "hex") hasHex = true;
}
CHECK(hasHex);
// Spot-check a few known distinct boot profiles.
if (id == 2) {
CHECK_EQ(algo, std::string("cave.cellular"));
CHECK_EQ(lootTable, std::string("cave"));
auto fovCfg = lv->getObject("fov");
REQUIRE(fovCfg);
CHECK_EQ(fovCfg->getValue<int>("radius"), 6);
} else if (id == 3) {
auto light = lv->getObject("light");
REQUIRE(light);
CHECK(std::fabs(float(light->getValue<double>("radius")) - 140.f) < 1e-3f);
} else if (id == 7) {
auto cc = lv->getObject("cellCost");
REQUIRE(cc);
CHECK(std::fabs(float(cc->getValue<double>("cost")) - 8.f) < 1e-3f);
} else if (id == 8) {
auto fovCfg = lv->getObject("fov");
REQUIRE(fovCfg);
CHECK_EQ(fovCfg->getValue<int>("heroRadius"), 4);
CHECK(std::fabs(float(fovCfg->getValue<double>("heroPerception")) - 2.f) < 1e-3f);
} else if (id == 15) {
CHECK_EQ(lootTable, std::string("equipment"));
auto parts = lv->getArray("particles");
REQUIRE(parts);
CHECK(parts->size() >= 3);
} else if (id == 26) {
CHECK_EQ(algo, std::string("cave.drunkard"));
} else if (id == 27) {
CHECK_EQ(algo, std::string("maze.backtrack"));
} else if (id == 28) {
CHECK_EQ(algo, std::string("wfc.simple"));
} else if (id == 30) {
CHECK_EQ(lootTable, std::string("raid"));
auto en = lv->getObject("enable");
REQUIRE(en);
CHECK(en->getValue<bool>("perception"));
CHECK(en->getValue<bool>("flow"));
}
delete pf;
layer->setVisible(false);
++booted;
(void)id;
}
CHECK_EQ(booted, int(levels->size()));
CHECK(booted >= 31);
}
TEST_CASE("hex.data.catalog.newLevels16to30Present") {
auto doc = loadJson("catalog.json");
auto levels = doc->object()->getArray("levels");
REQUIRE(levels);
std::vector<int> want = {16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30};
for (int id : want) {
bool found = false;
for (size_t i = 0; i < levels->size(); ++i)
if (levels->getObject(static_cast<unsigned int>(i))->getValue<int>("id") == id) found = true;
CHECK(found);
}
}
TEST_CASE("hex.data.lootTables.perceptionGates") {
auto lootDoc = loadJson("loot_tables.json");
auto root = lootDoc->object();
auto gates = root->getArray("perceptionGates");
REQUIRE(gates);
CHECK(gates->size() >= 3);
ItemRegistry::clear();
InventorySystem::ensureBuiltins();
auto *inv = Inventory::create();
inv->registerItemsFromJson(readTextFile(hexDataDir() + "/items.json"));
auto *mapMod = Map::create();
Fov *fov = mapMod->newFovSize(9, 9);
fov->setBlockEmpty(false);
fov->setTopology("hex");
fov->setDetectionMargin(0.f);
const int hero = fov->addRevealer(4, 4, 3);
fov->setRevealerPerception(hero, 2.f);
fov->compute();
CHECK(fov->isVisible(4, 4));
for (size_t i = 0; i < gates->size(); ++i) {
auto g = gates->getObject(static_cast<unsigned int>(i));
const std::string itemId = g->getValue<std::string>("itemId");
const float stealth = float(g->getValue<double>("stealth"));
const bool needVis = g->getValue<bool>("requireVisible");
CHECK(ItemRegistry::find(itemId) != nullptr);
const bool detect = fov->canDetect(hero, 4, 4, stealth);
if (needVis) {
// Visible cell: detect iff perception (>=2) covers stealth.
CHECK_EQ(detect, stealth <= 2.f);
} else {
// Gate flagged requireVisible=false — still uses canDetect which
// requires visibility; document that fixture flag is advisory for demos.
CHECK(fov->isVisible(4, 4));
(void)detect;
}
}
delete fov;
ItemRegistry::clear();
}
TEST_CASE("hex.data.lootTables.allTablesPlaceable") {
auto lootDoc = loadJson("loot_tables.json");
auto tables = lootDoc->object()->getObject("tables");
REQUIRE(tables);
ItemRegistry::clear();
InventorySystem::ensureBuiltins();
auto *inv = Inventory::create();
inv->registerItemsFromJson(readTextFile(hexDataDir() + "/items.json"));
auto *mapMod = Map::create();
auto *gen = Procgen::create();
hideLayers();
TileLayer *layer = mapMod->newLayer(20, 16, 64.f, 32.f);
{
auto c = layer->config();
c->orientation = MapOrientation::Hexagonal;
c->hexSideLength = 16.f;
c->staggerAxis = StaggerAxis::Y;
c->staggerIndex = StaggerIndex::Odd;
}
GeneratorRegistry::instance().registerBuiltins();
Params p;
p.setSeed(777);
p.setSize(20, 16);
Grid2D grid;
std::string err;
REQUIRE(GeneratorRegistry::instance().generate("dungeon.bsp", p, grid, err));
gen->setPaletteGid("hex_loot_all", "wall", 1);
gen->setPaletteGid("hex_loot_all", "floor", 2);
gen->setPaletteGid("hex_loot_all", "corridor", 2);
gen->setPaletteGid("hex_loot_all", "door", 3);
eve::test_support::applyGridToLayer(*gen, grid, "hex_loot_all", *layer);
int spawnX = -1, spawnY = -1;
for (int i = 0; i < grid.getObjectCount(); ++i) {
if (grid.getObjectType(i) == "spawn") {
spawnX = int(grid.getObjectX(i));
spawnY = int(grid.getObjectY(i));
}
}
if (spawnX < 0) {
for (int y = 0; y < grid.getHeight() && spawnX < 0; ++y)
for (int x = 0; x < grid.getWidth() && spawnX < 0; ++x)
if (grid.getCell(x, y) == int(Semantic::Floor)) {
spawnX = x;
spawnY = y;
}
}
REQUIRE(spawnX >= 0);
Pathfinder *pf = mapMod->newPathfinder(layer);
pf->blockGid(1);
pf->setTopology("hex");
const char *names[] = {"starter", "cave", "rich", "raid", "equipment"};
int tablesOk = 0;
for (const char *name : names) {
REQUIRE(tables->has(name));
auto arr = tables->getArray(name);
REQUIRE(arr);
Bag *bag = inv->newBag(32);
int placed = 0;
for (size_t i = 0; i < arr->size(); ++i) {
auto e = arr->getObject(static_cast<unsigned int>(i));
const std::string itemId = e->getValue<std::string>("itemId");
const int qty = e->has("qty") ? e->getValue<int>("qty") : 1;
CHECK(ItemRegistry::find(itemId) != nullptr);
int tx = spawnX, ty = spawnY;
if (e->has("offset")) {
auto off = e->getArray("offset");
tx = spawnX + off->getElement<int>(0);
ty = spawnY + off->getElement<int>(1);
}
if (!pf->isWalkable(tx, ty)) {
tx = spawnX;
ty = spawnY;
}
const int added = bag->addItem(itemId, qty);
CHECK(added > 0);
++placed;
}
CHECK(placed >= 1);
CHECK(bag->getUsedSlotCount() >= 1);
bag->destroy();
++tablesOk;
}
CHECK_EQ(tablesOk, 5);
delete pf;
layer->setVisible(false);
ItemRegistry::clear();
}
TEST_CASE("hex.data.catalog.enableFlagsConsistent") {
auto doc = loadJson("catalog.json");
auto levels = doc->object()->getArray("levels");
REQUIRE(levels);
int withEnable = 0;
int withFov = 0;
int withLight = 0;
for (size_t i = 0; i < levels->size(); ++i) {
auto lv = levels->getObject(static_cast<unsigned int>(i));
CHECK(lv->has("key"));
CHECK(lv->has("seed"));
CHECK(lv->has("lootTable"));
if (lv->has("enable")) {
++withEnable;
auto en = lv->getObject("enable");
// If FOV is disabled, radius may be 0 / enabled:false.
if (en->has("fov") && !en->getValue<bool>("fov")) {
auto fov = lv->getObject("fov");
if (fov && fov->has("enabled")) CHECK(!fov->getValue<bool>("enabled"));
}
if (en->has("light") && en->getValue<bool>("light")) {
// light feature on ⇒ prefer an explicit light block OR inherit demo default.
(void)lv->getObject("light");
}
if (en->has("cellcost") && en->getValue<bool>("cellcost")) {
CHECK(lv->has("cellCost"));
}
if (en->has("perception") && en->getValue<bool>("perception")) {
auto fov = lv->getObject("fov");
REQUIRE(fov);
const bool hasPerception = fov->has("heroPerception") || fov->has("perceptionScale");
CHECK(hasPerception);
}
}
if (lv->has("fov")) ++withFov;
if (lv->has("light")) ++withLight;
}
CHECK(withEnable >= 10);
CHECK(withFov >= 8);
CHECK(withLight >= 2);
}
TEST_CASE("hex.data.seedsMatrix.pathToExitSample") {
auto doc = loadJson("seeds_matrix.json");
auto root = doc->object();
auto seeds = root->getArray("seeds");
REQUIRE(seeds);
auto *mapMod = Map::create();
auto *gen = Procgen::create();
GeneratorRegistry::instance().registerBuiltins();
gen->setPaletteGid("hex_seed_path", "wall", 1);
gen->setPaletteGid("hex_seed_path", "floor", 2);
gen->setPaletteGid("hex_seed_path", "corridor", 2);
gen->setPaletteGid("hex_seed_path", "door", 3);
int ok = 0;
const size_t limit = std::min<size_t>(8, seeds->size());
for (size_t si = 0; si < limit; ++si) {
const uint32_t seed = uint32_t(seeds->getElement<int>(static_cast<unsigned int>(si)));
hideLayers();
TileLayer *layer = mapMod->newLayer(24, 18, 64.f, 32.f);
{
auto c = layer->config();
c->orientation = MapOrientation::Hexagonal;
c->hexSideLength = 16.f;
c->staggerAxis = StaggerAxis::Y;
c->staggerIndex = StaggerIndex::Odd;
}
Params p;
p.setSeed(seed);
p.setSize(24, 18);
Grid2D grid;
std::string err;
REQUIRE(GeneratorRegistry::instance().generate("dungeon.bsp", p, grid, err));
eve::test_support::applyGridToLayer(*gen, grid, "hex_seed_path", *layer);
int spawnX = -1, spawnY = -1, exitX = -1, exitY = -1;
for (int i = 0; i < grid.getObjectCount(); ++i) {
const std::string t = grid.getObjectType(i);
if (t == "spawn") {
spawnX = int(grid.getObjectX(i));
spawnY = int(grid.getObjectY(i));
} else if (t == "stairs") {
exitX = int(grid.getObjectX(i));
exitY = int(grid.getObjectY(i));
}
}
if (spawnX < 0) {
for (int y = 0; y < grid.getHeight() && spawnX < 0; ++y)
for (int x = 0; x < grid.getWidth() && spawnX < 0; ++x)
if (grid.getCell(x, y) == int(Semantic::Floor)) {
spawnX = x;
spawnY = y;
}
}
if (exitX < 0) {
int best = -1;
for (int y = 0; y < grid.getHeight(); ++y)
for (int x = 0; x < grid.getWidth(); ++x) {
if (grid.getCell(x, y) != int(Semantic::Floor) &&
grid.getCell(x, y) != int(Semantic::Corridor))
continue;
const int dist = (x - spawnX) * (x - spawnX) + (y - spawnY) * (y - spawnY);
if (dist > best) {
best = dist;
exitX = x;
exitY = y;
}
}
}
REQUIRE(spawnX >= 0);
REQUIRE(exitX >= 0);
Pathfinder *pf = mapMod->newPathfinder(layer);
pf->blockGid(1);
pf->setTopology("hex");
Path *path = pf->findPath(spawnX, spawnY, exitX, exitY);
REQUIRE(path != nullptr);
CHECK(path->getLength() > 0);
CHECK_EQ(path->getX(path->getLength() - 1), exitX);
CHECK_EQ(path->getY(path->getLength() - 1), exitY);
delete path;
delete pf;
layer->setVisible(false);
++ok;
}
CHECK_EQ(ok, int(limit));
}
TEST_CASE("hex.data.tinyMap.lootMarkersAndObjects") {
const std::string text = readTextFile(hexDataDir() + "/maps/tiny_hex.json");
REQUIRE(!text.empty());
hideLayers();
std::vector<MapObject> objects;
std::string err;
auto layers = loadMapText(text, &objects, &err);
REQUIRE(!layers.empty());
auto *mapMod = Map::create();
mapMod->setObjects(objects);
int lootCount = 0, spawnCount = 0, stairsCount = 0;
for (int i = 0; i < mapMod->getObjectCount(); ++i) {
const std::string t = mapMod->getObjectType(i);
if (t == "loot") ++lootCount;
if (t == "spawn") ++spawnCount;
if (t == "stairs") ++stairsCount;
}
CHECK_EQ(spawnCount, 1);
CHECK_EQ(stairsCount, 1);
CHECK(lootCount >= 2);
for (TileLayer *L : layers) L->setVisible(false);
}
TEST_CASE("hex.data.defaults.matchHexOrientation") {
auto doc = loadJson("catalog.json");
auto defaults = doc->object()->getObject("defaults");
REQUIRE(defaults);
CHECK_EQ(defaults->getValue<std::string>("orientation"), std::string("hexagonal"));
CHECK_EQ(defaults->getValue<std::string>("staggerAxis"), std::string("y"));
CHECK_EQ(defaults->getValue<std::string>("staggerIndex"), std::string("odd"));
CHECK_EQ(defaults->getValue<int>("wallGid"), 1);
CHECK_EQ(defaults->getValue<int>("floorGid"), 2);
CHECK_EQ(defaults->getValue<int>("doorGid"), 3);
CHECK(defaults->getValue<int>("hexSideLength") > 0);
CHECK(defaults->getValue<int>("tileW") > 0);
CHECK(defaults->getValue<int>("tileH") > 0);
}
TEST_CASE("hex.data.items.equipmentSlots") {
ItemRegistry::clear();
InventorySystem::ensureBuiltins();
auto *inv = Inventory::create();
const int n = inv->registerItemsFromJson(readTextFile(hexDataDir() + "/items.json"));
CHECK(n >= 12);
CHECK(ItemRegistry::find("hex.boots") != nullptr);
CHECK_EQ(ItemRegistry::find("hex.boots")->equipSlot, std::string("feet"));
CHECK(ItemRegistry::find("hex.shield") != nullptr);
CHECK_EQ(ItemRegistry::find("hex.shield")->equipSlot, std::string("offhand"));
EquipmentSet *eq = inv->newEquipmentSet();
eq->defineSlot("feet");
eq->addSlotAllowedTag("feet", "boots");
eq->defineSlot("offhand");
eq->addSlotAllowedTag("offhand", "shield");
Bag *bag = inv->newBag(4);
const int bootsIn = bag->addItem("hex.boots", 1);
const int shieldIn = bag->addItem("hex.shield", 1);
CHECK_EQ(bootsIn, 1);
CHECK_EQ(shieldIn, 1);
const int b = bag->findItem("hex.boots");
const int s = bag->findItem("hex.shield");
CHECK(eq->equipFromBag("feet", bag, b));
CHECK(eq->equipFromBag("offhand", bag, s));
CHECK_EQ(eq->getSlotItemId("feet"), std::string("hex.boots"));
CHECK_EQ(eq->getSlotItemId("offhand"), std::string("hex.shield"));
bag->destroy();
eq->destroy();
ItemRegistry::clear();
}
TEST_CASE("hex.data.particles.mistOnDungeon") {
auto *fs = eve::filesystem::Filesystem::create();
REQUIRE(fs->setIdentity("ev_ut_hex_mist", true));
REQUIRE(fs->setupWriteDirectory());
const std::string mist = readTextFile(hexDataDir() + "/particles/mist_fog.json");
REQUIRE(!mist.empty());
fs->write("mist_fog.json", mist.c_str(), mist.size());
auto *mapMod = Map::create();
auto *gen = Procgen::create();
hideLayers();
TileLayer *layer = mapMod->newLayer(16, 12, 64.f, 32.f);
{
auto c = layer->config();
c->orientation = MapOrientation::Hexagonal;
c->hexSideLength = 16.f;
c->staggerAxis = StaggerAxis::Y;
c->staggerIndex = StaggerIndex::Odd;
}
GeneratorRegistry::instance().registerBuiltins();
Params p;
p.setSeed(55);
p.setSize(16, 12);
Grid2D grid;
std::string err;
REQUIRE(GeneratorRegistry::instance().generate("dungeon.bsp", p, grid, err));
gen->setPaletteGid("hex_mist", "wall", 1);
gen->setPaletteGid("hex_mist", "floor", 2);
gen->setPaletteGid("hex_mist", "corridor", 2);
gen->setPaletteGid("hex_mist", "door", 3);
eve::test_support::applyGridToLayer(*gen, grid, "hex_mist", *layer);
auto *parts = Particles::create();
ParticleEmitter *fog = parts->newEmitterFromFile("mist_fog.json");
REQUIRE(fog != nullptr);
fog->setPosition(40.f, 40.f);
fog->start();
for (int i = 0; i < 10; ++i) parts->update(1.f / 60.f);
CHECK(fog->getCount() > 0);
fog->stop();
layer->setVisible(false);
}
TEST_CASE("hex.data.lootTables.itemIdsExistInItems") {
auto itemsDoc = loadJson("items.json");
auto lootDoc = loadJson("loot_tables.json");
REQUIRE(itemsDoc->isArray());
auto items = itemsDoc->array();
auto tables = lootDoc->object()->getObject("tables");
REQUIRE(items);
REQUIRE(tables);
std::vector<std::string> ids;
for (size_t i = 0; i < items->size(); ++i) {
auto o = items->getObject(static_cast<unsigned int>(i));
ids.push_back(o->getValue<std::string>("id"));
}
CHECK(ids.size() >= 12);
int refs = 0;
for (const char *name : {"starter", "cave", "rich", "raid", "equipment"}) {
auto arr = tables->getArray(name);
REQUIRE(arr);
for (size_t i = 0; i < arr->size(); ++i) {
const std::string itemId = arr->getObject(static_cast<unsigned int>(i))->getValue<std::string>("itemId");
bool found = false;
for (const auto &id : ids)
if (id == itemId) found = true;
CHECK(found);
++refs;
}
}
auto gates = lootDoc->object()->getArray("perceptionGates");
REQUIRE(gates);
for (size_t i = 0; i < gates->size(); ++i) {
const std::string itemId = gates->getObject(static_cast<unsigned int>(i))->getValue<std::string>("itemId");
bool found = false;
for (const auto &id : ids)
if (id == itemId) found = true;
CHECK(found);
++refs;
}
CHECK(refs >= 20);
}
TEST_CASE("hex.data.catalog.featureTagsCoverModules") {
auto doc = loadJson("catalog.json");
auto levels = doc->object()->getArray("levels");
REQUIRE(levels);
bool hasPath = false, hasFov = false, hasLight = false, hasParticles = false;
bool hasInventory = false, hasFlow = false, hasCell = false, hasPerc = false;
for (size_t i = 0; i < levels->size(); ++i) {
auto feats = levels->getObject(static_cast<unsigned int>(i))->getArray("features");
REQUIRE(feats);
for (size_t fi = 0; fi < feats->size(); ++fi) {
const std::string f = feats->getElement<std::string>(static_cast<unsigned int>(fi));
if (f == "pathfinding") hasPath = true;
if (f == "fov") hasFov = true;
if (f == "lighting") hasLight = true;
if (f == "particles") hasParticles = true;
if (f == "inventory") hasInventory = true;
if (f == "flowfield") hasFlow = true;
if (f == "cellcost") hasCell = true;
if (f == "perception") hasPerc = true;
}
}
CHECK(hasPath);
CHECK(hasFov);
CHECK(hasLight);
CHECK(hasParticles);
CHECK(hasInventory);
CHECK(hasFlow);
CHECK(hasCell);
CHECK(hasPerc);
}
TEST_CASE("hex.data.catalog.lootTableReferencesValid") {
auto catalog = loadJson("catalog.json");
auto loot = loadJson("loot_tables.json");
auto levels = catalog->object()->getArray("levels");
auto tables = loot->object()->getObject("tables");
REQUIRE(levels);
REQUIRE(tables);
for (size_t i = 0; i < levels->size(); ++i) {
auto lv = levels->getObject(static_cast<unsigned int>(i));
REQUIRE(lv->has("lootTable"));
const std::string name = lv->getValue<std::string>("lootTable");
CHECK(tables->has(name));
CHECK(tables->getArray(name)->size() >= 1);
}
}
TEST_CASE("hex.data.particles.configRequiredFields") {
auto *dm = DataModule::create();
const char *files[] = {"torch_fire.json", "pickup_burst.json", "ember_trail.json",
"mist_fog.json"};
for (const char *name : files) {
const std::string text = readTextFile(hexDataDir() + "/particles/" + name);
REQUIRE(!text.empty());
std::string err;
std::unique_ptr<JsonDocument> doc(dm->decodeJson(text, &err));
REQUIRE(doc.get() != nullptr);
REQUIRE(doc->isObject());
auto root = doc->object();
CHECK(root->has("buffer"));
const bool hasPresetOrRate = root->has("preset") || root->has("emissionRate");
CHECK(hasPresetOrRate);
CHECK(root->getValue<int>("buffer") > 0);
}
}
TEST_CASE("hex.data.seedsMatrix.algorithmsRegistered") {
auto doc = loadJson("seeds_matrix.json");
auto algos = doc->object()->getArray("algorithms");
REQUIRE(algos);
GeneratorRegistry::instance().registerBuiltins();
for (size_t i = 0; i < algos->size(); ++i) {
const std::string id = algos->getObject(static_cast<unsigned int>(i))->getValue<std::string>("id");
Params p;
p.setSeed(1);
p.setSize(16, 12);
auto params = algos->getObject(static_cast<unsigned int>(i))->getObject("params");
if (params) {
if (params->has("loops")) p.setInt("loops", params->getValue<int>("loops"));
if (params->has("fill")) p.setFloat("fill", float(params->getValue<double>("fill")));
if (params->has("floorPct"))
p.setFloat("floorPct", float(params->getValue<double>("floorPct")));
if (params->has("preset"))
p.setString("preset", params->getValue<std::string>("preset"));
if (params->has("maxAttempts"))
p.setInt("maxAttempts", params->getValue<int>("maxAttempts"));
}
Grid2D grid;
std::string err;
CHECK(GeneratorRegistry::instance().generate(id, p, grid, err));
CHECK(grid.getWidth() == 16);
CHECK(grid.getHeight() == 12);
}
}
TEST_CASE("hex.data.ringMap.doorShortestVsOuter") {
const std::string text = readTextFile(hexDataDir() + "/maps/ring_hex.json");
REQUIRE(!text.empty());
hideLayers();
std::vector<MapObject> objects;
std::string err;
auto layers = loadMapText(text, &objects, &err);
REQUIRE(!layers.empty());
TileLayer *layer = layers.front();
CHECK_EQ(layer->getTile(4, 3), 3); // door
CHECK_EQ(layer->getTile(4, 2), 1); // inner wall north of door
CHECK_EQ(layer->getTile(4, 4), 1); // inner wall south of door
auto *mapMod = Map::create();
Pathfinder *pf = mapMod->newPathfinder(layer);
pf->blockGid(1);
pf->setTopology("hex");
CHECK(pf->isWalkable(4, 3));
CHECK(!pf->isWalkable(4, 2));
CHECK(!pf->isWalkable(4, 4));
// Horizontal path through the door cell (row 3: wall,floor,door,floor,wall).
CHECK(pf->isWalkable(3, 3));
CHECK(pf->isWalkable(5, 3));
Path *throughDoor = pf->findPath(3, 3, 5, 3);
REQUIRE(throughDoor != nullptr);
CHECK(throughDoor->getLength() > 0);
bool usesDoor = false;
for (int i = 0; i < throughDoor->getLength(); ++i)
if (throughDoor->getX(i) == 4 && throughDoor->getY(i) == 3) usesDoor = true;
CHECK(usesDoor);
// Outer ring still connects spawn→exit without needing the door.
Path *outer = pf->findPath(1, 1, 7, 5);
REQUIRE(outer != nullptr);
CHECK(outer->getLength() > 0);
CHECK(outer->getLength() >= throughDoor->getLength());
delete throughDoor;
delete outer;
delete pf;
for (TileLayer *L : layers) L->setVisible(false);
}
TEST_CASE("hex.data.items.categoriesAndTags") {
auto doc = loadJson("items.json");
auto arr = doc->array();
REQUIRE(arr);
int withTags = 0, withCategory = 0, withExtra = 0;
for (size_t i = 0; i < arr->size(); ++i) {
auto o = arr->getObject(static_cast<unsigned int>(i));
CHECK(o->has("id"));
CHECK(o->has("displayName"));
CHECK(o->has("maxStack"));
if (o->has("category")) ++withCategory;
if (o->has("tags") && o->getArray("tags")->size() > 0) ++withTags;
if (o->has("extra")) ++withExtra;
}
CHECK(withTags == int(arr->size()));
CHECK(withCategory == int(arr->size()));
CHECK(withExtra >= 8);
}
TEST_CASE("hex.data.perception.detectionMarginFromCases") {
auto doc = loadJson("perception_cases.json");
auto cases = doc->object()->getArray("cases");
REQUIRE(cases);
auto *mapMod = Map::create();
Fov *fov = mapMod->newFovSize(5, 5);
fov->setBlockEmpty(false);
fov->setTopology("hex");
// Positive margin should allow barely-over stealth that previously failed.
fov->setDetectionMargin(0.2f);
CHECK(std::fabs(fov->getDetectionMargin() - 0.2f) < 1e-3f);
int flipped = 0;
for (size_t i = 0; i < cases->size(); ++i) {
auto c = cases->getObject(static_cast<unsigned int>(i));
const float perception = float(c->getValue<double>("perception"));
const float stealth = float(c->getValue<double>("stealth"));
const bool expectNoMargin = c->getValue<bool>("expectDetect");
fov->clearRevealers();
const int id = fov->addRevealer(2, 2, 2);
fov->setRevealerPerception(id, perception);
fov->setPerceptionRadiusScale(0.f);
fov->compute();
const bool withMargin = fov->canDetect(id, 2, 2, stealth);
if (!expectNoMargin && stealth <= perception + 0.2f + 1e-4f) {
CHECK(withMargin);
++flipped;
} else if (expectNoMargin) {
CHECK(withMargin);
}
}
CHECK(flipped >= 1);
delete fov;
}
TEST_CASE("hex.data.tinyMap.doorIsWalkable") {
const std::string text = readTextFile(hexDataDir() + "/maps/tiny_hex.json");
hideLayers();
std::vector<MapObject> objects;
std::string err;
auto layers = loadMapText(text, &objects, &err);
REQUIRE(!layers.empty());
TileLayer *layer = layers.front();
CHECK_EQ(layer->getTile(3, 3), 3);
auto *mapMod = Map::create();
Pathfinder *pf = mapMod->newPathfinder(layer);
pf->blockGid(1); // walls only — doors (gid 3) stay walkable
pf->setTopology("hex");
CHECK(pf->isWalkable(3, 3));
Path *through = pf->findPath(1, 1, 6, 4);
REQUIRE(through != nullptr);
CHECK(through->getLength() > 0);
delete through;
delete pf;
for (TileLayer *L : layers) L->setVisible(false);
}