First cut of pulling out the relevant parts of my original game to make a little framework.

This commit is contained in:
Zed A. Shaw 2026-03-22 10:37:45 -04:00
commit 6a0c9e8d46
177 changed files with 18197 additions and 0 deletions

208
tests/ai.cpp Normal file
View file

@ -0,0 +1,208 @@
#include <catch2/catch_test_macros.hpp>
#include "dbc.hpp"
#include "ai/ai.hpp"
#include "ai/ai_debug.hpp"
#include <iostream>
using namespace dbc;
using namespace nlohmann;
TEST_CASE("state and actions work", "[ai]") {
enum StateNames {
ENEMY_IN_RANGE,
ENEMY_DEAD
};
ai::State goal;
ai::State start;
std::vector<ai::Action> actions;
// start off enemy not dead and not in range
start[ENEMY_DEAD] = false;
start[ENEMY_IN_RANGE] = false;
// end goal is enemy is dead
goal[ENEMY_DEAD] = true;
ai::Action move_closer("move_closer", 10);
move_closer.needs(ENEMY_IN_RANGE, false);
move_closer.effect(ENEMY_IN_RANGE, true);
REQUIRE(move_closer.can_effect(start));
auto after_move_state = move_closer.apply_effect(start);
REQUIRE(start[ENEMY_IN_RANGE] == false);
REQUIRE(after_move_state[ENEMY_IN_RANGE] == true);
REQUIRE(after_move_state[ENEMY_DEAD] == false);
// start is clean but after move is dirty
REQUIRE(move_closer.can_effect(start));
REQUIRE(!move_closer.can_effect(after_move_state));
REQUIRE(ai::distance_to_goal(start, after_move_state) == 1);
ai::Action kill_it("kill_it", 10);
kill_it.needs(ENEMY_IN_RANGE, true);
kill_it.needs(ENEMY_DEAD, false);
kill_it.effect(ENEMY_DEAD, true);
REQUIRE(!kill_it.can_effect(start));
REQUIRE(kill_it.can_effect(after_move_state));
auto after_kill_state = kill_it.apply_effect(after_move_state);
REQUIRE(!kill_it.can_effect(after_kill_state));
REQUIRE(ai::distance_to_goal(after_move_state, after_kill_state) == 1);
kill_it.ignore(ENEMY_IN_RANGE);
REQUIRE(kill_it.can_effect(after_move_state));
actions.push_back(kill_it);
actions.push_back(move_closer);
REQUIRE(start != goal);
}
TEST_CASE("basic feature tests", "[ai]") {
enum StateNames {
ENEMY_IN_RANGE,
ENEMY_DEAD
};
ai::State goal;
ai::State start;
std::vector<ai::Action> actions;
// start off enemy not dead and not in range
start[ENEMY_DEAD] = false;
start[ENEMY_IN_RANGE] = false;
// end goal is enemy is dead
goal[ENEMY_DEAD] = true;
ai::Action move_closer("move_closer", 10);
move_closer.needs(ENEMY_IN_RANGE, false);
move_closer.effect(ENEMY_IN_RANGE, true);
ai::Action kill_it("kill_it", 10);
kill_it.needs(ENEMY_IN_RANGE, true);
// this is duplicated on purpose to confirm that setting
// a positive then a negative properly cancels out
kill_it.needs(ENEMY_DEAD, true);
kill_it.needs(ENEMY_DEAD, false);
// same thing with effects
kill_it.effect(ENEMY_DEAD, false);
kill_it.effect(ENEMY_DEAD, true);
// order seems to matter which is wrong
actions.push_back(kill_it);
actions.push_back(move_closer);
auto result = ai::plan_actions(actions, start, goal);
REQUIRE(result.complete);
auto state = start;
for(auto& action : result.script) {
state = action.apply_effect(state);
}
REQUIRE(state[ENEMY_DEAD]);
}
TEST_CASE("ai as a module like sound/sprites", "[ai]") {
ai::reset();
ai::init("tests/ai_fixture.json");
auto start = ai::load_state("test_start");
auto goal = ai::load_state("test_goal");
auto a_plan = ai::plan("test1", start, goal);
REQUIRE(a_plan.complete);
auto state = start;
for(auto& action : a_plan.script) {
fmt::println("ACTION: {}", action.name);
state = action.apply_effect(state);
}
REQUIRE(ai::test(state, "target_dead"));
}
TEST_CASE("ai autowalker ai test", "[ai]") {
ai::reset();
ai::init("ai");
auto start = ai::load_state("Host::initial_state");
auto goal = ai::load_state("Host::final_state");
int enemy_count = 5;
ai::set(start, "no_more_enemies", enemy_count == 0);
// find an enemy and kill them
auto a_plan = ai::plan("Host::actions", start, goal);
REQUIRE(!a_plan.complete);
auto result = ai::dump_script("\n\nWALKER KILL STUFF", start, a_plan.script);
REQUIRE(ai::test(result, "enemy_found"));
REQUIRE(!ai::test(result, "no_more_enemies"));
// health is low, go heal
ai::set(result, "health_good", false);
ai::set(result, "in_combat", false);
ai::set(result, "enemy_found", false);
ai::set(result, "have_healing", true);
ai::set(result, "have_item", true);
REQUIRE(!ai::test(result, "health_good"));
auto health_plan = ai::plan("Host::actions", result, goal);
result = ai::dump_script("\n\nWALKER NEED HEALTH", result, health_plan.script);
REQUIRE(!health_plan.complete);
REQUIRE(ai::test(result, "health_good"));
// health is good, enemies dead, go get stuff
ai::set(result, "no_more_enemies", true);
REQUIRE(ai::test(result, "no_more_enemies"));
auto new_plan = ai::plan("Host::actions", result, goal);
result = ai::dump_script("\n\nWALKER COLLECT ITEMS", result, new_plan.script);
REQUIRE(ai::test(result, "no_more_items"));
REQUIRE(ai::test(result, "no_more_enemies"));
}
TEST_CASE("Confirm EntityAI behaves as expected", "[ai]") {
ai::reset();
ai::init("ai");
auto ai_start = ai::load_state("Enemy::initial_state");
auto ai_goal = ai::load_state("Enemy::final_state");
ai::EntityAI enemy("Enemy::actions", ai_start, ai_goal);
enemy.set_state("detect_enemy", true);
enemy.update();
REQUIRE(enemy.wants_to("find_enemy"));
enemy.set_state("enemy_found", true);
enemy.set_state("in_combat", true);
enemy.update();
REQUIRE(enemy.wants_to("kill_enemy"));
enemy.set_state("have_item", true);
enemy.set_state("have_healing", true);
enemy.set_state("in_combat", false);
enemy.set_state("health_good", false);
enemy.update();
REQUIRE(enemy.wants_to("use_healing"));
enemy.set_state("have_healing", false);
enemy.set_state("tough_personality", true);
enemy.set_state("in_combat", true);
enemy.set_state("health_good", true);
enemy.update();
REQUIRE(enemy.wants_to("kill_enemy"));
fmt::println("\n\n\n\n=============================\n\n\n\n");
enemy.set_state("have_healing", false);
enemy.set_state("tough_personality", false);
enemy.set_state("in_combat", true);
enemy.set_state("health_good", false);
enemy.update();
REQUIRE(enemy.wants_to("run_away"));
}

85
tests/ai_fixture.json Normal file
View file

@ -0,0 +1,85 @@
{
"profile": {
"target_acquired": 0,
"target_lost": 1,
"target_in_warhead_range": 2,
"target_dead": 3
},
"actions": [
{
"name": "searchSpiral",
"cost": 10,
"needs": {
"target_acquired": false,
"target_lost": true
},
"effects": {
"target_acquired": true
}
},
{
"name": "searchSerpentine",
"cost": 5,
"needs": {
"target_acquired": false,
"target_lost": false
},
"effects": {
"target_acquired": true
}
},
{
"name": "searchSpiral",
"cost": 5,
"needs": {
"target_acquired": false,
"target_lost": true
},
"effects": {
"target_acquired": true
}
},
{
"name": "interceptTarget",
"cost": 5,
"needs": {
"target_acquired": true,
"target_dead": false
},
"effects": {
"target_in_warhead_range": true
}
},
{
"name": "detonateNearTarget",
"cost": 5,
"needs": {
"target_in_warhead_range": true,
"target_acquired": true,
"target_dead": false
},
"effects": {
"target_dead": true
}
}
],
"states": {
"test_start": {
"target_acquired": false,
"target_lost": true,
"target_in_warhead_range": false,
"target_dead": false
},
"test_goal": {
"target_dead": true
}
},
"scripts": {
"test1": [
"searchSpiral",
"searchSerpentine",
"searchSpiral",
"interceptTarget",
"detonateNearTarget"]
}
}

