Files
EVEngine/test/inventory.cpp
T
西风逍遥游 34d1fdb63b refactor(modules): clear remaining back-edges and split the largest TUs (#127)
* chore(ci): add clang-format gate, CODEOWNERS and agent collaboration rules

* refactor(modules): clear remaining back-edges and split the largest TUs

Three known dependency back-edges are gone (module_depgraph.py KNOWN_BACK_EDGES
is now empty; --check passes with 0 violations, 0 cycles):

- window -> graphics: window queries the new IWindowSurfaceHost capability
  (common/WindowSurfaceHost.h) instead of holding a Graphics pointer;
  Window::setGraphics was removed and all call sites updated.
- window -> image: Window::setIcon was replaced by setIconRGBA(const uint8_t*,
  w, h); the window no longer decodes ImageData.
- scene -> graphics: pickScreenAt / collectFrustumIdsAt implementations moved
  to graphics/ScenePicking.cpp (graphics -> scene is a legal downward edge);
  scene now depends only on spatial.

TU splits (pure moves, verified identical method sets and brace balance):

- vulkan/Graphics.cpp (5118 lines) -> Graphics.cpp (lifecycle), GraphicsPipeline,
  Graphics2D, Graphics3D, GraphicsMesh + shared GraphicsInternal.h.
- scene/Scene.cpp -> Scene.cpp (script layer / host lifecycle / serialization)
  + SceneNodes.cpp + shared SceneInternal.h.

Verified with a real build (MSVC 19.51, prebuilt third-party): full build green;
window.* and Scene.* suites pass, including scene picking/culling and GPU cases.

* refactor(graphics): narrow Graphics interfaces and clear public-header leaks

Step 4 — interface segregation:
- New lean interfaces: IGraphics2D, IGraphics3D, IResourceFactory, IPostFX
  (pure virtuals, forward-declared types only; graphics/Color.h extracted from
  Canvas.h so modules that only need the color type stop pulling Canvas.h).
- Graphics now implements all four facets; consumers can depend on the narrow
  interface they use instead of the 129-method god class.
- Migrated voxel (VoxelWorld::drawVisible -> IGraphics3D*) and model3d
  (ModelRenderer::buildRenderable(s) -> IResourceFactory&).
- Graphics.h slims from 24 sub-header includes to a handful of small type
  headers + forward declarations; the destructor moved out-of-line so
  unique_ptr effect members are destroyed where types are complete.

Step 5 — header leaks (module_depgraph.py '*' marks: 14 -> 7):
- map/TileLayer.h, particles/ParticleEmitter.h, avatar/AvatarInstance.h,
  animation/SpineAnim.h, map/TileSystem.h, model3d/ModelData.h,
  model3d/ModelRenderer.h, audio/Audio.h + Source.h, image/ImageData.h +
  Image.h, dialogue/Dialogue.h, procgen Palette/OutputSpec/TextureRecipe/
  PbrMaterial -> forward declarations, includes moved to .cpp.
- voxel/VoxelWorld.h: terrain sampler Pimpl'd (unique_ptr + out-of-line
  destructor), removing the procgen header dependency.
- gpgpu/vulkan backend headers stop pulling graphics/vulkan/Graphics.h.
- Remaining '*' marks are deliberate: tiny POD/enum headers (GridConfig,
  MapObject, BlendMode, Color), an internal backend header
  (GraphicsInternal.h -> filesystem), and lean interface references.

Verified with a full clean build (MSVC) and tests: window/Scene/voxel/
RenderSystem/Quad/shader/TextureSampler/resourceLifetime pass; broad suite
only shows the pre-existing PHYSFS-init filesystem-environment failures.

* feat(resources): revive ResourceManager as unified IAssetReloader-backed cache

* fix(resources): leak ResourceManager singleton to avoid exit-time teardown crashes

Cached CPU resources may own third-party handles (FreeType faces, Assimp
scenes, image decode handlers) whose libraries are torn down at process
exit in an unspecified TU order; destroying cached entries from the
singleton destructor can segfault at exit (seen on CI Windows in
model3d.materialApi.objMtl). Keep the singleton alive until OS reclaim;
explicit unload()/clear() still release entries during the run.

* refactor(devtools): consume business modules through capability interfaces

DevTools / cmdline no longer include business-module headers, so trimming
scene / physics / procgen / particles / audio / ui / graphics no longer
requires disabling devtools, and devtools can be switched off with
-DEVENGINE_MODULE_DEVTOOLS=OFF.

