refactor: behavior

This commit is contained in:
cha0s 2020-06-23 11:19:59 -05:00
parent de5b51b8ea
commit abbab55d30
54 changed files with 675 additions and 1232 deletions

View File

@ -1,18 +1,15 @@
import {compose, EventEmitter, TickingPromise} from '@avocado/core';
import {Traversal} from './traversal';
import {Traversals} from './traversals';
const decorate = compose(
EventEmitter,
);
export class Actions extends decorate(Traversals) {
class Actions {
constructor() {
super();
constructor(expressions) {
this.expressions = 'function' === typeof expressions ? expressions() : expressions;
this._index = 0;
this._actionPromise = null;
this.promise = null;
}
emitFinished() {
@ -33,29 +30,24 @@ export class Actions extends decorate(Traversals) {
tick(context, elapsed) {
// Empty resolves immediately.
if (this.traversals.length === 0) {
if (this.expressions.length === 0) {
this.emitFinished();
return;
}
// If the action promise ticks, tick it.
if (this._actionPromise && this._actionPromise instanceof TickingPromise) {
this._actionPromise.tick(elapsed);
if (this.promise && this.promise instanceof TickingPromise) {
this.promise.tick(elapsed);
return;
}
// Actions execute immediately until a promise is made, or they're all
// executed.
while (true) {
// Run the action.
const result = this.traversals[this.index].traverse(context);
const result = this.expressions[this.index](context);
// Deferred result.
if (result instanceof Promise) {
this._actionPromise = result;
// Handle any errors.
result.catch(console.error);
result.finally(() => {
// Finally, run the prologue.
this.prologue();
});
this.promise = result;
this.promise.catch(console.error).finally(() => this.prologue());
break;
}
// Immediate result.
@ -68,19 +60,24 @@ export class Actions extends decorate(Traversals) {
}
parallel(context) {
// Map all traversals to results.
const results = this.traversals.map((traversal) => {
return traversal.traverse(context);
});
// Wrap all results in a TickingPromise.
return TickingPromise.all(results);
const results = this.expressions.map((expression) => expression(context));
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result instanceof TickingPromise) {
return TickingPromise.all(results);
}
if (result instanceof Promise) {
return Promise.all(results);
}
}
return results;
}
prologue() {
// Clear out the action promise.
this._actionPromise = null;
this.promise = null;
// Increment and wrap the index.
this.index = (this.index + 1) % this.traversals.length;
this.index = (this.index + 1) % this.expressions.length;
// If rolled over, the actions are finished.
if (0 === this.index) {
this.emitFinished();
@ -103,3 +100,5 @@ export class Actions extends decorate(Traversals) {
}
}
export default decorate(Actions);

View File

@ -1,36 +1,29 @@
export function buildTraversal(path, value) {
const traversal = {
type: 'traversal',
steps: path.map((key) => {
return {
type: 'key',
key: key,
};
}),
export function buildExpression(path, value) {
const expression = {
type: 'expression',
ops: path.map((key) => ({type: 'key', key: key})),
};
if ('undefined' !== typeof value) {
traversal.value = buildValue(value);
expression.value = buildValue(value);
}
return traversal;
return expression;
}
export function buildInvoke (path, args = []) {
const traversal = buildTraversal(path);
traversal.steps.push({
const expression = buildExpression(path);
expression.ops.push({
type: 'invoke',
args: args.map((arg) => {
return buildValue(arg);
}),
args: args.map((arg) => buildValue(arg)),
});
return traversal;
return expression;
}
export function buildValue(value) {
if (
'object' === typeof value
&& (
'traversal' === value.type
|| 'actions' === value.type
'expression' === value.type
|| 'expressions' === value.type
|| 'condition' === value.type
)
) {
@ -46,8 +39,6 @@ export function buildCondition(operator, operands) {
return {
type: 'condition',
operator,
operands: operands.map((operand) => {
return buildValue(operand);
}),
operands: operands.map((operand) => buildValue(operand)),
};
}

View File

@ -0,0 +1,26 @@
import memoize from 'lodash.memoize';
import {invokeHookFlat, registerHooks} from 'scwp';
export const compilers = memoize(() => (
invokeHookFlat('behaviorCompilers')
.reduce((r, results) => ({
...r,
...results,
}), {})
));
export default function compile(variant) {
const {[variant.type]: compiler} = compilers();
if (!compiler) {
return () => Promise.reject(new Error(`No compiler for '${variant.type}'`));
}
return compiler(variant);
}
registerHooks({
autoreg$accept: (type, M) => {
if ('hooks' === type && 'behaviorCompilers' in M) {
compilers.cache.clear();
}
},
}, module.id);

View File

@ -0,0 +1,142 @@
import {fastApply} from '@avocado/core';
import compile from './compile';
const render = (ops) => ops.reduce((rendered, op) => {
switch (op.type) {
case 'key':
return `${rendered ? `${rendered}.` : ''}${op.key}`;
case 'invoke':
return `${rendered}(${op.args.length > 0 ? '...' : ''})`;
default:
return rendered;
}
}, '');
function compileOp(op) {
let args;
if ('invoke' === op.type) {
args = op.args.map(compile);
}
return (context, previous, current) => {
switch (op.type) {
case 'key':
return current[op.key];
case 'invoke':
// Pass the context itself as the last arg.
const evaluated = args.map((fn) => fn(context)).concat(context);
// Promises are resolved transparently.
const apply = (args) => fastApply(previous, current, args);
return evaluated.some((arg) => arg instanceof Promise)
? Promise.all(evaluated).then(apply)
: apply(evaluated);
}
};
}
function disambiguateResult(value, fn) {
const apply = (value) => fn(value);
return value instanceof Promise ? value.then(apply) : apply(value);
}
function compileExpression(expression) {
const assign = 'undefined' !== typeof expression.assign
? compile(expression.assign)
: undefined;
const ops = expression.ops.map(compileOp);
const {ops: rawOps} = expression;
return (context) => {
let previous = null;
let shorted = false;
const [first, ...rest] = ops;
return rest.reduce((current, op, index) => disambiguateResult(current, (current) => {
if (shorted) {
return undefined;
}
let next = undefined;
const isLastOp = index === ops.length - 2;
if (!isLastOp || !assign) {
if ('undefined' === typeof current) {
const rendered = render(rawOps.slice(0, index + 2));
next = Promise.reject(new Error(`'${rendered}' is undefined`));
shorted = true;
}
else {
next = op(context, previous, current);
}
}
else {
const rawOp = rawOps[index + 1];
switch (rawOp.type) {
case 'key':
if ('object' === typeof current) {
current[rawOp.key] = assign(context);
next = undefined;
}
break;
case 'invoke':
const rendered = render(rawOps.slice(0, index + 2));
next = Promise.reject(new Error(`invalid assignment to function '${rendered}'`));
break;
}
}
previous = current;
current = next;
return current;
}), context.getValue(rawOps[0].key));
};
}
function compileCondition(condition) {
const {operator} = condition;
const operands = condition.operands.map(compile);
return (context) => {
switch (operator) {
case 'is':
return operands[0](context) === operands[1](context);
case 'isnt':
return operands[0](context) !== operands[1](context);
case '>':
return operands[0](context) > operands[1](context);
case '>=':
return operands[0](context) >= operands[1](context);
case '<':
return operands[0](context) < operands[1](context);
case '<=':
return operands[0](context) <= operands[1](context);
case 'or':
if (0 === operands.length) {
return true;
}
for (let i = 0; i < operands.length; i++) {
if (!!operands[i](context)) {
return true;
}
}
return false;
case 'and':
if (0 === operands.length) {
return true;
}
for (let i = 0; i < operands.length; i++) {
if (!operands[i](context)) {
return false;
}
}
return true;
case 'contains':
const haystack = operands[0](context);
const needle = operands[1](context);
return -1 !== haystack.indexOf(needle);
}
};
}
export function behaviorCompilers() {
return {
condition: (condition) => compileCondition(condition),
literal: ({value}) => (context) => value,
expression: (expression) => compileExpression(expression),
expressions: ({expressions}) => () => expressions.map(compileExpression),
};
}

View File

@ -0,0 +1,74 @@
import {arrayUnique, flatten, mapObject} from '@avocado/core';
import memoize from 'lodash.memoize';
import {invokeHookFlat, registerHooks} from 'scwp';
export const globals = memoize(() => (
invokeHookFlat('behaviorContextGlobals')
.reduce((r, results) => ({
...r,
...results,
}), {})
));
export default class Context {
constructor(defaults = {}) {
this.map = new Map();
this.clear();
this.addObjectMap(defaults);
}
add(key, value, type = 'undefined') {
this.map.set(key, [value, type]);
}
addObjectMap(map) {
Object.entries(map)
.forEach(([key, [variable, type]]) => (
this.add(key, variable, type)
));
}
all() {
return Array.from(this.map.keys())
.reduce((r, key) => ({
...r,
[key]: this.get(key),
}), {});
}
clear() {
this.destroy();
this.add('context', this, 'context');
this.addObjectMap(globals());
}
destroy() {
this.map.clear();
}
get(key) {
return this.has(key) ? this.map.get(key) : [undefined, 'undefined'];
}
getValue(key) {
return this.get(key)[0];
}
getType(key) {
return this.get(key)[1];
}
has(key) {
return this.map.has(key);
}
}
registerHooks({
autoreg$accept: (type, M) => {
if ('hooks' === type && 'behaviorContextGlobals' in M) {
globals.cache.clear();
}
},
}, module.id);

View File

@ -1,34 +0,0 @@
import {Context} from './context';
export function behaviorContextTypes() {
return {
bool: {
defaultLiteral: true,
},
context: {
children: {
add: {
type: 'void',
label: 'Add $2 as $1.',
args: [
['key', {
type: 'string',
}],
['value', {
type: 'any',
}],
],
},
},
},
number: {
defaultLiteral: 0,
},
stream: {},
string: {
defaultLiteral: '',
},
void: {},
};
}

View File

@ -1,237 +0,0 @@
import {arrayUnique, flatten, mapObject} from '@avocado/core';
import D from 'debug';
import {invokeHookFlat} from 'scwp';
import {fastApply, objectFromEntries} from '@avocado/core';
const debug = D('@avocado:behavior:context');
export class Context {
constructor(defaults = {}) {
this.typeMap = new Map();
this.variableMap = new Map();
this.initialize();
this.addObjectMap(defaults);
}
static allTypesDigraph() {
return Object.keys(this.types())
.reduce((r, type) => ({...r, [type]: this.typeDigraph(type)}), {});
}
static allTypesInvertedDigraph() {
const digraph = this.allTypesDigraph();
let inverted = {};
const fromTypes = Object.keys(digraph);
for (let i = 0; i < fromTypes.length; i++) {
const fromType = fromTypes[i];
const toTypes = digraph[fromType];
for (let j = 0; j < toTypes.length; j++) {
const toType = toTypes[j];
inverted[toType] = (inverted[toType] || {});
inverted[toType][fromType] = true;
}
}
inverted = mapObject(inverted, (types) => Object.keys(types));
inverted = mapObject(
inverted,
(types) => arrayUnique(flatten(types.map((type) => (inverted[type] || []).concat(type)))),
);
return inverted;
}
static describe(description, fn) {
const {type} = description;
fn.type = type;
return fn;
}
static globals() {
return invokeHookFlat('behaviorContextGlobals')
.reduce((r, results) => ({...r, ...results}), {});
}
static typeDigraph(type, marked = {}) {
const types = this.types();
if (marked[type] || !types[type]) {
marked[type] = true;
return [];
}
const description = this.typeDescription(type, undefined);
const subtypes = Object.keys(
Object.values(description.children)
.reduce((r, spec) => ({...r, [spec.type]: true}), {})
);
subtypes.forEach((type) => marked[type] = true);
return arrayUnique(
flatten(
subtypes.map(
(type) => this.typeDigraph(type, marked),
),
).concat(subtypes),
);
}
static typeDescription(type, variable) {
const types = this.types();
if (!types[type]) {
return {};
}
return {
children: {},
type,
...('function' === typeof types[type] ? types[type](variable) : types[type])
};
}
static types() {
return invokeHookFlat('behaviorContextTypes')
.reduce((r, results) => ({
...r,
...results,
}), {});
}
add(key, value, type) {
this.typeMap.set(key, type);
this.variableMap.set(key, value);
}
addObjectMap(map) {
Object.entries(map).forEach(([key, [variable, type]]) => this.add(key, variable, type));
}
all() {
return Array.from(this.variableMap.keys())
.reduce((r, key) => ({...r, [key]: this.get(key)}), {});
}
initialize() {
this.typeMap.clear()
this.variableMap.clear()
this.add('context', this, 'context');
this.addObjectMap(this.constructor.globals());
}
clear() {
this.initialize();
}
destroy() {
this.typeMap.clear()
this.variableMap.clear();
}
get(key) {
const type = this.typeMap.has(key) ? this.typeMap.get(key) : 'undefined';
return [
this.variableMap.get(key),
type,
];
}
static renderSteps(steps) {
return steps.reduce((rendered, step) => {
switch (step.type) {
case 'key':
if (rendered) {
rendered += '.';
}
rendered += step.key;
break;
case 'invoke':
rendered += `(${step.args.length > 0 ? '...' : ''})`;
break;
}
return rendered;
}, '');
}
static renderStepsUntilNow(steps, step) {
const stepsUntilNow = steps.slice(0, steps.indexOf(step));
return this.renderSteps(stepsUntilNow);
}
takeStep(value, fn) {
if (value instanceof Promise) {
return value.then((value) => {
return fn(value);
});
}
else {
return fn(value);
}
}
traverse(traversal) {
const {steps, value} = traversal;
return this.traverseAndDo(steps, (node, previousNode, step, index) => {
const isLastStep = index === steps.length - 2;
// Traverse if we're not at the final step with a value.
if (!isLastStep || !value) {
return this.traverseOneStep(steps, previousNode, node, step);
}
// Try to set the value.
switch (step.type) {
case 'key':
if ('object' === typeof node) {
return node[step.key] = value.get(this);
}
case 'invoke':
const rendered = this.constructor.renderStepsUntilNow(steps, step);
debug(`invalid assignment to function "${rendered}"`);
return;
}
});
}
traverseAndDo(steps, fn) {
const [first, ...rest] = steps;
if ('key' !== first.type) {
throw new TypeError(`First step in a traversal must be type "key"`);
}
let previousNode = null;
return rest.reduce((node, step, index) => {
return this.takeStep(node, (node) => {
const result = fn(node, previousNode, step, index);
previousNode = node;
return result;
});
}, this.variableMap.get(first.key));
}
traverseOneStep(steps, previousNode, node, step) {
if ('undefined' === typeof node) {
const rendered = this.constructor.renderStepsUntilNow(steps, step);
debug(`"${rendered}" is traversed through but undefined`);
return;
}
switch (step.type) {
case 'key':
return node[step.key];
case 'invoke':
const args = step.args.map((arg) => arg.get(this));
// Pass the context itself as the last arg.
args.push(this);
// Any arg promises will be resolved; the arg values will be passed
// transparently to the invocation.
let hasPromise = false;
for (let i = 0; i < args.length; i++) {
if (args[i] instanceof Promise) {
hasPromise = true;
break;
}
}
if (hasPromise) {
return Promise.all(args).then((args) => {
return fastApply(previousNode, node, args);
});
}
else {
return fastApply(previousNode, node, args);
}
}
}
}

View File

@ -1,65 +0,0 @@
import {Context} from './context';
class Flow {
static conditional(condition, actions, context) {
if (condition.get(context)) {
return actions.serial(context);
}
};
static parallel(actions, context) {
return actions.parallel(context);
}
static serial(actions, context) {
return actions.serial(context);
}
}
export function behaviorContextGlobals() {
return {
Flow: [Flow, 'Flow'],
};
}
export function behaviorContextTypes() {
return {
Flow: {
children: {
conditional: {
type: 'bool',
label: 'If $1 then run $2.',
args: [
['condition', {
type: 'condition',
}],
['actions', {
type: 'actions',
}],
],
},
parallel: {
type: 'void',
label: 'Run $1 in parallel.',
args: [
['actions', {
type: 'actions',
}],
],
},
serial: {
type: 'void',
label: 'Run $1 serially.',
args: [
['actions', {
type: 'actions',
}],
],
},
},
},
};
}

View File

@ -1 +0,0 @@
export {Context} from './context';

View File

@ -1,41 +0,0 @@
import {TickingPromise} from '@avocado/core';
class Timing {
static wait (duration) {
return new TickingPromise(
() => {},
(elapsed, resolve) => {
duration -= elapsed;
if (duration <= 0) {
resolve();
}
},
);
}
}
export function behaviorContextTypes() {
return {
Timing: {
children: {
wait: {
type: 'void',
label: 'Wait for $1 seconds.',
args: [
['duration', {
type: 'number',
}],
],
},
},
},
};
}
export function behaviorContextGlobals() {
return {
Timing: [Timing, 'Timing'],
};
}

View File

@ -1,68 +0,0 @@
import {merge as mergeObject} from '@avocado/core';
class Utility {
static makeArray(...args) {
// No context!
args.pop();
return args;
};
static makeObject(...args) {
// No context!
args.pop();
const object = {};
while (args.length > 0) {
const key = args.shift();
const value = args.shift();
object[key] = value;
}
return object;
};
static log(...args) {
return console.log(...args);
}
static merge(...args) {
// No context!
args.pop();
return mergeObject(...args);
}
}
export function behaviorContextTypes() {
return {
Utility: {
children: {
makeArray: {
type: 'array',
label: 'Make array.',
args: [],
},
makeObject: {
type: 'object',
label: 'Make object.',
args: [],
},
log: {
type: 'void',
label: 'Log.',
args: [],
},
merge: {
type: 'object',
label: 'Merge objects.',
args: [],
},
},
},
};
}
export function behaviorContextGlobals() {
return {
Utility: [Utility, 'Utility'],
};
}

View File

@ -1,10 +1,29 @@
export {Context} from './context';
export {
fromJSON as behaviorItemFromJSON,
} from './item/registry';
default as Actions,
} from './actions';
export {
buildCondition,
buildInvoke,
buildTraversal,
buildExpression,
buildValue,
} from './builders';
export {
default as compile,
compilers,
} from './compile';
export {
default as Context,
globals as contextGlobals,
} from './context';
export {
candidates,
description,
digraph,
fitsInto,
invertedDigraph,
types,
} from './type';

View File

@ -1,12 +0,0 @@
import {Traversal} from './traversal';
export class Action extends Traversal {
toJSON() {
return {
...super.toJSON(),
type: 'action',
};
}
}

View File

@ -1,93 +0,0 @@
import {expect} from 'chai';
import {TickingPromise} from '@avocado/core';
import {buildInvoke, buildTraversal, buildValue} from '../builders';
import {Context} from '../context';
import {Actions} from './actions';
import './initialize'
describe('behavior', () => {
describe('Actions', () => {
const context = new Context();
beforeEach(() => {
context.clear();
});
it('may resolve immediately', (done) => {
const actions = new Actions();
let total = 0;
const increment = () => total++;
context.add('increment', increment);
actions.fromJSON({
type: 'actions',
traversals: [
buildTraversal(['increment']),
buildTraversal(['increment']),
buildTraversal(['increment']),
],
});
actions.on('actionsFinished', done);
actions.tick(context, 0);
expect(actions.index).to.equal(0);
expect(total).to.equal(3);
});
it('may defer', async () => {
const actions = new Actions();
let resolve;
const promise = new Promise((_) => resolve = _);
context.add('test', promise);
context.add('test2', new Promise(() => {}));
actions.fromJSON({
type: 'actions',
traversals: [
buildTraversal(['test']),
buildTraversal(['test2']),
],
});
actions.tick(context, 0);
// Waiting?
expect(actions.index).to.equal(0);
resolve();
return promise.finally(() => {
// Waiting on next...
expect(actions.index).to.equal(1);
});
});
it('may defer with ticking', async () => {
const actions = new Actions();
let resolve;
let duration = 1;
const tickingPromise = new TickingPromise(
() => {},
(elapsed, resolve) => {
duration -= elapsed;
if (duration <= 0) {
resolve();
}
},
);
context.add('test', tickingPromise);
context.add('test2', new Promise(() => {}));
actions.fromJSON({
type: 'actions',
traversals: [
buildTraversal(['test']),
buildTraversal(['test2']),
],
});
actions.tick(context, 0);
// Waiting?
expect(actions.index).to.equal(0);
actions.tick(context, 0.5);
expect(actions.index).to.equal(0);
actions.tick(context, 0.5);
// Still gotta be waiting...
expect(actions.index).to.equal(0);
return tickingPromise.finally(() => {
// Waiting on next...
expect(duration).to.equal(0);
expect(actions.index).to.equal(1);
});
});
});
});

View File

@ -1,46 +0,0 @@
import {fromJSON as behaviorItemFromJSON} from './registry';
export function Collection(type) {
const plural = `${type}s`;
return class Collection {
constructor() {
this[plural] = [];
}
clone(other) {
if (0 === other[plural].length) {
return;
}
const Item = other[plural][0].constructor;
for (let i = 0; i < other[plural].length; ++i) {
this[plural][i] = new Item();
this[plural][i].clone(other[plural][i]);
}
}
createClone() {
const Items = this.constructor;
const items = new Items();
items.clone(this);
return items;
}
count() {
return this[plural].length;
}
fromJSON(json) {
this[plural] = [];
for (const item of json[plural]) {
this[plural].push(behaviorItemFromJSON(item));
}
return this;
}
toJSON() {
return this[plural].map((item) => item.toJSON());
}
};
}

View File

@ -1,106 +0,0 @@
import {fromJSON as behaviorItemFromJSON} from './registry';
export class Condition {
constructor() {
this.operator = '';
this.operands = [];
}
check(context) {
return this.get(context);
}
clone(other) {
this.operator = other.operator;
this.operands = other.operands.map((operand) => operand.clone());
}
fromJSON(json) {
this.operator = json.operator;
this.operands = json.operands.map((operand) => {
return behaviorItemFromJSON(operand);
});
return this;
}
get(context) {
switch (this.operator) {
case 'is':
return this.operands[0].get(context) === this.operands[1].get(context);
case 'isnt':
return this.operands[0].get(context) !== this.operands[1].get(context);
case '>':
return this.operands[0].get(context) > this.operands[1].get(context);
case '>=':
return this.operands[0].get(context) >= this.operands[1].get(context);
case '<':
return this.operands[0].get(context) < this.operands[1].get(context);
case '<=':
return this.operands[0].get(context) <= this.operands[1].get(context);
case 'or':
if (0 === this.operands.length) {
return true;
}
for (const operand of this.operands) {
if (!!operand.get(context)) {
return true;
}
}
return false;
case 'and':
if (0 === this.operands.length) {
return true;
}
for (const operand of this.operands) {
if (!operand.get(context)) {
return false;
}
}
return true;
case 'contains':
if (this.operands.length < 2) {
return false;
}
const haystack = this.operands[0].get(context);
const needle = this.operands[1].get(context);
return -1 !== haystack.indexOf(needle);
}
}
operandCount() {
return this.operands.length;
}
operand(index) {
return this.operands[index];
}
operands() {
return this.operands;
}
operator() {
return this.operator;
}
setOperand(index, operand) {
this.operands[index] = operand;
}
setOperator(operator) {
this.operator = operator;
}
toJSON() {
return {
type: 'condition',
operator: this.operator,
operands: this.operands.map((operand) => operand.toJSON()),
};
}
}

View File

@ -1,109 +0,0 @@
import {expect} from 'chai';
import {Condition} from './condition';
import {Literal} from './literal';
import {deregister, register} from './registry';
describe('behavior', () => {
describe('condition', () => {
it('can do binary compares', () => {
const condition = new Condition();
condition.setOperand(0, new Literal().fromJSON({
type: 'literal',
value: 500,
}));
condition.setOperand(1, new Literal().fromJSON({
type: 'literal',
value: 420,
}));
// Greater than.
condition.setOperator('>');
expect(condition.check()).to.be.true;
// Less than.
condition.setOperator('<');
expect(condition.check()).to.be.false;
// Greater than or equal.
condition.setOperator('>=');
expect(condition.check()).to.be.true;
// Less than or equal.
condition.setOperator('<=');
expect(condition.check()).to.be.false;
// Is.
condition.setOperator('is');
expect(condition.check()).to.be.false;
// Is not.
condition.setOperator('isnt');
expect(condition.check()).to.be.true;
});
it('can do varnary compares', () => {
const condition = new Condition();
condition.setOperand(0, new Literal().fromJSON({
type: 'literal',
value: true,
}));
condition.setOperand(1, new Literal().fromJSON({
type: 'literal',
value: true,
}));
condition.setOperand(2, new Literal().fromJSON({
type: 'literal',
value: false,
}));
// AND, mixed.
condition.setOperator('and');
expect(condition.check()).to.be.false;
// OR, mixed.
condition.setOperator('or');
expect(condition.check()).to.be.true;
// OR, all true.
condition.setOperand(2, new Literal().fromJSON({
type: 'literal',
value: true,
}));
expect(condition.check()).to.be.true;
// AND, all true.
condition.setOperator('and');
expect(condition.check()).to.be.true;
// AND, all false.
for (const i of [0, 1, 2]) {
condition.setOperand(i, new Literal().fromJSON({
type: 'literal',
value: false,
}));
}
expect(condition.check()).to.be.false;
// OR, all false.
condition.setOperator('or');
expect(condition.check()).to.be.false;
});
describe('JSON', () => {
beforeEach(() => {
register(Literal);
});
afterEach(() => {
deregister(Literal);
});
it('can instantiate operands', () => {
const condition = new Condition();
// Greater than.
condition.fromJSON({
operator: '>',
operands: [
{
type: 'literal',
value: 500,
},
{
type: 'literal',
value: 420,
},
],
});
expect(condition.check()).to.be.true;
// Less than.
condition.setOperator('<');
expect(condition.check()).to.be.false;
});
});
});
});

View File

@ -1,14 +0,0 @@
import {Collection} from './collection';
export class Conditions extends Collection('condition') {
check(context) {
for (condition of this.conditions) {
if (!this.conditions[index].check(context)) {
return false;
}
}
return true;
}
}

View File

@ -1,23 +0,0 @@
import {Action} from './action';
import {Actions} from './actions';
import {Condition} from './condition';
import {Conditions} from './conditions';
import {Literal} from './literal';
import {Routine} from './routine';
import {Routines} from './routines';
import {Traversal} from './traversal';
import {Traversals} from './traversals';
export function avocadoBehaviorItems() {
return {
action: Action,
actions: Actions,
condition: Condition,
conditions: Conditions,
literal: Literal,
routine: Routine,
routines: Routines,
traversal: Traversal,
traversals: Traversals,
};
}

View File

@ -1,30 +0,0 @@
export class Literal {
constructor() {
this.value = null;
}
clone(other) {
this.value = other.value;
}
fromJSON(json) {
this.value = json.value;
return this;
}
get(context) {
return this.value;
}
set(value) {
this.value = value;
}
toJSON() {
return {
type: 'literal',
value: this.value,
}
}
}

View File

@ -1,22 +0,0 @@
import {expect} from 'chai';
import {Literal} from './literal';
describe('behavior', () => {
describe('Literal', () => {
let literal = undefined;
beforeEach(() => {
literal = new Literal();
literal.fromJSON({
value: 69,
});
});
it('can access literals', () => {
expect(literal.get()).to.equal(69);
});
it('can modify literals', () => {
literal.set(420);
expect(literal.get()).to.equal(420);
});
});
});

View File

@ -1,26 +0,0 @@
import {invokeHookFlat} from 'scwp';
let _behaviorItems;
export function fromJSON({type, ...json}) {
const items = behaviorItems();
const Class = items[type];
if (!Class) {
throw new TypeError(`There is no class for the behavior item "${type}"`);
}
return (new Class()).fromJSON(json);
}
function behaviorItems() {
if (!_behaviorItems) {
_behaviorItems = {};
const itemsLists = invokeHookFlat('avocadoBehaviorItems');
for (let i = 0; i < itemsLists.length; i++) {
_behaviorItems = {
..._behaviorItems,
...itemsLists[i],
};
}
}
return _behaviorItems;
}

View File

@ -1,29 +0,0 @@
import {Actions} from './actions';
export class Routine {
constructor() {
this.actions = new Actions();
}
clone(other) {
this.actions = other.actions.clone();
}
fromJSON(json) {
this.actions.fromJSON(json.routine);
return this;
}
tick(context, elapsed) {
this.actions.tick(context, elapsed);
}
toJSON() {
return {
type: 'routine',
actions: this.actions.toJSON(),
}
}
}

View File

@ -1,45 +0,0 @@
import {Routine} from './routine';
export class Routines {
constructor() {
this.routines = {};
}
*[Symbol.iterator]() {
for (const index in this.routines) {
const routine = this.routines[index];
yield routine;
}
}
clone(other) {
for (const i in other.routines) {
const routine = other.routines[i];
this.routines[i] = routine.clone();
}
}
fromJSON(json) {
for (const i in json.routines) {
const routineJSON = json.routines[i];
this.routines[i] = (new Routine()).fromJSON(routineJSON);
}
return this;
}
routine(index) {
return this.routines[index];
}
toJSON() {
const routines = {};
for (const i in this.routines) {
routines[i] = this.routines[i].toJSON();
}
return {
type: 'routines',
routines,
};
}
}

View File

@ -1,80 +0,0 @@
import {fromJSON as behaviorItemFromJSON} from './registry';
export class Traversal {
constructor(json) {
this.steps = [];
this.value = undefined;
if (json) {
this.fromJSON(json);
}
}
clone(other) {
this.steps = other.steps.map((step) => {
switch (step.type) {
case 'key':
return step;
case 'invoke':
return {
type: 'invoke',
args: step.args.map((arg, index) => {
const Item = arg.constructor;
const item = new Item();
item.clone(arg);
return item;
}),
};
}
});
if (other.value) {
this.value = other.value.clone();
}
}
fromJSON(json) {
this.steps = json.steps.map((step) => {
switch (step.type) {
case 'key':
return step;
case 'invoke':
return {
type: 'invoke',
args: step.args.map((arg) => behaviorItemFromJSON(arg)),
};
}
});
if (json.value) {
this.value = behaviorItemFromJSON(json.value);
}
return this;
}
get(context) {
return this.traverse(context);
}
traverse(context) {
if (context) {
return context.traverse(this);
}
}
toJSON() {
return {
type: 'traversal',
steps: this.steps.map((step) => {
switch (step.type) {
case 'key':
return step;
case 'invoke':
return {
type: 'invoke',
args: step.args.map((arg) => arg.toJSON()),
};
}
}),
value: this.value ? this.value.toJSON() : undefined,
};
}
}

View File

@ -1,21 +0,0 @@
import {TickingPromise} from '@avocado/core';
import {Collection} from './collection';
import {Traversal} from './traversal';
export class Traversals extends Collection('traversal') {
parallel(context) {
const results = this.traversals.map((traversal) => {
return traversal.traverse(context);
});
// Early out if no promises.
if (!results.reduce((has, result) => {
return has || result instanceof Promise;
}, false)) {
return results;
}
return TickingPromise.all(results);
}
}

View File

@ -1,8 +1,9 @@
import {compose, flatten} from '@avocado/core';
import {compose, flatten, mapObject} from '@avocado/core';
import {StateProperty, Trait} from '@avocado/entity';
import {Context} from '../context';
import {Routines} from '../item/routines';
import Actions from '../actions';
import compile from '../compile';
import Context from '../context';
const decorate = compose(
StateProperty('currentRoutine', {
@ -13,7 +14,7 @@ const decorate = compose(
export default class Behaved extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
context: {
type: 'context',
@ -67,7 +68,10 @@ export default class Behaved extends decorate(Trait) {
entity: [this.entity, 'entity'],
});
this._currentRoutine = undefined;
this._routines = (new Routines()).fromJSON(this.params.routines);
this._routines = mapObject(
this.params.routines,
(routine) => new Actions(compile(routine))
);
this.updateCurrentRoutine(this.state.currentRoutine);
}
@ -82,7 +86,7 @@ export default class Behaved extends decorate(Trait) {
}
updateCurrentRoutine(currentRoutine) {
this._currentRoutine = this._routines.routine(currentRoutine);
this._currentRoutine = this._routines[currentRoutine];
}
listeners() {

108
packages/behavior/type.js Normal file
View File

@ -0,0 +1,108 @@
import {arrayUnique, flatten, mapObject} from '@avocado/core';
import memoize from 'lodash.memoize';
import {invokeHookFlat, registerHooks} from 'scwp';
export const types = memoize(() => {
return invokeHookFlat('behaviorTypes')
.reduce((r, results) => ({
...r,
...results,
}), {});
});
function typeDigraph(type, marked = {}) {
const allTypes = types();
if (marked[type] || !allTypes[type]) {
marked[type] = true;
return [];
}
const description = description(type, undefined);
const subtypes = Object.keys(
Object.values(description.children)
.reduce((r, {type: childType}) => ({
...r,
[childType]: true,
}), {})
);
subtypes.forEach((type) => marked[type] = true);
return arrayUnique(flatten(subtypes.map((type) => typeDigraph(type, marked))).concat(subtypes));
}
export const digraph = memoize(() => {
return Object.keys(allTypes())
.reduce((r, type) => ({
...r,
[type]: typeDigraph(type),
}), {});
});
export const invertedDigraph = memoize(() => {
const digraph = digraph();
let inverted = {};
const fromTypes = Object.keys(digraph);
for (let i = 0; i < fromTypes.length; i++) {
const fromType = fromTypes[i];
const toTypes = digraph[fromType];
for (let j = 0; j < toTypes.length; j++) {
const toType = toTypes[j];
inverted[toType] = (inverted[toType] || {});
inverted[toType][fromType] = true;
}
}
inverted = mapObject(inverted, (types) => Object.keys(types));
inverted = mapObject(inverted, (types) => (
arrayUnique(flatten(types.map((type) => (inverted[type] || []).concat(type))))
));
return inverted;
});
export const candidates = (description, type) => {
const inverted = invertedDigraph();
const types = (inverted[type] || []).concat(type);
const {children} = description;
return 'any' === type
? Object.keys(children)
: Object.entries(children)
.reduce((r, [key, {type}]) => (
r.concat(
types.some((candidate) => typeFits(candidate, type))
? [key]
: []
)
), []);
};
export function description(type, instance) {
const allTypes = types();
const defaults = {
children: {},
type,
};
if (!allTypes[type]) {
return {
...defaults,
type: 'undefined',
};
}
return {
...defaults,
...('function' === typeof allTypes[type] ? allTypes[type](variable) : allTypes[type])
};
}
export function fitsInto(candidate, reference) {
if ('any' === reference) {
return true;
}
return -1 !== reference.split('|').map((type) => type.trim()).indexOf(candidate);
}
registerHooks({
autoreg$accept: (type, M) => {
if ('hooks' === type && 'behaviorTypes' in M) {
allTypes.cache.clear();
digraph.cache.clear();
invertedDigraph.cache.clear();
}
},
}, module.id);

View File

@ -0,0 +1,114 @@
import Flow from './types/flow';
import Timing from './types/timing';
import Utility from './types/utility';
export function behaviorContextGlobals() {
return {
Flow: [Flow, 'Flow'],
Timing: [Timing, 'Timing'],
Utility: [Utility, 'Utility'],
};
}
export function behaviorTypes() {
return {
bool: {
defaultLiteral: true,
},
context: {
children: {
add: {
type: 'void',
label: 'Add $2 as $1.',
args: [
['key', {
type: 'string',
}],
['value', {
type: 'any',
}],
],
},
},
},
Flow: {
children: {
conditional: {
type: 'bool',
label: 'If $1 then run $2.',
args: [
['condition', {
type: 'condition',
}],
['actions', {
type: 'actions',
}],
],
},
parallel: {
type: 'void',
label: 'Run $1 in parallel.',
args: [
['actions', {
type: 'actions',
}],
],
},
serial: {
type: 'void',
label: 'Run $1 serially.',
args: [
['actions', {
type: 'actions',
}],
],
},
},
},
number: {
defaultLiteral: 0,
},
stream: {},
string: {
defaultLiteral: '',
},
Timing: {
children: {
wait: {
type: 'void',
label: 'Wait for $1 seconds.',
args: [
['duration', {
type: 'number',
}],
],
},
},
},
Utility: {
children: {
makeArray: {
type: 'array',
label: 'Make array.',
args: [],
},
makeObject: {
type: 'object',
label: 'Make object.',
args: [],
},
log: {
type: 'void',
label: 'Log.',
args: [],
},
merge: {
type: 'object',
label: 'Merge objects.',
args: [],
},
},
},
void: {},
};
}

View File

@ -0,0 +1,19 @@
import Actions from '../actions';
export default class Flow {
static conditional(condition, expressions, context) {
if (condition.get(context)) {
return (new Actions(expressions)).serial(context);
}
};
static parallel(expressions, context) {
return (new Actions(expressions)).parallel(context);
}
static serial(expressions, context) {
return (new Actions(expressions)).serial(context);
}
}

View File

@ -0,0 +1,17 @@
import {TickingPromise} from '@avocado/core';
export default class Timing {
static wait (duration) {
return new TickingPromise(
() => {},
(elapsed, resolve) => {
duration -= elapsed;
if (duration <= 0) {
resolve();
}
},
);
}
}

View File

@ -0,0 +1,33 @@
import {merge as mergeObject} from '@avocado/core';
export default class Utility {
static makeArray(...args) {
// No context!
args.pop();
return args;
};
static makeObject(...args) {
// No context!
args.pop();
const object = {};
while (args.length > 0) {
const key = args.shift();
const value = args.shift();
object[key] = value;
}
return object;
};
static log(...args) {
return console.log(...args);
}
static merge(...args) {
// No context!
args.pop();
return mergeObject(...args);
}
}

View File

@ -243,10 +243,11 @@ export default class Entity extends decorate(Resource) {
promises.push(this._traitsFlat[i].hydrate());
}
this._hydrationPromise = Promise.all(promises);
this._hydrationPromise.then(() => {
this.tick(0);
});
}
return this._hydrationPromise.then(() => {
this.tick(0);
});
return this._hydrationPromise;
}
invokeHook(hook, ...args) {

View File

@ -1,4 +1,4 @@
export function behaviorContextTypes() {
export function behaviorTypes() {
return {
entity: (entity) => {
const {allTraits} = require('./trait/registrar');
@ -20,7 +20,7 @@ export function behaviorContextTypes() {
};
return Traits
.reduce((r, T) => ({
...r, children: {...r.children, ...T.behaviorContextTypes(), ...T.describeState()},
...r, children: {...r.children, ...T.behaviorTypes(), ...T.describeState()},
}), core);
},
};

View File

@ -71,6 +71,7 @@ export class EntityList extends decorate(class {}) {
if (AVOCADO_SERVER) {
this._informedEntities.set(entity, []);
}
entity.hydrate();
entity.attachToList(this);
entity.once('destroy', () => {
this.removeEntity(entity);

View File

@ -38,7 +38,7 @@ export class Trait extends decorate(class {}) {
this._fastDirtyCheck = false;
}
static behaviorContextTypes() {
static behaviorTypes() {
return {};
}

View File

@ -1,8 +1,9 @@
import {
behaviorItemFromJSON,
Actions,
buildCondition,
buildInvoke,
buildTraversal,
buildExpression,
compile,
Context,
} from '@avocado/behavior';
import {compose} from '@avocado/core';
@ -25,7 +26,7 @@ const decorate = compose(
export default class Alive extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
deathSound: {
type: 'string',
@ -40,7 +41,7 @@ export default class Alive extends decorate(Trait) {
static defaultParams() {
const playDeathSound = buildInvoke(['entity', 'playSound'], [
buildTraversal(['entity', 'deathSound']),
buildExpression(['entity', 'deathSound']),
]);
const squeeze = buildInvoke(['entity', 'transition'], [
{
@ -51,13 +52,13 @@ export default class Alive extends decorate(Trait) {
0.2,
]);
const isLifeGone = buildCondition('<=', [
buildTraversal(['entity', 'life']),
buildExpression(['entity', 'life']),
0,
]);
return {
deathActions: {
type: 'actions',
traversals: [
type: 'expressions',
expressions: [
playDeathSound,
squeeze,
],
@ -118,8 +119,8 @@ export default class Alive extends decorate(Trait) {
this._context = new Context({
entity: [this.entity, 'entity'],
});
this._deathActions = behaviorItemFromJSON(this.params.deathActions);
this._deathCondition = behaviorItemFromJSON(this.params.deathCondition);
this._deathActions = new Actions(compile(this.params.deathActions));
this._deathCondition = compile(this.params.deathCondition);
}
destroy() {
@ -183,13 +184,13 @@ export default class Alive extends decorate(Trait) {
return;
}
this.entity.isDying = true;
const dyingTickingPromise = this._deathActions.tickingPromise(this._context);
this.entity.addTickingPromise(dyingTickingPromise).then(() => {
const diedPromises = this.entity.invokeHookFlat('died');
Promise.all(diedPromises).then(() => {
this.entity.destroy();
});
});
return this.entity.addTickingPromise(this._deathActions.tickingPromise(this._context))
.then(() => (
Promise.all(this.entity.invokeHookFlat('died'))
.then(() => {
this.entity.destroy();
})
));
},
};
@ -197,7 +198,7 @@ export default class Alive extends decorate(Trait) {
tick(elapsed) {
if (AVOCADO_SERVER) {
if (!this.entity.isDying && this._deathCondition.check(this._context)) {
if (!this.entity.isDying && this._deathCondition(this._context)) {
this.entity.forceDeath();
}
}

View File

@ -10,7 +10,7 @@ const decorate = compose(
export default class Existent extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
destroy: {
type: 'void',

View File

@ -4,7 +4,7 @@ import {Trait} from '../trait';
export default class Listed extends Trait {
static behaviorContextTypes() {
static behaviorTypes() {
return {
detachFromList: {
type: 'void',

View File

@ -10,7 +10,7 @@ const decorate = compose(
export default class Mobile extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
moveFor: {
type: 'void',

View File

@ -17,7 +17,7 @@ const decorate = compose(
// < 16768 will pack into 1 short per axe and give +/- 0.25 precision.
export default class Positioned extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
position: {
type: 'vector',

View File

@ -13,7 +13,7 @@ const decorate = compose(
export default class Spawner extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
killAllChildren: {
type: 'void',

View File

@ -1,5 +1,3 @@
import * as I from 'immutable';
import {compose, Property} from '@avocado/core';
import {StateProperty, Trait} from '@avocado/entity';
import {Rectangle, Vector} from '@avocado/math';
@ -30,7 +28,7 @@ const decorate = compose(
export default class Visible extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
updateVisibleBoundingBox: {
advanced: true,

View File

@ -6,7 +6,7 @@ export function behaviorContextGlobals() {
};
}
export function behaviorContextTypes() {
export function behaviorTypes() {
return {
Math: (Math) => ({
children: {

View File

@ -1,4 +1,4 @@
export function behaviorContextTypes() {
export function behaviorTypes() {
return {
vector: {
defaultLiteral: [0, 0],

View File

@ -409,7 +409,7 @@ export class Range extends MathRange {
}
export function behaviorContextTypes() {
export function behaviorTypes() {
return {
type: 'Vector',
children: {

View File

@ -1,4 +1,4 @@
import {behaviorItemFromJSON, Context} from '@avocado/behavior';
import {Actions, compile, Context} from '@avocado/behavior';
import {compose, TickingPromise} from '@avocado/core';
import {StateProperty, Trait} from '@avocado/entity';
import {Rectangle, Vector} from '@avocado/math';
@ -10,7 +10,7 @@ const decorate = compose(
export default class Collider extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
collidesWith: {
advanced: true,
@ -61,8 +61,14 @@ export default class Collider extends decorate(Trait) {
collidesWithGroups: [
'default',
],
collisionEndActions: undefined,
collisionStartActions: undefined,
collisionEndActions: {
type: 'expressions',
expressions: [],
},
collisionStartActions: {
type: 'expressions',
expressions: [],
},
collisionGroup: 'default',
isSensor: false,
}
@ -123,22 +129,24 @@ export default class Collider extends decorate(Trait) {
constructor(entity, params, state) {
super(entity, params, state);
this._collidesWithGroups = this.params.collidesWithGroups;
if (this.params.collisionEndActions) {
this._collisionEndActions = behaviorItemFromJSON(
this.params.collisionEndActions,
);
}
if (this.params.collisionStartActions) {
this._collisionStartActions = behaviorItemFromJSON(
this.params.collisionStartActions,
);
}
this._collisionGroup = this.params.collisionGroup;
this._collisionTickingPromises = [];
const {
collidesWithGroups,
collisionEndActions,
collisionGroup,
collisionStartActions,
isSensor,
} = this.params;
this._collidesWithGroups = collidesWithGroups;
this._collisionEndActions = collisionEndActions.length > 0
? new Actions(compile(collisionEndActions))
: undefined;
this._collisionStartActions = collisionStartActions.length > 0
? new Actions(compile(collisionStartActions))
: undefined;
this._collisionGroup = collisionGroup;
this._doesNotCollideWith = [];
this._isCollidingWith = [];
this._isSensor = this.params.isSensor;
this._isSensor = isSensor;
}
destroy() {
@ -214,8 +222,7 @@ export default class Collider extends decorate(Trait) {
entity: [this.entity, 'entity'],
other: [this.entity, 'entity'],
});
const tickingPromise = actions.tickingPromise(context);
this._collisionTickingPromises.push(tickingPromise);
this.entity.addTickingPromise(actions.tickingPromise(context));
}
releaseAllCollisions() {
@ -243,10 +250,7 @@ export default class Collider extends decorate(Trait) {
if (-1 !== index) {
this._isCollidingWith.splice(index, 1);
if (this._collisionEndActions) {
this.pushCollisionTickingPromise(
this._collisionEndActions,
other
);
this.pushCollisionTickingPromise(this._collisionEndActions, other);
}
}
},
@ -256,10 +260,7 @@ export default class Collider extends decorate(Trait) {
if (-1 === index) {
this._isCollidingWith.push(other);
if (this._collisionStartActions) {
this.pushCollisionTickingPromise(
this._collisionStartActions,
other
);
this.pushCollisionTickingPromise(this._collisionStartActions, other);
}
}
},
@ -306,9 +307,6 @@ export default class Collider extends decorate(Trait) {
tick(elapsed) {
if (AVOCADO_SERVER) {
for (let i = 0; i < this._collisionTickingPromises.length; ++i) {
this._collisionTickingPromises[i].tick(elapsed);
}
this.checkActiveCollision();
}
}

View File

@ -8,7 +8,7 @@ const decorate = compose(
export default class Emitted extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
particle: {
type: 'particle',

View File

@ -14,7 +14,7 @@ const decorate = compose(
export default class Emitter extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
emitParticleEntity: {
cycle: true,

View File

@ -12,7 +12,7 @@ const decorate = compose(
export default class Physical extends decorate(Trait) {
static behaviorContextTypes() {
static behaviorTypes() {
return {
applyForce: {
type: 'void',

View File

@ -4,7 +4,7 @@ import {Sound} from '..';
export default class Audible extends Trait {
static behaviorContextTypes() {
static behaviorTypes() {
return {
hasSound: {
type: 'bool',

View File

@ -1,4 +1,4 @@
export function behaviorContextTypes() {
export function behaviorTypes() {
return {
layer: (layer) => {
return {

View File

@ -3,7 +3,7 @@ import {Vector} from '@avocado/math';
export default class Layered extends Trait {
static behaviorContextTypes() {
static behaviorTypes() {
return {
layer: {
type: 'layer',

View File

@ -2,7 +2,7 @@ import {Trait} from '@avocado/entity';
export default class Roomed extends Trait {
static behaviorContextTypes() {
static behaviorTypes() {
return {
detachFromRoom: {
type: 'void',