feat(bt): Add wait and whilst, rename leaf to action
This commit is contained in:
+18
-3
@@ -11,16 +11,31 @@ export {
|
||||
export type { TaskKind } from "./task";
|
||||
|
||||
export { TaskRunner } from "./runner";
|
||||
export type { LeafHandler, TerminalHandler } from "./runner";
|
||||
export type {
|
||||
ActionHandler,
|
||||
WaitHandler,
|
||||
ConditionHandler,
|
||||
TaskControl,
|
||||
TerminalHandler,
|
||||
} from "./runner";
|
||||
|
||||
export {
|
||||
buildTree,
|
||||
Cancel,
|
||||
leaf,
|
||||
action,
|
||||
wait,
|
||||
whilst,
|
||||
sequential,
|
||||
parallel,
|
||||
selector,
|
||||
random,
|
||||
cycle,
|
||||
} from "./tree-def";
|
||||
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";
|
||||
export type {
|
||||
TreeDef,
|
||||
TaskEntityDef,
|
||||
TaskMeta,
|
||||
ActionFn,
|
||||
WaitFn,
|
||||
ConditionFn,
|
||||
} from "./tree-def";
|
||||
|
||||
+122
-39
@@ -9,11 +9,34 @@ import {
|
||||
Cancelled,
|
||||
TERMINAL_TAGS,
|
||||
ChildOf,
|
||||
Cancel,
|
||||
} from "./task";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────
|
||||
/** Callback invoked when a leaf task starts executing. */
|
||||
export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
|
||||
/** Control object passed to wait tasks so they can complete themselves. */
|
||||
export interface TaskControl {
|
||||
succeed(): void;
|
||||
fail(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/** Callback invoked when an action task starts executing. */
|
||||
export type ActionHandler = (world: World, entity: Entity, dt: number) => void;
|
||||
|
||||
/** Callback invoked when a wait task starts executing. */
|
||||
export type WaitHandler = (
|
||||
world: World,
|
||||
entity: Entity,
|
||||
control: TaskControl,
|
||||
dt: number,
|
||||
) => void;
|
||||
|
||||
/** Callback invoked by whilst tasks before each iteration. */
|
||||
export type ConditionHandler = (
|
||||
world: World,
|
||||
entity: Entity,
|
||||
dt: number,
|
||||
) => boolean;
|
||||
|
||||
/** Callback invoked when a task reaches a terminal status. */
|
||||
export type TerminalHandler = (
|
||||
@@ -69,23 +92,11 @@ function parentOf(world: World, child: Entity): Entity | null {
|
||||
* Push-based behaviour-tree runner.
|
||||
*
|
||||
* Tasks tagged with `Scheduled` define tick boundaries. Once a scheduled
|
||||
* task starts, child/parent propagation is immediate until a running leaf or
|
||||
* an explicit scheduling boundary (such as `cycle`) yields to a future tick.
|
||||
* task starts, child/parent propagation is immediate until a running wait task
|
||||
* or an explicit scheduling boundary (`cycle`, `whilst`) yields to a future tick.
|
||||
*
|
||||
* Leaves are dispatched to a user-provided `onLeaf` callback.
|
||||
* Terminal results are dispatched to a user-provided `onTerminal` callback.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const runner = new TaskRunner(world);
|
||||
* runner.onLeaf = (world, entity) => {
|
||||
* // do the leaf's work, then call:
|
||||
* runner.succeed(entity);
|
||||
* };
|
||||
*
|
||||
* // Each frame:
|
||||
* runner.tick();
|
||||
* ```
|
||||
* Action, wait, and whilst condition callbacks are supplied by `buildTree` or
|
||||
* assigned directly when using `TaskRunner` manually.
|
||||
*/
|
||||
export class TaskRunner {
|
||||
private _world: World;
|
||||
@@ -95,8 +106,14 @@ export class TaskRunner {
|
||||
/** Root task entity, set by `buildTree` for convenience. */
|
||||
root?: Entity;
|
||||
|
||||
/** Called when a leaf task starts executing. */
|
||||
onLeaf: LeafHandler = () => {};
|
||||
/** Called when an action task starts executing. */
|
||||
onAction: ActionHandler = () => {};
|
||||
|
||||
/** Called when a wait task starts executing. */
|
||||
onWait: WaitHandler = () => {};
|
||||
|
||||
/** Called by whilst tasks before each iteration. */
|
||||
onCondition: ConditionHandler = () => false;
|
||||
|
||||
/** Called when any task reaches a terminal status. */
|
||||
onTerminal: TerminalHandler = () => {};
|
||||
@@ -132,12 +149,12 @@ export class TaskRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark a leaf task as succeeded and propagate upward. */
|
||||
/** Mark a task as succeeded and propagate upward. */
|
||||
succeed(entity: Entity): void {
|
||||
this._finish(entity, Succeeded);
|
||||
}
|
||||
|
||||
/** Mark a leaf task as failed and propagate upward. */
|
||||
/** Mark a task as failed and propagate upward. */
|
||||
fail(entity: Entity): void {
|
||||
this._finish(entity, Failed);
|
||||
}
|
||||
@@ -170,8 +187,11 @@ export class TaskRunner {
|
||||
|
||||
try {
|
||||
switch (t.kind) {
|
||||
case "leaf":
|
||||
this._executeLeaf(entity, dt);
|
||||
case "action":
|
||||
this._executeAction(entity, dt);
|
||||
break;
|
||||
case "wait":
|
||||
this._executeWait(entity, dt);
|
||||
break;
|
||||
case "sequential":
|
||||
this._executeSequential(entity, dt);
|
||||
@@ -185,6 +205,9 @@ export class TaskRunner {
|
||||
case "cycle":
|
||||
this._executeCycle(entity, dt);
|
||||
break;
|
||||
case "whilst":
|
||||
this._executeWhilst(entity, dt);
|
||||
break;
|
||||
case "selector":
|
||||
this._executeSelector(entity, dt);
|
||||
break;
|
||||
@@ -210,9 +233,31 @@ export class TaskRunner {
|
||||
this._execute(entity, dt);
|
||||
}
|
||||
|
||||
private _executeLeaf(entity: Entity, dt: number): void {
|
||||
private _executeAction(entity: Entity, dt: number): void {
|
||||
this._world.add(entity, Running);
|
||||
this.onLeaf(this._world, entity, dt);
|
||||
try {
|
||||
this.onAction(this._world, entity, dt);
|
||||
if (!isTerminal(this._world, entity)) {
|
||||
this.succeed(entity);
|
||||
}
|
||||
} catch (err) {
|
||||
this._finishFromThrown(entity, err);
|
||||
}
|
||||
}
|
||||
|
||||
private _executeWait(entity: Entity, dt: number): void {
|
||||
this._world.add(entity, Running);
|
||||
const control: TaskControl = {
|
||||
succeed: () => this.succeed(entity),
|
||||
fail: () => this.fail(entity),
|
||||
cancel: () => this.cancel(entity),
|
||||
};
|
||||
|
||||
try {
|
||||
this.onWait(this._world, entity, control, dt);
|
||||
} catch (err) {
|
||||
this._finishFromThrown(entity, err);
|
||||
}
|
||||
}
|
||||
|
||||
private _executeSequential(entity: Entity, dt: number): void {
|
||||
@@ -307,28 +352,51 @@ export class TaskRunner {
|
||||
}
|
||||
|
||||
private _executeCycle(entity: Entity, dt: number): void {
|
||||
const children = [...childrenOf(this._world, entity)];
|
||||
|
||||
// Cycle expects exactly one task child. Non-task child entities are ignored.
|
||||
if (children.length === 0) return;
|
||||
|
||||
const child = children[0];
|
||||
|
||||
if (isTerminal(this._world, child)) {
|
||||
clearSubtree(this._world, child);
|
||||
this._world.add(child, Scheduled);
|
||||
return;
|
||||
}
|
||||
const child = this._firstTaskChild(entity);
|
||||
if (!child) return;
|
||||
|
||||
this._executeChild(child, dt);
|
||||
|
||||
if (isTerminal(this._world, child)) {
|
||||
clearSubtree(this._world, child);
|
||||
this._world.add(child, Scheduled);
|
||||
this._world.add(entity, Scheduled);
|
||||
}
|
||||
// Cycle itself never terminates — it just creates tick-boundary cycles.
|
||||
}
|
||||
|
||||
private _executeWhilst(entity: Entity, dt: number): void {
|
||||
let condition: boolean;
|
||||
try {
|
||||
condition = this.onCondition(this._world, entity, dt);
|
||||
} catch (err) {
|
||||
this._finishFromThrown(entity, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!condition) {
|
||||
this._finish(entity, Succeeded);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = this._firstTaskChild(entity);
|
||||
if (!child) {
|
||||
this._finish(entity, Succeeded);
|
||||
return;
|
||||
}
|
||||
|
||||
this._executeChild(child, dt);
|
||||
|
||||
const status = terminalStatus(this._world, child);
|
||||
if (status === "succeeded") {
|
||||
clearSubtree(this._world, child);
|
||||
this._world.add(entity, Scheduled);
|
||||
} else if (status === "failed") {
|
||||
this._finish(entity, Failed);
|
||||
} else if (status === "cancelled") {
|
||||
this._finish(entity, Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
private _executeSelector(entity: Entity, dt: number): void {
|
||||
for (const child of childrenOf(this._world, entity)) {
|
||||
let status = terminalStatus(this._world, child);
|
||||
@@ -353,8 +421,23 @@ export class TaskRunner {
|
||||
this._finish(entity, Failed);
|
||||
}
|
||||
|
||||
private _firstTaskChild(entity: Entity): Entity | null {
|
||||
for (const child of childrenOf(this._world, entity)) {
|
||||
return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Completion propagation ────────────────────────
|
||||
|
||||
private _finishFromThrown(entity: Entity, err: unknown): void {
|
||||
if (err === Cancel) {
|
||||
this.cancel(entity);
|
||||
} else {
|
||||
this.fail(entity);
|
||||
}
|
||||
}
|
||||
|
||||
private _finish(
|
||||
entity: Entity,
|
||||
tag: typeof Succeeded | typeof Failed | typeof Cancelled,
|
||||
|
||||
+20
-5
@@ -1,12 +1,18 @@
|
||||
import { defineComponent } from "../component";
|
||||
import { defineRelationship } from "../relationship";
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────
|
||||
|
||||
/** Throw from an action or wait starter to cancel that task and its subtree. */
|
||||
export const Cancel: unique symbol = Symbol("task.cancel");
|
||||
|
||||
// ── Task component ────────────────────────────────────
|
||||
/**
|
||||
* Core component for behaviour-tree tasks.
|
||||
*
|
||||
* `kind` determines how the task evaluates its children:
|
||||
* - `"leaf"` — terminal node; external logic drives it to completion.
|
||||
* `kind` determines how the task evaluates:
|
||||
* - `"action"` — runs immediately and succeeds when its function returns.
|
||||
* - `"wait"` — starts once and remains running until external code completes it.
|
||||
* - `"sequential"` — runs children one at a time, left to right.
|
||||
* Succeeds when all children succeed; fails when any child fails.
|
||||
* - `"parallel"` — starts all children at once.
|
||||
@@ -16,12 +22,21 @@ import { defineRelationship } from "../relationship";
|
||||
* - `"cycle"` — runs its single child. When the child finishes, resets it
|
||||
* and schedules the next run for a future tick boundary. Never terminates
|
||||
* on its own (only via cancel).
|
||||
* - `"whilst"` — runs its single child while its condition is true.
|
||||
* Succeeds when the condition becomes false; fails/cancels with its child.
|
||||
* - `"selector"` — runs children left to right. Succeeds on the first
|
||||
* child that succeeds; fails only if all children fail.
|
||||
*/
|
||||
export const Task = defineComponent("task", {
|
||||
kind: "leaf" as
|
||||
"leaf" | "sequential" | "parallel" | "random" | "cycle" | "selector",
|
||||
kind: "action" as
|
||||
| "action"
|
||||
| "wait"
|
||||
| "sequential"
|
||||
| "parallel"
|
||||
| "random"
|
||||
| "cycle"
|
||||
| "whilst"
|
||||
| "selector",
|
||||
});
|
||||
|
||||
export type TaskKind = (typeof Task.type)["kind"];
|
||||
@@ -30,7 +45,7 @@ export type TaskKind = (typeof Task.type)["kind"];
|
||||
/** A task that should be executed this tick. */
|
||||
export const Scheduled = defineComponent("scheduled", {});
|
||||
|
||||
/** A task that is currently executing (multi-frame leaves). */
|
||||
/** A task that is currently executing or waiting for completion. */
|
||||
export const Running = defineComponent("running", {});
|
||||
|
||||
/** The task completed successfully. */
|
||||
|
||||
+106
-96
@@ -1,35 +1,32 @@
|
||||
import type { World, Entity } from "../index";
|
||||
import type { EntityDef, EntityDefChild } from "../entity-tree";
|
||||
import { Task, ChildOf } from "./task";
|
||||
import { TaskRunner } from "./runner";
|
||||
import { Task, ChildOf, Cancel } from "./task";
|
||||
import { TaskRunner, type TaskControl } from "./runner";
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────
|
||||
export { Cancel };
|
||||
|
||||
/**
|
||||
* Throw this inside a leaf `run` function to cancel the leaf and its subtree.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* leaf(() => { throw Cancel; })
|
||||
* ```
|
||||
*/
|
||||
export const Cancel: unique symbol = Symbol("leaf.cancel");
|
||||
// ── Task callback types ───────────────────────────────
|
||||
|
||||
// ── Tree definition ───────────────────────────────────
|
||||
/** Runs immediately. Return = success, throw = failure, throw Cancel = cancel. */
|
||||
export type ActionFn = (world: World, entity: Entity, dt: number) => void;
|
||||
|
||||
/** A leaf function — plain or generator. */
|
||||
export type LeafFn =
|
||||
| ((world: World, dt: number) => void)
|
||||
| (() => Generator<number | void, void, number>);
|
||||
/** Starts a task that remains Running until completed by the supplied control. */
|
||||
export type WaitFn = (
|
||||
world: World,
|
||||
entity: Entity,
|
||||
control: TaskControl,
|
||||
dt: number,
|
||||
) => void;
|
||||
|
||||
export interface LeafTaskMeta {
|
||||
readonly run: LeafFn;
|
||||
}
|
||||
/** Controls a `whilst` task. False means the loop has completed successfully. */
|
||||
export type ConditionFn = (world: World, entity: Entity, dt: number) => boolean;
|
||||
|
||||
export type TaskEntityDef = EntityDef<
|
||||
typeof Task.type,
|
||||
LeafTaskMeta | undefined
|
||||
>;
|
||||
export type TaskMeta =
|
||||
| { readonly mode: "action"; readonly run: ActionFn }
|
||||
| { readonly mode: "wait"; readonly start?: WaitFn }
|
||||
| { readonly mode: "whilst"; readonly condition: ConditionFn };
|
||||
|
||||
export type TaskEntityDef = EntityDef<typeof Task.type, TaskMeta | undefined>;
|
||||
|
||||
/** Behaviour-tree definition accepted by `buildTree`. */
|
||||
export type TreeDef = TaskEntityDef;
|
||||
@@ -43,7 +40,7 @@ function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
|
||||
function task(
|
||||
kind: typeof Task.type.kind,
|
||||
children: readonly EntityDefChild[] = [],
|
||||
meta?: LeafTaskMeta,
|
||||
meta?: TaskMeta,
|
||||
): TaskEntityDef {
|
||||
return {
|
||||
kind: "entity",
|
||||
@@ -54,12 +51,38 @@ function task(
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a leaf task entity definition. */
|
||||
export function leaf(
|
||||
run: LeafFn,
|
||||
/** Create an action task entity definition. */
|
||||
export function action(
|
||||
run: ActionFn,
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task("leaf", children, { run });
|
||||
return task("action", children, { mode: "action", run });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wait task entity definition.
|
||||
*
|
||||
* `wait()` creates a task that simply becomes Running. External systems can
|
||||
* complete it with `runner.succeed(entity)`, `runner.fail(entity)`, or
|
||||
* `runner.cancel(entity)`.
|
||||
*/
|
||||
export function wait(children?: readonly EntityDefChild[]): TaskEntityDef;
|
||||
export function wait(
|
||||
start: WaitFn,
|
||||
children?: readonly EntityDefChild[],
|
||||
): TaskEntityDef;
|
||||
export function wait(
|
||||
startOrChildren?: WaitFn | readonly EntityDefChild[],
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
const hasChildrenAsFirstArg = Array.isArray(startOrChildren);
|
||||
const start = hasChildrenAsFirstArg
|
||||
? undefined
|
||||
: (startOrChildren as WaitFn | undefined);
|
||||
return task("wait", hasChildrenAsFirstArg ? startOrChildren : children, {
|
||||
mode: "wait",
|
||||
start,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a sequential task entity definition. */
|
||||
@@ -107,28 +130,44 @@ export function cycle(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a conditional loop task entity definition.
|
||||
*
|
||||
* Runs its child while `condition` returns true. When the child succeeds, the
|
||||
* child subtree is reset and `whilst` schedules itself for the next tick
|
||||
* boundary. When `condition` returns false, `whilst` succeeds.
|
||||
*/
|
||||
export function whilst(condition: ConditionFn, child: EntityDef): TaskEntityDef;
|
||||
export function whilst(
|
||||
condition: ConditionFn,
|
||||
children?: readonly EntityDefChild[],
|
||||
): TaskEntityDef;
|
||||
export function whilst(
|
||||
condition: ConditionFn,
|
||||
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task(
|
||||
"whilst",
|
||||
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
|
||||
{ mode: "whilst", condition },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Builder ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Materialize a behaviour-tree definition into ECS entities and return a
|
||||
* fully-wired `TaskRunner`.
|
||||
*
|
||||
* Definitions are `EntityDef` trees produced by the task factories (`leaf`,
|
||||
* `sequential`, `parallel`, `selector`, `random`, `cycle`) and generic
|
||||
* single-component entity factories. Non-task child entities are materialized
|
||||
* into the ECS tree but ignored by `TaskRunner` execution.
|
||||
*
|
||||
* Leaf `run` functions:
|
||||
* - **Plain function** — runs once per tick. `return` = success. `throw` = fail.
|
||||
* `throw Cancel` = cancel.
|
||||
* - **Generator function** — each `yield` suspends until next tick. The value
|
||||
* yielded is the desired delay in ms (or `undefined` for next frame).
|
||||
* Generator completion = success. `throw` = fail. `throw Cancel` = cancel.
|
||||
* Definitions are `EntityDef` trees produced by the task factories (`action`,
|
||||
* `wait`, `sequential`, `parallel`, `selector`, `random`, `cycle`, `whilst`)
|
||||
* and generic single-component entity factories. Non-task child entities are
|
||||
* materialized into the ECS tree but ignored by `TaskRunner` execution.
|
||||
*/
|
||||
export function buildTree(world: World, def: TreeDef): TaskRunner {
|
||||
const leafHandlers = new Map<Entity, LeafFn>();
|
||||
// Track generator iterators for multi-frame leaves
|
||||
const generators = new Map<Entity, Generator<number | void, void, number>>();
|
||||
const actions = new Map<Entity, ActionFn>();
|
||||
const waits = new Map<Entity, WaitFn>();
|
||||
const conditions = new Map<Entity, ConditionFn>();
|
||||
|
||||
function build(def: EntityDef, parent?: Entity): Entity {
|
||||
const entity = world.spawn();
|
||||
@@ -140,12 +179,22 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
|
||||
|
||||
if (def.component === Task) {
|
||||
const taskData = world.get(entity, Task);
|
||||
if (taskData.kind === "leaf") {
|
||||
const run = (def.meta as LeafTaskMeta | undefined)?.run;
|
||||
if (!run) {
|
||||
throw new Error("Leaf task entity is missing a run function");
|
||||
const meta = def.meta as TaskMeta | undefined;
|
||||
|
||||
if (taskData.kind === "action") {
|
||||
if (meta?.mode !== "action") {
|
||||
throw new Error("Action task entity is missing an action function");
|
||||
}
|
||||
leafHandlers.set(entity, run);
|
||||
actions.set(entity, meta.run);
|
||||
} else if (taskData.kind === "wait") {
|
||||
if (meta?.mode === "wait" && meta.start) {
|
||||
waits.set(entity, meta.start);
|
||||
}
|
||||
} else if (taskData.kind === "whilst") {
|
||||
if (meta?.mode !== "whilst") {
|
||||
throw new Error("Whilst task entity is missing a condition function");
|
||||
}
|
||||
conditions.set(entity, meta.condition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,56 +214,17 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
|
||||
const runner = new TaskRunner(world);
|
||||
runner.root = root;
|
||||
|
||||
runner.onLeaf = (_w, entity, dt) => {
|
||||
const handler = leafHandlers.get(entity);
|
||||
if (!handler) return;
|
||||
|
||||
try {
|
||||
// Check if this leaf has an active generator
|
||||
let gen = generators.get(entity);
|
||||
|
||||
if (gen) {
|
||||
// Resume existing generator
|
||||
const result = gen.next(dt);
|
||||
if (result.done) {
|
||||
generators.delete(entity);
|
||||
runner.succeed(entity);
|
||||
}
|
||||
// If not done, leaf stays Running — nothing to do
|
||||
} else {
|
||||
// First invocation — call the handler
|
||||
const ret = handler(_w, dt);
|
||||
|
||||
// Check if it returned a generator
|
||||
if (ret != null && typeof (ret as any).next === "function") {
|
||||
const gen = ret as Generator<number | void, void, number>;
|
||||
generators.set(entity, gen);
|
||||
const result = gen.next(dt);
|
||||
if (result.done) {
|
||||
generators.delete(entity);
|
||||
runner.succeed(entity);
|
||||
}
|
||||
// Not done → leaf stays Running
|
||||
} else {
|
||||
// Plain function — returned undefined → success
|
||||
runner.succeed(entity);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Clean up generator if one was active
|
||||
generators.delete(entity);
|
||||
|
||||
if (err === Cancel) {
|
||||
runner.cancel(entity);
|
||||
} else {
|
||||
runner.fail(entity);
|
||||
}
|
||||
}
|
||||
runner.onAction = (world, entity, dt) => {
|
||||
actions.get(entity)?.(world, entity, dt);
|
||||
};
|
||||
|
||||
runner.onTerminal = (_w, entity) => {
|
||||
// Clean up generator when a leaf reaches terminal by external means
|
||||
generators.delete(entity);
|
||||
runner.onWait = (world, entity, control, dt) => {
|
||||
waits.get(entity)?.(world, entity, control, dt);
|
||||
};
|
||||
|
||||
runner.onCondition = (world, entity, dt) => {
|
||||
const condition = conditions.get(entity);
|
||||
return condition ? condition(world, entity, dt) : false;
|
||||
};
|
||||
|
||||
return runner;
|
||||
|
||||
Reference in New Issue
Block a user