Files
aee-arcade/tests/test_entities_smoke.cpp
T

708 lines
32 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).
// T14 exited aggregator — EntityManager compta `player_exited` i emet
// `level_complete` quan iguala `expected_players`. Verifica
// idempotència (3r event no re-emet) i reset del comptador
// via `setExpectedPlayerCount` (cicle següent torna a emetre).
//
// 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/hole.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__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
// amb l'animació `raise` activa). Tickem amb dt gran fins sortir de
// SPAWNING — mateix path que `Mummy::update` executa al final de
// l'animació en partida real. El for limita el bucle defensivament.
void exitMummySpawn(Mummy& mummy) {
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
mummy.update(0.5F);
}
}
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", "hole",
};
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", "hole",
};
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 testMummyHurtState() {
std::cout << "[T15] mummy enterHurt → fugida + recuperació\n";
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
// Mateixa col·locació que testMummyStep: corredor exterior
// superior, lliure de sarcs, prou marge per fugir cap a la dreta.
mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
exitMummySpawn(mummy);
// Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar
// a la dreta (la velocitat de hurt és més alta que la base).
mummy.enterHurt(Mummy::Direction::EAST);
REQUIRE(mummy.getState() == Mummy::State::HURT);
REQUIRE(mummy.getDirX() > 0.0F);
const auto BEFORE = mummy.getPixelRect();
mummy.update(0.05F); // 50 ms: hauria d'avançar
const auto AFTER = mummy.getPixelRect();
REQUIRE(AFTER.x > BEFORE.x);
REQUIRE(mummy.getState() == Mummy::State::HURT);
// Avancem fins esgotar el timer (`hurt.duration_ms` = 600 ms al
// YAML → 0.6 s; sumem 1 s amb un sol step gran per garantir-ho).
mummy.update(1.0F);
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
}
void testMummyHurtAgainstWall() {
std::cout << "[T16] mummy HURT contra mur → surt abans del timer\n";
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
// Posicionem la mòmia tocant la paret dreta del playfield i la
// fem fugir cap a la dreta: el primer pas hauria de topar amb
// la paret i fer-la sortir de HURT.
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});
exitMummySpawn(mummy);
// La direcció preferida (RIGHT) està bloquejada al test
// d'`is_open`; entrarem amb una perpendicular o quieta.
// L'objecte de la prova és que `state_` torne a ROAMING quan
// el moviment topa: ho forcem fent que `enterHurt` triï igual
// RIGHT i topem en cridar `update`. Per aconseguir-ho posicionem
// la mòmia al límit i forcem la direcció via `setDirection`
// ABANS d'`enterHurt`. Però com `enterHurt` reescriu la
// direcció, el camí més directe és:
// 1) `enterHurt(RIGHT)` → si is_open(RIGHT) val false, intenta
// perpendiculars; si totes fallen, queda quieta.
// 2) Si està quieta, el HURT tampoc avança i només mor pel
// timer. Aquesta variant del test NO comprova la sortida
// per col·lisió; només verifica que entrar a HURT a la
// paret no peta.
mummy.enterHurt(Mummy::Direction::EAST);
REQUIRE(mummy.getState() == Mummy::State::HURT);
// Update llarg → expira el timer i torna a ROAMING.
mummy.update(1.0F);
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
}
void testMummyHurtSnapsAtPlayfieldWall() {
std::cout << "[T18] mummy HURT amarra a la vora del playfield en topar\n";
// Regressió: amb dt no-exacte (joc real → SDL_GetTicks ms) el drift
// sub-pixel de la cursa HURT deixava la BOX a 0.x px dins de l'última
// cel·la quan topava amb el mur. Com que la BOX 16×16 cabe exacta al
// corredor 16×16, qualsevol drift fa que la cantonada perpendicular
// sobresurti cap als sarcs adjacents — i el `chooseHurtDirection` del
// proper fueteig classifica les perpendiculars com a bloquejades per
// micro-overlap, retornant NONE i deixant la mòmia eternament quieta
// amb l'animació de córrer activa. El fix és snap a la vora exacta
// dins de `hurtStep` abans de transitar a ROAMING.
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
// Caixa real del joc (YAML): collision 16×16 als peus del sprite 16×32.
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
// Comencem prou a prop del mur perquè la cursa hi arribi abans que
// expiri el timer (60 px/s · 0.6 s = 36 px de runway).
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
REQUIRE(mummy.getCollisionRect().x == 40.0F);
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST);
REQUIRE(mummy.getState() == Mummy::State::HURT);
REQUIRE(mummy.getDirX() == -1.0F);
// Avancem amb dt no-divisor de 1 (joc real) fins que topa el mur.
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
mummy.update(0.0173F);
}
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
// El fix garanteix snap exacte a playfield_x = 24.
REQUIRE(mummy.getCollisionRect().x == static_cast<float>(Map::playfield_x));
}
void testMummyRoamingRecoversFromZeroDir() {
std::cout << "[T19] mummy ROAMING amb dir=(0,0) re-decideix la propera step()\n";
// Xarxa de seguretat: si per qualsevol via la mòmia entra a ROAMING
// amb dir_x_/dir_y_ a zero, el `step()` anterior feia early return i
// es quedava quieta per sempre. Ara re-decideix com a una intersecció
// si troba alguna direcció vàlida.
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
// Posicionem-la a un creuament aliniat (interior, lluny de murs i
// sarcs problemàtics): vertical x=88 ∩ horitzontal y=80.
mummy.setPixelRect({.x = 88.0F, .y = 64.0F, .w = 16.0F, .h = 32.0F});
// Forcem dir=(0,0) sense passar per HURT — simula l'estat patològic.
mummy.setDirection(0.0F, 0.0F);
REQUIRE(mummy.getDirX() == 0.0F);
REQUIRE(mummy.getDirY() == 0.0F);
// Un sol step amb dt típic ha d'assignar alguna direcció no-zero.
mummy.step(0.0167F);
const bool RECOVERED = mummy.getDirX() != 0.0F || mummy.getDirY() != 0.0F;
REQUIRE(RECOVERED);
}
void testMummyHurtCornerNoSoftLock() {
std::cout << "[T20] mummy HURT contra cantonada + re-whip no fa soft-lock\n";
// Integració: cursa HURT cap a una cantonada del playfield, després
// fueteig immediat al mateix lloc. Amb el bug, la mòmia quedava amb
// dir=(0,0) i no es movia per molts fueteigs que rebés. Amb el fix
// (snap a hurtStep + recovery a step), o bé `chooseHurtDirection`
// troba una direcció lliure des de la posició aliniada, o bé el
// proper step la rescata. En cap cas pot quedar permanentment quieta.
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
// Mateix runway que T18.
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST);
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
mummy.update(0.0173F);
}
// Fueteig immediat: enterHurt al moment exacte de quedar al mur.
mummy.enterHurt(Mummy::Direction::WEST);
// Després d'una desena de frames, la mòmia s'ha de moure ja sigui via
// la fugida HURT (perpendicular trobada) o via recovery a ROAMING.
const auto BEFORE = mummy.getPixelRect();
for (int i = 0; i < 60; ++i) { mummy.update(0.0173F); }
const auto AFTER = mummy.getPixelRect();
const float DX_TOTAL = AFTER.x - BEFORE.x;
const float DY_TOTAL = AFTER.y - BEFORE.y;
REQUIRE((DX_TOTAL != 0.0F) || (DY_TOTAL != 0.0F));
}
void testMummyWhippableFlag() {
std::cout << "[T17] mummy YAML llig `whippable` correctament\n";
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
REQUIRE(mummy.isWhippable());
}
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);
}
void testExitedAggregator() {
std::cout << "[T14] EntityManager: player_exited → level_complete\n";
EntityManager manager;
// Comptador inicialitzat per a 2 jugadors. Sense aquesta crida,
// l'agregador no faria res (expected_players_ = 0).
manager.setExpectedPlayerCount(2);
int level_complete_count = 0;
manager.getBus().subscribe("level_complete", [&](const Event& ev) {
++level_complete_count;
// Event d'agregat: no atribuïble a un jugador concret.
REQUIRE(ev.player_id == -1);
});
// Primer player_exited: comptador a 1 < 2, no emet encara.
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
REQUIRE(manager.exitedPlayerCount() == 1);
REQUIRE(level_complete_count == 0);
// Segon player_exited: comptador a 2 == expected, emet una vegada.
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
REQUIRE(manager.exitedPlayerCount() == 2);
REQUIRE(level_complete_count == 1);
// Un tercer player_exited (no hauria de passar amb 2 jugadors,
// però defensa contra publicacions extra): no torna a emetre.
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
REQUIRE(level_complete_count == 1);
// setExpectedPlayerCount torna a obrir el cicle (això simularia
// el que fa GameScene després d'un reload de sala). El comptador
// d'EXITED es reinicia a 0 i el flag d'emissió també.
manager.setExpectedPlayerCount(2);
REQUIRE(manager.exitedPlayerCount() == 0);
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
REQUIRE(level_complete_count == 2); // segon cicle ha emès una vegada més
}
} // namespace
auto main() -> int {
std::cout << "== smoke tests: entity system ==\n";
testFactory();
testLoader();
testEventBus();
testSarcophagusPerimeter();
testEntityManagerEndToEnd();
testAnimationComponent();
testAllTemplatesLoad();
testDecorationFlags();
testLoadFromLevel();
testSarcophagusYaml();
testCollisionBox();
testMummyStep();
testMummyHurtState();
testMummyHurtAgainstWall();
testMummyHurtSnapsAtPlayfieldWall();
testMummyRoamingRecoversFromZeroDir();
testMummyHurtCornerNoSoftLock();
testMummyWhippableFlag();
testTorchAnimation();
testExitedAggregator();
if (g_failures == 0) {
std::cout << "ALL OK\n";
return 0;
}
std::cerr << g_failures << " smoke check(s) failed\n";
return 1;
}