// 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 #include #include #include #include #include #include #include #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 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() == "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(&ev.payload); if (v != nullptr) { int_received = *v; } }); bus.subscribe("a_ent", [&](const Event& ev) { if (std::get_if(&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(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> 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(EntityLoader::loadTemplate("sarcophagus")); sarc1->place(0, 0, {}); auto sarc2 = std::make_unique(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(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 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() == 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(); 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(RECT.x) == 40); REQUIRE(static_cast(RECT.y) == 64); const int ANCHOR_COL = (static_cast(RECT.x) - Map::tilemap_origin_x) / Map::tile_size; const int ANCHOR_ROW = (static_cast(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(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 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() == 3); REQUIRE(tmpl["tile_size"][1].get_value() == 1); REQUIRE(tmpl["sprite"]["size"][0].get_value() == 48); REQUIRE(tmpl["sprite"]["size"][1].get_value() == 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(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(TRECT.w) == 16); REQUIRE(static_cast(TRECT.h) == 16); REQUIRE(static_cast(TRECT.x) == 100); REQUIRE(static_cast(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(MRECT.x) == 100); REQUIRE(static_cast(MRECT.y) == 212); REQUIRE(static_cast(MRECT.w) == 12); REQUIRE(static_cast(MRECT.h) == 12); REQUIRE(static_cast(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(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}); // 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::RIGHT); 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(Map::playfield_x + Map::playfield_w); mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); // 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::RIGHT); 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 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(); 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; }