// 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 #include #include #include #include #include #include #include #include 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 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 doc(dm->decodeJson(text, &err)); REQUIRE(doc.get() != nullptr); return doc; } void hideLayers() { if (ecs::current()->getManager() == nullptr) return; auto view = ecs::View(); 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("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(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("key") == "pipeline_full") foundPipeline = 1; } CHECK_EQ(foundPipeline, 1); auto defaults = root->getObject("defaults"); REQUIRE(defaults); CHECK_EQ(defaults->getValue("orientation"), std::string("hexagonal")); CHECK_EQ(defaults->getValue("wallGid"), 1); CHECK_EQ(defaults->getValue("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(i)); const std::string itemId = entry->getValue("itemId"); const int qty = entry->has("qty") ? entry->getValue("qty") : 1; auto off = entry->getArray("offset"); REQUIRE(off); const int tx = spawnX + off->getElement(0); const int ty = spawnY + off->getElement(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("width"); const int h = size->getValue("height"); const int minWalkable = root->getObject("expectations")->getValue("minWalkable"); GeneratorRegistry::instance().registerBuiltins(); int okRuns = 0; const size_t seedLimit = std::min(6, seeds->size()); for (size_t si = 0; si < seedLimit; ++si) { const uint32_t seed = uint32_t(seeds->getElement(static_cast(si))); for (size_t ai = 0; ai < algos->size(); ++ai) { auto algoObj = algos->getObject(static_cast(ai)); const std::string algo = algoObj->getValue("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("loops")); if (params->has("fill")) p.setFloat("fill", float(params->getValue("fill"))); if (params->has("floorPct")) p.setFloat("floorPct", float(params->getValue("floorPct"))); if (params->has("preset")) p.setString("preset", params->getValue("preset")); if (params->has("maxAttempts")) p.setInt("maxAttempts", params->getValue("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 objects; std::string err; auto layers = loadMapText(text, &objects, &err); REQUIRE(!layers.empty()); TileLayer *layer = layers.front(); CHECK_EQ(static_cast(layer->config()->orientation), static_cast(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 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(i)); const float perception = float(c->getValue("perception")); const float stealth = float(c->getValue("stealth")); const bool expect = c->getValue("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(i)); const int base = c->getValue("baseRadius"); const float perception = float(c->getValue("perception")); const float scale = float(c->getValue("scale")); const int expect = c->getValue("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(i)); // Skip id 7 synthetic cost map (still dungeon.bsp — generate is fine). const uint32_t seed = uint32_t(lv->getValue("seed")); const int w = lv->getValue("width"); const int h = lv->getValue("height"); const std::string algo = lv->getValue("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("loops")); if (params->has("fill")) p.setFloat("fill", float(params->getValue("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 seenSeeds; std::vector seenKeys; int booted = 0; for (size_t i = 0; i < levels->size(); ++i) { auto lv = levels->getObject(static_cast(i)); REQUIRE(lv); const int id = lv->getValue("id"); const std::string key = lv->getValue("key"); const uint32_t seed = uint32_t(lv->getValue("seed")); const int w = lv->getValue("width"); const int h = lv->getValue("height"); const std::string algo = lv->getValue("algorithm"); const std::string lootTable = lv->has("lootTable") ? lv->getValue("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("loops")); if (params->has("fill")) p.setFloat("fill", float(params->getValue("fill"))); if (params->has("floorPct")) p.setFloat("floorPct", float(params->getValue("floorPct"))); if (params->has("preset")) p.setString("preset", params->getValue("preset")); if (params->has("maxAttempts")) p.setInt("maxAttempts", params->getValue("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("cost")); const int strip = cc->getValue("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("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("algorithm"); if (fovCfg->has("radius")) radius = fovCfg->getValue("radius"); if (fovCfg->has("heroRadius")) radius = fovCfg->getValue("heroRadius"); if (fovCfg->has("heroPerception")) perception = float(fovCfg->getValue("heroPerception")); if (fovCfg->has("perceptionScale")) scale = float(fovCfg->getValue("perceptionScale")); if (fovCfg->has("torchRadius")) torchRadius = fovCfg->getValue("torchRadius"); if (fovCfg->has("enabled") && !fovCfg->getValue("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("radius")) : 120.f; lamp->setRadius(radius); CHECK(std::fabs(lamp->getRadius() - radius) < 1e-3f); if (light->has("count")) CHECK(light->getValue("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(static_cast(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("radius"), 6); } else if (id == 3) { auto light = lv->getObject("light"); REQUIRE(light); CHECK(std::fabs(float(light->getValue("radius")) - 140.f) < 1e-3f); } else if (id == 7) { auto cc = lv->getObject("cellCost"); REQUIRE(cc); CHECK(std::fabs(float(cc->getValue("cost")) - 8.f) < 1e-3f); } else if (id == 8) { auto fovCfg = lv->getObject("fov"); REQUIRE(fovCfg); CHECK_EQ(fovCfg->getValue("heroRadius"), 4); CHECK(std::fabs(float(fovCfg->getValue("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("perception")); CHECK(en->getValue("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 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(i))->getValue("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(i)); const std::string itemId = g->getValue("itemId"); const float stealth = float(g->getValue("stealth")); const bool needVis = g->getValue("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(i)); const std::string itemId = e->getValue("itemId"); const int qty = e->has("qty") ? e->getValue("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(0); ty = spawnY + off->getElement(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(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("fov")) { auto fov = lv->getObject("fov"); if (fov && fov->has("enabled")) CHECK(!fov->getValue("enabled")); } if (en->has("light") && en->getValue("light")) { // light feature on ⇒ prefer an explicit light block OR inherit demo default. (void)lv->getObject("light"); } if (en->has("cellcost") && en->getValue("cellcost")) { CHECK(lv->has("cellCost")); } if (en->has("perception") && en->getValue("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(8, seeds->size()); for (size_t si = 0; si < limit; ++si) { const uint32_t seed = uint32_t(seeds->getElement(static_cast(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 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("orientation"), std::string("hexagonal")); CHECK_EQ(defaults->getValue("staggerAxis"), std::string("y")); CHECK_EQ(defaults->getValue("staggerIndex"), std::string("odd")); CHECK_EQ(defaults->getValue("wallGid"), 1); CHECK_EQ(defaults->getValue("floorGid"), 2); CHECK_EQ(defaults->getValue("doorGid"), 3); CHECK(defaults->getValue("hexSideLength") > 0); CHECK(defaults->getValue("tileW") > 0); CHECK(defaults->getValue("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 ids; for (size_t i = 0; i < items->size(); ++i) { auto o = items->getObject(static_cast(i)); ids.push_back(o->getValue("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(i))->getValue("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(i))->getValue("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(i))->getArray("features"); REQUIRE(feats); for (size_t fi = 0; fi < feats->size(); ++fi) { const std::string f = feats->getElement(static_cast(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(i)); REQUIRE(lv->has("lootTable")); const std::string name = lv->getValue("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 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("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(i))->getValue("id"); Params p; p.setSeed(1); p.setSize(16, 12); auto params = algos->getObject(static_cast(i))->getObject("params"); if (params) { if (params->has("loops")) p.setInt("loops", params->getValue("loops")); if (params->has("fill")) p.setFloat("fill", float(params->getValue("fill"))); if (params->has("floorPct")) p.setFloat("floorPct", float(params->getValue("floorPct"))); if (params->has("preset")) p.setString("preset", params->getValue("preset")); if (params->has("maxAttempts")) p.setInt("maxAttempts", params->getValue("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 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(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(i)); const float perception = float(c->getValue("perception")); const float stealth = float(c->getValue("stealth")); const bool expectNoMargin = c->getValue("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 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); }