169
tests/animation.cpp Normal file
View file

@ -0,0 +1,169 @@
#include <catch2/catch_test_macros.hpp>
#include "graphics/textures.hpp"
#include "algos/dinkyecs.hpp"
#include "game/config.hpp"
#include <iostream>
#include <memory>
#include <chrono>
#include <thread>
#include "algos/rand.hpp"
#include "graphics/animation.hpp"
#include "game/sound.hpp"
#include "game/components.hpp"
using namespace components;
using namespace textures;
using namespace std::chrono_literals;
using namespace animation;
Animation load_animation(const string& name) {
auto anim = animation::load("assets/animation.json", "rat_king_boss");
anim.set_form("attack");
anim.transform.looped = false;
for(size_t i = 0; i < anim.sequence.durations.size(); i++) {
anim.sequence.durations[i] = Random::uniform(1, 5);
}
return anim;
}
void FAKE_RENDER() {
std::this_thread::sleep_for(Random::milliseconds(5, 32));
}
void PLAY_TEST(Animation &anim) {
REQUIRE(anim.transform.looped == false);
anim.play();
while(anim.playing) {
anim.update();
FAKE_RENDER();
}
REQUIRE(anim.playing == false);
}
// /*
// * Animation is a Sheet + Sequence + Transform.
// *
// * A Sheet is just a grid of images with a predefined size for each cell. Arbitrary sized cells not supported.
// *
// * A Sequence is a list of Sheet cells _in any order_. See https://github.com/yottahmd/ganim8-lib. Sequences have a timing element for the cells, possibly a list of durations or a single duration.
// *
// * A Transform is combinations of scale and/or position easing/motion, shader effects, and things like if it's looped or toggled.
// *
// * I like the ganim8 onLoop concept, just a callback that says what to do when the animation has looped.
// */
// TEST_CASE("new animation system", "[animation-new]") {
// textures::init();
// sound::init();
// sound::mute(true);
//
// auto anim = load_animation("rat_king_boss");
// PLAY_TEST(anim);
//
// // test that toggled works
// anim.transform.toggled = true;
// PLAY_TEST(anim);
// REQUIRE(anim.sequence.current == anim.sequence.frames.size() - 1);
// anim.transform.toggled = false;
//
// bool onLoop_ran = false;
// anim.onLoop = [&](auto& seq, auto& tr) -> bool {
// seq.current = 0;
// onLoop_ran = true;
// return tr.looped;
// };
//
// PLAY_TEST(anim);
// REQUIRE(onLoop_ran == true);
//
// // only runs twice
// anim.onLoop = [](auto& seq, auto& tr) -> bool {
// if(seq.loop_count == 2) {
// seq.current = 0;
// return false;
// } else {
// seq.current = seq.current % seq.frame_count;
// return true;
// }
// };
//
// PLAY_TEST(anim);
// REQUIRE(anim.sequence.loop_count == 2);
// }
//
//
// TEST_CASE("confirm frame sequencing works", "[animation-new]") {
// textures::init();
// sound::init();
// sound::mute(true);
//
// auto anim = load_animation("rat_king_boss");
//
// auto boss = textures::get_sprite("rat_king_boss");
// sf::IntRect init_rect{{0,0}, {anim.sheet.frame_width, anim.sheet.frame_height}};
//
// anim.play();
// bool loop_ran = false;
//
// // this will check that it moved to the next frame
// anim.onLoop = [&](auto& seq, auto& tr) -> bool {
// seq.current = 0;
// loop_ran = true;
// return false;
// };
//
// anim.onFrame = [&](){
// anim.apply(*boss.sprite);
// };
//
// while(anim.playing) {
// anim.update();
// FAKE_RENDER();
// }
//
// REQUIRE(loop_ran == true);
// REQUIRE(anim.playing == false);
// }
//
// TEST_CASE("confirm transition changes work", "[animation-new]") {
// textures::init();
// sound::init();
// sound::mute(true);
//
// auto sprite = *textures::get_sprite("rat_king_boss").sprite;
// sf::Vector2f pos{100,100};
// sprite.setPosition(pos);
// auto scale = sprite.getScale();
// auto anim = load_animation("rat_king_boss");
//
// // also testing that onFrame being null means it's not run
// REQUIRE(anim.onFrame == nullptr);
//
// anim.play();
// REQUIRE(anim.playing == true);
//
// while(anim.playing) {
// anim.update();
// anim.motion(sprite, pos, scale);
// FAKE_RENDER();
// }
//
// REQUIRE(anim.playing == false);
// REQUIRE(pos == sf::Vector2f{100, 100});
// REQUIRE(scale != sf::Vector2f{0,0});
// }
TEST_CASE("playing with delta time", "[animation-new]") {
animation::Timer timer;
timer.start();
for(int i = 0; i < 20; i++) {
FAKE_RENDER();
auto [tick_count, alpha] = timer.commit();
// fmt::println("tick: {}, alpha: {}", tick_count, alpha);
}
}

9
tests/base.cpp Normal file
View file

@ -0,0 +1,9 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
using namespace fmt;
TEST_CASE("base test", "[base]") {
REQUIRE(1 == 1);
}

92
tests/battle.cpp Normal file
View file

@ -0,0 +1,92 @@
#include <catch2/catch_test_macros.hpp>
#include <iostream>
#include <set>
#include "combat/battle.hpp"
#include "algos/simplefsm.hpp"
#include "algos/dinkyecs.hpp"
#include "gui/backend.hpp"
#include "game/level.hpp"
#include "game/components.hpp"
#include "ai/ai.hpp"
#include "graphics/palette.hpp"
using namespace combat;
using namespace components;
TEST_CASE("battle operations fantasy", "[combat-battle]") {
ai::reset();
ai::init("ai");
auto ai_start = ai::load_state("Enemy::initial_state");
auto ai_goal = ai::load_state("Enemy::final_state");
auto host_start = ai::load_state("Host::initial_state");
auto host_goal = ai::load_state("Host::final_state");
BattleEngine battle;
DinkyECS::Entity host = 0;
ai::EntityAI host_ai("Host::actions", host_start, host_goal);
components::Combat host_combat{
.hp=100, .max_hp=100, .ap_delta=6, .max_ap=12, .damage=20};
battle.add_enemy({host, &host_ai, &host_combat, true});
DinkyECS::Entity axe_ranger = 1;
ai::EntityAI axe_ai("Enemy::actions", ai_start, ai_goal);
components::Combat axe_combat{
.hp=20, .max_hp=20, .ap_delta=8, .max_ap=12, .damage=20};
battle.add_enemy({axe_ranger, &axe_ai, &axe_combat});
DinkyECS::Entity rat = 2;
ai::EntityAI rat_ai("Enemy::actions", ai_start, ai_goal);
components::Combat rat_combat{
.hp=10, .max_hp=10, .ap_delta=2, .max_ap=10, .damage=10};
battle.add_enemy({rat, &rat_ai, &rat_combat});
battle.set_all("enemy_found", true);
battle.set_all("in_combat", true);
battle.set_all("tough_personality", true);
battle.set_all("health_good", true);
battle.set(rat, "tough_personality", false);
battle.set(host, "have_healing", false);
battle.set(host, "tough_personality", false);
while(host_combat.hp > 0) {
battle.set(host, "health_good", host_combat.hp > 20);
battle.player_request("use_healing");
battle.player_request("kill_enemy");
battle.ap_refresh();
battle.plan();
while(auto act = battle.next()) {
auto& [enemy, wants_to, cost, enemy_state] = *act;
// fmt::println(">>>>> entity: {} wants to {} cost={}; has {} HP; {} ap",
// enemy.entity, wants_to,
// cost, enemy.combat->hp,
// enemy.combat->ap);
switch(enemy_state) {
case BattleHostState::agree:
// fmt::println("HOST and PLAYER requests match {}, doing it.", wants_to);
break;
case BattleHostState::disagree:
// fmt::println("REBELIOUS ACT: {}", wants_to);
battle.clear_requests();
REQUIRE(battle.$player_requests.size() == 0);
break;
case BattleHostState::not_host:
if(wants_to == "kill_enemy") {
enemy.combat->attack(host_combat);
}
break;
case BattleHostState::out_of_ap:
// fmt::println("ENEMY OUT OF AP");
break;
}
}
REQUIRE(!battle.next());
}
}

