diff --git a/package-lock.json b/package-lock.json index 9041a4b4..52e6729a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ranvier", - "version": "3.0.1", + "version": "3.0.6", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 902be874..9279bbf7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "bugs": "https://github.com/ranviermud/core/issues", "license": "MIT", "author": "Shawn Biddle (http://shawnbiddle.com)", - "version": "3.0.3", + "version": "3.0.6", "repository": "github:ranviermud/core", "engines": { "node": ">= 10.12.0" diff --git a/src/Area.js b/src/Area.js index c4f2a4ba..2366466e 100644 --- a/src/Area.js +++ b/src/Area.js @@ -177,8 +177,23 @@ class Area extends GameEntity { this.addRoom(room); state.RoomManager.addRoom(room); room.hydrate(state); + /** + * Fires after the room is hydrated and added to its area + * @event Room#ready + */ + room.emit('ready'); } } + + /** + * Get all possible broadcast targets within an area. This includes all npcs, + * players, rooms, and the area itself + * @return {Array} + */ + getBroadcastTargets() { + const roomTargets = [...this.rooms].reduce((acc, [, room]) => acc.concat(room.getBroadcastTargets()), []); + return [this, ...roomTargets]; + } } module.exports = Area; diff --git a/src/AreaAudience.js b/src/AreaAudience.js index 1a714e76..a505c45f 100644 --- a/src/AreaAudience.js +++ b/src/AreaAudience.js @@ -9,16 +9,12 @@ const ChannelAudience = require('./ChannelAudience'); */ class AreaAudience extends ChannelAudience { getBroadcastTargets() { - // It would be more elegant to just pass the area but that could be very - // inefficient as it's much more likely that there are fewer players than - // there are rooms in the area - const players = this.state.PlayerManager.filter(player => - player.room && - (player.room.area === this.sender.room.area) && - (player !== this.sender) - ); + if (!this.sender.room) { + return []; + } - return players.concat(area.npcs); + const { area } = this.sender.room; + return area.getBroadcastTargets().filter(target => target !== this.sender); } } diff --git a/src/AreaFactory.js b/src/AreaFactory.js index 1bef84fc..a99f8fb1 100644 --- a/src/AreaFactory.js +++ b/src/AreaFactory.js @@ -26,7 +26,7 @@ class AreaFactory extends EntityFactory { const area = new Area(definition.bundle, entityRef, definition.manifest); if (this.scripts.has(entityRef)) { - this.scripts.get(entityRef).attach(entity); + this.scripts.get(entityRef).attach(area); } return area; diff --git a/src/BundleManager.js b/src/BundleManager.js index 75da0e05..042520b5 100644 --- a/src/BundleManager.js +++ b/src/BundleManager.js @@ -270,13 +270,13 @@ class BundleManager { const scriptPath = this._getAreaScriptPath(bundle, areaName); if (manifest.script) { - const scriptPath = `${scriptPath}/${area.script}.js`; - if (!fs.existsSync(scriptPath)) { - Logger.warn(`\t\t\t[${areaName}] has non-existent script "${area.script}"`); + const areaScriptPath = `${scriptPath}/${manifest.script}.js`; + if (!fs.existsSync(areaScriptPath)) { + Logger.warn(`\t\t\t[${areaName}] has non-existent script "${manifest.script}"`); } - Logger.verbose(`\t\t\tLoading Item Script [${entityRef}] ${item.script}`); - this.loadEntityScript(this.state.AreaFactory, entityRef, scriptPath); + Logger.verbose(`\t\t\tLoading Area Script for [${areaName}]: ${manifest.script}`); + this.loadEntityScript(this.state.AreaFactory, areaName, areaScriptPath); } Logger.verbose(`\t\tLOAD: Quests...`); @@ -573,7 +573,7 @@ class BundleManager { const loader = require(effectPath); Logger.verbose(`\t\t${effectName}`); - this.state.EffectFactory.add(effectName, this._getLoader(loader, srcPath)); + this.state.EffectFactory.add(effectName, this._getLoader(loader, srcPath), this.state); } Logger.verbose(`\tENDLOAD: Effects...`); diff --git a/src/Character.js b/src/Character.js index ddbaf586..8b63883c 100644 --- a/src/Character.js +++ b/src/Character.js @@ -524,12 +524,12 @@ class Character extends Metadatable(EventEmitter) { } /** - * @param {Character} target + * @param {Character} follower * @fires Character#lostFollower */ - removeFollower(target) { - this.followers.delete(target); - target.following = null; + removeFollower(follower) { + this.followers.delete(follower); + follower.following = null; /** * @event Character#lostFollower * @param {Character} follower diff --git a/src/CommandQueue.js b/src/CommandQueue.js index 1ece9c42..3a821002 100644 --- a/src/CommandQueue.js +++ b/src/CommandQueue.js @@ -1,5 +1,8 @@ 'use strict'; +/** @typedef {{ execute: function (), label: string, lag: number= }} */ +var CommandExecutable; + /** * Keeps track of the queue off commands to execute for a player */ @@ -11,7 +14,17 @@ class CommandQueue { } /** - * @param {{execute: function (), label: string}} executable Thing to run with an execute and a queue label + * Safely add lag to the current queue. This method will not let you add a + * negative amount as a safety measure. If you want to subtract lag you can + * directly manipulate the `lag` property. + * @param {number} amount milliseconds of lag + */ + addLag(amount) { + this.lag += Math.max(0, amount); + } + + /** + * @param {CommandExecutable} executable Thing to run with an execute and a queue label * @param {number} lag Amount of lag to apply to the queue after the command is run */ enqueue(executable, lag) { @@ -28,7 +41,7 @@ class CommandQueue { * @return {boolean} whether the command was executed */ execute() { - if (!this.commands.length || Date.now() - this.lastRun < this.lag) { + if (!this.commands.length || this.msTilNextRun > 0) { return false; } @@ -48,40 +61,69 @@ class CommandQueue { } /** - * Flush all pending commands + * Flush all pending commands. Does _not_ reset lastRun/lag. Meaning that if + * the queue is flushed after a command was just run its lag will still have + * to expire before another command can be run. To fully reset the queue use + * the reset() method. */ flush() { this.commands = []; + } + + /** + * Completely reset the queue and any lag. This is fairly dangerous as if the + * player could reliably reset the queue they could negate any command lag. To + * clear commands without altering lag use flush() + */ + reset() { + this.flush(); + this.lastRun = 0; this.lag = 0; - // do not clear lastRun otherwise player's could exploit by immediately - // clearing after a command was executed } /** - * In seconds get how long until the next command will run, rounded to nearest tenth of a second + * Seconds until the next command can be executed * @type {number} */ get lagRemaining() { - return this.commands.length ? this.getTimeTilRun(0) : 0; + return this.msTilNextRun / 1000; + } + + /** + * Milliseconds til the next command can be executed + * @type {number} + */ + get msTilNextRun() { + return Math.max(0, (this.lastRun + this.lag) - Date.now()); } /** - * For a given command index find how long until it will run + * For a given command index find how many seconds until it will run * @param {number} commandIndex + * @param {boolean} milliseconds * @return {number} */ getTimeTilRun(commandIndex) { + return this.getMsTilRun(commandIndex) / 1000; + } + + /** + * Milliseconds until the command at the given index can be run + * @param {number} commandIndex + * @return {number} + */ + getMsTilRun(commandIndex) { if (!this.commands[commandIndex]) { throw new RangeError("Invalid command index"); } - let lagTotal = 0; + let lagTotal = this.msTilNextRun; for (let i = 0; i < this.commands.length; i++) { - const command = this.commands[i]; - lagTotal += command.lag; if (i === commandIndex) { - return Math.max(0, this.lastRun + lagTotal - Date.now()) / 1000; + return lagTotal; } + + lagTotal += this.commands[i].lag; } } } diff --git a/src/EffectFactory.js b/src/EffectFactory.js index a4739ca0..ac10b4fb 100644 --- a/src/EffectFactory.js +++ b/src/EffectFactory.js @@ -17,15 +17,19 @@ class EffectFactory { /** * @param {string} id * @param {EffectConfig} config + * @param {GameState} state */ - add(id, config) { + add(id, config, state) { if (this.effects.has(id)) { return; } let definition = Object.assign({}, config); delete definition.listeners; - const listeners = config.listeners || {}; + let listeners = config.listeners || {}; + if (typeof listeners === 'function') { + listeners = listeners(state); + } const eventManager = new EventManager(); for (const event in listeners) { diff --git a/src/HelpManager.js b/src/HelpManager.js index 3a760eba..97dd5947 100644 --- a/src/HelpManager.js +++ b/src/HelpManager.js @@ -28,7 +28,7 @@ class HelpManager { */ find(search) { const results = new Map(); - for (const [ name, help ] of this.helps.entries()) { + for (const [name, help] of this.helps.entries()) { if (name.indexOf(search) === 0) { results.set(name, help); continue; @@ -39,6 +39,26 @@ class HelpManager { } return results; } + + /** + * Returns first help matching keywords + * @param {string} search + * @return {?string} + */ + getFirst(help) { + const results = this.find(help); + + if (!results.size) { + /** + * No results found + */ + return null; + } + + const [_, hfile] = [...results][0]; + + return hfile; + } } module.exports = HelpManager; diff --git a/src/Item.js b/src/Item.js index 0f581bf3..df5f3fc0 100644 --- a/src/Item.js +++ b/src/Item.js @@ -61,7 +61,13 @@ class Item extends GameEntity { this.room = item.room || null; this.roomDesc = item.roomDesc || ''; this.script = item.script || null; - this.type = typeof item.type === 'string' ? ItemType[item.type] : (item.type || ItemType.OBJECT); + + if (typeof item.type === 'string') { + this.type = ItemType[item.type] || item.type; + } else { + this.type = item.type || ItemType.OBJECT; + } + this.uuid = item.uuid || uuid(); this.closeable = item.closeable || item.closed || item.locked || false; this.closed = item.closed || false; diff --git a/src/ItemType.js b/src/ItemType.js index 0bd2e533..5f9eac65 100644 --- a/src/ItemType.js +++ b/src/ItemType.js @@ -2,13 +2,13 @@ /** * @module ItemType - * @enum {Symbol} + * @enum {number} */ module.exports = { - ARMOR: Symbol("ARMOR"), - CONTAINER: Symbol("CONTAINER"), - OBJECT: Symbol("OBJECT"), - POTION: Symbol("POTION"), - WEAPON: Symbol("WEAPON"), - RESOURCE: Symbol("RESOURCE"), + OBJECT: 1, + CONTAINER: 2, + ARMOR: 3, + WEAPON: 4, + POTION: 5, + RESOURCE: 6, }; diff --git a/src/Metadatable.js b/src/Metadatable.js index 302fd760..a4878b99 100644 --- a/src/Metadatable.js +++ b/src/Metadatable.js @@ -21,6 +21,7 @@ class extends parentClass { * @param {*} value Value must be JSON.stringify-able * @throws Error * @throws RangeError + * @fires Metadatable#metadataUpdate */ setMeta(key, value) { if (!this.metadata) { @@ -39,7 +40,16 @@ class extends parentClass { base = base[part]; } + const oldValue = base[property]; base[property] = value; + + /** + * @event Metadatable#metadataUpdate + * @param {string} key + * @param {*} newValue + * @param {*} oldValue + */ + this.emit('metadataUpdated', key, value, oldValue); } /** diff --git a/src/Npc.js b/src/Npc.js index 1320cb41..d0637041 100644 --- a/src/Npc.js +++ b/src/Npc.js @@ -48,6 +48,7 @@ class Npc extends Scriptable(Character) { * @fires Npc#enterRoom */ moveTo(nextRoom, onMoved = _ => _) { + const prevRoom = this.room; if (this.room) { /** * @event Room#npcLeave @@ -66,8 +67,9 @@ class Npc extends Scriptable(Character) { /** * @event Room#npcEnter * @param {Npc} npc + * @param {Room} prevRoom */ - nextRoom.emit('npcEnter', this); + nextRoom.emit('npcEnter', this, prevRoom); /** * @event Npc#enterRoom * @param {Room} room diff --git a/src/Player.js b/src/Player.js index ea0794e7..a6035447 100644 --- a/src/Player.js +++ b/src/Player.js @@ -133,6 +133,7 @@ class Player extends Character { * @fires Player#enterRoom */ moveTo(nextRoom, onMoved = _ => _) { + const prevRoom = this.room; if (this.room && this.room !== nextRoom) { /** * @event Room#playerLeave @@ -151,8 +152,9 @@ class Player extends Character { /** * @event Room#playerEnter * @param {Player} player + * @param {Room} prevRoom */ - nextRoom.emit('playerEnter', this); + nextRoom.emit('playerEnter', this, prevRoom); /** * @event Player#enterRoom * @param {Room} room diff --git a/src/Room.js b/src/Room.js index 42f7c408..c7232295 100644 --- a/src/Room.js +++ b/src/Room.js @@ -303,6 +303,7 @@ class Room extends GameEntity { /** * @param {GameState} state * @param {string} entityRef + * @return {Item} The newly created item */ spawnItem(state, entityRef) { Logger.verbose(`\tSPAWN: Adding item [${entityRef}] to room [${this.title}]`); @@ -315,6 +316,7 @@ class Room extends GameEntity { * @event Item#spawn */ newItem.emit('spawn'); + return newItem; } /** @@ -341,6 +343,13 @@ class Room extends GameEntity { hydrate(state) { this.setupBehaviors(state.RoomBehaviorManager); + /** + * Fires when the room is created but before it has hydrated its default + * contents. Use the `ready` event if you need default items to be there. + * @event Room#spawn + */ + this.emit('spawn'); + this.items = new Set(); // NOTE: This method effectively defines the fact that items/npcs do not diff --git a/src/Scriptable.js b/src/Scriptable.js index 463543a3..2817bd01 100644 --- a/src/Scriptable.js +++ b/src/Scriptable.js @@ -51,7 +51,7 @@ class extends parentClass { for (let [behaviorName, config] of this.behaviors) { let behavior = manager.get(behaviorName); if (!behavior) { - Logger.warn(`No script found for item behavior ${behaviorName}`); + Logger.warn(`No script found for [${this.constructor.name}] behavior '${behaviorName}'`); continue; } diff --git a/test/unit/CommandQueue.js b/test/unit/CommandQueue.js new file mode 100644 index 00000000..09b2e3c4 --- /dev/null +++ b/test/unit/CommandQueue.js @@ -0,0 +1,111 @@ +const assert = require('assert'); +const CommandQueue = require('../../src/CommandQueue'); + +describe('Command Queue', function () { + let queue = null; + const originalDateNow = Date.now; + + beforeEach(function (done) { + queue = new CommandQueue(); + done(); + }); + + afterEach(function (done) { + Date.now = originalDateNow; + done(); + }); + + describe('#enqueue', function () { + it('should have a command in the queue', function (done) { + const executable = { + execute: () => {} + }; + const index = queue.enqueue(executable, 1); + assert.equal(queue.commands.length, 1); + done(); + }); + + it('should execute the command', function (done) { + const executable = { + execute: () => { + done(); + } + }; + queue.enqueue(executable, 1); + queue.execute(); + }); + + it('should have lag', function (done) { + const lag = 2000; + const executable = { + execute: () => {} + }; + queue.enqueue(executable, lag); + queue.execute(); + assert.equal(queue.lag, lag); + done(); + }); + + it('should obey lag', function (done) { + let shouldSucceed = false; + const lag = 500; + const executableA = { + execute: () => {} + }; + const executableB = { + execute: () => { + if (shouldSucceed) { + return done(); + } + + done(new Error('Executed too early')); + } + }; + queue.enqueue(executableA, lag); + queue.enqueue(executableB, lag); + + Date.now = () => 1000; + queue.execute(); + assert.equal(queue.lag, lag); + + Date.now = () => 1200; + assert.equal(queue.execute(), false); + + Date.now = () => 1600; + shouldSucceed = true; + queue.execute(); + }); + + it('time to run is correct', function (done) { + const lag = 500; + const executableA = { + execute: () => {} + }; + queue.enqueue(executableA, lag); + queue.enqueue(executableA, lag); + queue.enqueue(executableA, lag); + + Date.now = () => 1000; + assert.equal(queue.msTilNextRun, 0); + assert.equal(queue.getMsTilRun(0), 0); + assert.equal(queue.getMsTilRun(1), lag); + assert.equal(queue.getMsTilRun(2), lag * 2); + + queue.execute(); + assert.equal(queue.msTilNextRun, lag); + assert.equal(queue.execute(), false); + + Date.now = () => 1500; + assert.equal(queue.msTilNextRun, 0); + assert.equal(queue.execute(), true); + + assert.equal(queue.msTilNextRun, lag); + queue.addLag(200); + assert.equal(queue.msTilNextRun, lag + 200); + + queue.reset(); + assert.equal(queue.msTilNextRun, 0); + done(); + }); + }); +});