Files
aee-arcade/tests/test_entities_smoke.cpp
T

478 lines
20 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.
// Smoke tests del sistema d'entitats. Stand-alone, sense frameworks externs.
//
// Tests coberts:
// T1 factory — tots els tipus es registren i es creen.
// T2 loader — llegeix `data/maps/test_entities.yaml` i retorna els spawns.
// T3 event bus — subscribe + publish + payload (entity*, int).
// T4 sarcòfag — perímetre computat correctament; notifyTilePainted
// passa a OPENING quan es visiten tots els tiles.
// T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta
// s'obri sola. El contacte de la porta amb un jugador
// nul·l és no-op segur (la porta ja no emet `level_complete`
// directament; ho fa EntityManager comptant `player_exited`).
// T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1.
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
// T8 decoration — Torch no col·lisiona ni és interactable.
// T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame.
// T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva:
// el sprite.height no canvia la caixa lògica 48×16.
// T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa
// retornada coincideix amb les dimensions del bloc
// `collision:` del YAML; sense caixa, coincideix amb el
// sprite sencer (pixel_rect).
// T13 mummy step — camina en línia recta pel playfield i gira en topar
// un mur (insidePlayfield=false) o sarc (Map::collides).
//
// Execució (des de la ruta del repo):
// make build && cd build && ctest --output-on-failure
// O bé:
// ./build/test_entities_smoke
#include <cassert>
#include <cstdio>
#include <iostream>
#include <memory>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
#include "game/entities/animation_component.hpp"
#include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/floor_trap.hpp"
#include "game/entities/concrete/mummy.hpp"
#include "game/entities/concrete/rolling_rock.hpp"
#include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/concrete/torch.hpp"
#include "game/entities/concrete/wall_trap.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
#include "game/map/map.hpp"
namespace {
int g_failures = 0;
void check(bool cond, const char* msg, const char* file, int line) {
if (!cond) {
std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n";
++g_failures;
}
}
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
void testFactory() {
std::cout << "[T1] factory\n";
auto& factory = EntityFactory::instance();
const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap",
};
for (const auto& type : TYPES) {
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
auto ent = factory.create(type, tmpl);
REQUIRE(ent != nullptr);
if (ent != nullptr) { REQUIRE(ent->getName() == type); }
}
}
void testLoader() {
std::cout << "[T2] loader\n";
auto spawns = EntityLoader::readSpawns("data/maps/test_entities.yaml");
// Al test_entities.yaml hem declarat 5 spawns.
REQUIRE(spawns.size() == 5);
// Primer spawn és torch.
if (!spawns.empty()) {
REQUIRE(spawns[0]["type"].get_value<std::string>() == "torch");
}
}
void testEventBus() {
std::cout << "[T3] event bus\n";
EventBus bus;
int int_received = -1;
int entity_ptr_flag = 0;
bus.subscribe("a_int", [&](const Event& ev) {
const int* v = std::get_if<int>(&ev.payload);
if (v != nullptr) { int_received = *v; }
});
bus.subscribe("a_ent", [&](const Event& ev) {
if (std::get_if<Entity*>(&ev.payload) != nullptr) { entity_ptr_flag = 1; }
});
bus.publish("a_int", 42);
REQUIRE(int_received == 42);
Entity fake;
bus.publish("a_ent", &fake);
REQUIRE(entity_ptr_flag == 1);
// Publish d'un nom sense subscriptors no ha de crashejar.
bus.publish("desconegut");
// Unsubscribe elimina els handlers.
auto id = bus.subscribe("a_int", [&](const Event&) { int_received = 999; });
bus.unsubscribe(id);
bus.publish("a_int", 7);
REQUIRE(int_received == 7);
}
void testSarcophagusPerimeter() {
std::cout << "[T4] sarcofag perimetre\n";
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
auto sarc = std::make_unique<Sarcophagus>(tmpl);
// Ancorat a (col=1, row=1) amb tile_size=[3,1] → rectangle de tiles
// cobreix (1,1), (2,1), (3,1). Perímetre ortogonal esperat (sense
// BlockedPredicate):
// Nord: (1,0), (2,0), (3,0)
// Sud: (1,2), (2,2), (3,2)
// Oest: (0,1)
// Est: (4,1)
// Total: 8 tiles.
sarc->place(1, 1, {});
REQUIRE(sarc->getPerimeterSize() == 8);
REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED);
// Visitem un tile que NO és al perímetre: res canvia.
sarc->notifyTilePainted(5, 5);
REQUIRE(sarc->getVisitedCount() == 0);
// Visitem tots els del perímetre.
const std::vector<std::pair<int, int>> PERIM = {
{1, 0}, {2, 0}, {3, 0}, {1, 2}, {2, 2}, {3, 2}, {0, 1}, {4, 1},
};
for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r); }
REQUIRE(sarc->getVisitedCount() == 8);
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
// Un update amb l'animació `opening` d'1 frame loop=false completa
// la transició a OPEN.
sarc->update(0.0F);
REQUIRE(sarc->getState() == Sarcophagus::State::OPEN);
}
void testEntityManagerEndToEnd() {
std::cout << "[T5] manager end-to-end\n";
EntityManager manager;
// 2 sarcòfags, 1 porta.
auto sarc1 = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc1->place(0, 0, {});
auto sarc2 = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc2->place(4, 4, {});
Sarcophagus* s1 = manager.addSarcophagus(std::move(sarc1));
Sarcophagus* s2 = manager.addSarcophagus(std::move(sarc2));
REQUIRE(manager.sarcophagusCount() == 2);
auto door = std::make_unique<Door>(EntityLoader::loadTemplate("door"));
Door* door_ptr = door.get();
manager.add(std::move(door));
manager.setDoor(door_ptr);
// Forcem l'obertura dels dos sarcs visitant els seus perímetres.
auto forceOpen = [](Sarcophagus* s) {
const int COL0 = s->getTilePos().col;
const int ROW0 = s->getTilePos().row;
const int W = s->getTileWidth();
const int H = s->getTileHeight();
for (int dc = 0; dc < W; ++dc) {
s->notifyTilePainted(COL0 + dc, ROW0 - 1);
s->notifyTilePainted(COL0 + dc, ROW0 + H);
}
for (int dr = 0; dr < H; ++dr) {
s->notifyTilePainted(COL0 - 1, ROW0 + dr);
s->notifyTilePainted(COL0 + W, ROW0 + dr);
}
};
forceOpen(s1);
forceOpen(s2);
// Un primer tick: cada sarc transiciona OPENING → OPEN i publica;
// l'handler del manager compta i, en arribar al total, crida
// door->open() — que en aquest mateix tick (la iteració sobre
// entities continua) farà transitar la porta OPENING → OPEN perquè
// l'animació és d'1 frame loop=false.
manager.update(0.0F);
REQUIRE(manager.openedSarcophagusCount() == 2);
REQUIRE(door_ptr->getState() == Door::State::OPEN);
// Contacte sense jugador (nullptr): no-op segur. La porta ja no
// emet `level_complete` directament — ara és EntityManager qui
// l'emet quan tots els jugadors han eixit. La cobertura
// d'aquest agregat viu al test específic d'EntityManager
// (test_game_fsm) perquè requereix instanciar Player, fora
// d'abast d'aquest smoke purament d'entitats.
bool level_complete_fired = false;
manager.getBus().subscribe("level_complete", [&](const Event&) {
level_complete_fired = true;
});
door_ptr->onPlayerContact(nullptr);
REQUIRE(!level_complete_fired);
}
void testAnimationComponent() {
std::cout << "[T6] animation component\n";
AnimationComponent anim({10, 20, 30}, 10.0F, true);
REQUIRE(anim.frameCount() == 3);
REQUIRE(anim.currentFrame() == 10);
anim.tick(0.15F); // 1.5 frames de 10 fps → hauria d'avançar a l'índex 1
REQUIRE(anim.currentFrame() == 20);
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 30);
// Loop: un tick més i torna al principi.
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 10);
// Variant no-loop: es para al darrer i queda completed.
AnimationComponent once({1, 2}, 10.0F, false);
once.tick(1.0F); // molts frames; ha d'arribar al final i quedar-se.
REQUIRE(once.currentFrame() == 2);
REQUIRE(once.completed());
// Variant d'1 frame no-loop: completed des del principi.
AnimationComponent single({5}, 1.0F, false);
REQUIRE(single.completed());
}
void testAllTemplatesLoad() {
std::cout << "[T7] plantilles YAML\n";
const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap",
};
for (const auto& type : TYPES) {
try {
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
REQUIRE(tmpl.contains("name"));
REQUIRE(tmpl["name"].get_value<std::string>() == type);
} catch (const std::exception& e) {
std::cerr << " excepció carregant '" << type << "': " << e.what() << '\n';
++g_failures;
}
}
}
void testDecorationFlags() {
std::cout << "[T8] decoration flags\n";
fkyaml::node tmpl = EntityLoader::loadTemplate("torch");
Torch torch(tmpl);
REQUIRE(!torch.collides());
REQUIRE(!torch.interactable());
}
void testLoadFromLevel() {
std::cout << "[T9] pyramid level load (globals + spawns + tile math + bus)\n";
// 1) Map::loadFrom carrega les tres globals noves (tile_size, origen).
Map::loadFrom("data/maps/pyramid.yaml");
REQUIRE(Map::tile_size == 16);
REQUIRE(Map::tilemap_origin_x == -8);
REQUIRE(Map::tilemap_origin_y == 0);
// 2) El pyramid.yaml conté spawns dels tipus esperats. Comptem al
// menys un de cada tipus que el nivell ha de tenir sí o sí (no
// fixem quants torches exactes perquè és decoratiu i pot
// variar). Així afegir decoració al YAML no trenca el smoke.
const auto SPAWNS = EntityLoader::readSpawns("data/maps/pyramid.yaml");
REQUIRE(!SPAWNS.empty());
int torches = 0;
int sarcs = 0;
int doors = 0;
int mummies = 0;
for (const auto& s : SPAWNS) {
const auto T = s["type"].get_value<std::string>();
if (T == "torch") { ++torches; }
else if (T == "sarcophagus") { ++sarcs; }
else if (T == "door") { ++doors; }
else if (T == "mummy") { ++mummies; }
}
REQUIRE(torches >= 1);
REQUIRE(sarcs >= 1);
REQUIRE(doors >= 1);
REQUIRE(mummies >= 1);
// 3) La conversió sarc-grid (0,0) → tile anchor (3,4) (i.e. la
// fórmula que usa loadFromLevel). Sarc rect = (40, 64, 48, 16).
const auto RECT = Map::getSarcRect(0, 0);
REQUIRE(static_cast<int>(RECT.x) == 40);
REQUIRE(static_cast<int>(RECT.y) == 64);
const int ANCHOR_COL = (static_cast<int>(RECT.x) - Map::tilemap_origin_x) / Map::tile_size;
const int ANCHOR_ROW = (static_cast<int>(RECT.y) - Map::tilemap_origin_y) / Map::tile_size;
REQUIRE(ANCHOR_COL == 3);
REQUIRE(ANCHOR_ROW == 4);
// 4) Fan-out del bus: un sarcòfag ancorat a (3,4) mida 3×1 rep via
// tile_painted una notificació sobre cada tile del seu perímetre
// → state canvia. Publiquem directament `tile_painted` (sense
// passar pel PaintLayer) per aïllar el fan-out de l'EntityManager.
EntityManager manager;
auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc->place(3, 4, {});
Sarcophagus* sarc_raw = manager.addSarcophagus(std::move(sarc));
REQUIRE(sarc_raw->getPerimeterSize() == 8);
// Les 8 tiles del perímetre: N (3..5,3), S (3..5,5), O (2,4), E (6,4).
const std::pair<int, int> PERIM[] = {
{3, 3}, {4, 3}, {5, 3},
{3, 5}, {4, 5}, {5, 5},
{2, 4}, {6, 4},
};
for (const auto& [c, r] : PERIM) {
manager.getBus().publish("tile_painted", TilePos{.col = c, .row = r});
}
REQUIRE(sarc_raw->getState() == Sarcophagus::State::OPENING);
}
void testSarcophagusYaml() {
std::cout << "[T11] sarcofag YAML + falsa perspectiva\n";
// El template ha de declarar tile_size [3,1]: el footprint lògic
// són els 48×16 inferiors del sprite, encara que el sprite sencer
// fa 48×32 (16 px de "alçada" per damunt, sense col·lisió).
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
REQUIRE(tmpl.contains("tile_size"));
REQUIRE(tmpl["tile_size"][0].get_value<int>() == 3);
REQUIRE(tmpl["tile_size"][1].get_value<int>() == 1);
REQUIRE(tmpl["sprite"]["size"][0].get_value<int>() == 48);
REQUIRE(tmpl["sprite"]["size"][1].get_value<int>() == 32);
// Amb tile_size [3,1], un sarc a ancoratge (3,4) conserva els 8
// tiles de perímetre (no es converteix en 10 per culpa del [3,2]
// antic). Això blinda la geometria contra regressions al YAML.
auto sarc = std::make_unique<Sarcophagus>(tmpl);
sarc->place(3, 4, {});
REQUIRE(sarc->getPerimeterSize() == 8);
}
void testCollisionBox() {
std::cout << "[T12] collision box encastada al sprite\n";
// Torch sense bloc `collision:` → getCollisionRect == pixel_rect.
fkyaml::node torch_tmpl = EntityLoader::loadTemplate("torch");
Torch torch(torch_tmpl);
torch.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 16.0F, .h = 16.0F});
const auto TRECT = torch.getCollisionRect();
REQUIRE(static_cast<int>(TRECT.w) == 16);
REQUIRE(static_cast<int>(TRECT.h) == 16);
REQUIRE(static_cast<int>(TRECT.x) == 100);
REQUIRE(static_cast<int>(TRECT.y) == 200);
// Mummy amb `collision: { size: [12, 12] }` i sprite 12×24 → caixa
// auto-centrada bottom dels peus: offset_x = (12-12)/2 = 0,
// offset_y = 24 - 12 = 12. Amb pixel_rect a (100, 200, 12, 24),
// la caixa ha de sortir a (100, 212, 12, 12) i feet_y = 224.
fkyaml::node mummy_tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(mummy_tmpl);
// Apliquem el CollisionBox com fa el loader (readCollisionBox)
// amb sprite 12×24, offset calculat per defecte.
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
mummy.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 12.0F, .h = 24.0F});
const auto MRECT = mummy.getCollisionRect();
REQUIRE(static_cast<int>(MRECT.x) == 100);
REQUIRE(static_cast<int>(MRECT.y) == 212);
REQUIRE(static_cast<int>(MRECT.w) == 12);
REQUIRE(static_cast<int>(MRECT.h) == 12);
REQUIRE(static_cast<int>(mummy.getFeetY()) == 224);
}
void testMummyStep() {
std::cout << "[T13] momia step amb col·lisió i gir\n";
// Cal Map::loadFrom (T9 ja el carrega, però el rerrumigo ací per
// aïllar la intenció del test).
Map::loadFrom("data/maps/pyramid.yaml");
// Momia al corredor exterior superior: playfield_y=48, alçada del
// corredor exterior = outer_corridor (16). Així el base box 12×12
// queda a y∈[52, 64] aprox. Collision box: offset_y=12 (sprite
// 12×24 amb peus 12×12 a baix). pixel_rect.x/y triat perquè els
// peus quedin al tile centrat (4, 3) del tilemap → peus a
// (col*16-8, row*16+0) i alineats bottom del tile.
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
// Posem els peus ben dins d'un corredor horitzontal lliure. El
// corredor superior exterior va de y=48 a y=64 (alçada 16). Amb
// collision_y=12, sprite 12×24 → pixel_rect.y perquè feetY=64.
// feetY = pixel_rect.y + offset_y + h = pixel_rect.y + 24 → pixel_rect.y = 40.
// X: fora d'un sarc; (x=60) és al corredor extern abans dels sarcs.
mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
const auto BEFORE = mummy.getPixelRect();
// Velocitat llegida del YAML és 40 px/s. A 0.1s hauria d'avançar
// 4 px en X (direcció inicial right=+1,0). Com que està en un
// corredor lliure, el step s'aplica.
mummy.step(0.1F);
const auto AFTER = mummy.getPixelRect();
REQUIRE(AFTER.x > BEFORE.x);
REQUIRE(AFTER.y == BEFORE.y);
// Posem la momia contra la paret dreta del playfield: avançar un
// frame la trauria fora → el step ha de girar. No assumim cap nova
// direcció concreta (perpendiculars lliures poden ser vàlides); el
// contracte és "ja no continua cap a la dreta".
const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w);
mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
const auto POS_BEFORE = mummy.getPixelRect();
mummy.step(0.1F);
const auto POS_AFTER = mummy.getPixelRect();
// No ha avançat cap a la dreta en el frame de gir.
REQUIRE(POS_AFTER.x == POS_BEFORE.x);
REQUIRE(POS_AFTER.y == POS_BEFORE.y);
// I ja no apunta cap a la dreta (ha triat perpendicular o U-turn).
REQUIRE(mummy.getDirX() <= 0.0F);
}
void testTorchAnimation() {
std::cout << "[T10] torch animation wired from yaml\n";
// Regressió: el torch.yaml declarava `frames: [0]` i el render no
// consultava `currentFrame()`, així que una antorxa animada no es
// veia viva. Ara el YAML ha de portar 4 frames i l'Entity::update
// ha d'avançar el frame via el component d'animació.
fkyaml::node tmpl = EntityLoader::loadTemplate("torch");
Torch torch(tmpl);
const auto& ANIM = torch.getAnimation();
REQUIRE(ANIM.has_value());
REQUIRE(ANIM->frameCount() == 4);
REQUIRE(ANIM->currentFrame() == 0);
// Amb fps=8, el pas entre frames és 0.125s. Un tick d'aquest valor
// ha de fer saltar current_ a 1 (i, com que `frames: [0..3]`, el
// valor retornat pel currentFrame coincideix amb l'índex).
torch.update(0.125F);
REQUIRE(torch.getAnimation()->currentFrame() == 1);
torch.update(0.125F);
REQUIRE(torch.getAnimation()->currentFrame() == 2);
}
} // namespace
auto main() -> int {
std::cout << "== smoke tests: entity system ==\n";
testFactory();
testLoader();
testEventBus();
testSarcophagusPerimeter();
testEntityManagerEndToEnd();
testAnimationComponent();
testAllTemplatesLoad();
testDecorationFlags();
testLoadFromLevel();
testSarcophagusYaml();
testCollisionBox();
testMummyStep();
testTorchAnimation();
if (g_failures == 0) {
std::cout << "ALL OK\n";
return 0;
}
std::cerr << g_failures << " smoke check(s) failed\n";
return 1;
}