A bit more cleanup to avoid duplicate testing and to separate the GOAP algorithm code from the little AI Manager thing.

master
Zed A. Shaw 11 months ago
parent b2c1b220ac
commit 3f83d3f0bb
  1. 1
      .gitignore
  2. 124
      ai.cpp
  3. 69
      ai.hpp
  4. 127
      goap.cpp
  5. 76
      goap.hpp
  6. 5
      meson.build
  7. 99
      tests/ai.cpp
  8. 85
      tests/ai_fixture.json

1
.gitignore vendored

@ -27,3 +27,4 @@ backup
*.dll *.dll
*.world *.world
coverage coverage
.venv

124
ai.cpp

@ -5,126 +5,6 @@ namespace ai {
using namespace nlohmann; using namespace nlohmann;
using namespace dbc; using namespace dbc;
bool is_subset(State& source, State& target) {
State result = source & target;
return result == target;
}
void Action::needs(int name, bool val) {
if(val) {
$positive_preconds[name] = true;
$negative_preconds[name] = false;
} else {
$negative_preconds[name] = true;
$positive_preconds[name] = false;
}
}
void Action::effect(int name, bool val) {
if(val) {
$positive_effects[name] = true;
$negative_effects[name] = false;
} else {
$negative_effects[name] = true;
$positive_effects[name] = false;
}
}
bool Action::can_effect(State& state) {
return ((state & $positive_preconds) == $positive_preconds) &&
((state & $negative_preconds) == ALL_ZERO);
}
State Action::apply_effect(State& state) {
return (state | $positive_effects) & ~$negative_effects;
}
int distance_to_goal(State& from, State& to) {
auto result = from ^ to;
return result.count();
}
Script reconstruct_path(std::unordered_map<Action, Action>& came_from, Action& current) {
Script total_path{current};
int count = 0;
while(came_from.contains(current) && count++ < 10) {
current = came_from.at(current);
if(current != FINAL_ACTION) {
total_path.push_front(current);
}
}
return total_path;
}
inline int h(State& start, State& goal) {
return distance_to_goal(start, goal);
}
inline int d(State& start, State& goal) {
return distance_to_goal(start, goal);
}
ActionState find_lowest(std::unordered_map<ActionState, int>& open_set) {
check(!open_set.empty(), "open set can't be empty in find_lowest");
const ActionState *result = nullptr;
int lowest_score = SCORE_MAX;
for(auto& kv : open_set) {
if(kv.second < lowest_score) {
lowest_score = kv.second;
result = &kv.first;
}
}
return *result;
}
std::optional<Script> plan_actions(std::vector<Action>& actions, State& start, State& goal) {
std::unordered_map<ActionState, int> open_set;
std::unordered_map<Action, Action> came_from;
std::unordered_map<State, int> g_score;
ActionState start_state{FINAL_ACTION, start};
g_score[start] = 0;
open_set[start_state] = g_score[start] + h(start, goal);
while(!open_set.empty()) {
auto current = find_lowest(open_set);
if(is_subset(current.state, goal)) {
return std::make_optional<Script>(reconstruct_path(came_from, current.action));
}
open_set.erase(current);
for(auto& neighbor_action : actions) {
// calculate the State being current/neighbor
if(!neighbor_action.can_effect(current.state)) {
continue;
}
auto neighbor = neighbor_action.apply_effect(current.state);
int d_score = d(current.state, neighbor);
int tentative_g_score = g_score[current.state] + d_score;
int neighbor_g_score = g_score.contains(neighbor) ? g_score[neighbor] : SCORE_MAX;
if(tentative_g_score < neighbor_g_score) {
came_from.insert_or_assign(neighbor_action, current.action);
g_score[neighbor] = tentative_g_score;
// open_set gets the fScore
ActionState neighbor_as{neighbor_action, neighbor};
open_set[neighbor_as] = tentative_g_score + h(neighbor, goal);
}
}
}
return std::nullopt;
}
static AIManager AIMGR; static AIManager AIMGR;
static bool initialized = false; static bool initialized = false;
@ -180,9 +60,9 @@ namespace ai {
return result; return result;
} }
void init() { void init(std::string config_path) {
initialized = true; initialized = true;
Config config("assets/ai.json"); Config config(config_path);
// profile specifies what keys (bitset indexes) are allowed // profile specifies what keys (bitset indexes) are allowed
// and how they map to the bitset of State // and how they map to the bitset of State

@ -6,62 +6,9 @@
#include <optional> #include <optional>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "config.hpp" #include "config.hpp"
#include "goap.hpp"
namespace ai { namespace ai {
constexpr const int SCORE_MAX = std::numeric_limits<int>::max();
constexpr const size_t STATE_MAX = 32;
using State = std::bitset<STATE_MAX>;
const State ALL_ZERO;
const State ALL_ONES = ~ALL_ZERO;
struct Action {
std::string $name;
int $cost = 0;
State $positive_preconds;
State $negative_preconds;
State $positive_effects;
State $negative_effects;
Action(std::string name, int cost) :
$name(name), $cost(cost) { }
void needs(int name, bool val);
void effect(int name, bool val);
bool can_effect(State& state);
State apply_effect(State& state);
bool operator==(const Action& other) const {
return other.$name == $name;
}
};
using Script = std::deque<Action>;
const Action FINAL_ACTION("END", SCORE_MAX);
struct ActionState {
Action action;
State state;
ActionState(Action action, State state) :
action(action), state(state) {}
bool operator==(const ActionState& other) const {
return other.action == action && other.state == state;
}
};
bool is_subset(State& source, State& target);
int distance_to_goal(State& from, State& to);
std::optional<Script> plan_actions(std::vector<Action>& actions, State& start, State& goal);
struct AIManager { struct AIManager {
nlohmann::json profile; nlohmann::json profile;
@ -70,7 +17,7 @@ namespace ai {
std::unordered_map<std::string, std::vector<Action>> scripts; std::unordered_map<std::string, std::vector<Action>> scripts;
}; };
void init(); void init(std::string config_path);
Action config_action(nlohmann::json& profile, nlohmann::json& config); Action config_action(nlohmann::json& profile, nlohmann::json& config);
State config_state(nlohmann::json& profile, nlohmann::json& config); State config_state(nlohmann::json& profile, nlohmann::json& config);
@ -82,15 +29,3 @@ namespace ai {
std::optional<Script> plan(std::string script_name, State start, State goal); std::optional<Script> plan(std::string script_name, State start, State goal);
} }
template<> struct std::hash<ai::Action> {
size_t operator()(const ai::Action& p) const {
return std::hash<std::string>{}(p.$name);
}
};
template<> struct std::hash<ai::ActionState> {
size_t operator()(const ai::ActionState& p) const {
return std::hash<ai::Action>{}(p.action) ^ std::hash<ai::State>{}(p.state);
}
};

@ -0,0 +1,127 @@
#include "dbc.hpp"
#include "goap.hpp"
namespace ai {
using namespace nlohmann;
using namespace dbc;
bool is_subset(State& source, State& target) {
State result = source & target;
return result == target;
}
void Action::needs(int name, bool val) {
if(val) {
$positive_preconds[name] = true;
$negative_preconds[name] = false;
} else {
$negative_preconds[name] = true;
$positive_preconds[name] = false;
}
}
void Action::effect(int name, bool val) {
if(val) {
$positive_effects[name] = true;
$negative_effects[name] = false;
} else {
$negative_effects[name] = true;
$positive_effects[name] = false;
}
}
bool Action::can_effect(State& state) {
return ((state & $positive_preconds) == $positive_preconds) &&
((state & $negative_preconds) == ALL_ZERO);
}
State Action::apply_effect(State& state) {
return (state | $positive_effects) & ~$negative_effects;
}
int distance_to_goal(State& from, State& to) {
auto result = from ^ to;
return result.count();
}
Script reconstruct_path(std::unordered_map<Action, Action>& came_from, Action& current) {
Script total_path{current};
int count = 0;
while(came_from.contains(current) && count++ < 10) {
current = came_from.at(current);
if(current != FINAL_ACTION) {
total_path.push_front(current);
}
}
return total_path;
}
inline int h(State& start, State& goal) {
return distance_to_goal(start, goal);
}
inline int d(State& start, State& goal) {
return distance_to_goal(start, goal);
}
ActionState find_lowest(std::unordered_map<ActionState, int>& open_set) {
check(!open_set.empty(), "open set can't be empty in find_lowest");
const ActionState *result = nullptr;
int lowest_score = SCORE_MAX;
for(auto& kv : open_set) {
if(kv.second < lowest_score) {
lowest_score = kv.second;
result = &kv.first;
}
}
return *result;
}
std::optional<Script> plan_actions(std::vector<Action>& actions, State& start, State& goal) {
std::unordered_map<ActionState, int> open_set;
std::unordered_map<Action, Action> came_from;
std::unordered_map<State, int> g_score;
ActionState start_state{FINAL_ACTION, start};
g_score[start] = 0;
open_set[start_state] = g_score[start] + h(start, goal);
while(!open_set.empty()) {
auto current = find_lowest(open_set);
if(is_subset(current.state, goal)) {
return std::make_optional<Script>(reconstruct_path(came_from, current.action));
}
open_set.erase(current);
for(auto& neighbor_action : actions) {
// calculate the State being current/neighbor
if(!neighbor_action.can_effect(current.state)) {
continue;
}
auto neighbor = neighbor_action.apply_effect(current.state);
int d_score = d(current.state, neighbor);
int tentative_g_score = g_score[current.state] + d_score;
int neighbor_g_score = g_score.contains(neighbor) ? g_score[neighbor] : SCORE_MAX;
if(tentative_g_score < neighbor_g_score) {
came_from.insert_or_assign(neighbor_action, current.action);
g_score[neighbor] = tentative_g_score;
// open_set gets the fScore
ActionState neighbor_as{neighbor_action, neighbor};
open_set[neighbor_as] = tentative_g_score + h(neighbor, goal);
}
}
}
return std::nullopt;
}
}

@ -0,0 +1,76 @@
#pragma once
#include <vector>
#include "matrix.hpp"
#include <bitset>
#include <limits>
#include <optional>
#include <nlohmann/json.hpp>
#include "config.hpp"
namespace ai {
constexpr const int SCORE_MAX = std::numeric_limits<int>::max();
constexpr const size_t STATE_MAX = 32;
using State = std::bitset<STATE_MAX>;
const State ALL_ZERO;
const State ALL_ONES = ~ALL_ZERO;
struct Action {
std::string $name;
int $cost = 0;
State $positive_preconds;
State $negative_preconds;
State $positive_effects;
State $negative_effects;
Action(std::string name, int cost) :
$name(name), $cost(cost) { }
void needs(int name, bool val);
void effect(int name, bool val);
bool can_effect(State& state);
State apply_effect(State& state);
bool operator==(const Action& other) const {
return other.$name == $name;
}
};
using Script = std::deque<Action>;
const Action FINAL_ACTION("END", SCORE_MAX);
struct ActionState {
Action action;
State state;
ActionState(Action action, State state) :
action(action), state(state) {}
bool operator==(const ActionState& other) const {
return other.action == action && other.state == state;
}
};
bool is_subset(State& source, State& target);
int distance_to_goal(State& from, State& to);
std::optional<Script> plan_actions(std::vector<Action>& actions, State& start, State& goal);
}
template<> struct std::hash<ai::Action> {
size_t operator()(const ai::Action& p) const {
return std::hash<std::string>{}(p.$name);
}
};
template<> struct std::hash<ai::ActionState> {
size_t operator()(const ai::ActionState& p) const {
return std::hash<ai::Action>{}(p.action) ^ std::hash<ai::State>{}(p.state);
}
};

@ -80,9 +80,8 @@ dependencies += [
sfml_window, ftxui_screen, ftxui_dom, ftxui_component sfml_window, ftxui_screen, ftxui_dom, ftxui_component
] ]
sources = [ sources = [
'ai.cpp',
'ansi_parser.cpp', 'ansi_parser.cpp',
'autowalker.cpp', 'autowalker.cpp',
'boss_fight_ui.cpp', 'boss_fight_ui.cpp',
@ -93,7 +92,7 @@ sources = [
'config.cpp', 'config.cpp',
'dbc.cpp', 'dbc.cpp',
'devices.cpp', 'devices.cpp',
'ai.cpp', 'goap.cpp',
'guecs.cpp', 'guecs.cpp',
'gui_fsm.cpp', 'gui_fsm.cpp',
'inventory.cpp', 'inventory.cpp',

@ -103,105 +103,9 @@ TEST_CASE("basic feature tests", "[ai]") {
REQUIRE(state[ENEMY_DEAD]); 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]") { TEST_CASE("ai as a module like sound/sprites", "[ai]") {
ai::init(); ai::init("tests/ai_fixture.json");
auto start = ai::load_state("test_start"); auto start = ai::load_state("test_start");
auto goal = ai::load_state("test_goal"); auto goal = ai::load_state("test_goal");
@ -216,5 +120,4 @@ TEST_CASE("ai as a module like sound/sprites", "[ai]") {
} }
REQUIRE(state[ai::state_id("target_dead")]); REQUIRE(state[ai::state_id("target_dead")]);
} }

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