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
17 changes: 17 additions & 0 deletions .claude/skills/jgengine-world/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, BlackboardValue> — 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<Context = unknown> = ( ctx: Context, params: Record<string, BlackboardValue> | 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<Context = unknown> — 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<string, BlackboardValue> } | { 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<Context = unknown>(graph: DecisionGraph, actions: Record<string, DecisionAction<Context>>): DecisionGraphRuntime<Context> — Creates a deterministic runtime for a serializable decision graph.

## @jgengine/core/ai/difficulty

- `DIFFICULTY_TIERS` (const): const DIFFICULTY_TIERS: Readonly<Record<DifficultyTier, DifficultyProfile>> — The canonical easy/standard/expert profiles. Frozen — use {@link difficultyProfile} to derive a tweaked copy instead of mutating.
Expand Down Expand Up @@ -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<string, Record<string, number>> — Live census of alive counts keyed `region -> species -> count`, used to reconcile the director to reality.
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/jgengine-world/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/ai/decisionGraph.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
127 changes: 127 additions & 0 deletions packages/core/src/ai/decisionGraph.ts
Original file line number Diff line number Diff line change
@@ -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<string, BlackboardValue>;

/** 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<string, BlackboardValue> }
| { 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<Context = unknown> = (
ctx: Context,
params: Record<string, BlackboardValue> | 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<Context = unknown> {
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<Context = unknown>(
graph: DecisionGraph,
actions: Record<string, DecisionAction<Context>>,
): DecisionGraphRuntime<Context> {
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];
},
};
}
27 changes: 27 additions & 0 deletions packages/core/src/ai/mobBrainGraph.ts
Original file line number Diff line number Diff line change
@@ -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" },
],
};
2 changes: 2 additions & 0 deletions scripts/export-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"@jgengine/core": [
".",
"./ai/crowd",
"./ai/decisionGraph",
"./ai/difficulty",
"./ai/driver",
"./ai/flock",
Expand All @@ -11,6 +12,7 @@
"./ai/jobBoard",
"./ai/laneSelect",
"./ai/mobBrain",
"./ai/mobBrainGraph",
"./ai/populationDirector",
"./ai/pursuit",
"./ai/spawnDirector",
Expand Down
Loading