@casual-simulation/aux-runtime
Version:
Runtime for AUX projects
681 lines • 22.4 kB
JavaScript
import { isBot, botAdded, botRemoved, DEFAULT_ENERGY, getOriginalObject, ORIGINAL_OBJECT, } from '@casual-simulation/aux-common/bots';
import { RealtimeEditMode } from './RuntimeBot';
import { RanOutOfEnergyError } from './AuxResults';
import { sortBy, sortedIndex, sortedIndexOf, sortedIndexBy, transform, } from 'es-toolkit/compat';
import './PerformanceNowPolyfill';
import { Observable, ReplaySubject, Subscription } from 'rxjs';
import { tap } from 'rxjs/operators';
import TWEEN from '@tweenjs/tween.js';
import { v4 as uuidv4 } from 'uuid';
import stableStringify from '@casual-simulation/fast-json-stable-stringify';
import { ensureBotIsSerializable } from '@casual-simulation/aux-common/partitions/PartitionUtils';
import { isGenerator } from '@casual-simulation/js-interpreter/InterpreterUtils';
/**
* The interval between animation frames in miliseconds when using setInterval().
*/
export const SET_INTERVAL_ANIMATION_FRAME_TIME = 16;
/**
* A symbol that can be specified on objects to influence how they are stringified
* when printed for debug/mock/error purposes.
*/
export const DEBUG_STRING = Symbol('debug_string');
/**
* Gets the index of the bot in the given context.
* Returns a negative number if the bot is not in the list.
* @param context The context.
* @param bot The bot.
*/
function indexInContext(context, bot) {
const index = sortedIndexBy(context.bots, bot, (sb) => sb.id);
const expected = context.bots.length > index ? context.bots[index] : null;
if (!!expected && expected.id === bot.id) {
return index;
}
return -1;
}
/**
* Inserts the given bot into the global context.
* @param context The context.
* @param bot The bot.
*/
export function addToContext(context, ...bots) {
for (let bot of bots) {
if (!!context.state[bot.id]) {
throw new Error('Bot already exists in the context!');
}
const index = sortedIndexBy(context.bots, bot, (sb) => sb.id);
context.bots.splice(index, 0, bot);
context.state[bot.id] = bot;
}
}
/**
* Removes the given bots from the given context.
* @param context The context that the bots should be removed from.
* @param bots The bots that should be removed.
*/
export function removeFromContext(context, bots, cancelTimers = true) {
for (let bot of bots) {
const index = indexInContext(context, bot);
if (index < 0) {
continue;
}
context.bots.splice(index, 1);
delete context.state[bot.id];
if (cancelTimers) {
context.cancelBotTimers(bot.id);
}
}
}
/**
* Gets whether a bot with the given ID is in the given context.
* @param context The context.
* @param bot The bot.
*/
export function isInContext(context, bot) {
return indexInContext(context, bot) >= 0;
}
/**
* Defines a global context that stores all information in memory.
*/
export class MemoryGlobalContext {
get localTime() {
return Date.now() - this._startTime;
}
get startTime() {
return this._startTime;
}
/**
* Creates a new global context.
* @param version The version number.
* @param device The device that we're running on.
* @param scriptFactory The factory that should be used to create new script bots.
* @param batcher The batcher that should be used to batch changes.
* @param generatorProcessor The processor that should be used to process generators created from bot timer handlers.
*/
constructor(version, device, scriptFactory, batcher, generatorProcessor) {
/**
* The ordered list of script bots.
*/
this.bots = [];
/**
* The state that the runtime bots occupy.
*/
this.state = {};
/**
* The list of actions that have been queued.
*/
this.actions = [];
/**
* The list of errors that have been queued.
*/
this.errors = [];
/**
* The map of task IDs to tasks.
*/
this.tasks = new Map();
/**
* The map of task IDs to iterable tasks.
*/
this.iterableTasks = new Map();
/**
* The player bot.
*/
this.playerBot = null;
/**
* The current energy that the context has.
*/
this.energy = DEFAULT_ENERGY;
this.global = {};
this.uuid = uuidv4;
this.instLatency = NaN;
this.instTimeOffset = NaN;
this.instTimeOffsetSpread = NaN;
this.forceUnguessableTaskIds = false;
this._taskCounter = 0;
this._shoutTimers = {};
this._numberOfTimers = 0;
this._loadTimes = {};
this.version = version;
this.device = device;
this._scriptFactory = scriptFactory;
this._batcher = batcher;
this._generatorProcessor = generatorProcessor;
this._listenerMap = new Map();
this._botTimerMap = new Map();
this._botWatcherMap = new Map();
this._portalWatcherMap = new Map();
this._mocks = new Map();
this._startTime = Date.now();
this.pseudoRandomNumberGenerator = null;
}
getBotIdsWithListener(tag) {
const set = this._listenerMap.get(tag);
if (!set) {
return [];
}
return set.slice();
}
recordListenerPresense(id, tag, hasListener) {
let set = this._listenerMap.get(tag);
if (!hasListener && !set) {
// we don't have a listener to record
// and there is no list for the tag
// so there is nothing to do.
return;
}
if (!set) {
set = [];
this._listenerMap.set(tag, set);
}
if (hasListener) {
const index = sortedIndex(set, id);
// ensure that our indexing is in bounds
// to prevent the array from being put into slow-mode
// see https://stackoverflow.com/a/26737403/1832856
if (index < set.length && index >= 0) {
const current = set[index];
if (current !== id) {
set.splice(index, 0, id);
}
}
else {
set.splice(index, 0, id);
}
}
else {
const index = sortedIndexOf(set, id);
if (index >= 0) {
set.splice(index, 1);
}
// Delete the tag from the list if there are no more IDs
if (set.length <= 0) {
this._listenerMap.delete(tag);
}
}
}
recordBotTimer(id, info) {
let list = this._botTimerMap.get(id);
if (!list) {
list = [];
this._botTimerMap.set(id, list);
}
list.push(info);
this._numberOfTimers += 1;
if (info.type === 'watch_bot') {
let watchers = this._botWatcherMap.get(info.botId);
if (!watchers) {
watchers = [];
this._botWatcherMap.set(info.botId, watchers);
}
watchers.push(info);
}
else if (info.type === 'watch_portal') {
let watchers = this._portalWatcherMap.get(info.portalId);
if (!watchers) {
watchers = [];
this._portalWatcherMap.set(info.portalId, watchers);
}
watchers.push(info);
}
}
removeBotTimer(id, type, timer) {
let list = this._botTimerMap.get(id);
if (list) {
let index = list.findIndex((t) => t.type === type && t.timerId === timer);
if (index >= 0) {
list.splice(index, 1);
this._numberOfTimers = Math.max(0, this._numberOfTimers - 1);
}
}
}
processBotTimerResult(result) {
if (isGenerator(result)) {
this._generatorProcessor.processGenerator(result);
}
}
getBotTimers(id) {
let timers = this._botTimerMap.get(id);
if (timers) {
return timers.slice();
}
return [];
}
getWatchersForBot(id) {
let watchers = this._botWatcherMap.get(id);
if (watchers) {
return watchers.slice();
}
return [];
}
getWatchersForPortal(id) {
let watchers = this._portalWatcherMap.get(id);
if (watchers) {
return watchers.slice();
}
return [];
}
getWatchedPortals() {
return new Set(this._portalWatcherMap.keys());
}
cancelAndRemoveBotTimer(id, type, timerId) {
let timers = this._botTimerMap.get(id);
if (!timers) {
return;
}
for (let i = 0; i < timers.length; i++) {
let timer = timers[i];
if (timer.timerId === timerId && timer.type === type) {
timers.splice(i, 1);
this._clearTimer(timer);
i -= 1;
}
}
}
cancelBotTimers(id) {
let list = this._botTimerMap.get(id);
if (list) {
this._clearTimers(list);
}
this._botTimerMap.delete(id);
}
cancelAllBotTimers() {
for (let list of this._botTimerMap.values()) {
this._clearTimers(list);
}
this._botTimerMap.clear();
}
cancelAndRemoveTimers(timerId, type) {
for (let list of this._botTimerMap.values()) {
for (let i = 0; i < list.length; i++) {
const timer = list[i];
if (timer.timerId === timerId &&
(!type || timer.type === type)) {
this._clearTimer(timer);
list.splice(i, 1);
i -= 1;
}
}
}
}
getNumberOfActiveTimers() {
return this._numberOfTimers;
}
_clearTimers(list) {
for (let timer of list) {
this._clearTimer(timer);
}
}
_clearTimer(timer) {
this._numberOfTimers = Math.max(0, this._numberOfTimers - 1);
if (timer.type === 'timeout') {
clearTimeout(timer.timerId);
}
else if (timer.type === 'interval') {
clearInterval(timer.timerId);
}
else if (timer.type === 'animation') {
timer.cancel();
}
else if (timer.type === 'watch_bot') {
let watchers = this._botWatcherMap.get(timer.botId);
if (watchers) {
let index = watchers.findIndex((w) => w.timerId === timer.timerId);
if (index >= 0) {
watchers.splice(index, 1);
}
}
}
else if (timer.type === 'watch_portal') {
let watchers = this._portalWatcherMap.get(timer.portalId);
if (watchers) {
let index = watchers.findIndex((w) => w.timerId === timer.timerId);
if (index >= 0) {
watchers.splice(index, 1);
}
}
}
}
/**
* Enqueues the given action.
* @param action The action to enqueue.
*/
enqueueAction(action) {
if (action.type === 'remote') {
const index = this.actions.indexOf(action.event);
if (index >= 0) {
this.actions[index] = action;
}
else {
this.actions.push(action);
this._batcher.notifyActionEnqueued(action);
this._batcher.notifyChange();
}
}
else {
this.actions.push(action);
this._batcher.notifyActionEnqueued(action);
this._batcher.notifyChange();
}
}
dequeueActions() {
let actions = this.actions;
this.actions = [];
return actions;
}
// TODO: Improve to correctly handle when a non ScriptError object is added
// but contains symbol properties that reference the throwing bot and tag.
// The AuxRuntime should look for these error objects and create ScriptErrors for them.
enqueueError(error) {
if (error instanceof RanOutOfEnergyError) {
throw error;
}
this.errors.push(error);
this._batcher.notifyChange();
}
dequeueErrors() {
let errors = this.errors;
this.errors = [];
return errors;
}
/**
* Converts the given bot into a non-script enabled version.
* @param bot The bot.
*/
unwrapBot(bot) {
if (isBot(bot)) {
return {
id: bot.id,
space: bot.space,
// TODO: Fix for proxy objects
tags: transform(bot.tags, (result, value, key) => {
result[key] = getOriginalObject(value);
}, {}),
};
}
return bot;
}
createBot(bot) {
const newBot = ensureBotIsSerializable(bot);
const script = this._scriptFactory.createRuntimeBot(newBot) || null;
if (script) {
addToContext(this, script);
if (script.listeners) {
for (let key in script.listeners) {
if (typeof script.listeners[key] === 'function') {
this.recordListenerPresense(script.id, key, true);
}
}
}
}
this.enqueueAction(botAdded(newBot));
return script;
}
/**
* Destroys the given bot.
* @param bot The bot to destroy.
*/
destroyBot(bot) {
const index = indexInContext(this, bot);
if (index < 0) {
return;
}
const mode = this._scriptFactory.destroyScriptBot(bot);
if (mode === RealtimeEditMode.Immediate) {
this.bots.splice(index, 1);
delete this.state[bot.id];
this.cancelBotTimers(bot.id);
if (bot.listeners) {
for (let key in bot.listeners) {
this.recordListenerPresense(bot.id, key, false);
}
}
}
this.enqueueAction(botRemoved(bot.id));
}
createTask(unguessableId, allowRemoteResolution) {
let resolve;
let reject;
let promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
const task = {
taskId: !unguessableId && !this.forceUnguessableTaskIds
? (this._taskCounter += 1)
: !this.forceUnguessableTaskIds
? this.uuid()
: uuidv4(),
allowRemoteResolution: allowRemoteResolution || false,
resolve: resolve,
reject: reject,
promise,
};
this.tasks.set(task.taskId, task);
return task;
}
createIterable(unguessableId, allowRemoteResolution) {
const subject = new ReplaySubject();
let resolved = false;
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = (value) => {
resolved = true;
res({
iterable: subject,
result: value,
});
};
reject = rej;
});
const task = {
taskId: !unguessableId && !this.forceUnguessableTaskIds
? (this._taskCounter += 1)
: !this.forceUnguessableTaskIds
? this.uuid()
: uuidv4(),
allowRemoteResolution: allowRemoteResolution || false,
resolve,
reject,
promise,
iterable: subject,
subject,
next: (val) => {
if (!resolved) {
resolve({ success: true });
}
subject.next(val);
},
complete: () => subject.complete(),
throw: (err) => subject.error(err),
};
this.iterableTasks.set(task.taskId, task);
return task;
}
iterableNext(taskId, value, remote) {
const task = this.iterableTasks.get(taskId);
if (task && (task.allowRemoteResolution || remote === false)) {
task.next(value);
return true;
}
return false;
}
iterableComplete(taskId, remote) {
const task = this.iterableTasks.get(taskId);
if (task && (task.allowRemoteResolution || remote === false)) {
this.iterableTasks.delete(taskId);
task.complete();
return true;
}
return false;
}
iterableThrow(taskId, value, remote) {
const task = this.iterableTasks.get(taskId);
if (task && (task.allowRemoteResolution || remote === false)) {
this.iterableTasks.delete(taskId);
task.throw(value);
return true;
}
return false;
}
resolveTask(taskId, result, remote) {
const task = this.tasks.get(taskId);
if (task && (task.allowRemoteResolution || remote === false)) {
this.tasks.delete(taskId);
task.resolve(result);
return true;
}
const iterableTask = this.iterableTasks.get(taskId);
if (iterableTask &&
(iterableTask.allowRemoteResolution || remote === false)) {
iterableTask.resolve(result);
return true;
}
return false;
}
rejectTask(taskId, error, remote) {
const task = this.tasks.get(taskId);
if (task && (task.allowRemoteResolution || remote === false)) {
this.tasks.delete(taskId);
task.reject(error);
return true;
}
return false;
}
getShoutTimers() {
const keys = Object.keys(this._shoutTimers);
const list = keys.map((k) => ({
tag: k,
timeMs: this._shoutTimers[k],
}));
return sortBy(list, (timer) => -timer.timeMs);
}
addShoutTime(shout, ms) {
if (ms < 0) {
throw new Error('Cannot add negative time to a shout timer.');
}
if (!(shout in this._shoutTimers)) {
this._shoutTimers[shout] = 0;
}
this._shoutTimers[shout] += ms;
}
getLoadTimes() {
return {
...this._loadTimes,
};
}
setLoadTime(key, ms) {
this._loadTimes[key] = ms;
}
startAnimationLoop() {
if (!this._animationLoop) {
const sub = animationLoop()
.pipe(tap(() => this._updateAnimationLoop()))
.subscribe();
this._animationLoop = new Subscription(() => {
sub.unsubscribe();
this._animationLoop = null;
});
}
return this._animationLoop;
}
/**
* Sets the data that should be used to mock the given function.
* @param func The function that the return values should be set for.
* @param returnValues The list of return values that should be used for the mock.
*/
setMockReturns(func, returnValues) {
if (ORIGINAL_OBJECT in func) {
func = func[ORIGINAL_OBJECT];
}
this._mocks.set(func, returnValues.slice());
}
/**
* Sets the data that should be used to mock the given function for the given arguments.
* @param func The function.
* @param args The arguments that should be matched against.
* @param returnValue The return value that should be used for the mock.
*/
setMockReturn(func, args, returnValue) {
if (ORIGINAL_OBJECT in func) {
func = func[ORIGINAL_OBJECT];
}
let mocks = this._mocks.get(func);
let map;
if (mocks instanceof Map) {
map = mocks;
}
else {
map = new Map();
this._mocks.set(func, map);
}
const argJson = stableStringify(args, { space: 2 });
map.set(argJson, returnValue);
}
/**
* Gets the data that should be used as the function's return value.
* @param func The function.
*/
getNextMockReturn(func, functionName, args) {
if (ORIGINAL_OBJECT in func) {
func = func[ORIGINAL_OBJECT];
}
if (!this._mocks.has(func)) {
throw new Error(`No mask data for function: ${debugStringifyFunction(functionName, args)}`);
}
let arrayOrMap = this._mocks.get(func);
if (arrayOrMap instanceof Map) {
const argJson = stableStringify(args, { space: 2 });
if (arrayOrMap.has(argJson)) {
return arrayOrMap.get(argJson);
}
else {
throw new Error(`No mask data for function (no matching input): ${debugStringifyFunction(functionName, args)}`);
}
}
else {
if (arrayOrMap.length > 0) {
return arrayOrMap.shift();
}
else {
throw new Error(`No mask data for function (out of return values): ${debugStringifyFunction(functionName, args)}`);
}
}
}
_updateAnimationLoop() {
TWEEN.update(this.localTime);
}
}
/**
* Creates a debug string that is useful for visualizing a function call.
* @param functionName The name of the function.
* @param args The arguments that were passed to the function.
* @returns
*/
export function debugStringifyFunction(functionName, args) {
const argList = args.map((a) => debugStringify(a)).join(', ');
return `${functionName}(${argList})`;
}
/**
* Creates a debug string from the given value.
* @param value
* @returns
*/
export function debugStringify(value) {
if ((typeof value === 'object' || typeof value === 'function') &&
DEBUG_STRING in value) {
return value[DEBUG_STRING];
}
return stableStringify(value, { space: 2 });
}
function animationLoop() {
return new Observable((observer) => {
let interval = setInterval(() => {
observer.next();
}, SET_INTERVAL_ANIMATION_FRAME_TIME);
return () => {
clearInterval(interval);
};
});
}
//# sourceMappingURL=AuxGlobalContext.js.map