6
tests/camera.cpp Normal file
View file

@ -0,0 +1,6 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
TEST_CASE("view based camera system", "[camera]") {
REQUIRE(1 == 1);
}

50
tests/components.cpp Normal file
View file

@ -0,0 +1,50 @@
#include <catch2/catch_test_macros.hpp>
#include "game/components.hpp"
#include "algos/dinkyecs.hpp"
#include "game/config.hpp"
#include <iostream>
using namespace components;
using namespace DinkyECS;
TEST_CASE("confirm component loading works", "[components]") {
std::vector<std::string> test_list{
"assets/enemies.json", "assets/items.json", "assets/devices.json"};
components::init();
DinkyECS::World world;
for(auto test_data : test_list) {
auto config = settings::get(test_data);
auto data_list = config.json();
for(auto& [key, data] : data_list.items()) {
auto& components = data["components"];
fmt::println("TEST COMPONENT: {} from file {}", key, test_data);
auto ent = world.entity();
components::configure_entity(world, ent, components);
auto tile = components::get<Tile>(components[0]);
REQUIRE(tile.display != L' ');
}
}
}
// TEST_CASE("make sure json_mods works", "[components]") {
// auto config = settings::get("bosses");
// // this confirms that loading something with an optional
// // field works with the json conversions in json_mods.hpp
// for(auto& comp_data : config["RAT_KING"]["components"]) {
// if(comp_data["_type"] == "AnimatedScene") {
// auto comp = components::convert<components::AnimatedScene>(comp_data);
// }
// }
//
// // this then confirms everything else about the json conversion
// components::init();
//
// DinkyECS::World world;
// auto rat_king = world.entity();
//
// components::configure_entity(world, rat_king, config["RAT_KING"]["components"]);
// auto boss = world.get<AnimatedScene>(rat_king);
// }

28
tests/config.cpp Normal file
View file

@ -0,0 +1,28 @@
#include <catch2/catch_test_macros.hpp>
#include "game/config.hpp"
#include <iostream>
TEST_CASE("confirm basic config loader ops", "[config]") {
settings::Config::set_base_dir("./");
auto config = settings::get("devices");
auto data_list = config.json();
auto the_keys = config.keys();
REQUIRE(the_keys.size() > 0);
for(auto& [key, data] : data_list.items()) {
auto wide1 = config.wstring(key, "name");
auto& comps = data["components"];
for(auto& comp_data : comps) {
REQUIRE(comp_data.contains("_type"));
}
}
auto indexed = settings::get("tests/config_test.json");
auto& test_0 = indexed[0];
REQUIRE(test_0["test"] == 0);
auto& test_1 = indexed[1];
REQUIRE(test_1["test"] == 1);
}

4
tests/config_test.json Normal file
View file

@ -0,0 +1,4 @@
[
{"test": 0},
{"test": 1}
]

137
tests/cyclic_rituals.json Normal file
View file

@ -0,0 +1,137 @@
{
"profile": {
"has_spikes": 0,
"has_magick": 1,
"shiny_bauble": 2,
"cursed_item": 3,
"$does_physical": 4,
"$does_magick": 5,
"$does_damage": 6,
"$user_cursed": 7,
"$does_healing": 8,
"$damage_boost": 9,
"$large_boost": 10,
"$is_complete": 11
},
"actions": [
{
"name": "pierce_type",
"cost": 100,
"needs": {
"has_spikes": true,
"$is_complete": false
},
"effects": {
"$does_physical": true,
"$does_damage": true
}
},
{
"name": "magick_type",
"cost": 100,
"needs": {
"$is_complete": false,
"has_magick": true
},
"effects": {
"$does_magick": true,
"$does_damage": true
}
},
{
"name": "combined",
"cost": 0,
"needs": {
"$does_damage": true
},
"effects": {
"$is_complete": true
}
},
{
"name": "boost_magick",
"cost": 0,
"needs": {
"shiny_bauble": true,
"$does_magick": true,
"$does_damage": true,
"$is_complete": false,
"$user_cursed": false
},
"effects": {
"$damage_boost": true
}
},
{
"name": "boost_damage_large",
"cost": 0,
"needs": {
"cursed_item": true,
"$is_complete": false,
"$does_damage": true
},
"effects": {
"$large_boost": true
}
},
{
"name": "curses_user",
"cost": 0,
"needs": {
"cursed_item": true
},
"effects": {
"$user_cursed": true
}
},
{
"name": "heals_user",
"cost": 0,
"needs": {
"cursed_item": true,
"$does_damage": false
},
"effects": {
"$does_healing": true,
"$is_complete": true
}
}
],
"states": {
"initial": {
"shiny_bauble": false,
"cursed_item": false,
"has_spikes": false,
"has_magick": false,
"$user_cursed": false,
"$does_damage": false,
"$is_complete": false,
"$does_healing": false,
"$does_magick": false,
"$does_physical": false,
"$large_boost": false,
"$damage_boost": false
},
"final": {
"$user_cursed": true,
"$does_damage": true,
"$is_complete": true,
"$does_healing": true,
"$does_magick": true,
"$does_physical": true,
"$large_boost": true,
"$damage_boost": true
}
},
"scripts": {
"actions": [
"boost_magick",
"pierce_type",
"magick_type",
"heals_user",
"curses_user",
"boost_damage_large",
"combined"
]
}
}

18
tests/dbc.cpp Normal file
View file

@ -0,0 +1,18 @@
#include <catch2/catch_test_macros.hpp>
#include "dbc.hpp"
using namespace dbc;
TEST_CASE("basic feature tests", "[dbc]") {
log("Logging a message.");
pre("confirm positive cases work", 1 == 1);
pre("confirm positive lambda", [&]{ return 1 == 1;});
post("confirm positive post", 1 == 1);
post("confirm postitive post with lamdba", [&]{ return 1 == 1;});
check(1 == 1, "one equals 1");
}

212
tests/dinkyecs.cpp Normal file
View file

