From 296b7f7fa8295db9dd716453c7a86715172bc513 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:57:58 -0400 Subject: [PATCH] feat-add-ai-decision-graphs --- .claude/skills/jgengine-world/api.md | 17 +++ .claude/skills/jgengine-world/capabilities.md | 4 + CHANGELOG.md | 1 + packages/core/src/ai/decisionGraph.test.ts | 62 +++++++++ packages/core/src/ai/decisionGraph.ts | 127 ++++++++++++++++++ packages/core/src/ai/mobBrainGraph.ts | 27 ++++ scripts/export-manifest.json | 2 + 7 files changed, 240 insertions(+) create mode 100644 packages/core/src/ai/decisionGraph.test.ts create mode 100644 packages/core/src/ai/decisionGraph.ts create mode 100644 packages/core/src/ai/mobBrainGraph.ts diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index c495aeeb..b02f60e4 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -14,6 +14,19 @@ - `VisitorPhase` (type): type VisitorPhase = "seeking" | "traveling" | "dwelling" | "departing" | "done" — A many-agent visitor's current step of the seek→travel→arrive→dwell→depart loop. - `VisitorStep` (interface): interface VisitorStep — One tick's result from a {@link VisitorLoop}: current phase, where to steer, and which POI it concerns. +## @jgengine/core/ai/decisionGraph + +- `Blackboard` (type): type Blackboard = Record — Named facts available to conditions, utilities, and actions. +- `BlackboardValue` (type): type BlackboardValue = number | boolean | string — Scalar values stored in a decision graph blackboard. +- `DecisionAction` (type): type DecisionAction = ( ctx: Context, params: Record | undefined, blackboard: Blackboard, ) => DecisionStatus — Callback implementation for an action node. +- `DecisionGraph` (type): type DecisionGraph = DecisionNode — Root node for a serializable AI decision graph. +- `DecisionGraphRuntime` (interface): interface DecisionGraphRuntime — Stateful evaluator for a serializable decision graph. +- `DecisionGraphSnapshot` (interface): interface DecisionGraphSnapshot — Serializable state retained by an active decision graph. +- `DecisionNode` (type): type DecisionNode = | { kind: "selector"; children: DecisionNode[] } | { kind: "sequence"; children: DecisionNode[] } | { kind: "condition"; key: string; op: DecisionOperator; value: BlackboardValue } | { kind: "action"; action: string; params?: Record } | { kind: "utility";… — Serializable selector, sequence, condition, action, or utility node. +- `DecisionOperator` (type): type DecisionOperator = "=" | "==" | "!=" | "<" | "<=" | ">" | ">=" | "eq" | "ne" | "lt" | "lte" | "gt" | "gte" — Comparison operators supported by decision conditions. +- `DecisionStatus` (type): type DecisionStatus = "running" | "done" | "failed" — Outcome reported when a decision graph evaluates. +- `createDecisionGraphRuntime` (function): function createDecisionGraphRuntime(graph: DecisionGraph, actions: Record>): DecisionGraphRuntime — Creates a deterministic runtime for a serializable decision graph. + ## @jgengine/core/ai/difficulty - `DIFFICULTY_TIERS` (const): const DIFFICULTY_TIERS: Readonly> — The canonical easy/standard/expert profiles. Frozen — use {@link difficultyProfile} to derive a tweaked copy instead of mutating. @@ -105,6 +118,10 @@ - `MobVec3` (type): type MobVec3 = readonly [number, number, number] — ⚠ undocumented - `MobWanderConfig` (interface): interface MobWanderConfig — ⚠ undocumented +## @jgengine/core/ai/mobBrainGraph + +- `mobBrainGraph` (const): const mobBrainGraph: DecisionGraph — Graph-shaped description of the mob brain's idle, wander, chase, engage, and evade decisions. + ## @jgengine/core/ai/populationDirector - `PopulationCensus` (type): type PopulationCensus = Record> — Live census of alive counts keyed `region -> species -> count`, used to reconcile the director to reality. diff --git a/.claude/skills/jgengine-world/capabilities.md b/.claude/skills/jgengine-world/capabilities.md index 8f84c531..b989fd1e 100644 --- a/.claude/skills/jgengine-world/capabilities.md +++ b/.claude/skills/jgengine-world/capabilities.md @@ -4,6 +4,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the primitive that already does it*. +## ai-decision-graph — Evaluate serializable selector, sequence, condition, action, and utility AI decisions. + +- `createDecisionGraphRuntime` (function) · `import { createDecisionGraphRuntime } from "@jgengine/core/ai/decisionGraph"` + ## ai-driver — difficulty-aware chase/route driving step producing throttle/brake/steer for the vehicle sim - `driveStep` (function) · `import { driveStep } from "@jgengine/core/ai/driver"` diff --git a/CHANGELOG.md b/CHANGELOG.md index cb655c79..f6b37fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ between (`--json` for structured output). - Core perception tracks deterministic sight, hearing, occlusion, and bounded observation memory for AI. - `syncWorldColliders` now keeps terrain and static object colliders synchronized with a physics backend. +- Core decision graphs provide deterministic selector, sequence, condition, action, and utility runtimes with snapshot/restore. - WS sessions now issue resume tickets and retain disconnected memberships for a 15-second grace window, allowing reconnects to rejoin without replacing player state. - Shell entity sprites can play atlas-backed sprite clips while preserving raw texture sprites. - Core sprite atlas adapters and deterministic 2D sprite clip playback primitives. diff --git a/packages/core/src/ai/decisionGraph.test.ts b/packages/core/src/ai/decisionGraph.test.ts new file mode 100644 index 00000000..89416b79 --- /dev/null +++ b/packages/core/src/ai/decisionGraph.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { createDecisionGraphRuntime, type DecisionNode } from "./decisionGraph"; + +const action = (name: string): DecisionNode => ({ kind: "action", action: name }); + +describe("decisionGraph", () => { + test("selector falls through failed branches", () => { + const calls: string[] = []; + const runtime = createDecisionGraphRuntime( + { kind: "selector", children: [{ kind: "condition", key: "ready", op: "=", value: true }, action("fallback")] }, + { fallback: () => { calls.push("fallback"); return "done"; } }, + ); + expect(runtime.tick({}, { ready: false }, 0.1)).toBe("done"); + expect(calls).toEqual(["fallback"]); + }); + + test("sequence stops on failure", () => { + const calls: string[] = []; + const runtime = createDecisionGraphRuntime( + { kind: "sequence", children: [action("first"), action("second")] }, + { + first: () => { calls.push("first"); return "failed"; }, + second: () => { calls.push("second"); return "done"; }, + }, + ); + expect(runtime.tick({}, {}, 0.1)).toBe("failed"); + expect(calls).toEqual(["first"]); + }); + + test("utility chooses the highest weighted score", () => { + const calls: string[] = []; + const runtime = createDecisionGraphRuntime( + { kind: "utility", options: [ + { score: [{ key: "hunger", weight: 1 }], node: action("eat") }, + { score: [{ key: "threat", weight: 2 }], node: action("hide") }, + ] }, + { eat: () => { calls.push("eat"); return "done"; }, hide: () => { calls.push("hide"); return "done"; } }, + ); + expect(runtime.tick({}, { hunger: 3, threat: 2 }, 0.1)).toBe("done"); + expect(calls).toEqual(["hide"]); + }); + + test("running action is called again on the next tick", () => { + let calls = 0; + const runtime = createDecisionGraphRuntime( + action("wait"), + { wait: () => { calls += 1; return calls < 2 ? "running" : "done"; } }, + ); + expect(runtime.tick({}, {}, 0.1)).toBe("running"); + expect(runtime.tick({}, {}, 0.1)).toBe("done"); + expect(calls).toBe(2); + }); + + test("snapshot and restore preserve the running marker", () => { + const runtime = createDecisionGraphRuntime(action("wait"), { wait: () => "running" }); + runtime.tick({}, {}, 0.1); + const snapshot = runtime.snapshot(); + const restored = createDecisionGraphRuntime(action("wait"), { wait: () => "running" }); + restored.restore(snapshot); + expect(restored.snapshot()).toEqual(snapshot); + }); +}); diff --git a/packages/core/src/ai/decisionGraph.ts b/packages/core/src/ai/decisionGraph.ts new file mode 100644 index 00000000..e45c7bf0 --- /dev/null +++ b/packages/core/src/ai/decisionGraph.ts @@ -0,0 +1,127 @@ +/** Scalar values stored in a decision graph blackboard. */ +export type BlackboardValue = number | boolean | string; +/** Named facts available to conditions, utilities, and actions. */ +export type Blackboard = Record; + +/** Comparison operators supported by decision conditions. */ +export type DecisionOperator = "=" | "==" | "!=" | "<" | "<=" | ">" | ">=" | "eq" | "ne" | "lt" | "lte" | "gt" | "gte"; + +/** Serializable selector, sequence, condition, action, or utility node. */ +export type DecisionNode = + | { kind: "selector"; children: DecisionNode[] } + | { kind: "sequence"; children: DecisionNode[] } + | { kind: "condition"; key: string; op: DecisionOperator; value: BlackboardValue } + | { kind: "action"; action: string; params?: Record } + | { kind: "utility"; options: { score: { key: string; weight: number }[]; node: DecisionNode }[] }; + +/** Root node for a serializable AI decision graph. */ +export type DecisionGraph = DecisionNode; +/** Outcome reported when a decision graph evaluates. */ +export type DecisionStatus = "running" | "done" | "failed"; + +/** Callback implementation for an action node. */ +export type DecisionAction = ( + ctx: Context, + params: Record | undefined, + blackboard: Blackboard, +) => DecisionStatus; + +/** Serializable state retained by an active decision graph. */ +export interface DecisionGraphSnapshot { + runningPath: number[] | null; +} + +/** Stateful evaluator for a serializable decision graph. */ +export interface DecisionGraphRuntime { + tick(ctx: Context, blackboard: Blackboard, dt: number): DecisionStatus; + snapshot(): DecisionGraphSnapshot; + restore(next: DecisionGraphSnapshot): void; +} + +function compare(left: BlackboardValue | undefined, op: DecisionOperator, right: BlackboardValue): boolean { + switch (op) { + case "=": + case "==": + case "eq": + return left === right; + case "!=": + case "ne": + return left !== right; + case "<": + case "lt": + return typeof left === "number" && typeof right === "number" && left < right; + case "<=": + case "lte": + return typeof left === "number" && typeof right === "number" && left <= right; + case ">": + case "gt": + return typeof left === "number" && typeof right === "number" && left > right; + case ">=": + case "gte": + return typeof left === "number" && typeof right === "number" && left >= right; + } +} + +/** Creates a deterministic runtime for a serializable decision graph. + * @capability ai-decision-graph Evaluate serializable selector, sequence, condition, action, and utility AI decisions. + */ +export function createDecisionGraphRuntime( + graph: DecisionGraph, + actions: Record>, +): DecisionGraphRuntime { + let runningPath: number[] | null = null; + + function run(node: DecisionNode, ctx: Context, blackboard: Blackboard, dt: number, path: number[]): DecisionStatus { + switch (node.kind) { + case "condition": + return compare(blackboard[node.key], node.op, node.value) ? "done" : "failed"; + case "action": { + const action = actions[node.action]; + if (action === undefined) throw new Error(`Decision action '${node.action}' is not registered`); + const status = action(ctx, node.params, blackboard); + if (status === "running") runningPath = path; + return status; + } + case "selector": + for (let index = 0; index < node.children.length; index += 1) { + const status = run(node.children[index]!, ctx, blackboard, dt, [...path, index]); + if (status !== "failed") return status; + } + return "failed"; + case "sequence": + for (let index = 0; index < node.children.length; index += 1) { + const status = run(node.children[index]!, ctx, blackboard, dt, [...path, index]); + if (status !== "done") return status; + } + return "done"; + case "utility": { + let best = -Infinity; + let bestIndex = -1; + for (let index = 0; index < node.options.length; index += 1) { + const score = node.options[index]!.score.reduce( + (total, term) => total + (typeof blackboard[term.key] === "number" ? blackboard[term.key] as number : 0) * term.weight, + 0, + ); + if (score > best) { + best = score; + bestIndex = index; + } + } + return bestIndex < 0 ? "failed" : run(node.options[bestIndex]!.node, ctx, blackboard, dt, [...path, bestIndex]); + } + } + } + + return { + tick(ctx, blackboard, dt) { + runningPath = null; + return run(graph, ctx, blackboard, dt, []); + }, + snapshot() { + return { runningPath: runningPath === null ? null : [...runningPath] }; + }, + restore(next) { + runningPath = next.runningPath === null ? null : [...next.runningPath]; + }, + }; +} diff --git a/packages/core/src/ai/mobBrainGraph.ts b/packages/core/src/ai/mobBrainGraph.ts new file mode 100644 index 00000000..5230cef3 --- /dev/null +++ b/packages/core/src/ai/mobBrainGraph.ts @@ -0,0 +1,27 @@ +import type { DecisionGraph } from "./decisionGraph"; + +/** Graph-shaped description of the mob brain's idle, wander, chase, engage, and evade decisions. */ +export const mobBrainGraph: DecisionGraph = { + kind: "selector", + children: [ + { kind: "sequence", children: [ + { kind: "condition", key: "evading", op: "=", value: true }, + { kind: "action", action: "evade" }, + ] }, + { kind: "sequence", children: [ + { kind: "condition", key: "targetId", op: "!=", value: "" }, + { kind: "selector", children: [ + { kind: "sequence", children: [ + { kind: "condition", key: "leashExceeded", op: "=", value: true }, + { kind: "action", action: "evade" }, + ] }, + { kind: "sequence", children: [ + { kind: "condition", key: "inAttackRange", op: "=", value: true }, + { kind: "action", action: "engage" }, + ] }, + { kind: "action", action: "chase" }, + ] }, + ] }, + { kind: "action", action: "wanderOrIdle" }, + ], +}; diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 1ecaff91..3c0591db 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -2,6 +2,7 @@ "@jgengine/core": [ ".", "./ai/crowd", + "./ai/decisionGraph", "./ai/difficulty", "./ai/driver", "./ai/flock", @@ -11,6 +12,7 @@ "./ai/jobBoard", "./ai/laneSelect", "./ai/mobBrain", + "./ai/mobBrainGraph", "./ai/populationDirector", "./ai/pursuit", "./ai/spawnDirector",