@yantra-core/sutra
Version:
A JavaScript behavior tree library for easily creating and managing complex behavior patterns in game development.
665 lines (573 loc) • 21.8 kB
JavaScript
import exportToEnglish from "./exportToEnglish.js";
import serializeToJson from "./serializeToJson.js";
import evaluateCondition from "./evaluateCondition.js";
import evaluateSingleCondition from "./evaluateSingleCondition.js";
import evaluateDSLCondition from "./evaluateDSLCondition.js";
import evaluateCompositeCondition from "./evaluateCompositeCondition.js";
import parsePath from "./parsePath.js";
import operatorAliases from "./operatorAliases.js";
let logger = function () { };
// logger = console.log.bind(console);
class Sutra {
constructor() {
this.tree = [];
this.conditions = {};
this.listeners = {};
this.maps = {};
this.operators = [
'equals',
'notEquals',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'and',
'or',
'not'
];
this.operatorAliases = operatorAliases;
this.exportToEnglish = exportToEnglish;
this.serializeToJson = serializeToJson;
this.toJSON = serializeToJson;
this.toEnglish = exportToEnglish;
this.evaluateCondition = evaluateCondition;
this.evaluateSingleCondition = evaluateSingleCondition;
this.evaluateDSLCondition = evaluateDSLCondition;
this.evaluateCompositeCondition = evaluateCompositeCondition;
this.parsePath = parsePath;
this.nodeIdCounter = 0; // New property to keep track of node IDs
}
use(subSutra, name, insertAt = this.tree.length, shareListeners = true) {
// Store a reference to the subSutra for subtree-specific logic
this.subtrees = this.subtrees || {};
subSutra.isSubtree = true;
subSutra.parent = this;
this.subtrees[name] = subSutra;
if (shareListeners) {
this.sharedListeners = true;
// Merge subtree's listeners into the main tree's listeners
this.listeners = { ...this.listeners, ...subSutra.listeners };
this.anyListeners = [...(this.anyListeners || []), ...(subSutra.anyListeners || [])];
// Optionally, update the subtree's listeners to reflect this change
// This ensures that both the subtree and main tree have the same set of listeners
subSutra.listeners = this.listeners;
subSutra.anyListeners = this.anyListeners;
}
// Integrate conditions from the subSutra
Object.entries(subSutra.conditions).forEach(([conditionName, condition]) => {
if (this.conditions[conditionName]) {
console.warn(`Condition '${conditionName}' from subtree '${name}' will overwrite an existing condition in the main Sutra.`);
}
this.addCondition(conditionName, condition);
});
// always combine conditions from subtrees
subSutra.conditions = { ...this.conditions, ...subSutra.conditions };
}
on(event, listener) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(listener);
}
emit(event, ...args) {
// Emit to the current instance's listeners
this.emitLocal(event, ...args);
// If this instance is a subtree and sharedListeners is true, propagate to the parent tree
if (this.isSubtree && this.sharedListeners && this.parent) {
this.parent.emitShared(event, ...args);
}
}
emitLocal(event, ...args) {
// Trigger all listeners for this specific event
if (this.listeners[event]) {
this.listeners[event].forEach(listener => listener(...args));
}
// Trigger all 'any' listeners, regardless of the event type
if (this.anyListeners) {
this.anyListeners.forEach(listener => listener(event, ...args));
}
}
emitShared(event, ...args) {
// Emit to main tree's listeners
if (this.listeners[event]) {
this.listeners[event].forEach(listener => listener(...args));
}
if (this.anyListeners) {
this.anyListeners.forEach(listener => listener(event, ...args));
}
// Additionally, emit to each subtree's listeners
Object.values(this.subtrees).forEach(subtree => {
if (subtree.listeners[event]) {
subtree.listeners[event].forEach(listener => listener(...args));
}
if (subtree.anyListeners) {
subtree.anyListeners.forEach(listener => listener(event, ...args));
}
});
}
onAny(listener) {
// Initialize the anyListeners array if it doesn't exist
this.anyListeners = this.anyListeners || [];
this.anyListeners.push(listener);
}
if() {
const conditions = Array.from(arguments); // Convert arguments to an array
const lastNode = this.tree.length > 0 ? this.tree[this.tree.length - 1] : null;
if (lastNode && !lastNode.then) {
// If the last node exists and doesn't have a 'then', add conditions to it
if (!Array.isArray(lastNode.if)) {
lastNode.if = [lastNode.if];
}
lastNode.if = lastNode.if.concat(conditions);
} else {
// Create a new node
const node = { if: conditions.length > 1 ? conditions : conditions[0] };
this.addAction(node);
}
return this; // Return this for chaining
}
then(actionOrFunction, data = null) {
const lastNode = this.tree[this.tree.length - 1];
if (!lastNode.then) {
lastNode.then = [];
}
if (typeof actionOrFunction === 'function') {
// Create a scoped context
const scopedContext = {
if: (condition) => {
const node = { if: condition, then: [] };
node.sutraPath = `${lastNode.sutraPath}.then[${lastNode.then.length}]`;
lastNode.then.push(node);
return scopedContext; // Allow chaining within the scoped context
},
then: (action) => {
const node = lastNode.then[lastNode.then.length - 1];
if (!node.then) {
node.then = [];
}
const actionNode = { action: action };
actionNode.sutraPath = `${node.sutraPath}.then[${node.then.length}]`;
node.then.push(actionNode);
return scopedContext;
},
else: (action) => {
const node = lastNode.then[lastNode.then.length - 1];
if (!node.else) {
node.else = [];
}
const actionNode = { action: action };
actionNode.sutraPath = `${node.sutraPath}.else[${node.else.length}]`;
node.else.push(actionNode);
return scopedContext;
},
map: (name) => {
// Add the map node to the last 'then' node
let mapNode = { map: name };
lastNode.then.push(mapNode);
return scopedContext; // Allow chaining within the scoped context
}
};
// Execute the function in the scoped context
actionOrFunction(scopedContext);
} else {
// check see if string matches name of known subtree
let subSutra;
if (this.subtrees) {
subSutra = this.subtrees[actionOrFunction];
}
const actionNode = {};
if (data) {
actionNode.data = data;
}
if (subSutra) {
// If it's a subtree, add a subtree node
lastNode.subtree = actionOrFunction;
delete lastNode.then;
} else {
// Otherwise, add an action node
actionNode.action = actionOrFunction;
actionNode.sutraPath = `${lastNode.sutraPath}.then[${lastNode.then.length}]`;
lastNode.then.push(actionNode);
}
}
return this; // Return this for chaining
}
else(actionOrFunction, data = null) {
const lastNode = this.tree[this.tree.length - 1];
if (!lastNode.else) {
lastNode.else = [];
}
if (typeof actionOrFunction === 'function') {
// Create a scoped context for else
const scopedContext = {
if: (condition) => {
const node = { if: condition, then: [], else: [] };
lastNode.else.push(node);
return scopedContext; // Allow chaining within the scoped context
},
then: (action) => {
const node = lastNode.else[lastNode.else.length - 1];
if (!node.then) {
node.then = [];
}
node.then.push({ action: action, data: data });
return scopedContext;
},
else: (action) => {
const node = lastNode.else[lastNode.else.length - 1];
if (!node.else) {
node.else = [];
}
node.else.push({ action: action, data: data });
return scopedContext;
}
};
// Execute the function in the scoped context
actionOrFunction(scopedContext);
} else {
lastNode.else.push({ action: actionOrFunction, data: data });
}
return this; // Return this for chaining
}
addMap(name, mapFunction) {
const mapNode = { map: name, func: mapFunction };
this.maps[name] = mapNode;
this.generateSutraPath(mapNode, 'tree', this.tree.length - 1, null);
}
// Method to execute a map node
executeMap(mapNode, data, gameState) {
if (typeof mapNode.func === 'function') {
// Execute the map function and update data and gameState accordingly
const result = mapNode.func(data, gameState);
if (result !== undefined) {
// Update data and gameState if result is returned
return result;
}
}
return data; // Return original data if no transformation occurred
}
// Fluent API for map
map(name) {
let lastNode = this.tree[this.tree.length - 1];
// If there's no last 'then' node or it's a placeholder, create a new node
if (!lastNode) {
lastNode = { then: [] };
// lastNode.then.push(lastThenNode);
}
if (!lastNode.then) {
lastNode.then = [];
}
// Add the map node to the last 'then' node
let mapNode = { map: name };
lastNode.then.push(mapNode);
return this; // Allow chaining within the scoped context
}
addAction(node) {
this.tree.push(node);
this.generateSutraPath(node, 'tree', this.tree.length - 1, null);
}
addCondition(name, conditionObj) {
this.originalConditions = this.originalConditions || {};
if (Array.isArray(conditionObj)) {
this.conditions[name] = conditionObj.map(cond => {
if (typeof cond === 'function') {
this.originalConditions[name] = this.originalConditions[name] || [];
this.originalConditions[name].push({ type: 'function', func: cond });
return { func: (data, gameState) => cond(data, gameState), original: null };
} else {
this.originalConditions[name] = this.originalConditions[name] || [];
this.originalConditions[name].push(cond);
const conditionFunc = (data, gameState) => this.evaluateDSLCondition(cond, data, gameState);
return { func: conditionFunc, original: cond };
}
});
} else {
this.storeSingleCondition(name, conditionObj);
}
}
removeCondition(name) {
if (this.conditions[name]) {
delete this.conditions[name];
if (this.originalConditions && this.originalConditions[name]) {
delete this.originalConditions[name];
}
return true;
}
return false; // Condition name not found
}
updateCondition(name, newConditionObj) {
if (!this.conditions[name]) {
return false;
}
// If the new condition is a function, update directly
if (typeof newConditionObj === 'function') {
this.conditions[name] = newConditionObj;
} else if (typeof newConditionObj === 'object') {
// Handle if newConditionObj is an array
if (Array.isArray(newConditionObj)) {
// Update each condition in the array
newConditionObj.forEach(condition => {
if (condition.op === 'and' || condition.op === 'or' || condition.op === 'not') {
// Composite condition for each element in the array
const conditionFunc = (data, gameState) => this.evaluateDSLCondition(condition, data, gameState);
conditionFunc.original = condition;
this.conditions[name] = conditionFunc;
} else {
// DSL condition for each element in the array
const conditionFunc = (data, gameState) => this.evaluateDSLCondition(condition, data, gameState);
conditionFunc.original = condition;
this.conditions[name] = conditionFunc;
}
});
} else if (newConditionObj.op === 'and' || newConditionObj.op === 'or' || newConditionObj.op === 'not') {
// Composite condition
const conditionFunc = (data, gameState) => this.evaluateDSLCondition(newConditionObj, data, gameState);
conditionFunc.original = newConditionObj;
this.conditions[name] = conditionFunc;
} else {
// DSL condition
const conditionFunc = (data, gameState) => this.evaluateDSLCondition(newConditionObj, data, gameState);
conditionFunc.original = newConditionObj;
this.conditions[name] = conditionFunc;
}
} else {
return false;
}
// Update original conditions for GUI use
this.originalConditions[name] = newConditionObj;
return true;
}
storeSingleCondition(name, conditionObj) {
// Store the original condition object separately for GUI use
if (!(typeof conditionObj === 'function' && conditionObj.original)) {
this.originalConditions = this.originalConditions || {};
this.originalConditions[name] = conditionObj;
}
if (conditionObj.op === 'and' || conditionObj.op === 'or' || conditionObj.op === 'not') {
// Store composite conditions directly
this.conditions[name] = conditionObj;
this.originalConditions[name] = conditionObj;
} else if (typeof conditionObj === 'function') {
// Wrap custom function conditions to include gameState
this.conditions[name] = function (data, gameState) {
let val = false;
try {
val = conditionObj(data, gameState);
} catch (err) {
// console.log('warning: error in condition function', err)
}
return val;
}
} else {
// For DSL conditions, pass gameState to the evaluateDSLCondition function
const conditionFunc = (data, gameState) => this.evaluateDSLCondition(conditionObj, data, gameState);
conditionFunc.original = conditionObj;
this.conditions[name] = conditionFunc;
}
}
resolveOperator(operator) {
return this.operatorAliases[operator] || operator;
}
// Method to set or update aliases
setOperatorAlias(alias, operator) {
this.operatorAliases[alias] = operator;
}
getConditionFunction(name) {
return this.conditions[name];
}
getCondition(name) {
return this.originalConditions ? this.originalConditions[name] : undefined;
}
getOperators() {
return Object.keys(this.operatorAliases);
}
traverseNode(node, data, gameState, mappedData = null) {
if (node.subtree) {
const subSutra = this.subtrees[node.subtree];
if (subSutra) {
const conditionMet = node.if ? this.evaluateCondition(node.if, data, gameState, subSutra) : true;
if (conditionMet) {
subSutra.tick(data, gameState);
}
} else {
console.warn(`Subtree '${node.subtree}' not found.`);
}
return;
}
// Use mappedData if available, otherwise use the original data
const currentData = mappedData || data;
// Process the map node if present
if (node.map) {
let map = this.maps[node.map];
if (!map) {
throw new Error(`Map "${node.map}" not found`);
}
const newMappedData = this.executeMap(map, currentData, gameState);
if (newMappedData !== undefined) {
mappedData = newMappedData;
}
}
// Execute action if present
if (node.action) {
this.executeAction(node.action, mappedData || currentData, node, gameState);
return;
}
const conditionMet = node.if ? this.evaluateCondition(node.if, mappedData || currentData, gameState) : true;
if (conditionMet) {
this.processBranch(node.then, mappedData || currentData, gameState, mappedData);
} else {
this.processBranch(node.else, mappedData || currentData, gameState, mappedData);
}
}
processBranch(branch, data, gameState, mappedData = null) {
if (Array.isArray(branch)) {
branch.forEach(childNode => this.traverseNode(childNode, data, gameState, mappedData));
}
}
executeAction(action, data, node, gameState) {
let object = {};
if (!node.data) {
node.data = {};
}
let entityData = data;
Object.entries(entityData).forEach(([key, value]) => {
// check data to see if any of the keys at the first level are functions
// if so, execute them and replace the value with the result
// this is to allow for dynamic data to be passed to the action
if (typeof value === 'function') {
object[key] = value(entityData, gameState, node);
} else {
object[key] = value;
}
});
Object.entries(node.data).forEach(([key, value]) => {
if (typeof value === 'function') {
object[key] = value(entityData, gameState, node);
} else {
object[key] = value;
}
});
let mergedData = object;
this.emit(action, mergedData, node, gameState);
}
updateEntity(entity, updateData, gameState) {
Object.entries(updateData).forEach(([key, value]) => {
if (typeof value === 'function') {
entity[key] = value();
} else {
entity[key] = value;
}
});
}
generateSutraPath(node, parentPath, index, parent) {
const path = index === -1 ? parentPath : `${parentPath}[${index}]`;
node.sutraPath = path;
node.parent = parent; // Set the parent reference
if (node.then && Array.isArray(node.then)) {
node.then.forEach((child, idx) => this.generateSutraPath(child, `${path}.then`, idx, node));
}
if (node.else && Array.isArray(node.else)) {
node.else.forEach((child, idx) => this.generateSutraPath(child, `${path}.else`, idx, node));
}
}
getNestedValue(obj, path) {
const pathArray = this.parsePath(path);
return pathArray.reduce((current, part) => {
return current && current[part] !== undefined ? current[part] : undefined;
}, obj);
}
findNode(path) {
// Remark: findNode is intentionally not recursive / doesn't use visitor pattern
// This choice is based on performance considerations
// Feel free to create a benchmark to compare the performance of this
let obj = this;
const pathArray = this.parsePath(path);
let current = obj;
for (const part of pathArray) {
if (current[part] === undefined) {
return undefined;
}
current = current[part];
}
return current;
}
removeNode(path) {
// Split the path into segments and find the parent node and the index of the node to be removed
const pathArray = this.parsePath(path);
let current = this;
for (let i = 0; i < pathArray.length - 1; i++) {
const part = pathArray[i];
if (current[part] === undefined) {
return; // Node doesn't exist, nothing to remove
}
current = current[part];
}
const nodeToRemoveIndex = pathArray[pathArray.length - 1];
if (Array.isArray(current) && typeof nodeToRemoveIndex === 'number') {
// If current is an array and nodeToRemoveIndex is an index, use splice
if (current.length > nodeToRemoveIndex) {
current.splice(nodeToRemoveIndex, 1);
// Reconstruct the parentPath
const parentPath = pathArray.slice(0, -1).reduce((acc, curr, idx) => {
// Append array indices with brackets and property names with dots
return idx === 0 ? curr : (!isNaN(curr) ? `${acc}[${curr}]` : `${acc}.${curr}`);
}, '');
// Update sutraPath for subsequent nodes in the same array
this.updateSutraPaths(current, nodeToRemoveIndex, parentPath);
}
} else if (current[nodeToRemoveIndex] !== undefined) {
// If it's a regular object property
delete current[nodeToRemoveIndex];
}
}
updateSutraPaths(nodes, startIndex, parentPath) {
for (let i = startIndex; i < nodes.length; i++) {
// Convert dot notation to bracket notation for indices in the parentPath
const adjustedParentPath = parentPath.replace(/\.(\d+)(?=\[|$)/g, '[$1]');
this.generateSutraPath(nodes[i], adjustedParentPath, i, nodes[i].parent);
}
}
updateNode(path, newNodeData) {
const node = this.findNode(path);
if (node) {
Object.assign(node, newNodeData);
return true;
}
return false;
}
tick(data, gameState = {}) {
let subtreeEvaluated = false;
// Iterate over the main tree's nodes
this.tree.forEach(node => {
if (node.subtree) {
subtreeEvaluated = true;
this.traverseNode(node, data, gameState);
} else {
this.traverseNode(node, data, gameState);
}
});
// If no subtrees were directly evaluated in the main tree, then evaluate all subtrees
if (this.subtrees && !subtreeEvaluated) {
Object.values(this.subtrees).forEach(subSutra => {
subSutra.tick(data, gameState);
});
}
}
getSubtree(subtreeName) {
return this.subtrees[subtreeName];
}
getReadableSutraPath(sutraPath) {
const node = this.findNode(sutraPath);
if (!node) return 'Invalid path';
// Recursive function to build the readable path
const buildPath = (node, path = '') => {
if (!node.parent) return path;
const parent = node.parent;
const part = parent.if ? `${parent.if}` : (parent.action ? `${parent.action}` : 'unknown');
const newPath = part + (path ? ' and ' + path : '');
return buildPath(parent, newPath);
};
return buildPath(node);
}
}
export default Sutra;