Now have the ability to do partial solutions that will create potential paths to the goal, and a test that runs the scripts from plans in different scenarios. Also, this ai_debug thing needs some work.
This commit is contained in:
parent
3f83d3f0bb
commit
fc66d221d4
11 changed files with 252 additions and 107 deletions
2
Makefile
2
Makefile
|
@ -41,7 +41,7 @@ clean:
|
|||
meson compile --clean -C builddir
|
||||
|
||||
debug_test: build
|
||||
gdb --nx -x .gdbinit --ex run --args builddir/runtests.exe -e "[goap]"
|
||||
gdb --nx -x .gdbinit --ex run --args builddir/runtests.exe -e "[ai]"
|
||||
|
||||
win_installer:
|
||||
powershell 'start "C:\Program Files (x86)\solicus\InstallForge\bin\ifbuilderenvx86.exe" win_installer.ifp'
|
||||
|
|
54
ai.cpp
54
ai.cpp
|
@ -15,12 +15,10 @@ namespace ai {
|
|||
}
|
||||
}
|
||||
|
||||
Action config_action(nlohmann::json& profile, nlohmann::json& config) {
|
||||
Action config_action(AIProfile& 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"),
|
||||
|
@ -30,44 +28,52 @@ namespace ai {
|
|||
|
||||
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));
|
||||
result.needs(profile.at(name_key), 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));
|
||||
result.effect(profile.at(name_key), bool(value));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
State config_state(nlohmann::json& profile, nlohmann::json& config) {
|
||||
State config_state(AIProfile& 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);
|
||||
int name_id = profile.at(name_key);
|
||||
result[name_id] = bool(value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* This is only used in tests so I can load different fixtures.
|
||||
*/
|
||||
void reset() {
|
||||
initialized = false;
|
||||
AIMGR.actions.clear();
|
||||
AIMGR.states.clear();
|
||||
AIMGR.scripts.clear();
|
||||
AIMGR.profile = R"({})"_json;
|
||||
}
|
||||
|
||||
void init(std::string config_path) {
|
||||
initialized = true;
|
||||
if(!initialized) {
|
||||
Config config(config_path);
|
||||
|
||||
// profile specifies what keys (bitset indexes) are allowed
|
||||
// and how they map to the bitset of State
|
||||
validate_profile(config["profile"]);
|
||||
|
||||
// relies on json conversion?
|
||||
AIMGR.profile = config["profile"];
|
||||
validate_profile(AIMGR.profile);
|
||||
|
||||
// load all actions
|
||||
auto& actions = config["actions"];
|
||||
|
@ -97,6 +103,10 @@ namespace ai {
|
|||
|
||||
AIMGR.scripts.insert_or_assign(script_name, the_script);
|
||||
}
|
||||
initialized = true;
|
||||
} else {
|
||||
dbc::sentinel("DOUBLE INIT: AI manager should only be intialized once if not in tests.");
|
||||
}
|
||||
}
|
||||
|
||||
State load_state(std::string state_name) {
|
||||
|
@ -122,7 +132,7 @@ namespace ai {
|
|||
return AIMGR.scripts.at(script_name);
|
||||
}
|
||||
|
||||
std::optional<Script> plan(std::string script_name, State start, State goal) {
|
||||
ActionPlan 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);
|
||||
|
@ -134,4 +144,16 @@ namespace ai {
|
|||
name));
|
||||
return AIMGR.profile.at(name);
|
||||
}
|
||||
|
||||
void set(State& state, std::string name, bool value) {
|
||||
state.set(state_id(name), value);
|
||||
}
|
||||
|
||||
bool test(State state, std::string name) {
|
||||
return state.test(state_id(name));
|
||||
}
|
||||
|
||||
AIProfile* profile() {
|
||||
return &AIMGR.profile;
|
||||
}
|
||||
}
|
||||
|
|
11
ai.hpp
11
ai.hpp
|
@ -10,13 +10,15 @@
|
|||
|
||||
namespace ai {
|
||||
struct AIManager {
|
||||
nlohmann::json profile;
|
||||
AIProfile profile;
|
||||
|
||||
std::unordered_map<std::string, Action> actions;
|
||||
std::unordered_map<std::string, State> states;
|
||||
std::unordered_map<std::string, std::vector<Action>> scripts;
|
||||
};
|
||||
|
||||
/* This is really only used in test to load different fixtures. */
|
||||
void reset();
|
||||
void init(std::string config_path);
|
||||
|
||||
Action config_action(nlohmann::json& profile, nlohmann::json& config);
|
||||
|
@ -27,5 +29,10 @@ namespace ai {
|
|||
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);
|
||||
void set(State& state, std::string name, bool value=true);
|
||||
bool test(State state, std::string name);
|
||||
ActionPlan plan(std::string script_name, State start, State goal);
|
||||
|
||||
/* Mostly used for debugging and validation. */
|
||||
AIProfile* profile();
|
||||
}
|
||||
|
|
56
ai_debug.cpp
Normal file
56
ai_debug.cpp
Normal file
|
@ -0,0 +1,56 @@
|
|||
#include "ai_debug.hpp"
|
||||
|
||||
namespace ai {
|
||||
|
||||
/*
|
||||
* Yeah this is weird but it's only to debug things like
|
||||
* the preconditions which are weirdly done.
|
||||
*/
|
||||
void dump_only(AIProfile& profile, State state, bool matching, bool show_as) {
|
||||
for(auto& [name, name_id] : profile) {
|
||||
if(state.test(name_id) == matching) {
|
||||
fmt::println("\t{}={}", name, show_as);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dump_state(AIProfile& profile, State state) {
|
||||
for(auto& [name, name_id] : profile) {
|
||||
fmt::println("\t{}={}", name,
|
||||
state.test(name_id));
|
||||
}
|
||||
}
|
||||
|
||||
void dump_action(AIProfile& profile, Action& action) {
|
||||
fmt::println(" --ACTION: {}, cost={}", action.$name, action.$cost);
|
||||
|
||||
fmt::println(" PRECONDS:");
|
||||
dump_only(profile, action.$positive_preconds, true, true);
|
||||
dump_only(profile, action.$negative_preconds, true, false);
|
||||
|
||||
fmt::println(" EFFECTS:");
|
||||
dump_only(profile, action.$positive_effects, true, true);
|
||||
dump_only(profile, action.$negative_effects, true, false);
|
||||
}
|
||||
|
||||
State dump_script(AIProfile& profile, std::string msg, State start, Script& script) {
|
||||
fmt::println("--SCRIPT DUMP: {}", msg);
|
||||
fmt::println("# STATE BEFORE:");
|
||||
dump_state(profile, start);
|
||||
fmt::print("% ACTIONS PLANNED:");
|
||||
for(auto& action : script) {
|
||||
fmt::print("{} ", action.$name);
|
||||
}
|
||||
fmt::print("\n");
|
||||
|
||||
for(auto& action : script) {
|
||||
dump_action(profile, action);
|
||||
|
||||
start = action.apply_effect(start);
|
||||
fmt::println(" ## STATE AFTER:");
|
||||
dump_state(profile, start);
|
||||
}
|
||||
|
||||
return start;
|
||||
}
|
||||
}
|
9
ai_debug.hpp
Normal file
9
ai_debug.hpp
Normal file
|
@ -0,0 +1,9 @@
|
|||
#pragma once
|
||||
#include "goap.hpp"
|
||||
|
||||
namespace ai {
|
||||
void dump_only(AIProfile& profile, State state, bool matching, bool show_as);
|
||||
void dump_state(AIProfile& profile, State state);
|
||||
void dump_action(AIProfile& profile, Action& action);
|
||||
State dump_script(AIProfile& profile, std::string msg, State start, Script& script);
|
||||
}
|
|
@ -1,85 +1,81 @@
|
|||
{
|
||||
"profile": {
|
||||
"target_acquired": 0,
|
||||
"target_lost": 1,
|
||||
"target_in_warhead_range": 2,
|
||||
"target_dead": 3
|
||||
"enemy_found": 0,
|
||||
"enemy_dead": 1,
|
||||
"health_good": 2,
|
||||
"no_more_items": 3,
|
||||
"no_more_enemies": 4
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "searchSpiral",
|
||||
"cost": 10,
|
||||
"name": "find_enemy",
|
||||
"cost": 5,
|
||||
"needs": {
|
||||
"target_acquired": false,
|
||||
"target_lost": true
|
||||
"no_more_enemies": false,
|
||||
"health_good": true,
|
||||
"enemy_found": false
|
||||
},
|
||||
"effects": {
|
||||
"target_acquired": true
|
||||
"enemy_found": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "searchSerpentine",
|
||||
"name": "kill_enemy",
|
||||
"cost": 5,
|
||||
"needs": {
|
||||
"target_acquired": false,
|
||||
"target_lost": false
|
||||
"no_more_enemies": false,
|
||||
"enemy_found": true,
|
||||
"health_good": true,
|
||||
"enemy_dead": false
|
||||
},
|
||||
"effects": {
|
||||
"target_acquired": true
|
||||
"enemy_dead": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "searchSpiral",
|
||||
"name": "collect_items",
|
||||
"cost": 5,
|
||||
"needs": {
|
||||
"target_acquired": false,
|
||||
"target_lost": true
|
||||
"no_more_enemies": true,
|
||||
"no_more_items": false
|
||||
},
|
||||
"effects": {
|
||||
"target_acquired": true
|
||||
"no_more_items": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "interceptTarget",
|
||||
"name": "find_healing",
|
||||
"cost": 5,
|
||||
"needs": {
|
||||
"target_acquired": true,
|
||||
"target_dead": false
|
||||
"health_good": false,
|
||||
"no_more_items": 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
|
||||
"health_good": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"states": {
|
||||
"test_start": {
|
||||
"target_acquired": false,
|
||||
"target_lost": true,
|
||||
"target_in_warhead_range": false,
|
||||
"target_dead": false
|
||||
"Walker::initial_state": {
|
||||
"enemy_found": false,
|
||||
"enemy_dead": false,
|
||||
"health_good": true,
|
||||
"no_more_items": false,
|
||||
"no_more_enemies": false
|
||||
},
|
||||
"test_goal": {
|
||||
"target_dead": true
|
||||
"Walker::final_state": {
|
||||
"enemy_found": true,
|
||||
"enemy_dead": true,
|
||||
"health_good": true,
|
||||
"no_more_items": true,
|
||||
"no_more_enemies": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test1": [
|
||||
"searchSpiral",
|
||||
"searchSerpentine",
|
||||
"searchSpiral",
|
||||
"interceptTarget",
|
||||
"detonateNearTarget"]
|
||||
"Walker::actions":
|
||||
["find_enemy",
|
||||
"kill_enemy",
|
||||
"find_healing",
|
||||
"collect_items"]
|
||||
}
|
||||
}
|
||||
|
|
15
goap.cpp
15
goap.cpp
|
@ -1,5 +1,6 @@
|
|||
#include "dbc.hpp"
|
||||
#include "goap.hpp"
|
||||
#include "ai_debug.hpp"
|
||||
|
||||
namespace ai {
|
||||
using namespace nlohmann;
|
||||
|
@ -82,21 +83,21 @@ namespace ai {
|
|||
return *result;
|
||||
}
|
||||
|
||||
std::optional<Script> plan_actions(std::vector<Action>& actions, State& start, State& goal) {
|
||||
ActionPlan 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};
|
||||
ActionState current{FINAL_ACTION, start};
|
||||
|
||||
g_score[start] = 0;
|
||||
open_set[start_state] = g_score[start] + h(start, goal);
|
||||
open_set[current] = g_score[start] + h(start, goal);
|
||||
|
||||
while(!open_set.empty()) {
|
||||
auto current = find_lowest(open_set);
|
||||
current = find_lowest(open_set);
|
||||
|
||||
if(is_subset(current.state, goal)) {
|
||||
return std::make_optional<Script>(reconstruct_path(came_from, current.action));
|
||||
return {true,
|
||||
reconstruct_path(came_from, current.action)};
|
||||
}
|
||||
|
||||
open_set.erase(current);
|
||||
|
@ -122,6 +123,6 @@ namespace ai {
|
|||
}
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
return {false, reconstruct_path(came_from, current.action)};
|
||||
}
|
||||
}
|
||||
|
|
10
goap.hpp
10
goap.hpp
|
@ -8,6 +8,9 @@
|
|||
#include "config.hpp"
|
||||
|
||||
namespace ai {
|
||||
// ZED: I don't know if this is the best place for this
|
||||
using AIProfile = std::unordered_map<std::string, int>;
|
||||
|
||||
constexpr const int SCORE_MAX = std::numeric_limits<int>::max();
|
||||
constexpr const size_t STATE_MAX = 32;
|
||||
|
||||
|
@ -56,11 +59,16 @@ namespace ai {
|
|||
}
|
||||
};
|
||||
|
||||
struct ActionPlan {
|
||||
bool complete = false;
|
||||
Script script;
|
||||
};
|
||||
|
||||
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);
|
||||
ActionPlan plan_actions(std::vector<Action>& actions, State start, State goal);
|
||||
}
|
||||
|
||||
template<> struct std::hash<ai::Action> {
|
||||
|
|
2
main.cpp
2
main.cpp
|
@ -2,12 +2,14 @@
|
|||
#include "textures.hpp"
|
||||
#include "sound.hpp"
|
||||
#include "autowalker.hpp"
|
||||
#include "ai.hpp"
|
||||
#include <iostream>
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
try {
|
||||
textures::init();
|
||||
sound::init();
|
||||
ai::init("assets/ai.json");
|
||||
sound::mute(true);
|
||||
gui::FSM main;
|
||||
main.event(gui::Event::STARTED);
|
||||
|
|
|
@ -82,6 +82,7 @@ dependencies += [
|
|||
|
||||
sources = [
|
||||
'ai.cpp',
|
||||
'ai_debug.cpp',
|
||||
'ansi_parser.cpp',
|
||||
'autowalker.cpp',
|
||||
'boss_fight_ui.cpp',
|
||||
|
|
55
tests/ai.cpp
55
tests/ai.cpp
|
@ -2,6 +2,7 @@
|
|||
#include "dbc.hpp"
|
||||
#include "ai.hpp"
|
||||
#include <iostream>
|
||||
#include "ai_debug.hpp"
|
||||
|
||||
using namespace dbc;
|
||||
using namespace nlohmann;
|
||||
|
@ -92,11 +93,11 @@ TEST_CASE("basic feature tests", "[ai]") {
|
|||
actions.push_back(move_closer);
|
||||
|
||||
auto result = ai::plan_actions(actions, start, goal);
|
||||
REQUIRE(result != std::nullopt);
|
||||
REQUIRE(result.complete);
|
||||
|
||||
auto state = start;
|
||||
|
||||
for(auto& action : *result) {
|
||||
for(auto& action : result.script) {
|
||||
state = action.apply_effect(state);
|
||||
}
|
||||
|
||||
|
@ -105,19 +106,61 @@ TEST_CASE("basic feature tests", "[ai]") {
|
|||
|
||||
|
||||
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 script = ai::plan("test1", start, goal);
|
||||
REQUIRE(script != std::nullopt);
|
||||
auto a_plan = ai::plan("test1", start, goal);
|
||||
REQUIRE(a_plan.complete);
|
||||
|
||||
auto state = start;
|
||||
for(auto& action : *script) {
|
||||
for(auto& action : a_plan.script) {
|
||||
fmt::println("ACTION: {}", action.$name);
|
||||
state = action.apply_effect(state);
|
||||
}
|
||||
|
||||
REQUIRE(state[ai::state_id("target_dead")]);
|
||||
REQUIRE(ai::test(state, "target_dead"));
|
||||
}
|
||||
|
||||
TEST_CASE("ai autowalker ai test", "[ai]") {
|
||||
ai::reset();
|
||||
ai::init("assets/ai.json");
|
||||
ai::AIProfile* profile = ai::profile();
|
||||
auto start = ai::load_state("Walker::initial_state");
|
||||
auto goal = ai::load_state("Walker::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("Walker::actions", start, goal);
|
||||
REQUIRE(!a_plan.complete);
|
||||
|
||||
auto result = ai::dump_script(*profile, "\n\nWALKER KILL STUFF", start, a_plan.script);
|
||||
REQUIRE(ai::test(result, "enemy_found"));
|
||||
REQUIRE(ai::test(result, "enemy_dead"));
|
||||
REQUIRE(!ai::test(result, "no_more_enemies"));
|
||||
|
||||
// health is low, go heal
|
||||
ai::set(result, "health_good", false);
|
||||
REQUIRE(!ai::test(result, "health_good"));
|
||||
|
||||
auto health_plan = ai::plan("Walker::actions", result, goal);
|
||||
result = ai::dump_script(*profile, "\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("Walker::actions", result, goal);
|
||||
result = ai::dump_script(*profile, "\n\nWALKER COMPLETE", result, new_plan.script);
|
||||
REQUIRE(new_plan.complete);
|
||||
|
||||
REQUIRE(ai::test(result, "enemy_found"));
|
||||
REQUIRE(ai::test(result, "enemy_dead"));
|
||||
REQUIRE(ai::test(result, "no_more_enemies"));
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue