Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
{
"label": "compile",
"type": "shell",
"command": "npm run compile",
"command": "npm.cmd run compile",
"group": { "kind": "build", "isDefault": true },
"presentation": { "reveal": "silent" },
"problemMatcher": "$tsc"
},
{
"label": "watch",
"type": "shell",
"command": "npm run watch",
"command": "npm.cmd run watch",
"isBackground": true,
"group": "build",
"presentation": { "reveal": "silent" },
Expand Down
43 changes: 42 additions & 1 deletion media/editor/btEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ROOT_NODE_ID } from "./layout/dagreLayout";
import { NodePalette } from "./components/NodePalette";
import { NodeConfigPanel } from "./components/NodeConfigPanel";
import { useBtEditor } from "./hooks/useBtEditor";
import { observerAbortTargets } from "./utils/observerAbort";
import type { BtNode } from "../../shared/types";
import {
BT_PARALLEL_FAILURE_CHILD_ONE,
Expand Down Expand Up @@ -304,6 +305,30 @@ function BtEditorInner() {
const selectedIsPending = selectedNode?.id.startsWith("pending-") ?? false;

const activeSub = state.subtrees[state.activeIndex];
const observerTargets = observerAbortTargets(
state.nodes,
state.edges,
selectedBtNode?.kind === "decorator" ? selectedNode?.id : undefined,
state.typeVars,
activeSub?.bindings,
);
const displayNodes = state.nodes.map((node) => {
const highlightColor = observerTargets.self.has(node.id)
? "#38bdf8"
: observerTargets.lowerPriority.has(node.id)
? "#f97316"
: undefined;
return highlightColor
? {
...node,
style: {
...node.style,
boxShadow: `0 0 0 3px ${highlightColor}, 0 0 14px ${highlightColor}99`,
borderRadius: 5,
},
}
: node;
});

return (
<TypeVarsContext.Provider value={state.typeVars}>
Expand Down Expand Up @@ -433,6 +458,22 @@ function BtEditorInner() {
</button>
</div>
)}
{observerTargets.mode !== "none" && (
<div
style={{
display: "flex",
gap: 12,
padding: "4px 12px",
fontSize: 11,
background: "var(--vscode-editor-inactiveSelectionBackground, #264f78)",
color: "var(--vscode-editor-foreground, #ccc)",
flexShrink: 0,
}}
>
{observerTargets.self.size > 0 && <span style={{ color: "#38bdf8" }}>■ Self abort</span>}
{observerTargets.lowerPriority.size > 0 && <span style={{ color: "#f97316" }}>■ Lower-priority abort</span>}
</div>
)}
{/* Main canvas row */}
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
<NodePalette
Expand All @@ -451,7 +492,7 @@ function BtEditorInner() {
onDrop={onDrop}
>
<ReactFlow
nodes={state.nodes}
nodes={displayNodes}
edges={state.edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
Expand Down
115 changes: 115 additions & 0 deletions media/editor/utils/observerAbort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { Edge, Node } from "@xyflow/react";
import {
BT_ABORT_BOTH,
BT_ABORT_LOWER_PRIORITY,
BT_ABORT_NONE,
BT_ABORT_SELF,
} from "../../../shared/btConstants";
import type { BtBindingDeclarations, BtNode } from "../../../shared/types";
import type { TypeVarsMap } from "../contexts/TypeVarsContext";

export type ObserverAbortHighlight = "none" | "self" | "lower" | "both";

export interface ObserverAbortTargets {
mode: ObserverAbortHighlight;
self: Set<string>;
lowerPriority: Set<string>;
}

function btNodeFor(node: Node | undefined): BtNode | undefined {
return node?.data._btNode as BtNode | undefined;
}

function observerAbortMode(
decorator: Extract<BtNode, { kind: "decorator" }>,
typeVars: TypeVarsMap | null,
bindings: BtBindingDeclarations | undefined,
): ObserverAbortHighlight {
const declaredDefault = typeVars?.[decorator.nodeType]?.vars.find(
(variable) => variable.name === "observer_abort",
)?.defaultValue;
let value = decorator.vars.observer_abort ?? declaredDefault ?? BT_ABORT_NONE;
if (Array.isArray(value)) value = value[0] ?? BT_ABORT_NONE;
if (value.startsWith("$") && bindings) value = bindings[value.slice(1)]?.default ?? value;

switch (value.trim()) {
case BT_ABORT_SELF:
return "self";
case BT_ABORT_LOWER_PRIORITY:
return "lower";
case BT_ABORT_BOTH:
return "both";
default:
return "none";
}
}

function descendants(rootId: string, childrenByParent: Map<string, string[]>): Set<string> {
const result = new Set<string>();
const pending = [rootId];
while (pending.length > 0) {
const id = pending.pop()!;
if (result.has(id)) continue;
result.add(id);
pending.push(...(childrenByParent.get(id) ?? []));
}
return result;
}

/**
* Identify the branches that an observer decorator can abort. Lower-priority
* branches are the later children of the closest enclosing selector.
*/
export function observerAbortTargets(
nodes: Node[],
edges: Edge[],
decoratorId: string | undefined,
typeVars: TypeVarsMap | null,
bindings: BtBindingDeclarations | undefined,
): ObserverAbortTargets {
const empty: ObserverAbortTargets = { mode: "none", self: new Set(), lowerPriority: new Set() };
if (!decoratorId) return empty;

const nodeById = new Map(nodes.map((node) => [node.id, node]));
const decorator = btNodeFor(nodeById.get(decoratorId));
if (decorator?.kind !== "decorator") return empty;

const mode = observerAbortMode(decorator, typeVars, bindings);
if (mode === "none") return empty;

const parentByChild = new Map<string, string>();
const childrenByParent = new Map<string, string[]>();
for (const edge of edges) {
parentByChild.set(edge.target, edge.source);
const children = childrenByParent.get(edge.source) ?? [];
children.push(edge.target);
childrenByParent.set(edge.source, children);
}

const self = mode === "self" || mode === "both"
? descendants(decoratorId, childrenByParent)
: new Set<string>();
const lowerPriority = new Set<string>();

if (mode === "lower" || mode === "both") {
let branchId = decoratorId;
let parentId = parentByChild.get(branchId);
while (parentId) {
const parent = btNodeFor(nodeById.get(parentId));
if (parent?.kind === "selector") {
const branchIndex = Number(nodeById.get(branchId)?.data.childIndex);
for (const siblingId of childrenByParent.get(parentId) ?? []) {
const siblingIndex = Number(nodeById.get(siblingId)?.data.childIndex);
if (Number.isFinite(branchIndex) && siblingIndex > branchIndex) {
for (const id of descendants(siblingId, childrenByParent)) lowerPriority.add(id);
}
}
break;
}
branchId = parentId;
parentId = parentByChild.get(branchId);
}
}

return { mode, self, lowerPriority };
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "behaviortreeg",
"displayName": "BehaviorTreeG",
"description": "Visual behavior tree editor for tgstation's implementation of behavior trees",
"version": "0.2.5",
"version": "0.2.6",
"icon": "media/bt-editor-logo.png",
"license": "GPL-3.0",
"publisher": "behaviortreeg",
Expand Down
68 changes: 68 additions & 0 deletions tests/observerAbort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Edge, Node } from "@xyflow/react";
import { BT_ABORT_BOTH, BT_ABORT_LOWER_PRIORITY, BT_ABORT_SELF } from "../shared/btConstants";
import { observerAbortTargets } from "../media/editor/utils/observerAbort";

function node(id: string, kind: string, childIndex?: number, vars: Record<string, string> = {}): Node {
const btNode = kind === "decorator"
? { kind, nodeType: "/datum/bt_node/decorator/observer", vars }
: kind === "selector"
? { kind, children: [] }
: { kind: "leaf", behaviorType: "/datum/bt_node/ai_behavior/test", vars: {} };
return { id, position: { x: 0, y: 0 }, data: { _btNode: btNode, childIndex } };
}

const edges: Edge[] = [
{ id: "e0", source: "selector", target: "decorator" },
{ id: "e1", source: "decorator", target: "self-child" },
{ id: "e2", source: "selector", target: "lower" },
{ id: "e3", source: "lower", target: "lower-child" },
];

const baseNodes = [
node("selector", "selector"),
node("decorator", "decorator", 0),
node("self-child", "leaf"),
node("lower", "leaf", 1),
node("lower-child", "leaf"),
];

describe("observerAbortTargets", () => {
it("highlights the decorator branch for self aborts", () => {
const nodes = baseNodes.map((n) => n.id === "decorator"
? node("decorator", "decorator", 0, { observer_abort: BT_ABORT_SELF })
: n);
const targets = observerAbortTargets(nodes, edges, "decorator", null, undefined);
expect(targets.mode).toBe("self");
expect([...targets.self]).toEqual(["decorator", "self-child"]);
expect(targets.lowerPriority).toEqual(new Set());
});

it("highlights later selector branches for lower-priority aborts", () => {
const nodes = baseNodes.map((n) => n.id === "decorator"
? node("decorator", "decorator", 0, { observer_abort: BT_ABORT_LOWER_PRIORITY })
: n);
const targets = observerAbortTargets(nodes, edges, "decorator", null, undefined);
expect(targets.mode).toBe("lower");
expect(targets.self).toEqual(new Set());
expect([...targets.lowerPriority]).toEqual(["lower", "lower-child"]);
});

it("uses the scanned default and active binding when the decorator does not override it", () => {
const typeVars = {
"/datum/bt_node/decorator/observer": {
params: [],
vars: [{ name: "observer_abort", defaultValue: "$abort_mode" }],
},
};
const targets = observerAbortTargets(
baseNodes,
edges,
"decorator",
typeVars,
{ abort_mode: { label: "Abort mode", default: BT_ABORT_BOTH } },
);
expect(targets.mode).toBe("both");
expect([...targets.self]).toEqual(["decorator", "self-child"]);
expect([...targets.lowerPriority]).toEqual(["lower", "lower-child"]);
});
});
Loading