@ -0,0 +1,212 @@
#include <catch2/catch_test_macros.hpp>
#include "algos/dinkyecs.hpp"
#include <iostream>
#include <fmt/core.h>
using namespace fmt;
using DinkyECS::Entity;
using std::string;
struct Point {
size_t x;
size_t y;
};
struct Player {
string name;
Entity eid;
};
struct Position {
Point location;
};
struct Motion {
int dx;
int dy;
bool random=false;
};
struct Velocity {
double x, y;
};
struct Gravity {
double level;
};
struct DaGUI {
int event;
};
/*
* Using a function catches instances where I'm not copying
* the data into the world.
*/
void configure(DinkyECS::World &world, Entity &test) {
println("---Configuring the base system.");
Entity test2 = world.entity();
world.set<Position>(test, {10,20});
world.set<Velocity>(test, {1,2});
world.set<Position>(test2, {1,1});
world.set<Velocity>(test2, {9,19});
println("---- Setting up the player as a fact in the system.");
auto player_eid = world.entity();
Player player_info{"Zed", player_eid};
// just set some player info as a fact with the entity id
world.set_the<Player>(player_info);
world.set<Velocity>(player_eid, {0,0});
world.set<Position>(player_eid, {0,0});
auto enemy = world.entity();
world.set<Velocity>(enemy, {0,0});
world.set<Position>(enemy, {0,0});
println("--- Creating facts (singletons)");
world.set_the<Gravity>({0.9});
}
TEST_CASE("confirm ECS system works", "[ecs]") {
DinkyECS::World world;
Entity test = world.entity();
configure(world, test);
Position &pos = world.get<Position>(test);
REQUIRE(pos.location.x == 10);
REQUIRE(pos.location.y == 20);
Velocity &vel = world.get<Velocity>(test);
REQUIRE(vel.x == 1);
REQUIRE(vel.y == 2);
world.query<Position>([](const auto &ent, auto &pos) {
REQUIRE(ent > 0);
REQUIRE(pos.location.x >= 0);
REQUIRE(pos.location.y >= 0);
});
world.query<Velocity>([](const auto &ent, auto &vel) {
REQUIRE(ent > 0);
REQUIRE(vel.x >= 0);
REQUIRE(vel.y >= 0);
});
println("--- Manually get the velocity in position system:");
world.query<Position>([&](const auto &ent, auto &pos) {
Velocity &vel = world.get<Velocity>(ent);
REQUIRE(ent > 0);
REQUIRE(pos.location.x >= 0);
REQUIRE(pos.location.y >= 0);
REQUIRE(ent > 0);
REQUIRE(vel.x >= 0);
REQUIRE(vel.y >= 0);
});
println("--- Query only entities with Position and Velocity:");
world.query<Position, Velocity>([&](const auto &ent, auto &pos, auto &vel) {
Gravity &grav = world.get_the<Gravity>();
REQUIRE(grav.level <= 1.0f);
REQUIRE(grav.level > 0.5f);
REQUIRE(ent > 0);
REQUIRE(pos.location.x >= 0);
REQUIRE(pos.location.y >= 0);
REQUIRE(ent > 0);
REQUIRE(vel.x >= 0);
REQUIRE(vel.y >= 0);
});
// now remove Velocity
REQUIRE(world.has<Velocity>(test));
world.remove<Velocity>(test);
REQUIRE_THROWS(world.get<Velocity>(test));
REQUIRE(!world.has<Velocity>(test));
println("--- After remove test, should only result in test2:");
world.query<Position, Velocity>([&](const auto &ent, auto &pos, auto &vel) {
auto &in_position = world.get<Position>(ent);
auto &in_velocity = world.get<Velocity>(ent);
REQUIRE(pos.location.x >= 0);
REQUIRE(pos.location.y >= 0);
REQUIRE(in_position.location.x == pos.location.x);
REQUIRE(in_position.location.y == pos.location.y);
REQUIRE(in_velocity.x == vel.x);
REQUIRE(in_velocity.y == vel.y);
});
}
enum GUIEvent {
HIT, MISS
};
TEST_CASE("confirm that the event system works", "[ecs]") {
DinkyECS::World world;
DinkyECS::Entity player = world.entity();
// this confirms we can send these in a for-loop and get them out
int i = 0;
for(; i < 10; i++) {
world.send<GUIEvent>(GUIEvent::HIT, player, string{"hello"});
}
// just count down and should get the same number
while(world.has_event<GUIEvent>()) {
auto [event, entity, data] = world.recv<GUIEvent>();
REQUIRE(event == GUIEvent::HIT);
REQUIRE(entity == player);
auto &str_data = std::any_cast<string&>(data);
REQUIRE(string{"hello"} == str_data);
i--;
}
REQUIRE(i == 0);
}
TEST_CASE("confirm copying and constants", "[ecs-constants]") {
DinkyECS::World world1;
Player player_info{"Zed", world1.entity()};
world1.set_the<Player>(player_info);
world1.set<Position>(player_info.eid, {10,10});
world1.make_constant(player_info.eid);
DinkyECS::World world2;
world1.clone_into(world2);
auto &test1 = world1.get<Position>(player_info.eid);
auto &test2 = world2.get<Position>(player_info.eid);
REQUIRE(test2.location.x == test1.location.x);
REQUIRE(test2.location.y == test1.location.y);
// check for accidental reference
test1.location.x = 100;
REQUIRE(test2.location.x != test1.location.x);
// test the facts copy over
auto &player2 = world2.get_the<Player>();
REQUIRE(player2.eid == player_info.eid);
}
TEST_CASE("can destroy all entity", "[ecs-destroy]") {
DinkyECS::World world;
auto entity = world.entity();
world.set<Velocity>(entity, {10,10});
world.set<Gravity>(entity, {1});
world.set<Motion>(entity, {0,0});
world.destroy(entity);
REQUIRE(!world.has<Velocity>(entity));
REQUIRE(!world.has<Gravity>(entity));
REQUIRE(!world.has<Motion>(entity));
}

57
tests/event_router.cpp Normal file
View file

@ -0,0 +1,57 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "gui/event_router.hpp"
using namespace fmt;
using namespace gui;
using enum gui::routing::Event;
using enum gui::routing::State;
using EventScript = std::vector<routing::Event>;
void run_script(routing::Router& router, routing::State expected, EventScript script) {
for(auto ev : script) {
router.event(ev);
}
REQUIRE(router.in_state(expected));
}
TEST_CASE("basic router operations test", "[event_router]") {
routing::Router router;
// start goes to idle
run_script(router, IDLE, {
STARTED
});
// simulate drag and drop
run_script(router, IDLE, {
MOUSE_DOWN,
MOUSE_MOVE,
MOUSE_UP,
KEY_PRESS
});
// moving the mouse outside dnd
run_script(router, IDLE, {
MOUSE_MOVE,
KEY_PRESS,
MOUSE_MOVE
});
// regular mouse click
run_script(router, IDLE, {
MOUSE_DOWN,
MOUSE_UP
});
// possible bad key press in a move?
run_script(router, IDLE, {
MOUSE_DOWN,
MOUSE_MOVE,
KEY_PRESS,
MOUSE_UP,
});
}

67
tests/fsm.cpp Normal file
View file

@ -0,0 +1,67 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "algos/simplefsm.hpp"
using namespace fmt;
using std::string;
enum class MyState {
START, RUNNING, END
};
enum class MyEvent {
STARTED, PUSH, QUIT
};
class MyFSM : public DeadSimpleFSM<MyState, MyEvent> {
public:
void event(MyEvent ev, string data="") {
switch($state) {
FSM_STATE(MyState, START, ev);
FSM_STATE(MyState, RUNNING, ev, data);
FSM_STATE(MyState, END, ev);
}
}
void START(MyEvent ev) {
println("<<< START {}", (int)ev);
state(MyState::RUNNING);
}
void RUNNING(MyEvent ev, string &data) {
if(ev == MyEvent::QUIT) {
println("<<< QUITTING {}", data);
state(MyState::END);
} else {
println("<<< RUN: {}", data);
state(MyState::RUNNING);
}
}
void END(MyEvent ev) {
println("<<< STOP {}", (int)ev);
state(MyState::END);
}
};
TEST_CASE("confirm fsm works with optional data", "[utils]") {
MyFSM fsm;
REQUIRE(fsm.in_state(MyState::START));
fsm.event(MyEvent::STARTED);
REQUIRE(fsm.in_state(MyState::RUNNING));
fsm.event(MyEvent::PUSH);
REQUIRE(fsm.in_state(MyState::RUNNING));
fsm.event(MyEvent::PUSH);
REQUIRE(fsm.in_state(MyState::RUNNING));
fsm.event(MyEvent::PUSH);
REQUIRE(fsm.in_state(MyState::RUNNING));
fsm.event(MyEvent::QUIT, "DONE!");
REQUIRE(fsm.in_state(MyState::END));
}

52
tests/inventory.cpp Normal file
View file

@ -0,0 +1,52 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "game/inventory.hpp"
using namespace fmt;
TEST_CASE("base test", "[inventory]") {
return;
inventory::Model inv;
DinkyECS::Entity test_ent = 1;
bool good = inv.add("hand_l", test_ent);
inv.invariant();
REQUIRE(good);
auto& slot = inv.get(test_ent);
REQUIRE(slot == "hand_l");
// confirm that we get false when trying to do it again
// BUG: this dies
good = inv.add("hand_l", test_ent);
REQUIRE(!good);
auto ent = inv.get(slot);
REQUIRE(ent == test_ent);
REQUIRE(inv.has(ent));
REQUIRE(inv.has(slot));
// test base remove
inv.remove(ent);
REQUIRE(!inv.has(slot));
REQUIRE(!inv.has(ent));
}
TEST_CASE("test swapping items", "[inventory]") {
inventory::Model inv;
DinkyECS::Entity hand_l_ent = 10;
DinkyECS::Entity hand_r_ent = 20;
inv.add("hand_l", hand_l_ent);
inv.add("hand_r", hand_r_ent);
REQUIRE(inv.count() == 2);
inv.swap(hand_l_ent, hand_r_ent);
REQUIRE(inv.get("hand_l") == hand_r_ent);
REQUIRE(inv.get("hand_r") == hand_l_ent);
REQUIRE(inv.count() == 2);
}

42
tests/lighting.cpp Normal file
View file