New capability interfaces in common/:
- ISceneQuery (scene) - host/node inspection + mutation + transform sync
- IRenderCapture (graphics) - render status, PNG capture, camera control,
  visible-entity snapshot, entity-ID mask, G-buffer PNGs
- IPhysicsQuery (physics) - 2D world lifecycle + ray casts
- IProcgenQuery (procgen) - recipe listing, map/mesh generation
- IParticlesQuery (particles) - emitter status + one-shot emit
- IAudioQuery (audio) - volume / stop-all
- IEditorHost (ui) - declarative editor host (used by cmdline too)

Providers register in their module constructors. DevTools rewires McpServer
(render/scene/procgen/physics/particles/audio/host tools), SceneInspect
(now delegates to ISceneQuery + IRenderCapture) and RenderVision (uses
IRenderCapture::capturePngDataUrl); cmdline/Mcp.cpp uses IEditorHost.

manifest: devtools DEPS graphics scene physics procgen particles audio ui
image event filesystem -> event; cmdline DEPS filesystem ui -> filesystem.

Verified: full build green; devtools.mcp.* tests pass; broad suite shows
only the pre-existing PHYSFS-init filesystem-environment failures.

* style: apply clang-format to changed lines after dev merge

* fix(webgpu): include RenderControl/TextureSampler explicitly after Graphics.h slimming

* fix(webgpu): provide newFont/print stubs to satisfy IGraphics2D/IResourceFactory vtables

* fix(common): make ProcgenQuery.h self-sufficient (cstdint/utility for GCC)
2026-08-20 05:53:33 +08:00

