ai.cpp now has a nice easy to use API for loading and running the GOAP things.

master
Zed A. Shaw 11 months ago
parent a079f882df
commit b2c1b220ac
  1. 158
      ai.cpp
  2. 22
      ai.hpp
  3. 85
      assets/ai.json
  4. 220
      tests/ai.cpp

158
ai.cpp

@ -3,6 +3,7 @@
namespace ai {
using namespace nlohmann;
using namespace dbc;
bool is_subset(State& source, State& target) {
State result = source & target;
@ -29,31 +30,6 @@ namespace ai {
}
}
void Action::load(nlohmann::json& profile, nlohmann::json& config) {
dbc::check(config.contains("needs"),
fmt::format("Action.load({}): no 'needs' field", $name));
dbc::check(config.contains("effects"),
fmt::format("Action.load({}): no 'effects' field", $name));
for(auto& [name_key, value] : profile.items()) {
dbc::check(value < STATE_MAX, fmt::format("Action.load({}): profile field {} has value {} greater than STATE_MAX {}", $name, (std::string)name_key, (int)value, STATE_MAX));
}
for(auto& [name_key, value] : config["needs"].items()) {
dbc::check(profile.contains(name_key), fmt::format("Action.load({}): profile does not have name {}", $name, name_key));
int name = profile[name_key].template get<int>();
needs(name, bool(value));
}
for(auto& [name_key, value] : config["effects"].items()) {
dbc::check(profile.contains(name_key), fmt::format("Action.load({}): profile does not have name {}", $name, name_key));
int name = profile[name_key].template get<int>();
effect(name, bool(value));
}
}
bool Action::can_effect(State& state) {
return ((state & $positive_preconds) == $positive_preconds) &&
@ -92,7 +68,7 @@ namespace ai {
}
ActionState find_lowest(std::unordered_map<ActionState, int>& open_set) {
dbc::check(!open_set.empty(), "open set can't be empty in find_lowest");
check(!open_set.empty(), "open set can't be empty in find_lowest");
const ActionState *result = nullptr;
int lowest_score = SCORE_MAX;
@ -148,4 +124,134 @@ namespace ai {
return std::nullopt;
}
static AIManager AIMGR;
static bool initialized = false;
inline void validate_profile(nlohmann::json& profile) {
for(auto& [name_key, value] : profile.items()) {
check(value < STATE_MAX,
fmt::format("profile field {} has value {} greater than STATE_MAX {}", (std::string)name_key, (int)value, STATE_MAX));
}
}
Action config_action(nlohmann::json& profile, nlohmann::json& config) {
check(config.contains("name"), "config_action: action config missing name");
check(config.contains("cost"), "config_action: action config missing cost");
validate_profile(profile);
Action result(config["name"], config["cost"]);
check(config.contains("needs"),
fmt::format("config_action: no 'needs' field", result.$name));
check(config.contains("effects"),
fmt::format("config_action: no 'effects' field", result.$name));
for(auto& [name_key, value] : config["needs"].items()) {
check(profile.contains(name_key), fmt::format("config_action: profile does not have name {}", result.$name, name_key));
int name = profile[name_key].template get<int>();
result.needs(name, bool(value));
}
for(auto& [name_key, value] : config["effects"].items()) {
check(profile.contains(name_key), fmt::format("config_action: profile does not have name {}", result.$name, name_key));
int name = profile[name_key].template get<int>();
result.effect(name, bool(value));
}
return result;
}
State config_state(nlohmann::json& profile, nlohmann::json& config) {
State result;
validate_profile(profile);
for(auto& [name_key, value] : config.items()) {
check(profile.contains(name_key), fmt::format("config_state: profile does not have name {}", name_key));
int name = profile[name_key].template get<int>();
result[name] = bool(value);
}
return result;
}
void init() {
initialized = true;
Config config("assets/ai.json");
// profile specifies what keys (bitset indexes) are allowed
// and how they map to the bitset of State
AIMGR.profile = config["profile"];
validate_profile(AIMGR.profile);
// load all actions
auto& actions = config["actions"];
for(auto& action_vars : actions) {
auto the_action = config_action(AIMGR.profile, action_vars);
AIMGR.actions.insert_or_assign(the_action.$name, the_action);
}
// load all states
auto& states = config["states"];
for(auto& [name, state_vars] : states.items()) {
auto the_state = config_state(AIMGR.profile, state_vars);
AIMGR.states.insert_or_assign(name, the_state);
}
auto& scripts = config["scripts"];
for(auto& [script_name, action_names] : scripts.items()) {
std::vector<Action> the_script;
for(auto name : action_names) {
check(AIMGR.actions.contains(name),
fmt::format("ai::init(): script {} uses action {} that doesn't exist",
(std::string)script_name, (std::string)name));
the_script.push_back(AIMGR.actions.at(name));
}
AIMGR.scripts.insert_or_assign(script_name, the_script);
}
}
State load_state(std::string state_name) {
check(initialized, "you forgot to initialize the AI first.");
check(AIMGR.states.contains(state_name), fmt::format(
"ai::load_state({}): state does not exist in config",
state_name));
return AIMGR.states.at(state_name);
}
Action load_action(std::string action_name) {
check(initialized, "you forgot to initialize the AI first.");
check(AIMGR.states.contains(action_name), fmt::format(
"ai::load_action({}): action does not exist in config",
action_name));
return AIMGR.actions.at(action_name);
}
std::vector<Action> load_script(std::string script_name) {
check(AIMGR.scripts.contains(script_name), fmt::format(
"ai::load_script(): no script named {} configured", script_name));
return AIMGR.scripts.at(script_name);
}
std::optional<Script> plan(std::string script_name, State start, State goal) {
check(initialized, "you forgot to initialize the AI first.");
auto script = load_script(script_name);
return plan_actions(script, start, goal);
}
int state_id(std::string name) {
check(AIMGR.profile.contains(name), fmt::format(
"ai::state_id({}): id is not configured in profile",
name));
return AIMGR.profile.at(name);
}
}

@ -5,6 +5,7 @@
#include <limits>
#include <optional>
#include <nlohmann/json.hpp>
#include "config.hpp"
namespace ai {
constexpr const int SCORE_MAX = std::numeric_limits<int>::max();
@ -30,7 +31,6 @@ namespace ai {
void needs(int name, bool val);
void effect(int name, bool val);
void load(nlohmann::json &profile, nlohmann::json& config);
bool can_effect(State& state);
State apply_effect(State& state);
@ -61,6 +61,26 @@ namespace ai {
int distance_to_goal(State& from, State& to);
std::optional<Script> plan_actions(std::vector<Action>& actions, State& start, State& goal);
struct AIManager {
nlohmann::json profile;
std::unordered_map<std::string, Action> actions;
std::unordered_map<std::string, State> states;
std::unordered_map<std::string, std::vector<Action>> scripts;
};
void init();
Action config_action(nlohmann::json& profile, nlohmann::json& config);
State config_state(nlohmann::json& profile, nlohmann::json& config);
int state_id(std::string name);
State load_state(std::string state_name);
Action load_action(std::string action_name);
std::vector<Action> load_script(std::string script_name);
std::optional<Script> plan(std::string script_name, State start, State goal);
}
template<> struct std::hash<ai::Action> {

@ -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"]
}
}

@ -0,0 +1,220 @@
#include <catch2/catch_test_macros.hpp>
#include "dbc.hpp"
#include "ai.hpp"
#include <iostream>
using namespace dbc;
using namespace nlohmann;
TEST_CASE("worldstate works", "[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);
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 != std::nullopt);
auto state = start;
for(auto& action : *result) {
state = action.apply_effect(state);
}
REQUIRE(state[ENEMY_DEAD]);
}
TEST_CASE("wargame test from cppAI", "[ai]") {
std::vector<ai::Action> actions;
auto profile = R"({
"target_acquired": 0,
"target_lost": 1,
"target_in_warhead_range": 2,
"target_dead": 3
})"_json;
// Now establish all the possible actions for the action pool
// In this example we're providing the AI some different FPS actions
auto config = R"({
"name": "searchSpiral",
"cost": 5,
"needs": {
"target_acquired": false,
"target_lost": true
},
"effects": {
"target_acquired": true
}
})"_json;
auto spiral = ai::config_action(profile, config);
actions.push_back(spiral);
config = R"({
"name": "searchSerpentine",
"cost": 5,
"needs": {
"target_acquired": false,
"target_lost": false
},
"effects": {
"target_acquired": true
}
})"_json;
auto serpentine = ai::config_action(profile, config);
actions.push_back(serpentine);
config = R"({
"name": "interceptTarget",
"cost": 5,
"needs": {
"target_acquired": true,
"target_dead": false
},
"effects": {
"target_in_warhead_range": true
}
})"_json;
auto intercept = ai::config_action(profile, config);
actions.push_back(intercept);
config = R"({
"name": "detonateNearTarget",
"cost": 5,
"needs": {
"target_in_warhead_range": true,
"target_acquired": true,
"target_dead": false
},
"effects": {
"target_dead": true
}
})"_json;
auto detonateNearTarget = ai::config_action(profile, config);
actions.push_back(detonateNearTarget);
// Here's the initial state...
config = R"({
"target_acquired": false,
"target_lost": true,
"target_in_warhead_range": false,
"target_dead": false
})"_json;
auto initial_state = ai::config_state(profile, config);
// ...and the goal state
config = R"({
"target_dead": true
})"_json;
auto goal_target_dead = ai::config_state(profile, config);
auto result = ai::plan_actions(actions, initial_state, goal_target_dead);
REQUIRE(result != std::nullopt);
auto state = initial_state;
for(auto& action : *result) {
fmt::println("ACTION: {}", action.$name);
state = action.apply_effect(state);
}
REQUIRE(state[profile["target_dead"]]);
}
TEST_CASE("ai as a module like sound/sprites", "[ai]") {
ai::init();
auto start = ai::load_state("test_start");
auto goal = ai::load_state("test_goal");
auto script = ai::plan("test1", start, goal);
REQUIRE(script != std::nullopt);
auto state = start;
for(auto& action : *script) {
fmt::println("ACTION: {}", action.$name);
state = action.apply_effect(state);
}
REQUIRE(state[ai::state_id("target_dead")]);
}