@ -0,0 +1,42 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <nlohmann/json.hpp>
#include <fstream>
#include "game/map.hpp"
#include "game/level.hpp"
#include "graphics/lights.hpp"
#include "algos/point.hpp"
using namespace lighting;
TEST_CASE("lighting a map works", "[lighting]") {
GameDB::init();
auto& level = GameDB::current_level();
auto& map = *level.map;
Point light1, light2;
REQUIRE(map.place_entity(0, light1));
REQUIRE(map.place_entity(0, light1));
LightSource source1{6, 1.0};
LightSource source2{4,3};
LightRender lr(map.walls());
lr.reset_light();
lr.set_light_target(light1);
lr.set_light_target(light2);
lr.path_light(map.walls());
lr.render_light(source1, light1);
lr.render_light(source2, light2);
lr.clear_light_target(light1);
lr.clear_light_target(light2);
Matrix &lighting = lr.lighting();
(void)lighting;
}

21
tests/loot.cpp Normal file
View file

@ -0,0 +1,21 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "game/components.hpp"
#include "algos/dinkyecs.hpp"
using namespace fmt;
using namespace components;
TEST_CASE("test the loot ui", "[loot]") {
auto items = settings::get("assets/items.json");
DinkyECS::World world;
auto torch = world.entity();
auto& data = items["TORCH_BAD"];
components::init();
components::configure_entity(world, torch, data["components"]);
auto& torch_sprite = world.get<Sprite>(torch);
REQUIRE(torch_sprite.name == "torch_horizontal_floor");
}

85
tests/map.cpp Normal file
View file

@ -0,0 +1,85 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <nlohmann/json.hpp>
#include <fstream>
#include "game/map.hpp"
#include "game/level.hpp"
#include "game/systems.hpp"
#include <cmath>
#include "graphics/textures.hpp"
#include "algos/rand.hpp"
using namespace fmt;
using namespace nlohmann;
using std::string;
json load_test_data(const string &fname) {
std::ifstream infile(fname);
return json::parse(infile);
}
TEST_CASE("camera control", "[map]") {
GameDB::init();
auto& level = GameDB::current_level();
auto& map = *level.map;
Point center = map.center_camera({10,10}, 5, 5);
// map.dump(center.x, center.y);
REQUIRE(center.x == 8);
REQUIRE(center.y == 8);
Point translation = map.map_to_camera({10,10}, center);
REQUIRE(translation.x == 2);
REQUIRE(translation.y == 2);
}
TEST_CASE("map placement test", "[map-fail]") {
GameDB::init();
for(int i = 0; i < 10; i++) {
auto& level = GameDB::create_level();
for(size_t rnum = 0; rnum < level.map->room_count(); rnum++) {
Point pos;
REQUIRE(level.map->place_entity(rnum, pos));
REQUIRE(!level.map->iswall(pos.x, pos.y));
REQUIRE(level.map->inmap(pos.x, pos.y));
}
}
}
TEST_CASE("map image test", "[map]") {
GameDB::init();
auto& level = GameDB::current_level();
Matrix map_tiles = matrix::make(7,7);
EntityGrid entity_map;
auto render = std::make_shared<sf::RenderTexture>();
sf::Sprite sprite{render->getTexture()};
auto player = level.world->get_the<components::Player>();
auto& player_pos = level.world->get<components::Position>(player.entity);
auto player_display = level.world->get<components::Tile>(player.entity).display;
for(matrix::each_row it{level.map->walls()}; it.next();) {
player_pos.location.x = it.x;
player_pos.location.y = it.y;
System::draw_map(map_tiles, entity_map);
System::render_map(map_tiles, entity_map, *render, 2, player_display);
// randomly test about 80% of them
if(Random::uniform(0, 100) < 20) break;
#ifdef TEST_RENDER
// confirm we get two different maps
auto out_img = render->getTexture().copyToImage();
bool worked = out_img.saveToFile(fmt::format("tmp/map_render{}{}.png", it.x, it.y));
REQUIRE(worked);
#endif
}
}

277
tests/matrix.cpp Normal file
View file

@ -0,0 +1,277 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "game/config.hpp"
#include "algos/matrix.hpp"
#include "algos/rand.hpp"
#include "game/level.hpp"
#include <nlohmann/json.hpp>
#include <fstream>
#include "game/map.hpp"
#include <memory>
#include "graphics/textures.hpp"
using namespace nlohmann;
using namespace fmt;
using std::string, std::shared_ptr;
using matrix::Matrix;
std::shared_ptr<Map> make_map() {
GameDB::init();
return GameDB::current_level().map;
}
// BUG: create a test that randomizes a map then does matrix ops on it
inline void random_matrix(Matrix &out) {
for(size_t y = 0; y < out.size(); y++) {
for(size_t x = 0; x < out[0].size(); x++) {
out[y][x] = Random::uniform<int>(-10,10);
}
}
}
TEST_CASE("thrash matrix iterators", "[matrix]") {
for(int count = 0; count < 5; count++) {
size_t width = Random::uniform<size_t>(1, 100);
size_t height = Random::uniform<size_t>(1, 100);
Matrix test(height, matrix::Row(width));
random_matrix(test);
// first make a randomized matrix
matrix::each_cell cells{test};
cells.next(); // kick off the other iterator
for(matrix::each_row it{test};
it.next(); cells.next())
{
REQUIRE(test[cells.y][cells.x] == test[it.y][it.x]);
}
}
}
TEST_CASE("thrash box distance iterators", "[matrix]") {
size_t width = Random::uniform<size_t>(10, 21);
size_t height = Random::uniform<size_t>(10, 25);
Matrix result(height, matrix::Row(width));
matrix::assign(result, 0);
size_t size = Random::uniform<int>(4, 10);
Point target{width/2, height/2};
matrix::box box{result, target.x, target.y, size};
while(box.next()) {
result[box.y][box.x] = box.distance();
}
// matrix::dump(format("MAP {}x{} @ {},{}; BOX {}x{}; size: {}",
// matrix::width(result), matrix::height(result),
// target.x, target.y, box.right - box.left, box.bottom - box.top, size),
// result, target.x, target.y);
}
TEST_CASE("thrash box iterators", "[matrix]") {
for(int count = 0; count < 5; count++) {
size_t width = Random::uniform<size_t>(1, 25);
size_t height = Random::uniform<size_t>(1, 33);
Matrix test(height, matrix::Row(width));
random_matrix(test);
// this will be greater than the random_matrix cells
int test_i = Random::uniform<size_t>(20,30);
// go through every cell
for(matrix::each_cell target{test}; target.next();) {
PointList result;
// make a random size box
size_t size = Random::uniform<int>(1, 33);
matrix::box box{test, target.x, target.y, size};
while(box.next()) {
test[box.y][box.x] = test_i;
result.push_back({box.x, box.y});
}
for(auto point : result) {
REQUIRE(test[point.y][point.x] == test_i);
test[point.y][point.x] = 10; // kind of reset it for another try
}
}
}
}
TEST_CASE("thrash compass iterators", "[matrix]") {
for(int count = 0; count < 5; count++) {
size_t width = Random::uniform<size_t>(1, 25);
size_t height = Random::uniform<size_t>(1, 33);
Matrix test(height, matrix::Row(width));
random_matrix(test);
// this will be greater than the random_matrix cells
int test_i = Random::uniform<size_t>(20,30);
// go through every cell
for(matrix::each_cell target{test}; target.next();) {
PointList result;
// make a random size box
matrix::compass compass{test, target.x, target.y};
while(compass.next()) {
test[compass.y][compass.x] = test_i;
result.push_back({compass.x, compass.y});
}
for(auto point : result) {
REQUIRE(test[point.y][point.x] == test_i);
test[point.y][point.x] = 10; // kind of reset it for another try
}
}
}
}
TEST_CASE("prototype line algorithm", "[matrix]") {
size_t width = Random::uniform<size_t>(10, 12);
size_t height = Random::uniform<size_t>(10, 15);
Map map(width,height);
// create a target for the paths
Point start{.x=map.width() / 2, .y=map.height()/2};
for(matrix::box box{map.walls(), start.x, start.y, 3};
box.next();)
{
Matrix result = map.walls();
result[start.y][start.x] = 1;
Point end{.x=box.x, .y=box.y};
for(matrix::line it{start, end}; it.next();)
{
REQUIRE(map.inmap(it.x, it.y));
result[it.y][it.x] = 15;
}
result[start.y][start.x] = 15;
// matrix::dump("RESULT AFTER LINE", result, end.x, end.y);
bool f_found = false;
for(matrix::each_cell it{result}; it.next();) {
if(result[it.y][it.x] == 15) {
f_found = true;
break;
}
}
REQUIRE(f_found);
}
}
TEST_CASE("prototype circle algorithm", "[matrix]") {
for(int count = 0; count < 5; count++) {
size_t width = Random::uniform<size_t>(10, 13);
size_t height = Random::uniform<size_t>(10, 15);
int pos_mod = Random::uniform<int>(-3,3);
Map map(width,height);
// create a target for the paths
Point start{.x=map.width() / 2 + pos_mod, .y=map.height()/2 + pos_mod};
for(float radius = 1.0f; radius < 4.0f; radius += 0.1f) {
// use an empty map
Matrix result = map.walls();
for(matrix::circle it{result, start, radius}; it.next();) {
for(int x = it.left; x < it.right; x++) {
// println("top={}, bottom={}, center.y={}, dy={}, left={}, right={}, x={}, y={}", it.top, it.bottom, it.center.y, it.dy, it.left, it.right, x, it.y);
// println("RESULT {},{}", matrix::width(result), matrix::height(result));
REQUIRE(it.y >= 0);
REQUIRE(x >= 0);
REQUIRE(it.y < int(matrix::height(result)));
REQUIRE(x < int(matrix::width(result)));
result[it.y][x] += 1;
}
}
// matrix::dump(format("RESULT AFTER CIRCLE radius {}", radius), result, start.x, start.y);
}
}
}
TEST_CASE("viewport iterator", "[matrix]") {
components::init();
textures::init();
GameDB::init();
size_t width = Random::uniform<size_t>(20, 22);
size_t height = Random::uniform<size_t>(21, 25);
shared_ptr<Map> map = make_map();
size_t view_width = width/2;
size_t view_height = height/2;
Point player;
REQUIRE(map->place_entity(1, player));
Point start = map->center_camera(player, view_width, view_height);
size_t end_x = std::min(view_width, map->width() - start.x);
size_t end_y = std::min(view_height, map->height() - start.y);
matrix::viewport it{map->walls(), start, int(view_width), int(view_height)};
for(size_t y = 0; y < end_y; ++y) {
for(size_t x = 0; x < end_x && it.next(); ++x) {
// still working on this
}
}
}
TEST_CASE("random rectangle", "[matrix]") {
components::init();
for(int i = 0; i < 5; i++) {
shared_ptr<Map> map = make_map();
map->invert_space();
auto wall_copy = map->walls();
for(size_t rnum = 0; rnum < map->room_count(); rnum++) {
Room &room = map->room(rnum);
Point pos;
for(matrix::rando_rect it{map->walls(), room.x, room.y, room.width, room.height}; it.next();)
{
REQUIRE(size_t(it.x) >= room.x);
REQUIRE(size_t(it.y) >= room.y);
REQUIRE(size_t(it.x) <= room.x + room.width);
REQUIRE(size_t(it.y) <= room.y + room.height);
wall_copy[it.y][it.x] = wall_copy[it.y][it.x] + 5;
}
}
// matrix::dump("WALLS FILLED", wall_copy);
}
}
TEST_CASE("standard rectangle", "[matrix]") {
components::init();
for(int i = 0; i < 5; i++) {
shared_ptr<Map> map = make_map();
auto wall_copy = map->walls();
for(size_t rnum = 0; rnum < map->room_count(); rnum++) {
Room &room = map->room(rnum);
Point pos;
for(matrix::rectangle it{map->walls(), room.x, room.y, room.width, room.height}; it.next();)
{
REQUIRE(size_t(it.x) >= room.x);
REQUIRE(size_t(it.y) >= room.y);
REQUIRE(size_t(it.x) <= room.x + room.width);
REQUIRE(size_t(it.y) <= room.y + room.height);
wall_copy[it.y][it.x] = wall_copy[it.y][it.x] + 5;
}
}
// matrix::dump("WALLS FILLED", wall_copy);
}
}

182
tests/mazes.cpp Normal file
View file

@ -0,0 +1,182 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "algos/matrix.hpp"
#include "algos/rand.hpp"
#include "constants.hpp"
#include "algos/maze.hpp"
#include "algos/stats.hpp"
#define DUMP 0
using std::string;
using matrix::Matrix;
TEST_CASE("hunt-and-kill", "[mazes]") {
Map map(21, 21);
maze::Builder maze(map);
maze.hunt_and_kill();
REQUIRE(maze.repair() == true);
if(DUMP) maze.dump("BASIC MAZE");
maze.randomize_rooms(ROOM_SIZE);
maze.hunt_and_kill();
maze.place_doors();
REQUIRE(maze.repair() == true);
if(DUMP) maze.dump("ROOM MAZE");
REQUIRE(map.$dead_ends.size() > 0);
REQUIRE(map.$rooms.size() > 0);
}
TEST_CASE("hunt-and-kill box", "[mazes]") {
for(int i = 25; i < 65; i += 2) {
Map map(i, i);
maze::Builder maze(map);
maze.hunt_and_kill();
maze.clear();
maze.inner_box(6, 4);
maze.randomize_rooms(ROOM_SIZE);
maze.hunt_and_kill();
maze.open_box(6);
maze.place_doors();
auto valid = maze.repair();
if(i == 41 && DUMP) {
maze.dump(valid ? "INNER BOX" : "FAILED BOX");
}
}
}
TEST_CASE("hunt-and-kill ring", "[mazes]") {
Map map(21, 21);
maze::Builder maze(map);
maze.inner_donut(5.5, 3.5);
maze.hunt_and_kill();
REQUIRE(maze.repair() == true);
if(DUMP) maze.dump("INNER RING");
REQUIRE(maze.$rooms.size() == 0);
}
TEST_CASE("hunt-and-kill fissure", "[mazes]") {
Map map(21, 21);
maze::Builder maze(map);
maze.divide({3,3}, {19,18});
maze.hunt_and_kill();
REQUIRE(maze.repair() == true);
if(DUMP) maze.dump("FISSURE MAZE");
REQUIRE(maze.$rooms.size() == 0);
}
TEST_CASE("hunt-and-kill no-dead-ends", "[mazes]") {
Map map(21, 21);
maze::Builder maze(map);
maze.hunt_and_kill();
maze.remove_dead_ends();
REQUIRE(maze.repair() == true);
if(DUMP) maze.dump("NO DEAD ENDS");
}
TEST_CASE("hunt-and-kill too much", "[mazes]") {
for(int i = 25; i < 65; i += 2) {
Map map(i, i);
maze::Builder maze(map);
maze.hunt_and_kill();
maze.randomize_rooms(ROOM_SIZE);
maze.clear();
maze.inner_donut(9, 4);
maze.divide({3,3}, {15,16});
maze.hunt_and_kill();
maze.place_doors();
auto valid = maze.repair();
if(i == 41 && DUMP && valid) {
maze.dump("COMBINED");
}
}
}
TEST_CASE("hunt-and-kill validator", "[mazes]") {
bool valid = true;
Stats mofm;
for(int i = 0; i < 10; i++) {
Stats door_prob;
do {
Map map(33, 33);
maze::Builder maze(map);
maze.hunt_and_kill();
maze.clear();
maze.inner_box(6, 4);
maze.randomize_rooms(ROOM_SIZE);
maze.hunt_and_kill();
maze.open_box(6);
maze.place_doors();
valid = maze.repair();
if(i == 9 && DUMP) {
maze.dump(valid ? "VALIDATED" : "FAILED!");
}
door_prob.sample(valid);
} while(!valid);
if(DUMP) door_prob.dump();
mofm.sample(door_prob.mean());
}
if(DUMP) {
fmt::println("FINAL m-of-m");
mofm.dump();
}
REQUIRE(mofm.mean() > 0.20);
}
TEST_CASE("hunt-and-kill scripting", "[mazes]") {
using namespace nlohmann::literals;
// go up by 2 to keep odd
for(int i = 0; i < 20; i+=2) {
auto script = R"(
[
{"action": "hunt_and_kill"},
{"action": "clear"},
{"action": "inner_box", "data": [6, 4]},
{"action": "randomize_rooms", "data": [4]},
{"action": "divide", "data": [3, 3, 10, 10]},
{"action": "inner_donut", "data": [5.5,4.5]},
{"action": "hunt_and_kill"},
{"action": "open_box", "data": [6]},
{"action": "remove_dead_ends"},
{"action": "place_doors"}
]
)"_json;
Map map(23+i, 23+i);
auto [maze, valid] = maze::script(map, script);
if(valid) {
REQUIRE(maze.validate() == true);
REQUIRE(map.INVARIANT() == true);
}
if(DUMP) maze.dump(valid ? "SCRIPTED" : "SCRIPTED FAIL!");
}
}

