UNPKG

@casual-simulation/aux-runtime

Version:
681 lines 22.4 kB
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