Lua action APIs

This commit is contained in:
2026-04-30 10:03:56 +03:00
parent 998984f75a
commit 0ed83966da
31 changed files with 2417 additions and 209 deletions

View File

@@ -151,6 +151,7 @@ set(EDITSCENE_SOURCES
lua/LuaEntityApi.cpp
lua/LuaComponentApi.cpp
lua/LuaEventApi.cpp
lua/LuaActionApi.cpp
)
set(EDITSCENE_HEADERS
@@ -299,6 +300,7 @@ set(EDITSCENE_HEADERS
lua/LuaEntityApi.hpp
lua/LuaComponentApi.hpp
lua/LuaEventApi.hpp
lua/LuaActionApi.hpp
)
add_executable(editSceneEditor ${EDITSCENE_SOURCES} ${EDITSCENE_HEADERS})
@@ -334,6 +336,31 @@ target_include_directories(editSceneEditor PRIVATE
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# ---------------------------------------------------------------------------
# Test: ActionDatabase Lua API
# ---------------------------------------------------------------------------
# Standalone test that verifies the ActionDatabase singleton and Lua API
# work correctly. Does not require OGRE or Flecs - only Lua and the
# core component types.
add_executable(action_db_lua_test
tests/action_db_lua_test.cpp
components/ActionDatabase.cpp
components/GoapBlackboard.cpp
components/GoapGoal.cpp
components/GoapExpression.cpp
lua/LuaActionApi.cpp
)
target_link_libraries(action_db_lua_test
lua
)
target_include_directories(action_db_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Copy local resources (materials, etc.)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/resources")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/resources"

View File

@@ -87,6 +87,7 @@
#include "lua/LuaEntityApi.hpp"
#include "lua/LuaComponentApi.hpp"
#include "lua/LuaEventApi.hpp"
#include "lua/LuaActionApi.hpp"
//=============================================================================
// ImGuiRenderListener Implementation
@@ -504,6 +505,7 @@ void EditorApp::setup()
editScene::registerLuaEntityApi(L);
editScene::registerLuaComponentApi(L);
editScene::registerLuaEventApi(L);
editScene::registerLuaActionApi(L);
// Run late setup: load data.lua and initial scripts.
m_lua.lateSetup();
@@ -669,7 +671,7 @@ void EditorApp::setupECS()
m_world.component<SkyboxComponent>();
// Register AI/GOAP components
m_world.component<ActionDatabase>();
// ActionDatabase is now a singleton, registered in ActionDatabaseModule
m_world.component<ActionDebug>();
m_world.component<BehaviorTreeComponent>();
m_world.component<GoapBlackboard>();

View File

@@ -1,5 +1,24 @@
#include "ActionDatabase.hpp"
// ---------------------------------------------------------------------------
// Singleton
// ---------------------------------------------------------------------------
ActionDatabase &ActionDatabase::getSingleton()
{
static ActionDatabase instance;
return instance;
}
ActionDatabase *ActionDatabase::getSingletonPtr()
{
return &getSingleton();
}
// ---------------------------------------------------------------------------
// Find methods
// ---------------------------------------------------------------------------
const GoapAction *ActionDatabase::findAction(const Ogre::String &name) const
{
for (const auto &action : actions) {
@@ -36,6 +55,36 @@ GoapGoal *ActionDatabase::findGoal(const Ogre::String &name)
return nullptr;
}
// ---------------------------------------------------------------------------
// Add or replace
// ---------------------------------------------------------------------------
void ActionDatabase::addOrReplaceAction(const GoapAction &action)
{
for (auto &a : actions) {
if (a.name == action.name) {
a = action;
return;
}
}
actions.push_back(action);
}
void ActionDatabase::addOrReplaceGoal(const GoapGoal &goal)
{
for (auto &g : goals) {
if (g.name == goal.name) {
g = goal;
return;
}
}
goals.push_back(goal);
}
// ---------------------------------------------------------------------------
// Remove methods
// ---------------------------------------------------------------------------
bool ActionDatabase::removeAction(const Ogre::String &name)
{
for (auto it = actions.begin(); it != actions.end(); ++it) {
@@ -58,8 +107,12 @@ bool ActionDatabase::removeGoal(const Ogre::String &name)
return false;
}
const GoapGoal *ActionDatabase::selectBestGoal(
const GoapBlackboard &blackboard) const
// ---------------------------------------------------------------------------
// Selection / validation
// ---------------------------------------------------------------------------
const GoapGoal *
ActionDatabase::selectBestGoal(const GoapBlackboard &blackboard) const
{
const GoapGoal *best = nullptr;
int bestPriority = -1;
@@ -75,8 +128,8 @@ const GoapGoal *ActionDatabase::selectBestGoal(
return best;
}
std::vector<const GoapAction *> ActionDatabase::getValidActions(
const GoapBlackboard &blackboard) const
std::vector<const GoapAction *>
ActionDatabase::getValidActions(const GoapBlackboard &blackboard) const
{
std::vector<const GoapAction *> result;
for (const auto &action : actions) {
@@ -85,3 +138,27 @@ std::vector<const GoapAction *> ActionDatabase::getValidActions(
}
return result;
}
// ---------------------------------------------------------------------------
// Clear
// ---------------------------------------------------------------------------
void ActionDatabase::clear()
{
actions.clear();
goals.clear();
}
// ---------------------------------------------------------------------------
// ActionDatabaseComponent
// ---------------------------------------------------------------------------
void ActionDatabaseComponent::syncToSingleton() const
{
auto &db = ActionDatabase::getSingleton();
db.clear();
for (const auto &action : actions)
db.addOrReplaceAction(action);
for (const auto &goal : goals)
db.addOrReplaceGoal(goal);
}

View File

@@ -5,14 +5,22 @@
#include "GoapAction.hpp"
#include "GoapGoal.hpp"
#include <vector>
#include <unordered_map>
/**
* Global action database component.
* Global action database singleton.
*
* Holds the master list of GOAP actions and goals that characters can use.
* Typically attached to a single "game manager" entity.
* This is a singleton accessible from anywhere in the codebase.
* The ActionDatabaseComponent on a scene entity stores the actions/goals
* and syncs them to the singleton on scene load.
*/
struct ActionDatabase {
class ActionDatabase {
public:
/** Get the singleton instance */
static ActionDatabase &getSingleton();
static ActionDatabase *getSingletonPtr();
std::vector<GoapAction> actions;
std::vector<GoapGoal> goals;
@@ -24,6 +32,12 @@ struct ActionDatabase {
const GoapGoal *findGoal(const Ogre::String &name) const;
GoapGoal *findGoal(const Ogre::String &name);
// Add or replace an action by name
void addOrReplaceAction(const GoapAction &action);
// Add or replace a goal by name
void addOrReplaceGoal(const GoapGoal &goal);
// Remove an action by name
bool removeAction(const Ogre::String &name);
@@ -35,8 +49,29 @@ struct ActionDatabase {
const GoapGoal *selectBestGoal(const GoapBlackboard &blackboard) const;
// Build a list of actions that can run from a given blackboard state
std::vector<const GoapAction *> getValidActions(
const GoapBlackboard &blackboard) const;
std::vector<const GoapAction *>
getValidActions(const GoapBlackboard &blackboard) const;
// Clear all actions and goals
void clear();
private:
ActionDatabase() = default;
~ActionDatabase() = default;
ActionDatabase(const ActionDatabase &) = delete;
ActionDatabase &operator=(const ActionDatabase &) = delete;
};
/**
* Flecs component that stores action database data on a scene entity.
* When set on an entity, it syncs its contents to the ActionDatabase singleton.
*/
struct ActionDatabaseComponent {
std::vector<GoapAction> actions;
std::vector<GoapGoal> goals;
/** Sync this component's data to the ActionDatabase singleton */
void syncToSingleton() const;
};
#endif // EDITSCENE_ACTION_DATABASE_HPP

View File

@@ -8,21 +8,21 @@
#include "../ui/ActionDatabaseEditor.hpp"
#include "../ui/BehaviorTreeEditor.hpp"
REGISTER_COMPONENT_GROUP("Action Database", "AI", ActionDatabase,
REGISTER_COMPONENT_GROUP("Action Database", "AI", ActionDatabaseComponent,
ActionDatabaseEditor)
{
registry.registerComponent<ActionDatabase>(
registry.registerComponent<ActionDatabaseComponent>(
"Action Database", "AI",
std::make_unique<ActionDatabaseEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<ActionDatabase>())
e.set<ActionDatabase>({});
if (!e.has<ActionDatabaseComponent>())
e.set<ActionDatabaseComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<ActionDatabase>())
e.remove<ActionDatabase>();
if (e.has<ActionDatabaseComponent>())
e.remove<ActionDatabaseComponent>();
});
}
@@ -30,8 +30,7 @@ REGISTER_COMPONENT_GROUP("Behavior Tree", "AI", BehaviorTreeComponent,
BehaviorTreeEditor)
{
registry.registerComponent<BehaviorTreeComponent>(
"Behavior Tree", "AI",
std::make_unique<BehaviorTreeEditor>(),
"Behavior Tree", "AI", std::make_unique<BehaviorTreeEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<BehaviorTreeComponent>()) {

View File

@@ -0,0 +1,418 @@
-- =============================================================================
-- Action Database Lua API Examples
-- =============================================================================
-- This file demonstrates how to create, query, and manage GOAP actions and
-- goals from Lua using the ecs.action_db API.
--
-- The ActionDatabase is a global singleton. Actions and goals defined here
-- are immediately available to all characters in the scene.
-- =============================================================================
-- =============================================================================
-- Defining Bit Names
-- =============================================================================
-- Before using bits in preconditions/effects, you should define meaningful
-- names for the 64 available bit slots. This makes your actions readable.
--
-- Bit names are global across the entire game session. They map
-- human-readable names (like "has_axe", "is_hungry") to bit indices
-- (0-63) used in GoapBlackboard preconditions and effects.
--
-- You can define bits explicitly at startup:
-- =============================================================================
-- Explicitly assign bit names to specific indices:
ecs.action_db.set_bit_name(0, "has_axe")
ecs.action_db.set_bit_name(1, "has_wood")
ecs.action_db.set_bit_name(2, "is_hungry")
ecs.action_db.set_bit_name(3, "near_tree")
ecs.action_db.set_bit_name(4, "near_well")
ecs.action_db.set_bit_name(5, "has_bucket")
ecs.action_db.set_bit_name(6, "has_food")
ecs.action_db.set_bit_name(7, "near_fire")
ecs.action_db.set_bit_name(8, "has_cooked_food")
ecs.action_db.set_bit_name(9, "is_awake")
ecs.action_db.set_bit_name(10, "at_market")
ecs.action_db.set_bit_name(11, "at_home")
ecs.action_db.set_bit_name(12, "near_chair")
ecs.action_db.set_bit_name(13, "is_sitting")
ecs.action_db.set_bit_name(14, "near_forest")
ecs.action_db.set_bit_name(15, "is_strong")
ecs.action_db.set_bit_name(16, "has_strength")
-- Or use auto_assign_bit() to let the system pick the index:
local idx = ecs.action_db.auto_assign_bit("has_water")
print("'has_water' assigned to bit " .. idx)
-- Look up a bit by name:
local bit_idx = ecs.action_db.find_bit_by_name("has_axe")
print("'has_axe' is at bit " .. bit_idx)
-- Get the name for a bit index:
local name = ecs.action_db.get_bit_name(0)
print("Bit 0 is named '" .. name .. "'")
-- List all currently assigned bit names:
local bits = ecs.action_db.list_bit_names()
print("Assigned bit names:")
for _, b in ipairs(bits) do
print(" Bit " .. b.index .. ": " .. b.name)
end
-- NOTE: If you use a bit name in an action's preconditions/effects
-- that hasn't been explicitly assigned, it will be auto-assigned
-- to the first free slot automatically. So you don't HAVE to
-- pre-define them, but it's good practice for clarity.
-- =============================================================================
-- Creating Actions
-- =============================================================================
-- Simple action with just a name and cost:
ecs.action_db.add_action("idle", 1)
-- Action with preconditions (what must be true before the action can run):
ecs.action_db.add_action("chop_wood", 2,
{
bits = { has_axe = true },
values = { stamina = 10 }
},
{ -- effects (what becomes true after the action runs)
bits = { has_wood = true },
values = { stamina = -5 }
}
)
-- Action with only preconditions, no effects:
ecs.action_db.add_action("fetch_water", 3,
{
bits = { near_well = true, has_bucket = true },
values = { thirst = 50 }
}
)
-- Action with only effects, no preconditions:
ecs.action_db.add_action("rest", 1,
{},
{
values = { stamina = 100, energy = 100 }
}
)
-- Action with float and string values in blackboard:
ecs.action_db.add_action("cook_food", 4,
{
bits = { has_food = true, near_fire = true },
values = { cooking_skill = 3 },
floatValues = { hunger = 50.0 }
},
{
bits = { has_cooked_food = true },
values = { hunger = -30 },
stringValues = { last_action = "cooking" }
}
)
-- =============================================================================
-- Creating Actions WITH Behavior Trees
-- =============================================================================
-- Action with a simple leaf behavior tree (plays an animation):
ecs.action_db.add_action("wave", 1,
{}, -- no preconditions
{}, -- no effects
{ -- behavior tree (arg 5)
type = "sequence",
children = {
{ type = "setAnimationState", name = "SM/Wave" },
{ type = "delay", params = "2.0" },
{ type = "setAnimationState", name = "SM/Idle" }
}
}
)
-- Action with a selector behavior tree (try to chop, fall back to idle):
ecs.action_db.add_action("chop_tree", 3,
{
bits = { near_tree = true },
values = { stamina = 15 }
},
{
bits = { has_wood = true },
values = { stamina = -8, wood_count = 1 }
},
{
type = "selector",
children = {
{
type = "sequence",
children = {
{ type = "checkBit", name = "has_axe", params = "1" },
{ type = "setAnimationState", name = "SM/Chop" },
{ type = "delay", params = "3.0" },
{ type = "setAnimationState", name = "SM/Idle" },
{ type = "setBit", name = "has_wood", params = "1" }
}
},
{
type = "sequence",
children = {
{ type = "debugPrint", name = "No axe! Picking up stick..." },
{ type = "setAnimationState", name = "SM/Pickup" },
{ type = "delay", params = "1.0" },
{ type = "setBit", name = "has_wood", params = "1" }
}
}
}
}
)
-- Action with blackboard checks and value manipulation:
ecs.action_db.add_action("travel_to_market", 5,
{
bits = { is_awake = true },
values = { energy = 20 }
},
{
bits = { at_market = true },
values = { energy = -15 }
},
{
type = "sequence",
children = {
{ type = "checkValue", name = "energy", params = ">= 20" },
{ type = "setAnimationState", name = "SM/Walk" },
{ type = "delay", params = "5.0" },
{ type = "setAnimationState", name = "SM/Idle" },
{ type = "setBit", name = "at_market", params = "1" },
{ type = "setBit", name = "at_home", params = "0" }
}
}
)
-- Action with inventory operations:
ecs.action_db.add_action("gather_wood", 2,
{
bits = { near_forest = true }
},
{
values = { wood_count = 3 }
},
{
type = "sequence",
children = {
{ type = "setAnimationState", name = "SM/Gather" },
{ type = "delay", params = "2.0" },
{ type = "addItemToInventory", params = "wood,Firewood,material,3,1.0,0" },
{ type = "setAnimationState", name = "SM/Idle" }
}
}
)
-- Action that teleports character to a smart object child:
ecs.action_db.add_action("sit_on_chair", 1,
{
bits = { near_chair = true }
},
{
bits = { is_sitting = true }
},
{
type = "sequence",
children = {
{ type = "teleportToChild", name = "SitTarget" },
{ type = "disablePhysics" },
{ type = "setAnimationState", name = "SM/Sit" },
{ type = "delay", params = "5.0" },
{ type = "setAnimationState", name = "SM/Stand" },
{ type = "enablePhysics" }
}
}
)
-- =============================================================================
-- Creating Goals
-- =============================================================================
-- Simple goal with just a name and priority:
ecs.action_db.add_goal("survive", 100)
-- Goal with a target blackboard state:
ecs.action_db.add_goal("gather_resources", 50,
{
bits = { has_wood = true, has_water = true },
values = { wood_count = 5, water_count = 3 }
}
)
-- Goal with a condition expression (evaluated against character's blackboard):
ecs.action_db.add_goal("stay_healthy", 80,
{
values = { health = 100, stamina = 80 }
},
"health < 50 || stamina < 30" -- only valid when character needs healing
)
-- Goal with full specification:
ecs.action_db.add_goal("become_strong", 30,
{
bits = { is_strong = true },
values = { strength = 100 }
},
"strength < 100" -- only valid if not already strong
)
-- =============================================================================
-- Querying Actions and Goals
-- =============================================================================
-- Find an action by name:
local action = ecs.action_db.find_action("chop_wood")
if action then
print("Found action: " .. action.name .. " (cost: " .. action.cost .. ")")
-- action.preconditions and action.effects are tables with:
-- .bits - table of boolean flags
-- .values - table of integer values
-- .floatValues - table of float values
-- .stringValues - table of string values
-- action.behaviorTree is a table with:
-- .type - node type string
-- .name - optional name
-- .params - optional params
-- .children - optional array of child nodes
end
-- Find a goal by name:
local goal = ecs.action_db.find_goal("gather_resources")
if goal then
print("Found goal: " .. goal.name .. " (priority: " .. goal.priority .. ")")
-- goal.target is a blackboard table
-- goal.condition is the condition string
end
-- =============================================================================
-- Listing All Actions and Goals
-- =============================================================================
-- List all action names:
local actions = ecs.action_db.list_actions()
print("Available actions:")
for i, name in ipairs(actions) do
print(" " .. i .. ". " .. name)
end
-- List all goal names:
local goals = ecs.action_db.list_goals()
print("Available goals:")
for i, name in ipairs(goals) do
print(" " .. i .. ". " .. name)
end
-- =============================================================================
-- Removing Actions and Goals
-- =============================================================================
-- Remove an action by name:
local removed = ecs.action_db.remove_action("idle")
if removed then
print("Removed action: idle")
end
-- Remove a goal by name:
ecs.action_db.remove_goal("become_strong")
-- =============================================================================
-- Replacing Actions (same name = replace)
-- =============================================================================
-- If you add an action with the same name as an existing one, it replaces it:
ecs.action_db.add_action("chop_wood", 5,
{
bits = { has_axe = true, has_strength = true },
values = { stamina = 20 }
},
{
bits = { has_wood = true },
values = { stamina = -10, wood_count = 2 }
}
)
-- The old "chop_wood" action is replaced with this new definition.
-- =============================================================================
-- Clearing Everything
-- =============================================================================
-- Remove all actions and goals:
-- ecs.action_db.clear()
-- =============================================================================
-- Blackboard Table Format Reference
-- =============================================================================
--
-- The blackboard table passed to add_action/add_goal has this structure:
--
-- {
-- bits = {
-- has_axe = true, -- boolean flags (use named bits)
-- has_wood = false,
-- is_hungry = true
-- },
-- values = { -- integer values
-- health = 100,
-- stamina = 50,
-- wood_count = 0
-- },
-- floatValues = { -- float values
-- hunger = 75.5,
-- speed = 1.2
-- },
-- stringValues = { -- string values
-- last_action = "idle",
-- current_state = "exploring"
-- }
-- }
--
-- All sub-tables are optional. An empty table or nil means no constraints.
-- =============================================================================
-- =============================================================================
-- Behavior Tree Table Format Reference
-- =============================================================================
--
-- The behaviorTree table (arg 5 of add_action) has this structure:
--
-- {
-- type = "sequence", -- node type (required)
-- name = "optional_name", -- depends on type (task name, anim state, etc.)
-- params = "optional_params", -- extra parameters (delay seconds, bit index, etc.)
-- children = { -- array of child nodes (for sequence/selector/invert)
-- { type = "task", name = "myAction" },
-- { type = "setAnimationState", name = "SM/Walk" },
-- { type = "delay", params = "2.0" },
-- { type = "checkBit", name = "has_axe", params = "1" }
-- }
-- }
--
-- Common node types:
-- "sequence" - Execute children in order until one fails
-- "selector" - Execute children in order until one succeeds
-- "invert" - Invert the result of a single child
-- "task" - Leaf: references a named task
-- "check" - Leaf: references a named condition
-- "debugPrint" - Leaf: prints 'name' to console
-- "setAnimationState"- Leaf: sets animation state (name="SM/State")
-- "isAnimationEnded" - Leaf: true if animation ended
-- "setBit" - Leaf: sets blackboard bit (name=bit, params=0/1)
-- "checkBit" - Leaf: true if blackboard bit is set
-- "setValue" - Leaf: sets blackboard value (name=key, params=val)
-- "checkValue" - Leaf: blackboard comparison (name=key, params="op val")
-- "delay" - Leaf: waits N seconds (params=seconds as float)
-- "teleportToChild" - Leaf: teleports to named child of Smart Object
-- "disablePhysics" - Leaf: removes character from physics
-- "enablePhysics" - Leaf: re-adds character to physics
-- "hasItem" - Leaf check: true if inventory has itemId
-- "pickupItem" - Leaf: picks up nearest item
-- "dropItem" - Leaf: drops item from inventory
-- "useItem" - Leaf: uses item from inventory
-- "addItemToInventory"- Leaf: adds item directly to inventory
-- =============================================================================

View File

@@ -0,0 +1,576 @@
#include "LuaActionApi.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/GoapBlackboard.hpp"
#include "../components/BehaviorTree.hpp"
#include <OgreLogManager.h>
#include <cstring>
namespace editScene
{
// ---------------------------------------------------------------------------
// Helper: push a BehaviorTreeNode as a Lua table
// ---------------------------------------------------------------------------
static void pushBehaviorTree(lua_State *L, const BehaviorTreeNode &node)
{
lua_newtable(L);
lua_pushstring(L, node.type.c_str());
lua_setfield(L, -2, "type");
if (!node.name.empty()) {
lua_pushstring(L, node.name.c_str());
lua_setfield(L, -2, "name");
}
if (!node.params.empty()) {
lua_pushstring(L, node.params.c_str());
lua_setfield(L, -2, "params");
}
if (!node.children.empty()) {
lua_newtable(L);
for (size_t i = 0; i < node.children.size(); i++) {
pushBehaviorTree(L, node.children[i]);
lua_rawseti(L, -2, (int)i + 1);
}
lua_setfield(L, -2, "children");
}
}
// ---------------------------------------------------------------------------
// Helper: read a BehaviorTreeNode from a Lua table at given index
// ---------------------------------------------------------------------------
static BehaviorTreeNode readBehaviorTree(lua_State *L, int idx)
{
BehaviorTreeNode node;
if (!lua_istable(L, idx))
return node;
// type (required)
lua_getfield(L, idx, "type");
if (lua_isstring(L, -1))
node.type = lua_tostring(L, -1);
lua_pop(L, 1);
// name (optional)
lua_getfield(L, idx, "name");
if (lua_isstring(L, -1))
node.name = lua_tostring(L, -1);
lua_pop(L, 1);
// params (optional)
lua_getfield(L, idx, "params");
if (lua_isstring(L, -1))
node.params = lua_tostring(L, -1);
lua_pop(L, 1);
// children (optional array)
lua_getfield(L, idx, "children");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
// key is numeric index, value is child table
if (lua_istable(L, -1)) {
node.children.push_back(
readBehaviorTree(L, lua_gettop(L)));
}
lua_pop(L, 1);
}
}
lua_pop(L, 1);
return node;
}
// ---------------------------------------------------------------------------
// Helper: push a GoapBlackboard as a Lua table
// ---------------------------------------------------------------------------
static void pushBlackboard(lua_State *L, const GoapBlackboard &bb)
{
lua_newtable(L); // blackboard table
// Bits
lua_newtable(L); // bits table
for (int i = 0; i < 64; i++) {
if (bb.hasBit(i)) {
const char *name = GoapBlackboard::getBitName(i);
const char *key = name ? name : "";
lua_pushboolean(L, bb.getBit(i));
lua_setfield(L, -2, key);
}
}
lua_setfield(L, -2, "bits");
// Integer values
lua_newtable(L);
for (const auto &kv : bb.values) {
lua_pushinteger(L, kv.second);
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "values");
// Float values
lua_newtable(L);
for (const auto &kv : bb.floatValues) {
lua_pushnumber(L, kv.second);
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "floatValues");
// String values
lua_newtable(L);
for (const auto &kv : bb.stringValues) {
lua_pushstring(L, kv.second.c_str());
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "stringValues");
}
// ---------------------------------------------------------------------------
// Helper: read a GoapBlackboard from a Lua table (optional, at given index)
// ---------------------------------------------------------------------------
static GoapBlackboard readBlackboard(lua_State *L, int idx)
{
GoapBlackboard bb;
if (!lua_istable(L, idx))
return bb;
// Read bits
lua_getfield(L, idx, "bits");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
// key is the bit name (string), value is boolean
if (lua_isstring(L, -2) && lua_isboolean(L, -1)) {
const char *name = lua_tostring(L, -2);
bool val = lua_toboolean(L, -1) != 0;
// Find bit index by name (auto-registers if new)
int idx2 = GoapBlackboard::findBitByName(name);
if (idx2 < 0) {
// Find first free slot
for (int i = 0; i < 64; i++) {
if (GoapBlackboard::getBitName(
i) == nullptr) {
GoapBlackboard::setBitName(
i, name);
idx2 = i;
break;
}
}
}
if (idx2 >= 0)
bb.setBit(idx2, val);
}
lua_pop(L, 1);
}
}
lua_pop(L, 1);
// Read integer values
lua_getfield(L, idx, "values");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isinteger(L, -1))
bb.values[lua_tostring(L, -2)] =
(int)lua_tointeger(L, -1);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
// Read float values
lua_getfield(L, idx, "floatValues");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isnumber(L, -1))
bb.floatValues[lua_tostring(L, -2)] =
(float)lua_tonumber(L, -1);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
// Read string values
lua_getfield(L, idx, "stringValues");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isstring(L, -1))
bb.stringValues[lua_tostring(L, -2)] =
lua_tostring(L, -1);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
return bb;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.add_action(name, cost, preconds, effects, behaviorTree)
// ---------------------------------------------------------------------------
static int luaAddAction(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
int cost = (int)luaL_optinteger(L, 2, 1);
GoapAction action(name, cost);
// Optional preconditions table (arg 3)
if (lua_gettop(L) >= 3 && lua_istable(L, 3))
action.preconditions = readBlackboard(L, 3);
// Optional effects table (arg 4)
if (lua_gettop(L) >= 4 && lua_istable(L, 4))
action.effects = readBlackboard(L, 4);
// Optional behavior tree table (arg 5)
if (lua_gettop(L) >= 5 && lua_istable(L, 5))
action.behaviorTree = readBehaviorTree(L, 5);
ActionDatabase::getSingleton().addOrReplaceAction(action);
Ogre::LogManager::getSingleton().stream()
<< "[Lua] Added action: " << name;
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.add_goal(name, priority, target, condition)
// ---------------------------------------------------------------------------
static int luaAddGoal(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
int priority = (int)luaL_optinteger(L, 2, 1);
GoapGoal goal(name, priority);
// Optional target blackboard (arg 3)
if (lua_gettop(L) >= 3 && lua_istable(L, 3))
goal.target = readBlackboard(L, 3);
// Optional condition string (arg 4)
if (lua_gettop(L) >= 4 && lua_isstring(L, 4))
goal.condition = lua_tostring(L, 4);
ActionDatabase::getSingleton().addOrReplaceGoal(goal);
Ogre::LogManager::getSingleton().stream()
<< "[Lua] Added goal: " << name;
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.remove_action(name) -> bool
// ---------------------------------------------------------------------------
static int luaRemoveAction(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
bool removed = ActionDatabase::getSingleton().removeAction(name);
lua_pushboolean(L, removed);
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.remove_goal(name) -> bool
// ---------------------------------------------------------------------------
static int luaRemoveGoal(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
bool removed = ActionDatabase::getSingleton().removeGoal(name);
lua_pushboolean(L, removed);
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.find_action(name) -> table or nil
// ---------------------------------------------------------------------------
static int luaFindAction(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
const GoapAction *action =
ActionDatabase::getSingleton().findAction(name);
if (!action) {
lua_pushnil(L);
return 1;
}
lua_newtable(L);
lua_pushstring(L, action->name.c_str());
lua_setfield(L, -2, "name");
lua_pushinteger(L, action->cost);
lua_setfield(L, -2, "cost");
pushBlackboard(L, action->preconditions);
lua_setfield(L, -2, "preconditions");
pushBlackboard(L, action->effects);
lua_setfield(L, -2, "effects");
// Behavior tree
pushBehaviorTree(L, action->behaviorTree);
lua_setfield(L, -2, "behaviorTree");
// Behavior tree name (optional reference)
if (!action->behaviorTreeName.empty()) {
lua_pushstring(L, action->behaviorTreeName.c_str());
lua_setfield(L, -2, "behaviorTreeName");
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.find_goal(name) -> table or nil
// ---------------------------------------------------------------------------
static int luaFindGoal(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
const GoapGoal *goal = ActionDatabase::getSingleton().findGoal(name);
if (!goal) {
lua_pushnil(L);
return 1;
}
lua_newtable(L);
lua_pushstring(L, goal->name.c_str());
lua_setfield(L, -2, "name");
lua_pushinteger(L, goal->priority);
lua_setfield(L, -2, "priority");
pushBlackboard(L, goal->target);
lua_setfield(L, -2, "target");
lua_pushstring(L, goal->condition.c_str());
lua_setfield(L, -2, "condition");
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.list_actions() -> table of action names
// ---------------------------------------------------------------------------
static int luaListActions(lua_State *L)
{
const auto &db = ActionDatabase::getSingleton();
lua_newtable(L);
int idx = 1;
for (const auto &action : db.actions) {
lua_pushstring(L, action.name.c_str());
lua_rawseti(L, -2, idx++);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.list_goals() -> table of goal names
// ---------------------------------------------------------------------------
static int luaListGoals(lua_State *L)
{
const auto &db = ActionDatabase::getSingleton();
lua_newtable(L);
int idx = 1;
for (const auto &goal : db.goals) {
lua_pushstring(L, goal.name.c_str());
lua_rawseti(L, -2, idx++);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.clear() -> nil
// ---------------------------------------------------------------------------
static int luaClear(lua_State *L)
{
(void)L;
ActionDatabase::getSingleton().clear();
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.set_bit_name(index, name) -> nil
// ---------------------------------------------------------------------------
static int luaSetBitName(lua_State *L)
{
int index = (int)luaL_checkinteger(L, 1);
const char *name = luaL_checkstring(L, 2);
if (index < 0 || index >= 64)
luaL_error(L, "bit index must be 0-63, got %d", index);
GoapBlackboard::setBitName(index, name);
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.find_bit_by_name(name) -> index or nil
// ---------------------------------------------------------------------------
static int luaFindBitByName(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
int index = GoapBlackboard::findBitByName(name);
if (index < 0) {
lua_pushnil(L);
} else {
lua_pushinteger(L, index);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.get_bit_name(index) -> name or nil
// ---------------------------------------------------------------------------
static int luaGetBitName(lua_State *L)
{
int index = (int)luaL_checkinteger(L, 1);
if (index < 0 || index >= 64) {
lua_pushnil(L);
return 1;
}
const char *name = GoapBlackboard::getBitName(index);
if (name) {
lua_pushstring(L, name);
} else {
lua_pushnil(L);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.list_bit_names() -> table of { index, name }
// ---------------------------------------------------------------------------
static int luaListBitNames(lua_State *L)
{
lua_newtable(L);
int idx = 1;
for (int i = 0; i < 64; i++) {
const char *name = GoapBlackboard::getBitName(i);
if (name) {
lua_newtable(L);
lua_pushinteger(L, i);
lua_setfield(L, -2, "index");
lua_pushstring(L, name);
lua_setfield(L, -2, "name");
lua_rawseti(L, -2, idx++);
}
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.auto_assign_bit(name) -> index
// ---------------------------------------------------------------------------
// Finds a bit by name, or auto-assigns the first free slot if not found.
// Returns the bit index, or -1 if all 64 slots are full.
static int luaAutoAssignBit(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
int index = GoapBlackboard::findBitByName(name);
if (index >= 0) {
lua_pushinteger(L, index);
return 1;
}
// Find first free slot
for (int i = 0; i < 64; i++) {
if (GoapBlackboard::getBitName(i) == nullptr) {
GoapBlackboard::setBitName(i, name);
lua_pushinteger(L, i);
return 1;
}
}
lua_pushinteger(L, -1);
return 1;
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
void registerLuaActionApi(lua_State *L)
{
// Get or create the "ecs" global table
lua_getglobal(L, "ecs");
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
}
// Create the action_db sub-table
lua_newtable(L);
lua_pushcfunction(L, luaAddAction);
lua_setfield(L, -2, "add_action");
lua_pushcfunction(L, luaAddGoal);
lua_setfield(L, -2, "add_goal");
lua_pushcfunction(L, luaRemoveAction);
lua_setfield(L, -2, "remove_action");
lua_pushcfunction(L, luaRemoveGoal);
lua_setfield(L, -2, "remove_goal");
lua_pushcfunction(L, luaFindAction);
lua_setfield(L, -2, "find_action");
lua_pushcfunction(L, luaFindGoal);
lua_setfield(L, -2, "find_goal");
lua_pushcfunction(L, luaListActions);
lua_setfield(L, -2, "list_actions");
lua_pushcfunction(L, luaListGoals);
lua_setfield(L, -2, "list_goals");
lua_pushcfunction(L, luaClear);
lua_setfield(L, -2, "clear");
// Bit name management
lua_pushcfunction(L, luaSetBitName);
lua_setfield(L, -2, "set_bit_name");
lua_pushcfunction(L, luaFindBitByName);
lua_setfield(L, -2, "find_bit_by_name");
lua_pushcfunction(L, luaGetBitName);
lua_setfield(L, -2, "get_bit_name");
lua_pushcfunction(L, luaListBitNames);
lua_setfield(L, -2, "list_bit_names");
lua_pushcfunction(L, luaAutoAssignBit);
lua_setfield(L, -2, "auto_assign_bit");
// Set action_db as a field of ecs
lua_setfield(L, -2, "action_db");
// Ensure ecs is global
lua_setglobal(L, "ecs");
}
} // namespace editScene

View File

@@ -0,0 +1,101 @@
#ifndef EDITSCENE_LUA_ACTION_API_HPP
#define EDITSCENE_LUA_ACTION_API_HPP
#pragma once
#include <lua.hpp>
/**
* @file LuaActionApi.hpp
* @brief Lua API for the ActionDatabase singleton.
*
* Provides Lua functions to create, query, and manage GOAP actions
* and goals in the global ActionDatabase singleton.
*
* Exposed Lua globals (under the "ecs" table):
* ecs.action_db.add_action(name, cost, preconds, effects, behaviorTree) -> nil
* ecs.action_db.add_goal(name, priority, target, condition) -> nil
* ecs.action_db.remove_action(name) -> bool
* ecs.action_db.remove_goal(name) -> bool
* ecs.action_db.find_action(name) -> table or nil
* ecs.action_db.find_goal(name) -> table or nil
* ecs.action_db.list_actions() -> table of action names
* ecs.action_db.list_goals() -> table of goal names
* ecs.action_db.clear() -> nil
*
* Bit Name Management:
* ecs.action_db.set_bit_name(index, name) -> nil
* Assign a human-readable name to a bit slot (0-63).
* Example: ecs.action_db.set_bit_name(0, "has_axe")
*
* ecs.action_db.find_bit_by_name(name) -> index or nil
* Look up which bit index a name is assigned to.
* Returns nil if the name hasn't been assigned yet.
*
* ecs.action_db.get_bit_name(index) -> name or nil
* Get the name assigned to a bit slot, or nil if unassigned.
*
* ecs.action_db.list_bit_names() -> table of { index, name }
* Returns an array of all assigned bit names with their indices.
*
* ecs.action_db.auto_assign_bit(name) -> index
* Find a bit by name, or auto-assign the first free slot.
* Returns the bit index, or -1 if all 64 slots are full.
* This is the same logic used internally by readBlackboard()
* when it encounters an unknown bit name in preconditions/effects.
*
* Bit Name Convention:
* Bit names are global across the entire game session. They map
* human-readable names (like "has_axe", "is_hungry") to bit indices
* (0-63) used in GoapBlackboard preconditions and effects.
*
* You can define bits explicitly at startup:
* ecs.action_db.set_bit_name(0, "has_axe")
* ecs.action_db.set_bit_name(1, "has_wood")
* ecs.action_db.set_bit_name(2, "is_hungry")
*
* Or let them be auto-assigned when you use them in actions:
* ecs.action_db.add_action("chop_wood", 2,
* { bits = { has_axe = true } }, -- "has_axe" auto-assigned if new
* { bits = { has_wood = true } })
*
* Use auto_assign_bit() to explicitly reserve a name:
* local idx = ecs.action_db.auto_assign_bit("my_flag")
* -- idx is now the bit index for "my_flag"
*
* Use list_bit_names() to see all currently assigned names:
* local bits = ecs.action_db.list_bit_names()
* for _, b in ipairs(bits) do
* print(b.index .. ": " .. b.name)
* end
*
* Behavior Tree Table Format (arg 5 of add_action):
* {
* type = "sequence", -- node type: sequence, selector, invert, task, check, etc.
* name = "optional_name", -- action/condition name, animation state, etc.
* params = "optional_params", -- extra parameters (e.g. delay seconds, bit index)
* children = { -- array of child nodes (for sequence/selector/invert)
* { type = "task", name = "myAction" },
* { type = "setAnimationState", name = "SM/Walk" },
* { type = "delay", params = "2.0" }
* }
* }
*
* See BehaviorTree.hpp for full list of node types.
*/
namespace editScene
{
/**
* @brief Register all ActionDatabase-related Lua API functions.
*
* Adds action_db sub-table to the "ecs" global table.
* Must be called after LuaState is constructed.
*
* @param L The Lua state.
*/
void registerLuaActionApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_ACTION_API_HPP

View File

@@ -1652,14 +1652,8 @@ static void registerAllComponents()
c.autoReplan = lua_toboolean(L, -1) != 0;
lua_pop(L, 1););
// --- ActionDatabase ---
REGISTER_COMPONENT(ActionDatabase, "ActionDatabase",
lua_pushinteger(L, (int)c.actions.size());
lua_setfield(L, -2, "numActions");
lua_pushinteger(L, (int)c.goals.size());
lua_setfield(L, -2, "numGoals");
/* end PushBody */
, (void)idx;);
// ActionDatabase is now a singleton, not a per-entity component.
// Use ActionDatabase:getSingleton() from Lua API instead.
// --- ActionDebug ---
REGISTER_COMPONENT(

View File

@@ -118,13 +118,8 @@ bool ActuatorSystem::isActionComplete(flecs::entity character, float deltaTime)
if (m_executingActionName.empty())
return true;
// Look up the action in the database to get its behavior tree
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Look up the action in the singleton database to get its behavior tree
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db)
return true;

View File

@@ -994,12 +994,7 @@ void BehaviorTreeSystem::update(float deltaTime)
});
/* --- ActionDebug test runs --- */
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db)
return;

View File

@@ -606,8 +606,7 @@ void EditorUISystem::renderEntityNode(flecs::entity entity, int depth)
indicators += " [Mat]";
if (entity.has<AnimationTreeComponent>())
indicators += " [Anim]";
if (entity.has<ActionDatabase>())
indicators += " [AI]";
// ActionDatabase is now a singleton, not a per-entity component
if (entity.has<ActionDebug>())
indicators += " [Debug]";
if (entity.has<BehaviorTreeComponent>())
@@ -999,12 +998,7 @@ void EditorUISystem::renderComponentList(flecs::entity entity)
componentCount++;
}
// Render ActionDatabase if present
if (entity.has<ActionDatabase>()) {
auto &db = entity.get_mut<ActionDatabase>();
m_componentRegistry.render<ActionDatabase>(entity, db);
componentCount++;
}
// ActionDatabase is now a singleton, not a per-entity component
// Render ActionDebug if present
if (entity.has<ActionDebug>()) {

View File

@@ -182,13 +182,8 @@ void EventHandlerSystem::update(float deltaTime)
flecs::entity_t id = pair.first;
ActiveHandler &ah = pair.second;
// Look up the action in the database
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Look up the action in the singleton database
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db) {
completedHandlers.push_back(id);
continue;

View File

@@ -115,13 +115,8 @@ void GoapPlannerSystem::planForEntity(flecs::entity e,
{
(void)e;
// Find ActionDatabase
const ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Find ActionDatabase singleton
const ActionDatabase *db = ActionDatabase::getSingletonPtr();
// Select best valid goal
const GoapGoal *goal = selectGoal(planner, db, blackboard);

View File

@@ -102,13 +102,8 @@ bool GoapRunnerSystem::startNextAction(flecs::entity e)
runner.planActions[runner.currentActionIndex];
runner.currentActionName = actionName;
// Find action database
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Find action database singleton
ActionDatabase *db = ActionDatabase::getSingletonPtr();
const GoapAction *action = db ? db->findAction(actionName) : nullptr;
@@ -273,15 +268,7 @@ void GoapRunnerSystem::update(float deltaTime)
// Apply action effects
if (e.has<GoapBlackboard>()) {
ActionDatabase *db = nullptr;
m_world
.query<ActionDatabase>()
.each([&](flecs::entity,
ActionDatabase
&database) {
if (!db)
db = &database;
});
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (db) {
const GoapAction *action =
db->findAction(

View File

@@ -322,13 +322,8 @@ bool ItemSystem::useItem(flecs::entity characterEntity,
// Execute the use action via behavior tree
if (m_btSystem && characterEntity.is_alive()) {
// Look up the action in the database
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Look up the action in the singleton database
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (db) {
const GoapAction *action =

View File

@@ -74,6 +74,9 @@ bool SceneSerializer::saveToFile(const std::string &filepath)
}
});
// Save ActionDatabase singleton at scene level
scene["actionDatabase"] = serializeActionDatabase();
// Write to file
std::ofstream file(filepath);
if (!file.is_open()) {
@@ -121,6 +124,11 @@ bool SceneSerializer::loadFromFile(const std::string &filepath,
}
}
// Load ActionDatabase singleton at scene level
if (scene.contains("actionDatabase")) {
deserializeActionDatabase(scene["actionDatabase"]);
}
// Clear entity map for new load
m_entityMap.clear();
@@ -295,9 +303,7 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
json["waterPlane"] = serializeWaterPlane(entity);
}
if (entity.has<ActionDatabase>()) {
json["actionDatabase"] = serializeActionDatabase(entity);
}
// ActionDatabase is now a singleton, serialized at scene level
if (entity.has<ActionDebug>()) {
json["actionDebug"] = serializeActionDebug(entity);
}
@@ -515,9 +521,7 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
deserializeWaterPlane(entity, json["waterPlane"]);
}
if (json.contains("actionDatabase")) {
deserializeActionDatabase(entity, json["actionDatabase"]);
}
// ActionDatabase is now a singleton, deserialized at scene level
if (json.contains("actionDebug")) {
deserializeActionDebug(entity, json["actionDebug"]);
}
@@ -807,9 +811,7 @@ void SceneSerializer::deserializeEntityComponents(
deserializeWaterPlane(entity, json["waterPlane"]);
}
if (json.contains("actionDatabase")) {
deserializeActionDatabase(entity, json["actionDatabase"]);
}
// ActionDatabase is now a singleton, deserialized at scene level
if (json.contains("actionDebug")) {
deserializeActionDebug(entity, json["actionDebug"]);
}
@@ -3396,9 +3398,12 @@ static void deserializeGoapGoal(GoapGoal &goal, const nlohmann::json &json)
goal.condition = json.value("condition", "");
}
nlohmann::json SceneSerializer::serializeActionDatabase(flecs::entity entity)
nlohmann::json SceneSerializer::serializeActionDatabase()
{
const ActionDatabase &db = entity.get<ActionDatabase>();
const ActionDatabase *dbPtr = ActionDatabase::getSingletonPtr();
if (!dbPtr)
return nlohmann::json();
const ActionDatabase &db = *dbPtr;
nlohmann::json json;
json["actions"] = nlohmann::json::array();
@@ -3426,16 +3431,17 @@ nlohmann::json SceneSerializer::serializeActionDatabase(flecs::entity entity)
return json;
}
void SceneSerializer::deserializeActionDatabase(flecs::entity entity,
const nlohmann::json &json)
void SceneSerializer::deserializeActionDatabase(const nlohmann::json &json)
{
ActionDatabase db;
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db)
return;
if (json.contains("actions") && json["actions"].is_array()) {
for (const auto &actionJson : json["actions"]) {
GoapAction action;
deserializeGoapAction(action, actionJson);
db.actions.push_back(action);
db->addOrReplaceAction(action);
}
}
@@ -3443,7 +3449,7 @@ void SceneSerializer::deserializeActionDatabase(flecs::entity entity,
for (const auto &goalJson : json["goals"]) {
GoapGoal goal;
deserializeGoapGoal(goal, goalJson);
db.goals.push_back(goal);
db->addOrReplaceGoal(goal);
}
}
@@ -3456,8 +3462,6 @@ void SceneSerializer::deserializeActionDatabase(flecs::entity entity,
entry["name"].get<std::string>());
}
}
entity.set<ActionDatabase>(db);
}
nlohmann::json SceneSerializer::serializeActionDebug(flecs::entity entity)

View File

@@ -212,7 +212,7 @@ private:
const nlohmann::json &json);
// AI/GOAP serialization
nlohmann::json serializeActionDatabase(flecs::entity entity);
nlohmann::json serializeActionDatabase();
nlohmann::json serializeActionDebug(flecs::entity entity);
nlohmann::json serializePathFollowing(flecs::entity entity);
nlohmann::json serializeSmartObject(flecs::entity entity);
@@ -220,8 +220,7 @@ private:
nlohmann::json serializeEventHandler(flecs::entity entity);
nlohmann::json serializeGoapPlanner(flecs::entity entity);
nlohmann::json serializeBehaviorTree(flecs::entity entity);
void deserializeActionDatabase(flecs::entity entity,
const nlohmann::json &json);
void deserializeActionDatabase(const nlohmann::json &json);
void deserializeActionDebug(flecs::entity entity,
const nlohmann::json &json);
void deserializePathFollowing(flecs::entity entity,

View File

@@ -156,13 +156,8 @@ bool SmartObjectSystem::testSmartObjectAction(flecs::entity character,
if (!character.is_alive() || !smartObject.is_alive())
return false;
// Find the action in the database
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Find the action in the singleton database
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db)
return false;
@@ -208,13 +203,8 @@ void SmartObjectSystem::update(float deltaTime)
navmeshEntity = e;
});
// Find the action database
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Get the action database singleton
ActionDatabase *db = ActionDatabase::getSingletonPtr();
// Determine if we're in game mode. In game mode, player-controlled
// characters are managed by PlayerControllerSystem and should NOT

View File

@@ -0,0 +1,91 @@
/**
* @file Ogre.h
* @brief Stub Ogre.h for standalone tests.
*
* Provides minimal Ogre type aliases needed to compile
* ActionDatabase, GoapBlackboard, GoapGoal, GoapAction,
* and BehaviorTree components without the full Ogre SDK.
*
* Only for use in standalone tests (action_db_lua_test).
*/
#ifndef OGRE_STUB_H
#define OGRE_STUB_H
#include <string>
#include <vector>
#include <cstdint>
#include <cstdio>
#include <sstream>
namespace Ogre
{
// String is just std::string
using String = std::string;
// Minimal Vector3 for GoapBlackboard
struct Vector3 {
float x, y, z;
Vector3()
: x(0)
, y(0)
, z(0)
{
}
Vector3(float x_, float y_, float z_)
: x(x_)
, y(y_)
, z(z_)
{
}
static const Vector3 ZERO;
// Member operator== defined inline inside the struct body
// This ensures it's found by ADL for std::pair::operator==
bool operator==(const Vector3 &other) const
{
return x == other.x && y == other.y && z == other.z;
}
};
inline const Vector3 Vector3::ZERO(0, 0, 0);
// Minimal StringConverter stub for GoapBlackboard::dump()
struct StringConverter {
static String toString(int val)
{
return std::to_string(val);
}
static String toString(float val)
{
return std::to_string(val);
}
};
// Minimal LogManager stub (used by LuaActionApi)
class LogManager {
public:
static LogManager &getSingleton()
{
static LogManager instance;
return instance;
}
class Stream {
public:
template <typename T> Stream &operator<<(const T &)
{
return *this;
}
};
Stream stream()
{
return Stream();
}
};
} // namespace Ogre
#endif // OGRE_STUB_H

View File

@@ -0,0 +1,14 @@
/**
* @file OgreLogManager.h
* @brief Stub OgreLogManager.h for standalone tests.
*
* Provides Ogre::LogManager for LuaActionApi.cpp compilation
* in standalone test builds.
*/
#ifndef OGRE_LOG_MANAGER_H
#define OGRE_LOG_MANAGER_H
#include "Ogre.h"
#endif // OGRE_LOG_MANAGER_H

View File

@@ -0,0 +1,895 @@
/**
* @file action_db_lua_test.cpp
* @brief Compile-time test for ActionDatabase Lua API.
*
* This test creates a Lua state, registers the ActionDatabase singleton
* and the Lua action API, then runs Lua scripts that create actions
* (including with behavior trees), goals, queries them, and verifies
* the results match expectations.
*
* Build with:
* g++ -std=c++17 -I. -I../.. -I../../lua/lua-5.4.8/src \
* action_db_lua_test.cpp \
* ../components/ActionDatabase.cpp \
* ../components/GoapBlackboard.cpp \
* ../components/GoapGoal.cpp \
* ../components/GoapAction.cpp \
* ../../lua/lua-5.4.8/src/liblua.a \
* -o action_db_lua_test -lm
*
* Or via CMake (see CMakeLists.txt in this directory).
*/
#include <cstdio>
#include <cstring>
#include <cassert>
#include <string>
#include <vector>
// Ogre stub (provides Ogre::String, Ogre::Vector3, Ogre::LogManager)
// Must be included before any component headers that use Ogre types.
#include "ogre_stub.h"
// Include Lua
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
// Include the components we need
#include "../components/ActionDatabase.hpp"
#include "../components/GoapBlackboard.hpp"
#include "../components/GoapAction.hpp"
#include "../components/GoapGoal.hpp"
#include "../components/BehaviorTree.hpp"
// Forward declare the registration function
namespace editScene
{
void registerLuaActionApi(lua_State *L);
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
static int testCount = 0;
static int passCount = 0;
#define TEST(name) \
do { \
testCount++; \
printf(" TEST %d: %s ... ", testCount, name); \
} while (0)
#define PASS() \
do { \
passCount++; \
printf("PASS\n"); \
} while (0)
#define FAIL(msg) \
do { \
printf("FAIL: %s\n", msg); \
return 1; \
} while (0)
// ---------------------------------------------------------------------------
// Helper: run a Lua string and check for errors
// ---------------------------------------------------------------------------
static bool runLua(lua_State *L, const char *code)
{
if (luaL_dostring(L, code) != LUA_OK) {
fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Test 1: Basic action creation and lookup
// ---------------------------------------------------------------------------
static int testBasicAction(lua_State *L)
{
TEST("create and find a simple action");
// Clear any previous state
ActionDatabase::getSingleton().clear();
// Create action via Lua
bool ok = runLua(
L,
"ecs.action_db.add_action('test_action', 5, "
" { bits = { has_axe = true }, values = { stamina = 10 } }, "
" { bits = { has_wood = true }, values = { stamina = -5 } }"
")");
if (!ok)
FAIL("failed to run Lua");
// Verify via C++ API
const GoapAction *action =
ActionDatabase::getSingleton().findAction("test_action");
if (!action)
FAIL("action not found");
if (action->name != "test_action")
FAIL("wrong name");
if (action->cost != 5)
FAIL("wrong cost");
// Verify preconditions
if (!action->preconditions.getBit(0))
FAIL("has_axe bit not set");
if (action->preconditions.values.at("stamina") != 10)
FAIL("wrong stamina precondition");
// Verify effects
if (!action->effects.getBit(1))
FAIL("has_wood bit not set");
if (action->effects.values.at("stamina") != -5)
FAIL("wrong stamina effect");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 2: Action with behavior tree
// ---------------------------------------------------------------------------
static int testActionWithBehaviorTree(lua_State *L)
{
TEST("create action with behavior tree");
ActionDatabase::getSingleton().clear();
bool ok = runLua(L,
"ecs.action_db.add_action('wave', 1, {}, {}, {"
" type = 'sequence',"
" children = {"
" { type = 'setAnimationState', name = 'SM/Wave' },"
" { type = 'delay', params = '2.0' },"
" { type = 'setAnimationState', name = 'SM/Idle' }"
" }"
"})");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("wave");
if (!action)
FAIL("action not found");
// Verify behavior tree structure
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.type != "sequence")
FAIL("expected sequence root");
if (bt.children.size() != 3)
FAIL("expected 3 children");
if (bt.children[0].type != "setAnimationState")
FAIL("child 0 wrong type");
if (bt.children[0].name != "SM/Wave")
FAIL("child 0 wrong name");
if (bt.children[1].type != "delay")
FAIL("child 1 wrong type");
if (bt.children[1].params != "2.0")
FAIL("child 1 wrong params");
if (bt.children[2].type != "setAnimationState")
FAIL("child 2 wrong type");
if (bt.children[2].name != "SM/Idle")
FAIL("child 2 wrong name");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 3: Nested behavior tree (selector with sequences)
// ---------------------------------------------------------------------------
static int testNestedBehaviorTree(lua_State *L)
{
TEST("create action with nested behavior tree");
ActionDatabase::getSingleton().clear();
bool ok = runLua(
L,
"ecs.action_db.add_action('chop_tree', 3,"
" { bits = { near_tree = true }, values = { stamina = 15 } },"
" { bits = { has_wood = true }, values = { stamina = -8 } },"
" {"
" type = 'selector',"
" children = {"
" {"
" type = 'sequence',"
" children = {"
" { type = 'checkBit', name = 'has_axe', params = '1' },"
" { type = 'setAnimationState', name = 'SM/Chop' },"
" { type = 'delay', params = '3.0' }"
" }"
" },"
" {"
" type = 'sequence',"
" children = {"
" { type = 'debugPrint', name = 'No axe!' },"
" { type = 'setAnimationState', name = 'SM/Pickup' }"
" }"
" }"
" }"
" }"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("chop_tree");
if (!action)
FAIL("action not found");
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.type != "selector")
FAIL("expected selector root");
if (bt.children.size() != 2)
FAIL("expected 2 children");
// First child: sequence with 3 nodes
const BehaviorTreeNode &seq1 = bt.children[0];
if (seq1.type != "sequence")
FAIL("child 0 should be sequence");
if (seq1.children.size() != 3)
FAIL("seq1 expected 3 children");
if (seq1.children[0].type != "checkBit")
FAIL("seq1 child 0 wrong type");
if (seq1.children[0].name != "has_axe")
FAIL("seq1 child 0 wrong name");
if (seq1.children[0].params != "1")
FAIL("seq1 child 0 wrong params");
// Second child: sequence with 2 nodes
const BehaviorTreeNode &seq2 = bt.children[1];
if (seq2.type != "sequence")
FAIL("child 1 should be sequence");
if (seq2.children.size() != 2)
FAIL("seq2 expected 2 children");
if (seq2.children[0].type != "debugPrint")
FAIL("seq2 child 0 wrong type");
if (seq2.children[0].name != "No axe!")
FAIL("seq2 child 0 wrong name");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 4: Goal creation and lookup
// ---------------------------------------------------------------------------
static int testGoal(lua_State *L)
{
TEST("create and find a goal");
ActionDatabase::getSingleton().clear();
bool ok = runLua(L, "ecs.action_db.add_goal('test_goal', 80,"
" { values = { health = 100, stamina = 80 } },"
" 'health < 50 || stamina < 30'"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapGoal *goal =
ActionDatabase::getSingleton().findGoal("test_goal");
if (!goal)
FAIL("goal not found");
if (goal->name != "test_goal")
FAIL("wrong name");
if (goal->priority != 80)
FAIL("wrong priority");
if (goal->condition != "health < 50 || stamina < 30")
FAIL("wrong condition");
if (goal->target.values.at("health") != 100)
FAIL("wrong health target");
if (goal->target.values.at("stamina") != 80)
FAIL("wrong stamina target");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 5: Action replacement (same name)
// ---------------------------------------------------------------------------
static int testActionReplacement(lua_State *L)
{
TEST("replace action with same name");
ActionDatabase::getSingleton().clear();
// Create initial action
runLua(L,
"ecs.action_db.add_action('replace_me', 1, "
"{ bits = { old_flag = true } }, { bits = { old_done = true } })");
// Verify initial
const GoapAction *first =
ActionDatabase::getSingleton().findAction("replace_me");
if (!first)
FAIL("initial action not found");
if (first->cost != 1)
FAIL("initial cost wrong");
// Replace with new definition
runLua(L,
"ecs.action_db.add_action('replace_me', 99, "
"{ bits = { new_flag = true } }, { bits = { new_done = true } })");
// Verify replacement
const GoapAction *second =
ActionDatabase::getSingleton().findAction("replace_me");
if (!second)
FAIL("replaced action not found");
if (second->cost != 99)
FAIL("replaced cost wrong");
if (second->preconditions.getBit(0))
FAIL("old precondition still present");
// Should only be one action with this name
int count = 0;
for (const auto &a : ActionDatabase::getSingleton().actions) {
if (a.name == "replace_me")
count++;
}
if (count != 1)
FAIL("expected exactly one action with this name");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 6: Remove action
// ---------------------------------------------------------------------------
static int testRemoveAction(lua_State *L)
{
TEST("remove action");
ActionDatabase::getSingleton().clear();
runLua(L, "ecs.action_db.add_action('to_remove', 1)");
runLua(L, "ecs.action_db.add_action('to_keep', 2)");
if (!ActionDatabase::getSingleton().findAction("to_remove"))
FAIL("action should exist before removal");
// Remove via Lua
bool ok = runLua(L,
"local r = ecs.action_db.remove_action('to_remove');"
"assert(r == true, 'remove should return true')");
if (!ok)
FAIL("failed to run Lua remove");
if (ActionDatabase::getSingleton().findAction("to_remove"))
FAIL("action should not exist after removal");
if (!ActionDatabase::getSingleton().findAction("to_keep"))
FAIL("other action should still exist");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 7: List actions
// ---------------------------------------------------------------------------
static int testListActions(lua_State *L)
{
TEST("list actions");
ActionDatabase::getSingleton().clear();
runLua(L, "ecs.action_db.add_action('alpha', 1)");
runLua(L, "ecs.action_db.add_action('beta', 2)");
runLua(L, "ecs.action_db.add_action('gamma', 3)");
bool ok = runLua(
L, "local list = ecs.action_db.list_actions();"
"assert(#list == 3, 'expected 3 actions, got ' .. #list);"
"assert(list[1] == 'alpha', 'expected alpha first');"
"assert(list[2] == 'beta', 'expected beta second');"
"assert(list[3] == 'gamma', 'expected gamma third')");
if (!ok)
FAIL("list actions assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 8: Find action via Lua (returns table)
// ---------------------------------------------------------------------------
static int testFindActionLua(lua_State *L)
{
TEST("find action from Lua returns table");
ActionDatabase::getSingleton().clear();
runLua(L, "ecs.action_db.add_action('find_me', 7,"
" { values = { x = 42 } },"
" { values = { y = 99 } },"
" { type = 'task', name = 'do_something' }"
")");
bool ok = runLua(
L,
"local a = ecs.action_db.find_action('find_me');"
"assert(a ~= nil, 'action should exist');"
"assert(a.name == 'find_me', 'wrong name');"
"assert(a.cost == 7, 'wrong cost');"
"assert(a.preconditions.values.x == 42, 'wrong precond');"
"assert(a.effects.values.y == 99, 'wrong effects');"
"assert(a.behaviorTree ~= nil, 'should have behaviorTree');"
"assert(a.behaviorTree.type == 'task', 'wrong bt type');"
"assert(a.behaviorTree.name == 'do_something', 'wrong bt name')");
if (!ok)
FAIL("find action Lua assertions failed");
// Test nil for non-existent
ok = runLua(L, "local a = ecs.action_db.find_action('nonexistent');"
"assert(a == nil, 'nonexistent should be nil')");
if (!ok)
FAIL("nonexistent action should be nil");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 9: Clear all
// ---------------------------------------------------------------------------
static int testClear(lua_State *L)
{
TEST("clear all actions and goals");
ActionDatabase::getSingleton().clear();
runLua(L, "ecs.action_db.add_action('a1', 1)");
runLua(L, "ecs.action_db.add_action('a2', 2)");
runLua(L, "ecs.action_db.add_goal('g1', 10)");
if (ActionDatabase::getSingleton().actions.size() != 2)
FAIL("expected 2 actions before clear");
if (ActionDatabase::getSingleton().goals.size() != 1)
FAIL("expected 1 goal before clear");
runLua(L, "ecs.action_db.clear()");
if (ActionDatabase::getSingleton().actions.size() != 0)
FAIL("expected 0 actions after clear");
if (ActionDatabase::getSingleton().goals.size() != 0)
FAIL("expected 0 goals after clear");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 10: Action with inventory behavior tree nodes
// ---------------------------------------------------------------------------
static int testInventoryBehaviorTree(lua_State *L)
{
TEST("action with inventory behavior tree nodes");
ActionDatabase::getSingleton().clear();
bool ok = runLua(
L, "ecs.action_db.add_action('gather_wood', 2,"
" { bits = { near_forest = true } },"
" { values = { wood_count = 3 } },"
" {"
" type = 'sequence',"
" children = {"
" { type = 'setAnimationState', name = 'SM/Gather' },"
" { type = 'delay', params = '2.0' },"
" { type = 'addItemToInventory',"
" params = 'wood,Firewood,material,3,1.0,0' },"
" { type = 'setAnimationState', name = 'SM/Idle' }"
" }"
" }"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("gather_wood");
if (!action)
FAIL("action not found");
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.type != "sequence")
FAIL("expected sequence");
if (bt.children.size() != 4)
FAIL("expected 4 children");
if (bt.children[2].type != "addItemToInventory")
FAIL("wrong node type");
if (bt.children[2].params != "wood,Firewood,material,3,1.0,0")
FAIL("wrong inventory params");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 11: Action with teleport/disablePhysics behavior tree
// ---------------------------------------------------------------------------
static int testPhysicsBehaviorTree(lua_State *L)
{
TEST("action with physics behavior tree nodes");
ActionDatabase::getSingleton().clear();
bool ok = runLua(
L, "ecs.action_db.add_action('sit_on_chair', 1,"
" { bits = { near_chair = true } },"
" { bits = { is_sitting = true } },"
" {"
" type = 'sequence',"
" children = {"
" { type = 'teleportToChild', name = 'SitTarget' },"
" { type = 'disablePhysics' },"
" { type = 'setAnimationState', name = 'SM/Sit' },"
" { type = 'delay', params = '5.0' },"
" { type = 'setAnimationState', name = 'SM/Stand' },"
" { type = 'enablePhysics' }"
" }"
" }"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("sit_on_chair");
if (!action)
FAIL("action not found");
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.children.size() != 6)
FAIL("expected 6 children");
if (bt.children[0].type != "teleportToChild")
FAIL("wrong child 0 type");
if (bt.children[0].name != "SitTarget")
FAIL("wrong child 0 name");
if (bt.children[1].type != "disablePhysics")
FAIL("wrong child 1 type");
if (bt.children[4].type != "setAnimationState")
FAIL("wrong child 4 type");
if (bt.children[4].name != "SM/Stand")
FAIL("wrong child 4 name");
if (bt.children[5].type != "enablePhysics")
FAIL("wrong child 5 type");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 12: Action with blackboard check nodes
// ---------------------------------------------------------------------------
static int testBlackboardCheckBehaviorTree(lua_State *L)
{
TEST("action with blackboard check behavior tree nodes");
ActionDatabase::getSingleton().clear();
bool ok = runLua(
L,
"ecs.action_db.add_action('travel_to_market', 5,"
" { bits = { is_awake = true }, values = { energy = 20 } },"
" { bits = { at_market = true }, values = { energy = -15 } },"
" {"
" type = 'sequence',"
" children = {"
" { type = 'checkValue', name = 'energy', params = '>= 20' },"
" { type = 'setAnimationState', name = 'SM/Walk' },"
" { type = 'delay', params = '5.0' },"
" { type = 'setAnimationState', name = 'SM/Idle' },"
" { type = 'setBit', name = 'at_market', params = '1' },"
" { type = 'setBit', name = 'at_home', params = '0' }"
" }"
" }"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("travel_to_market");
if (!action)
FAIL("action not found");
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.children.size() != 6)
FAIL("expected 6 children");
if (bt.children[0].type != "checkValue")
FAIL("wrong child 0 type");
if (bt.children[0].name != "energy")
FAIL("wrong child 0 name");
if (bt.children[0].params != ">= 20")
FAIL("wrong child 0 params");
if (bt.children[4].type != "setBit")
FAIL("wrong child 4 type");
if (bt.children[4].name != "at_market")
FAIL("wrong child 4 name");
if (bt.children[4].params != "1")
FAIL("wrong child 4 params");
if (bt.children[5].type != "setBit")
FAIL("wrong child 5 type");
if (bt.children[5].name != "at_home")
FAIL("wrong child 5 name");
if (bt.children[5].params != "0")
FAIL("wrong child 5 params");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 13: Set and get bit names from Lua
// ---------------------------------------------------------------------------
static int testSetGetBitName(lua_State *L)
{
TEST("set and get bit names from Lua");
// Clear any previous bit names
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
// Set bit names via Lua
bool ok = runLua(L, "ecs.action_db.set_bit_name(0, 'has_axe');"
"ecs.action_db.set_bit_name(1, 'has_wood');"
"ecs.action_db.set_bit_name(5, 'is_hungry')");
if (!ok)
FAIL("failed to set bit names");
// Verify via C++ API
const char *name0 = GoapBlackboard::getBitName(0);
if (!name0 || strcmp(name0, "has_axe") != 0)
FAIL("bit 0 should be 'has_axe'");
const char *name1 = GoapBlackboard::getBitName(1);
if (!name1 || strcmp(name1, "has_wood") != 0)
FAIL("bit 1 should be 'has_wood'");
const char *name5 = GoapBlackboard::getBitName(5);
if (!name5 || strcmp(name5, "is_hungry") != 0)
FAIL("bit 5 should be 'is_hungry'");
// Verify unset bits are null
if (GoapBlackboard::getBitName(2) != nullptr)
FAIL("bit 2 should be unset");
// Verify via Lua get_bit_name
ok = runLua(
L,
"local n0 = ecs.action_db.get_bit_name(0);"
"assert(n0 == 'has_axe', 'expected has_axe, got ' .. tostring(n0));"
"local n1 = ecs.action_db.get_bit_name(1);"
"assert(n1 == 'has_wood', 'expected has_wood, got ' .. tostring(n1));"
"local n5 = ecs.action_db.get_bit_name(5);"
"assert(n5 == 'is_hungry', 'expected is_hungry, got ' .. tostring(n5));"
"local n2 = ecs.action_db.get_bit_name(2);"
"assert(n2 == nil, 'bit 2 should be nil, got ' .. tostring(n2))");
if (!ok)
FAIL("get_bit_name assertions failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 14: Find bit by name from Lua
// ---------------------------------------------------------------------------
static int testFindBitByName(lua_State *L)
{
TEST("find bit by name from Lua");
// Clear and set known bits
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
GoapBlackboard::setBitName(3, "near_tree");
GoapBlackboard::setBitName(7, "near_fire");
bool ok = runLua(
L,
"local idx3 = ecs.action_db.find_bit_by_name('near_tree');"
"assert(idx3 == 3, 'expected 3, got ' .. tostring(idx3));"
"local idx7 = ecs.action_db.find_bit_by_name('near_fire');"
"assert(idx7 == 7, 'expected 7, got ' .. tostring(idx7));"
"local nilIdx = ecs.action_db.find_bit_by_name('nonexistent');"
"assert(nilIdx == nil, 'expected nil, got ' .. tostring(nilIdx))");
if (!ok)
FAIL("find_bit_by_name assertions failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 15: Auto-assign bit from Lua
// ---------------------------------------------------------------------------
static int testAutoAssignBit(lua_State *L)
{
TEST("auto-assign bit from Lua");
// Clear all bits
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
// Auto-assign a new name
bool ok = runLua(
L,
"local idx = ecs.action_db.auto_assign_bit('my_flag');"
"assert(idx == 0, 'expected first free slot 0, got ' .. tostring(idx));"
"local name = ecs.action_db.get_bit_name(0);"
"assert(name == 'my_flag', 'expected my_flag, got ' .. tostring(name))");
if (!ok)
FAIL("first auto_assign failed");
// Auto-assign another - should get slot 1
ok = runLua(
L,
"local idx = ecs.action_db.auto_assign_bit('other_flag');"
"assert(idx == 1, 'expected slot 1, got ' .. tostring(idx))");
if (!ok)
FAIL("second auto_assign failed");
// Auto-assign an already-existing name - should return existing index
ok = runLua(
L,
"local idx = ecs.action_db.auto_assign_bit('my_flag');"
"assert(idx == 0, 'expected existing slot 0, got ' .. tostring(idx))");
if (!ok)
FAIL("re-assign existing name failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 16: List bit names from Lua
// ---------------------------------------------------------------------------
static int testListBitNames(lua_State *L)
{
TEST("list bit names from Lua");
// Clear and set known bits
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
GoapBlackboard::setBitName(0, "alpha");
GoapBlackboard::setBitName(5, "beta");
GoapBlackboard::setBitName(10, "gamma");
bool ok = runLua(
L,
"local bits = ecs.action_db.list_bit_names();"
"assert(#bits == 3, 'expected 3 bits, got ' .. #bits);"
"assert(bits[1].index == 0 and bits[1].name == 'alpha', 'wrong bit 0');"
"assert(bits[2].index == 5 and bits[2].name == 'beta', 'wrong bit 5');"
"assert(bits[3].index == 10 and bits[3].name == 'gamma', 'wrong bit 10')");
if (!ok)
FAIL("list_bit_names assertions failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 17: Bit names auto-assigned when used in action preconditions
// ---------------------------------------------------------------------------
static int testAutoAssignInAction(lua_State *L)
{
TEST("bit names auto-assigned in action preconditions");
// Clear all bits
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
// Create an action with a bit name that hasn't been defined yet
bool ok = runLua(L, "ecs.action_db.add_action('test_auto_bit', 1,"
" { bits = { brand_new_bit = true } },"
" { bits = { another_new_bit = true } })");
if (!ok)
FAIL("failed to create action with auto-assigned bits");
// The bits should have been auto-assigned
int idx1 = GoapBlackboard::findBitByName("brand_new_bit");
if (idx1 < 0)
FAIL("brand_new_bit should have been auto-assigned");
if (idx1 != 0)
FAIL("brand_new_bit should be at slot 0");
int idx2 = GoapBlackboard::findBitByName("another_new_bit");
if (idx2 < 0)
FAIL("another_new_bit should have been auto-assigned");
if (idx2 != 1)
FAIL("another_new_bit should be at slot 1");
// Verify the action's preconditions use the correct bit
const GoapAction *action =
ActionDatabase::getSingleton().findAction("test_auto_bit");
if (!action)
FAIL("action not found");
if (!action->preconditions.getBit(idx1))
FAIL("precondition bit should be set");
if (!action->effects.getBit(idx2))
FAIL("effect bit should be set");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
int main()
{
printf("ActionDatabase Lua API Tests\n");
printf("============================\n\n");
// Create Lua state
lua_State *L = luaL_newstate();
if (!L) {
fprintf(stderr, "Failed to create Lua state\n");
return 1;
}
luaL_openlibs(L);
// Register the action API
editScene::registerLuaActionApi(L);
// Run tests
int failures = 0;
failures += testBasicAction(L);
failures += testActionWithBehaviorTree(L);
failures += testNestedBehaviorTree(L);
failures += testGoal(L);
failures += testActionReplacement(L);
failures += testRemoveAction(L);
failures += testListActions(L);
failures += testFindActionLua(L);
failures += testClear(L);
failures += testInventoryBehaviorTree(L);
failures += testPhysicsBehaviorTree(L);
failures += testBlackboardCheckBehaviorTree(L);
failures += testSetGetBitName(L);
failures += testFindBitByName(L);
failures += testAutoAssignBit(L);
failures += testListBitNames(L);
failures += testAutoAssignInAction(L);
// Cleanup
lua_close(L);
printf("\nResults: %d/%d passed, %d failed\n", passCount, testCount,
failures);
return failures > 0 ? 1 : 0;
}

View File

@@ -0,0 +1,77 @@
/**
* @file ogre_stub.h
* @brief Minimal Ogre stub for standalone tests.
*
* Provides just enough Ogre type aliases to compile the
* ActionDatabase, GoapBlackboard, GoapGoal, GoapAction,
* and BehaviorTree components without the full Ogre SDK.
*
* Only for use in standalone tests (action_db_lua_test).
*/
#ifndef OGRE_STUB_H
#define OGRE_STUB_H
#include <string>
#include <vector>
#include <cstdint>
namespace Ogre
{
// String is just std::string
using String = std::string;
// Minimal Vector3 for GoapBlackboard
struct Vector3 {
float x, y, z;
Vector3()
: x(0)
, y(0)
, z(0)
{
}
Vector3(float x_, float y_, float z_)
: x(x_)
, y(y_)
, z(z_)
{
}
static const Vector3 ZERO;
// Member operator== needed by std::pair::operator== when comparing
// unordered_map<string, Vector3> in GoapBlackboard::operator==
bool operator==(const Vector3 &other) const
{
return x == other.x && y == other.y && z == other.z;
}
};
inline const Vector3 Vector3::ZERO(0, 0, 0);
// Minimal LogManager stub (used by LuaActionApi)
class LogManager {
public:
static LogManager &getSingleton()
{
static LogManager instance;
return instance;
}
class Stream {
public:
template <typename T> Stream &operator<<(const T &)
{
return *this;
}
};
Stream stream()
{
return Stream();
}
};
} // namespace Ogre
#endif // OGRE_STUB_H

View File

@@ -4,7 +4,7 @@
#include <imgui.h>
bool ActionDatabaseEditor::renderComponent(flecs::entity entity,
ActionDatabase &db)
ActionDatabaseComponent &db)
{
bool modified = false;
(void)entity;
@@ -14,15 +14,18 @@ bool ActionDatabaseEditor::renderComponent(flecs::entity entity,
ImGuiTreeNodeFlags_DefaultOpen))
modified |= renderBitNames();
if (ImGui::CollapsingHeader("Actions",
ImGuiTreeNodeFlags_DefaultOpen))
if (ImGui::CollapsingHeader("Actions", ImGuiTreeNodeFlags_DefaultOpen))
renderActions(db);
if (ImGui::CollapsingHeader("Goals",
ImGuiTreeNodeFlags_DefaultOpen))
if (ImGui::CollapsingHeader("Goals", ImGuiTreeNodeFlags_DefaultOpen))
renderGoals(db);
ImGui::PopID();
// Sync to singleton if modified
if (modified)
db.syncToSingleton();
return modified;
}
@@ -53,7 +56,7 @@ bool ActionDatabaseEditor::renderBitNames()
return changed;
}
void ActionDatabaseEditor::renderActions(ActionDatabase &db)
void ActionDatabaseEditor::renderActions(ActionDatabaseComponent &db)
{
ImGui::Indent();
@@ -71,6 +74,7 @@ void ActionDatabaseEditor::renderActions(ActionDatabase &db)
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
db.actions.erase(db.actions.begin() + i);
db.syncToSingleton();
if (m_selectedAction == (int)i)
m_selectedAction = -1;
else if (m_selectedAction > (int)i)
@@ -86,18 +90,20 @@ void ActionDatabaseEditor::renderActions(ActionDatabase &db)
snprintf(newName, sizeof(newName), "Action_%zu",
db.actions.size());
db.actions.emplace_back(newName);
db.syncToSingleton();
m_selectedAction = (int)db.actions.size() - 1;
}
if (m_selectedAction >= 0 &&
m_selectedAction < (int)db.actions.size()) {
renderActionEditor(db.actions[m_selectedAction]);
db.syncToSingleton();
}
ImGui::Unindent();
}
void ActionDatabaseEditor::renderGoals(ActionDatabase &db)
void ActionDatabaseEditor::renderGoals(ActionDatabaseComponent &db)
{
ImGui::Indent();
@@ -115,6 +121,7 @@ void ActionDatabaseEditor::renderGoals(ActionDatabase &db)
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
db.goals.erase(db.goals.begin() + i);
db.syncToSingleton();
if (m_selectedGoal == (int)i)
m_selectedGoal = -1;
else if (m_selectedGoal > (int)i)
@@ -127,15 +134,15 @@ void ActionDatabaseEditor::renderGoals(ActionDatabase &db)
if (ImGui::Button("Add Goal")) {
char newName[64];
snprintf(newName, sizeof(newName), "Goal_%zu",
db.goals.size());
snprintf(newName, sizeof(newName), "Goal_%zu", db.goals.size());
db.goals.emplace_back(newName);
db.syncToSingleton();
m_selectedGoal = (int)db.goals.size() - 1;
}
if (m_selectedGoal >= 0 &&
m_selectedGoal < (int)db.goals.size()) {
if (m_selectedGoal >= 0 && m_selectedGoal < (int)db.goals.size()) {
renderGoalEditor(db.goals[m_selectedGoal]);
db.syncToSingleton();
}
ImGui::Unindent();
@@ -168,8 +175,7 @@ void ActionDatabaseEditor::renderActionEditor(GoapAction &action)
}
if (ImGui::TreeNode("Preconditions")) {
GoapBlackboardEditor::render(action.preconditions,
"precond");
GoapBlackboardEditor::render(action.preconditions, "precond");
ImGui::TreePop();
}
@@ -179,8 +185,7 @@ void ActionDatabaseEditor::renderActionEditor(GoapAction &action)
for (int i = 0; i < 64; i += 8) {
for (int j = 0; j < 8 && (i + j) < 64; j++) {
int bit = i + j;
bool enabled = (action.preconditionMask >>
bit) & 1ULL;
bool enabled = (action.preconditionMask >> bit) & 1ULL;
char label[8];
snprintf(label, sizeof(label), "%d", bit);
if (j > 0)

View File

@@ -6,21 +6,25 @@
#include "../components/ActionDatabase.hpp"
/**
* Editor for ActionDatabase component.
* Editor for ActionDatabaseComponent.
*
* Allows editing the global list of GOAP actions and goals.
* Edits the component data and syncs to the ActionDatabase singleton.
*/
class ActionDatabaseEditor : public ComponentEditor<ActionDatabase> {
class ActionDatabaseEditor : public ComponentEditor<ActionDatabaseComponent> {
public:
const char *getName() const override { return "Action Database"; }
const char *getName() const override
{
return "Action Database";
}
protected:
bool renderComponent(flecs::entity entity,
ActionDatabase &db) override;
ActionDatabaseComponent &db) override;
private:
void renderActions(ActionDatabase &db);
void renderGoals(ActionDatabase &db);
void renderActions(ActionDatabaseComponent &db);
void renderGoals(ActionDatabaseComponent &db);
void renderActionEditor(GoapAction &action);
void renderGoalEditor(GoapGoal &goal);
bool renderBitNames();

View File

@@ -7,14 +7,8 @@
ActionDatabase *ActionDebugEditor::findDatabase(flecs::entity entity)
{
auto world = entity.world();
ActionDatabase *db = nullptr;
world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
return db;
(void)entity;
return ActionDatabase::getSingletonPtr();
}
bool ActionDebugEditor::renderComponent(flecs::entity entity,

View File

@@ -4,14 +4,8 @@
ActionDatabase *ActuatorEditor::findDatabase(flecs::entity entity)
{
auto world = entity.world();
ActionDatabase *db = nullptr;
world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
return db;
(void)entity;
return ActionDatabase::getSingletonPtr();
}
bool ActuatorEditor::renderComponent(flecs::entity entity,

View File

@@ -4,14 +4,8 @@
static ActionDatabase *findDatabase(flecs::entity entity)
{
auto world = entity.world();
ActionDatabase *db = nullptr;
world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
return db;
(void)entity;
return ActionDatabase::getSingletonPtr();
}
bool EventHandlerEditor::renderComponent(flecs::entity entity,

View File

@@ -5,35 +5,9 @@
ActionDatabase *GoapPlannerEditor::findDatabase(
flecs::entity entity, GoapPlannerComponent &planner)
{
auto world = entity.world();
// 1. Try to find by referenced entity name
if (!planner.actionDatabaseRef.empty()) {
ActionDatabase *found = nullptr;
world.query<ActionDatabase>()
.each([&](flecs::entity dbEntity, ActionDatabase &db) {
if (found)
return;
if (dbEntity.has<EntityNameComponent>()) {
auto &name =
dbEntity.get<EntityNameComponent>();
if (name.name ==
planner.actionDatabaseRef)
found = &db;
}
});
if (found)
return found;
}
// 2. Fall back to any ActionDatabase in the world
ActionDatabase *db = nullptr;
world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
return db;
(void)entity;
(void)planner;
return ActionDatabase::getSingletonPtr();
}
bool GoapPlannerEditor::renderComponent(flecs::entity entity,

View File

@@ -83,14 +83,8 @@ bool ItemEditor::renderComponent(flecs::entity entity, ItemComponent &item)
modified = true;
}
// Pick from action database
ActionDatabase *db = nullptr;
auto world = entity.world();
world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Pick from action database singleton
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (db && !db->actions.empty()) {
static int selectedAction = -1;

View File

@@ -3,14 +3,8 @@
ActionDatabase *SmartObjectEditor::findDatabase(flecs::entity entity)
{
auto world = entity.world();
ActionDatabase *db = nullptr;
world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
return db;
(void)entity;
return ActionDatabase::getSingletonPtr();
}
bool SmartObjectEditor::renderComponent(flecs::entity entity,