27
tests/meson.build Normal file
View file

@ -0,0 +1,27 @@
tests = files(
'ai.cpp',
'animation.cpp',
'base.cpp',
'battle.cpp',
'camera.cpp',
'components.cpp',
'config.cpp',
'dbc.cpp',
'dinkyecs.cpp',
'event_router.cpp',
'fsm.cpp',
'inventory.cpp',
'lighting.cpp',
'loot.cpp',
'map.cpp',
'matrix.cpp',
'mazes.cpp',
'palette.cpp',
'pathing.cpp',
'shaders.cpp',
'sound.cpp',
'spatialmap.cpp',
'stats.cpp',
'systems.cpp',
'textures.cpp',
)

24
tests/palette.cpp Normal file
View file

@ -0,0 +1,24 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "graphics/palette.hpp"
using namespace fmt;
TEST_CASE("color palette test", "[color-palette]") {
palette::init();
REQUIRE(palette::initialized() == true);
// confirm it's idempotent
palette::init();
sf::Color expect{10, 10, 10, 255};
auto gui_text = palette::get("gui/theme:dark_dark");
REQUIRE(gui_text == expect);
gui_text = palette::get("gui/theme", "mid");
REQUIRE(gui_text != expect);
expect = {100, 100, 100, 255};
REQUIRE(gui_text == expect);
}

53
tests/pathing.cpp Normal file
View file

@ -0,0 +1,53 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <nlohmann/json.hpp>
#include <fstream>
#include "algos/pathing.hpp"
#include "algos/matrix.hpp"
#include "ai/ai.hpp"
#include "game/level.hpp"
#include <chrono>
#include <thread>
#include "algos/rand.hpp"
#include "game/systems.hpp"
#include "constants.hpp"
using namespace fmt;
using namespace nlohmann;
using std::string;
using namespace components;
using namespace std::chrono_literals;
json load_test_pathing(const string &fname) {
std::ifstream infile(fname);
return json::parse(infile);
}
TEST_CASE("multiple targets can path", "[pathing]") {
GameDB::init();
auto level = GameDB::create_level();
auto walls_copy = level.map->$walls;
Pathing paths{matrix::width(walls_copy), matrix::height(walls_copy)};
System::multi_path<Combat>(level, paths, walls_copy);
bool diag = Random::uniform<int>(0, 1);
auto pos = GameDB::player_position().location;
auto found = paths.find_path(pos, PATHING_TOWARD, diag);
while(found == PathingResult::CONTINUE) {
// fmt::println("\033[2J\033[1;1H");
// matrix::dump(diag ? "diag" : "simple", paths.$paths, pos.x, pos.y);
// std::this_thread::sleep_for(200ms);
found = paths.find_path(pos, PATHING_TOWARD, diag);
}
// fmt::println("\033[2J\033[1;1H");
// matrix::dump(diag ? "diag" : "simple", paths.$paths, pos.x, pos.y);
if(found == PathingResult::FOUND) {
fmt::println("FOUND!");
} else if(found == PathingResult::FAIL && !diag) {
REQUIRE(found != PathingResult::FAIL);
}
}

65
tests/save.cpp Normal file
View file

@ -0,0 +1,65 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "algos/dinkyecs.hpp"
#include "game/components.hpp"
#include "save.hpp"
#include <optional>
#include <iostream>
#include "game/map.hpp"
#include "game/worldbuilder.hpp"
#include "tser.hpp"
using namespace fmt;
using std::string;
using namespace components;
TEST_CASE("basic save a world", "[save]") {
/*
DinkyECS::World world;
Map map(20, 20);
WorldBuilder builder(map);
builder.generate_map();
// configure a player as a fact of the world
Player player{world.entity()};
world.set_the<Player>(player);
world.set<Position>(player.entity, {10,10});
world.set<Motion>(player.entity, {0, 0});
world.set<Combat>(player.entity, {100, 10});
world.set<Tile>(player.entity, {"@"});
world.set<Inventory>(player.entity, {102});
save::to_file("./savetest.world", world, map);
DinkyECS::World in_world;
Map in_map(0, 0); // this will be changed on load
save::from_file("./savetest.world", in_world, in_map);
Position &position1 = world.get<Position>(player.entity);
Position &position2 = in_world.get<Position>(player.entity);
REQUIRE(position1.location.x == position2.location.x);
REQUIRE(position1.location.y == position2.location.y);
Combat &combat1 = world.get<Combat>(player.entity);
Combat &combat2 = in_world.get<Combat>(player.entity);
REQUIRE(combat1.hp == combat2.hp);
Motion &motion1 = world.get<Motion>(player.entity);
Motion &motion2 = in_world.get<Motion>(player.entity);
REQUIRE(motion1.dx == motion2.dx);
REQUIRE(motion1.dy == motion2.dy);
Tile &tile1 = world.get<Tile>(player.entity);
Tile &tile2 = in_world.get<Tile>(player.entity);
REQUIRE(tile1.chr == tile2.chr);
REQUIRE(map.width() == in_map.width());
REQUIRE(map.height() == in_map.height());
REQUIRE(map.$walls == in_map.$walls);
Inventory &inv = world.get<Inventory>(player.entity);
REQUIRE(inv.gold == 102);
*/
}

26
tests/shaders.cpp Normal file
View file

@ -0,0 +1,26 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "graphics/shaders.hpp"
using namespace fmt;
TEST_CASE("shader loading/init works", "[shaders]") {
shaders::init();
int version = shaders::version();
std::shared_ptr<sf::Shader> ui_shader = shaders::get("ui_shader");
auto other_test = shaders::get("ui_shader");
REQUIRE(ui_shader != nullptr);
REQUIRE(ui_shader == other_test);
REQUIRE(shaders::updated(version) == false);
int new_version = shaders::reload();
REQUIRE(version != shaders::version());
REQUIRE(version != new_version);
REQUIRE(shaders::version() == new_version);
REQUIRE(shaders::updated(version) == true);
version = new_version;
}

14
tests/sound.cpp Normal file
View file

@ -0,0 +1,14 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "game/sound.hpp"
using namespace fmt;
TEST_CASE("test sound manager", "[sound]") {
sound::init();
sound::play("blank");
sound::play_at("blank", 0.1, 0.1, 0.1);
}

252
tests/spatialmap.cpp Normal file
View file