444 lines
13 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "graphics/AmbientOcclusion.h"
#include "graphics/AntiAliasing.h"
#include "graphics/Canvas.h"
#include "graphics/DrawItem2D.h"
#include "graphics/Font.h"
#include "graphics/GBuffer.h"
#include "graphics/GlobalIllumination.h"
#include "graphics/Graphics.h"
#include "graphics/Grass.h"
#include "graphics/Light.h"
#include "graphics/Material.h"
#include "graphics/Mesh.h"
#include "graphics/Outline.h"
#include "graphics/Quad.h"
#include "graphics/RenderControl.h"
#include "graphics/RenderSystem.h"
#include "graphics/ScreenSpaceReflection.h"
#include "graphics/Shader.h"
#include "graphics/Texture.h"
#include "graphics/Volumetric.h"
#include "graphics/Water.h"
#include "graphics/Waterfall.h"
#include "inventory/Bag.h"
#include "inventory/Equipment.h"
#include "inventory/Inventory.h"
#include "inventory/InventorySystem.h"
#include "inventory/Item.h"
#include "ui/UI.h"
#include "ui/UISystem.h"
#include "ui/Widget.h"
#include "window/Window.h"
#include <SDL2/SDL.h>
#include <cmath>
#include <string>
#include <vector>
// Color lives in eve::graphics (see graphics/Canvas.h); keep the unqualified form.
using eve::graphics::Color;
using namespace eve::inventory;
using namespace eve::graphics;
using namespace eve::ui;
namespace {
bool approxEq(float a, float b, float eps = 1e-5f) { return std::fabs(a - b) < eps; }
} // namespace
TEST_CASE("inventory.itemRegistry.registerAndJson") {
ItemRegistry::clear();
ItemDefinition potion;
potion.id = "potion.hp";
potion.displayName = "Heal Potion";
potion.maxStack = 20;
potion.weight = 0.2f;
potion.tags = {"consumable", "potion"};
potion.extra["icon"] = "ui/potion.png";
ItemRegistry::registerItem(potion);
CHECK(ItemRegistry::count() == 1);
CHECK(ItemRegistry::find("potion.hp") != nullptr);
CHECK_EQ(ItemRegistry::find("potion.hp")->maxStack, 20);
CHECK(ItemRegistry::find("potion.hp")->hasTag("potion"));
CHECK_EQ(ItemRegistry::find("potion.hp")->getExtra("icon"), "ui/potion.png");
int n = ItemRegistry::loadFromJson(R"([
{"id":"ore.iron","displayName":"Iron Ore","maxStack":50,"weight":1.0,"tags":["material"]},
{"id":"sword.iron","displayName":"Iron Sword","maxStack":1,"weight":3.5,
"equipSlot":"weapon","tags":["weapon","melee"],"extra":{"damage":"10"}}
])");
CHECK_EQ(n, 2);
CHECK_EQ(ItemRegistry::count(), 3);
CHECK_EQ(ItemRegistry::find("sword.iron")->equipSlot, "weapon");
ItemRegistry::clear();
CHECK_EQ(ItemRegistry::count(), 0);
}
TEST_CASE("inventory.bag.addStackRemove") {
ItemRegistry::clear();
InventorySystem::clearEvents();
InventorySystem::ensureBuiltins();
ItemDefinition coin;
coin.id = "coin";
coin.maxStack = 99;
coin.weight = 0.01f;
ItemRegistry::registerItem(coin);
Bag bag(5);
bag.setId("player");
bag.setMaxWeight(100.f);
CHECK(bag.canAddItem("coin", 10));
// NOTE: zeroerr CHECK_EQ evaluates the LHS twice (compare + print), so
// side-effecting calls must be stored in a local first.
int added = bag.addItem("coin", 150);
CHECK_EQ(added, 150); // 99 + 51 across two slots
CHECK_EQ(bag.countItem("coin"), 150);
CHECK_EQ(bag.getUsedSlotCount(), 2);
CHECK_EQ(bag.getSlotQuantity(0), 99);
CHECK_EQ(bag.getSlotQuantity(1), 51);
int removed = bag.removeItem("coin", 60);
CHECK_EQ(removed, 60);
CHECK_EQ(bag.countItem("coin"), 90);
CHECK_EQ(bag.getSlotQuantity(0), 39); // removed from first stack first
CHECK_EQ(bag.getSlotQuantity(1), 51);
ItemRegistry::clear();
}
TEST_CASE("inventory.bag.acceptTagsAndReject") {
ItemRegistry::clear();
InventorySystem::ensureBuiltins();
ItemDefinition quest;
quest.id = "quest.letter";
quest.tags = {"quest"};
ItemRegistry::registerItem(quest);
ItemDefinition junk;
junk.id = "junk";
junk.tags = {"trash"};
ItemRegistry::registerItem(junk);
Bag questBag(4);
questBag.addAcceptTag("quest");
CHECK(questBag.canAddItem("quest.letter", 1));
CHECK(!questBag.canAddItem("junk", 1));
CHECK_EQ(questBag.canAddItemReason("junk", 1), "accept_tag_mismatch");
Bag general(4);
general.addRejectTag("quest");
CHECK(!general.canAddItem("quest.letter", 1));
CHECK_EQ(general.canAddItemReason("quest.letter", 1), "rejected_tag");
CHECK(general.canAddItem("junk", 1));
ItemRegistry::clear();
}
TEST_CASE("inventory.bag.weightCapacity") {
ItemRegistry::clear();
InventorySystem::ensureBuiltins();
ItemDefinition ore;
ore.id = "ore";
ore.maxStack = 10;
ore.weight = 2.f;
ItemRegistry::registerItem(ore);
Bag bag(10);
bag.setMaxWeight(5.f);
bag.setCapacityPolicy("slotsAndWeight");
int added = bag.addItem("ore", 3);
CHECK_EQ(added, 2); // 2*2=4 <=5, third would be 6
CHECK(approxEq(bag.getUsedWeight(), 4.f));
CHECK(!bag.canAddItem("ore", 1));
CHECK_EQ(bag.canAddItemReason("ore", 1), "over_weight");
ItemRegistry::clear();
}
TEST_CASE("inventory.bag.moveSwapSplit") {
ItemRegistry::clear();
InventorySystem::ensureBuiltins();
ItemDefinition gem;
gem.id = "gem";
gem.maxStack = 10;
ItemRegistry::registerItem(gem);
Bag bag(4);
bag.addItem("gem", 8);
CHECK(bag.splitStack(0, 3, 1));
CHECK_EQ(bag.getSlotQuantity(0), 5);
CHECK_EQ(bag.getSlotQuantity(1), 3);
CHECK(bag.moveSlot(1, 2));
CHECK(bag.isSlotEmpty(1));
CHECK_EQ(bag.getSlotQuantity(2), 3);
CHECK(bag.swapSlots(0, 2));
CHECK_EQ(bag.getSlotQuantity(0), 3);
CHECK_EQ(bag.getSlotQuantity(2), 5);
// merge via move
CHECK(bag.moveSlot(0, 2));
CHECK(bag.isSlotEmpty(0));
CHECK_EQ(bag.getSlotQuantity(2), 8);
ItemRegistry::clear();
}
TEST_CASE("inventory.bag.transferBetweenBags") {
ItemRegistry::clear();
InventorySystem::clearEvents();
InventorySystem::ensureBuiltins();
ItemDefinition herb;
herb.id = "herb";
herb.maxStack = 20;
herb.weight = 0.1f;
ItemRegistry::registerItem(herb);
Bag a(3);
a.setId("a");
Bag b(3);
b.setId("b");
a.addItem("herb", 15);
int moved = InventorySystem::transfer(&a, &b, "herb", 10);
CHECK_EQ(moved, 10);
CHECK_EQ(a.countItem("herb"), 5);
CHECK_EQ(b.countItem("herb"), 10);
ItemRegistry::clear();
}
TEST_CASE("inventory.extension.customAcceptAndStack") {
ItemRegistry::clear();
InventorySystem::ensureBuiltins();
ItemDefinition a;
a.id = "scroll.a";
a.maxStack = 5;
a.extra["level"] = "3";
ItemRegistry::registerItem(a);
InventorySystem::registerAcceptRule("minLevel3", [](const Bag &, const ItemDefinition &def,
int, std::string *reason) {
int level = 0;
try {
level = std::stoi(def.getExtra("level", "0"));
} catch (...) {
}
if (level < 3) {
if (reason) *reason = "level_too_low";
return false;
}
return true;
});
InventorySystem::registerStackRule("neverSame",
[](const ItemStack &, const ItemStack &,
const ItemDefinition &) { return false; });
Bag bag(4);
bag.setAcceptRule("minLevel3");
bag.setStackRule("neverSame");
CHECK(bag.canAddItem("scroll.a", 1));
int added = bag.addItem("scroll.a", 3);
CHECK_EQ(added, 3);
// never stack -> each unit occupies its own slot
CHECK_EQ(bag.getUsedSlotCount(), 3);
InventorySystem::unregisterAcceptRule("minLevel3");
InventorySystem::unregisterStackRule("neverSame");
ItemRegistry::clear();
}
TEST_CASE("inventory.equipment.equipUnequip") {
ItemRegistry::clear();
InventorySystem::clearEvents();
InventorySystem::ensureBuiltins();
ItemDefinition sword;
sword.id = "sword.iron";
sword.maxStack = 1;
sword.equipSlot = "weapon";
sword.tags = {"weapon"};
ItemRegistry::registerItem(sword);
ItemDefinition helm;
helm.id = "helm.iron";
helm.maxStack = 1;
helm.equipSlot = "head";
helm.tags = {"armor"};
ItemRegistry::registerItem(helm);
Bag bag(4);
bag.setId("player");
bag.addItem("sword.iron", 1);
bag.addItem("helm.iron", 1);
EquipmentSet eq;
eq.setId("eq");
eq.defineSlot("weapon");
eq.addSlotAllowedTag("weapon", "weapon");
eq.defineSlot("head");
int swordSlot = bag.findItem("sword.iron");
CHECK(swordSlot >= 0);
CHECK(eq.equipFromBag("weapon", &bag, swordSlot));
CHECK_EQ(eq.getSlotItemId("weapon"), "sword.iron");
CHECK_EQ(bag.countItem("sword.iron"), 0);
// wrong slot
int helmSlot = bag.findItem("helm.iron");
CHECK(!eq.equipFromBag("weapon", &bag, helmSlot));
CHECK(eq.unequipToBag("weapon", &bag));
CHECK(eq.isSlotEmpty("weapon"));
CHECK_EQ(bag.countItem("sword.iron"), 1);
ItemRegistry::clear();
}
TEST_CASE("inventory.events.andFacade") {
ItemRegistry::clear();
InventorySystem::clearEvents();
Inventory *inv = Inventory::create();
CHECK(inv != nullptr);
CHECK_EQ(inv->getName(), "Inventory");
int n = inv->registerItemsFromJson(
R"([{"id":"apple","maxStack":10,"weight":0.2,"tags":["food"]}])");
CHECK_EQ(n, 1);
CHECK(inv->hasItemDefinition("apple"));
CHECK(inv->hasAcceptRule("default"));
CHECK(inv->hasCapacityPolicy("slotsAndWeight"));
CHECK(inv->hasStackRule("sameItem"));
Bag *bag = inv->newBag(4);
bag->setId("pack");
int added = bag->addItem("apple", 4);
CHECK_EQ(added, 4);
CHECK(inv->getChangeEventCount() >= 1);
bool sawAdd = false;
for (int i = 0; i < inv->getChangeEventCount(); ++i) {
if (inv->getChangeEventAction(i) == "add" && inv->getChangeEventItemId(i) == "apple")
sawAdd = true;
}
CHECK(sawAdd);
inv->clearChangeEvents();
CHECK_EQ(inv->getChangeEventCount(), 0);
bag->destroy();
inv->clearItemDefinitions();
}
TEST_CASE("inventory.bag.uiPreview") {
ItemRegistry::clear();
InventorySystem::clearEvents();
InventorySystem::ensureBuiltins();
ItemRegistry::loadFromJson(R"([
{"id":"potion.hp","displayName":"Heal Potion","maxStack":20,"weight":0.2,"tags":["consumable"]},
{"id":"ore.iron","displayName":"Iron Ore","maxStack":50,"weight":1.0,"tags":["material"]},
{"id":"sword.iron","displayName":"Iron Sword","maxStack":1,"weight":3.5,
"equipSlot":"weapon","tags":["weapon"]},
{"id":"coin","displayName":"Gold Coin","maxStack":99,"weight":0.01,"tags":["currency"]}
])");
Bag bag(8);
bag.setId("adventurer");
bag.setMaxWeight(40.f);
bag.addItem("potion.hp", 7);
bag.addItem("ore.iron", 23);
bag.addItem("sword.iron", 1);
bag.addItem("coin", 120);
auto *win = eve::window::Window::create();
auto *gfx = Graphics::create();
auto *ui = UI::create();
REQUIRE(win != nullptr);
REQUIRE(gfx != nullptr);
REQUIRE(ui != nullptr);
eve::window::WindowSettings s;
s.width = 520;
s.height = 420;
s.centered = true;
REQUIRE(win->setWindowSettings(s));
REQUIRE(ui->initBackend());
gfx->setBackgroundColor(Color(0.09f, 0.10f, 0.14f, 1.f));
auto *cam = Camera2D::createCamera();
cam->data()->r = 0.09f;
cam->data()->g = 0.10f;
cam->data()->b = 0.14f;
// Soft bag silhouette behind the panel.
auto *panel = Renderable2D::create();
panel->transform()->x = 40;
panel->transform()->y = 50;
panel->sprite()->width = 280;
panel->sprite()->height = 320;
panel->sprite()->r = 0.18f;
panel->sprite()->g = 0.22f;
panel->sprite()->b = 0.30f;
panel->sprite()->visible = true;
int highlighted = 0;
for (int frame = 0; frame < 90; ++frame) {
if (frame % 18 == 0) highlighted = (highlighted + 1) % bag.getSlotCount();
std::vector<WidgetDesc> slots;
slots.push_back(text("Adventurer Pack", "title"));
slots.push_back(separator("sep"));
slots.push_back(text("weight " + std::to_string(int(bag.getUsedWeight())) + " / " +
std::to_string(int(bag.getMaxWeight())),
"weight"));
slots.push_back(progress(bag.getUsedWeight() / bag.getMaxWeight(), "wbar", "load"));
slots.push_back(separator("sep2"));
for (int i = 0; i < bag.getSlotCount(); ++i) {
const std::string id = bag.getSlotItemId(i);
std::string label = "[" + std::to_string(i) + "] empty";
if (!id.empty()) {
const ItemDefinition *def = ItemRegistry::find(id);
const std::string name = def ? def->displayName : id;
label = "[" + std::to_string(i) + "] " + name + " x" +
std::to_string(bag.getSlotQuantity(i));
if (i == highlighted) label = "> " + label;
}
slots.push_back(text(label, "s" + std::to_string(i)));
}
ui->remountAs("bag", window("Inventory", slots, "root"));
ui->beginFrameAndRender();
RenderSystem::render(*gfx);
ui->dispatchEvents();
SDL_Event e;
while (SDL_PollEvent(&e)) {
ui->processEvent(&e);
if (e.type == SDL_QUIT) break;
}
SDL_Delay(16);
}
CHECK_EQ(bag.countItem("coin"), 120);
panel->sprite()->visible = false;
ItemRegistry::clear();
win->close();
}