Files
EVEngine/test/hex_level_data.cpp
T
西风逍遥游andCursor Agent c0d9fe4a7d test(hex): 程序化六边形关卡套件(FOV / 光照 / 寻路 / 拾取 / 粒子) (#50)
* test(hex): add procedural hex dungeon levels for engine QA

Cross-module simulation levels exercise hex tilemap procgen, FOV,
2D lighting, spatial pickup + inventory, and particles. Includes a
playable examples/hex-levels demo with level select and a pipeline crawl.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* test(hex): expand levels 06–15 and fog-raid pipeline

Add Flow Field swarm, cell-cost detours, multi-revealer perception,
FoW mask/algorithms, camera pick, dual-grid, procgen variants,
QuadTree cull, multi-light, particle/stash transfer, and a fog-raid
pipeline. Update the playable hex-levels demo for levels 6–9.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* test(hex): add fixture JSON data and data-driven hex tests

Expand catalog/items/loot/seeds/perception fixtures, handcrafted maps,
and particle configs. Wire the hex-levels demo to load items and particle
JSON plus fixtures.nut loot tables. Register hex_level_data.cpp coverage.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* test(hex): fix data fixtures assertions for zeroerr and sparse gens

Avoid CHECK_EQ on bag->addItem (double-eval), and only enforce minWalkable
on dungeon.bsp in the seeds matrix smoke.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(hex): boot each level from distinct catalog configs

Enrich catalog/fixtures with per-level FOV, light, cell-cost, and enable
flags. Wire examples/hex-levels to apply those boot settings on level
switch. Add hex.data.catalog.bootEachLevelDistinctConfig to verify each
catalog entry starts with its own applied configuration.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* docs(hex): note per-level boot configs in data README

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* test(hex): add levels 16–20 and extra data-driven coverage

Expand simulation with facing-cone FOV, flow+cell-cost, seed
reproducibility, corner peek, equipment equip, and torchEscort /
catalogRaid pipelines. Add fixture tests for perception gates, all loot
tables, enable-flag consistency, seed path samples, map markers,
defaults, equipment slots, and mist particles on dungeon.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* docs(hex): clarify expanded test coverage in README

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* test(hex): add levels 21–25 and broader fixture validation

Cover hex world↔tile roundtrip, FOV memory clear, findGroupPath,
multi-algo FOV smoke, blocked-cell sync, and costlyFogPickup pipeline.
Add data tests for loot/item referential integrity, feature tags,
particle schema, algorithm registration, ring-door path, categories,
detection margin, and door walkability.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(hex): expand catalog to 31 levels (0–30) and playable cycle

Add levels 16–30 with distinct boot configs (facing cone, flow+cost,
maze/WFC/drunkard, mist particles, raid combo). Regenerate fixtures.nut
with LEVEL_IDS; main.nut cycles all catalog levels via N/B. Add
simulation cases 26–30 and catalog presence tests.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* fix(hex): harden level 02/17 against non-portable procgen paths

macOS CI failed hex.level.02.dynamicFov (empty path) and
hex.level.17.flowPlusCellCost (path length == 2) because spawn↔exit
layouts from DTL/std distributions differ across libstdc++ vs libc++.
Use handcrafted hex maps so FOV memory and flow+cost assertions are
platform-stable.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-13 11:54:41 +08:00

1434 lines
52 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 "data/DataModule.h"
#include "data/JsonDocument.h"
#include "filesystem/Filesystem.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 "graphics/Light.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() {
std::string here = __FILE__;
const auto slash = here.find_last_of("/\\");
const std::string testDir =
(slash == std::string::npos) ? std::string(".") : here.substr(0, slash);
return testDir + "/../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(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);
REQUIRE(gen->applyToLayer(&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(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>(si));
for (size_t ai = 0; ai < algos->size(); ++ai) {
auto algoObj = algos->getObject(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(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(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(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));
REQUIRE(gen->applyToLayer(&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(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));
REQUIRE(gen->applyToLayer(&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>(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(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(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);
REQUIRE(gen->applyToLayer(&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(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(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>(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));
REQUIRE(gen->applyToLayer(&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);
REQUIRE(gen->applyToLayer(&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(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(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(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(i)->getArray("features");
REQUIRE(feats);
for (size_t fi = 0; fi < feats->size(); ++fi) {
const std::string f = feats->getElement<std::string>(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(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(i)->getValue<std::string>("id");
Params p;
p.setSeed(1);
p.setSize(16, 12);
auto params = algos->getObject(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(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(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);
}