@ -0,0 +1,252 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "algos/spatialmap.hpp"
#include "algos/dinkyecs.hpp"
#include "algos/rand.hpp"
#include <limits>
#include <fmt/core.h>
using DinkyECS::Entity;
using namespace fmt;
TEST_CASE("SpatialMap::insert", "[spatialmap]") {
DinkyECS::World world;
SpatialMap map;
auto player = world.entity();
auto item = world.entity();
auto potion = world.entity();
auto enemy = world.entity();
Point at{10,10};
Point enemy_at{11,11};
map.insert(at, item, false);
map.insert(at, potion, false);
REQUIRE(!map.occupied(at));
map.insert(at, player, true);
REQUIRE(map.occupied_by(at) == player);
REQUIRE_THROWS(map.insert(at, enemy, true));
map.insert(enemy_at, enemy, true);
REQUIRE(map.occupied_by(enemy_at) == enemy);
REQUIRE(map.occupied(enemy_at));
}
TEST_CASE("SpatialMap::remove", "[spatialmap]") {
DinkyECS::World world;
SpatialMap map;
auto player = world.entity();
auto item = world.entity();
Point at{120, 120};
// confirm that things can be in any order
map.insert(at, player, true);
map.insert(at, item, false);
REQUIRE(map.occupied(at));
REQUIRE(map.occupied_by(at) == player);
auto data = map.remove(at, player);
REQUIRE(!map.occupied(at));
REQUIRE(data.entity == player);
REQUIRE(data.collision == true);
REQUIRE_THROWS(map.remove(at, player));
}
TEST_CASE("SpatialMap::move", "[spatialmap]") {
DinkyECS::World world;
SpatialMap map;
auto player = world.entity();
auto item = world.entity();
Point at{10, 320};
map.insert(at, player, true);
map.insert(at, item, false);
REQUIRE(map.occupied(at));
auto enemy = world.entity();
auto potion = world.entity();
Point enemy_at{11, 320};
map.insert(enemy_at, enemy, true);
map.insert(enemy_at, potion, false);
REQUIRE(map.occupied(enemy_at));
REQUIRE(map.occupied_by(enemy_at) == enemy);
Point target{at.x + 1, at.y};
// try bad move with a slot that's empty
REQUIRE_THROWS(map.move({0,0}, target, player));
// try move into an occupied spot also fails
REQUIRE_THROWS(map.move(at, target, player));
// now move to a new spot, need to add them back
map.insert(at, player, true);
target.x++; // just move farther
map.move(at, target, player);
REQUIRE(map.occupied(target));
REQUIRE(map.occupied_by(target) == player);
auto data = map.remove(target, player);
REQUIRE(data.entity == player);
REQUIRE(data.collision == true);
}
TEST_CASE("SpatialMap::occupied/something_there", "[spatialmap]") {
DinkyECS::World world;
SpatialMap map;
auto player = world.entity();
auto item = world.entity();
Point at{1000, 20};
// first test empty locations
REQUIRE(!map.something_there(at));
REQUIRE(!map.occupied(at));
// then when there's something without collision
map.insert(at, item, false);
REQUIRE(map.something_there(at));
REQUIRE(!map.occupied(at));
// finally with collision and an item there
map.insert(at, player, true);
REQUIRE(map.something_there(at));
REQUIRE(map.occupied(at));
REQUIRE(map.occupied_by(at) == player);
// then remove the item and still have collision
map.remove(at, item);
REQUIRE(map.something_there(at));
REQUIRE(map.occupied(at));
REQUIRE(map.occupied_by(at) == player);
// remove player and back to no collision
map.remove(at, player);
REQUIRE(!map.something_there(at));
REQUIRE(!map.occupied(at));
// last thing, put just the player in at a new spot
Point target{at.x+1, at.y+10};
map.insert(target, player, true);
REQUIRE(map.something_there(target));
REQUIRE(map.occupied(target));
REQUIRE(map.occupied_by(target) == player);
}
TEST_CASE("SpatialMap::get", "[spatialmap]") {
DinkyECS::World world;
SpatialMap map;
auto player = world.entity();
auto item = world.entity();
Point at{101, 31};
// finally with collision and an item there
map.insert(at, player, true);
REQUIRE(map.occupied(at));
REQUIRE(map.occupied_by(at) == player);
auto entity = map.get(at);
REQUIRE(player == entity);
// This probably doesn't work so need to
// rethink how get works.
map.insert(at, item, false);
entity = map.get(at);
REQUIRE(entity == item);
}
TEST_CASE("SpatialMap::find", "[spatialmap-find]") {
DinkyECS::World world;
SpatialMap map;
Point at{101, 31};
DinkyECS::Entity should_collide = DinkyECS::NONE;
for(int i = 0; i < 10; i++) {
auto ent = world.entity();
map.insert(at, ent, i == 8);
if(i == 8) {
should_collide = ent;
}
}
auto collision = map.find(at, [&](auto data) -> bool {
return data.collision;
});
REQUIRE(collision == should_collide);
auto no_collide = map.find(at, [&](auto data) -> bool {
return !data.collision;
});
REQUIRE(no_collide != should_collide);
}
TEST_CASE("SpatialMap::neighbors", "[spatialmap-neighbors]") {
DinkyECS::World world;
SpatialMap map;
auto player = world.entity();
auto enemy1 = world.entity();
auto enemy2 = world.entity();
//auto item1 = world.entity();
//auto item2 = world.entity();
Point at{101, 31};
map.insert(at, player, true);
map.insert({at.x+1, at.y}, enemy1, true);
map.insert({at.x-1, at.y+1}, enemy2, true);
auto result = map.neighbors(at, true);
REQUIRE(result.found);
REQUIRE(result.nearby.size() == 2);
bool maybe = result.nearby[0] == enemy1 || result.nearby[1] == enemy1;
REQUIRE(maybe);
maybe = result.nearby[0] == enemy2 || result.nearby[1] == enemy2;
REQUIRE(maybe);
result = map.neighbors(at, false);
REQUIRE(result.found);
REQUIRE(result.nearby.size() == 1);
REQUIRE(result.nearby[0] == enemy1);
}
TEST_CASE("SpatialMap::distance_sorted", "[spatialmap]") {
DinkyECS::World world;
SpatialMap map;
auto player = world.entity();
auto enemy1 = world.entity();
auto item = world.entity();
map.insert({1,1}, player, true);
map.insert({4,4}, enemy1, true);
map.insert({3, 3}, item, false);
SortedEntities result;
map.distance_sorted(result, {1, 1}, 100);
REQUIRE(result.size() == 3);
REQUIRE(result[0].entity == enemy1);
REQUIRE(result[1].entity == item);
REQUIRE(result[2].entity == player);
int prev_dist = std::numeric_limits<int>::max();
for(auto rec : result) {
REQUIRE(rec.dist_square < prev_dist);
prev_dist = rec.dist_square;
}
}

27
tests/stats.cpp Normal file
View file

@ -0,0 +1,27 @@
#include <catch2/catch_test_macros.hpp>
#include "algos/stats.hpp"
#include "algos/rand.hpp"
#include <cmath>
#include <fmt/core.h>
TEST_CASE("basic stats tests", "[stats]") {
Stats stat1;
stat1.sample(1.0);
for(int i = 0; i < 20; i++) {
double x = Random::normal(20.0,5.0);
stat1.sample(x);
REQUIRE(!std::isnan(stat1.stddev()));
REQUIRE(stat1.mean() < stat1.mean() + stat1.stddev() * 4.0);
}
stat1.dump();
stat1.reset();
REQUIRE(stat1.n == 0.0);
auto timer = stat1.time_start();
for(int i = 0; i < 20; i++) {
stat1.sample_time(timer);
}
}

36
tests/systems.cpp Normal file
View file

@ -0,0 +1,36 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include "game/systems.hpp"
#include <cmath>
#include <numbers>
TEST_CASE("figure out best rotation direction", "[systems-rotate]") {
Matrix map = matrix::make(3, 3);
Point player_at{1, 1};
map[player_at.y][player_at.x] = 2;
for(matrix::box target{map, player_at.x, player_at.y, 1}; target.next();) {
for(matrix::box aiming_at{map, player_at.x, player_at.y, 1}; aiming_at.next();) {
map[aiming_at.y][aiming_at.x] = 10;
float target_dx = float(player_at.x) - float(target.x);
float target_dy = float(player_at.y) - float(target.y);
float aiming_dx = float(player_at.x) - float(aiming_at.x);
float aiming_dy = float(player_at.y) - float(aiming_at.y);
float target_angle = atan2(-target_dy, target_dx) * (180.0 / std::numbers::pi);
float aiming_angle = atan2(-aiming_dy, aiming_dx) * (180.0 / std::numbers::pi);
float diff = target_angle - aiming_angle;
double normalized = fmod(diff + 360.0, 360.0);
REQUIRE(normalized >= 0);
REQUIRE(normalized <= 360);
map[aiming_at.y][aiming_at.x] = 0;
}
}
}

43
tests/textures.cpp Normal file
View file

@ -0,0 +1,43 @@
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include <string>
#include "graphics/textures.hpp"
#include "constants.hpp"
#include "game/components.hpp"
using namespace fmt;
TEST_CASE("test texture management", "[textures]") {
components::init();
textures::init();
auto spider = textures::get_sprite("rat_with_sword");
REQUIRE(spider.sprite != nullptr);
REQUIRE(spider.texture != nullptr);
REQUIRE(spider.frame_size.x == TEXTURE_WIDTH);
REQUIRE(spider.frame_size.y == TEXTURE_HEIGHT);
auto image = textures::load_image("assets/sprites/rat_with_sword.png");
size_t floor_tile = textures::get_id("floor_tile");
size_t gray_stone = textures::get_id("door_plain");
auto floor_ptr = textures::get_surface(floor_tile);
REQUIRE(floor_ptr != nullptr);
auto gray_stone_ptr = textures::get_surface(gray_stone);
REQUIRE(gray_stone_ptr != nullptr);
auto& light = textures::get_ambient_light();
REQUIRE(light.size() > 0);
REQUIRE(light[floor_tile] == 0);
REQUIRE(light[gray_stone] > 0);
auto& tiles = textures::get_map_tile_set();
REQUIRE(tiles.size() > 0);
REQUIRE(tiles[floor_tile] > 0);
REQUIRE(tiles[gray_stone] > 0);
auto ceiling = textures::get_ceiling(floor_tile);
REQUIRE(ceiling != nullptr);
}