/* exported StartGame */
/* exported StartGame */

// Include core files
/**
* Rulebook system
* Allows for dynamic creation of complex rulesets/filters to be applied to existing verbs, raw text and regex'd input.
* Uses method chaining for expressive style.
*/

var Rules = function () {
   this.rules = {
       'before': {},
       'after': {},
       'internal': {}
   };

   // Add default rule filter groups
   // TODO: I don't remember how these work
   this.filterGroups.default = new RuleFilterGroup(`default`, RuleFilterGroup.MODE_AND);
   this.filterGroups.region = new RuleFilterGroup(`region`, RuleFilterGroup.MODE_OR);
   this.filterGroups.location = new RuleFilterGroup(`location`, RuleFilterGroup.MODE_OR);
   this.filterGroups.target = new RuleFilterGroup(`target`, RuleFilterGroup.MODE_OR);
   this.filterGroups.modifier = new RuleFilterGroup(`modifier`, RuleFilterGroup.MODE_OR);
   this.filterGroups.internal = new RuleFilterGroup(`internal`, RuleFilterGroup.MODE_OR);

   // Add default objects
   this.registerObject(`player`, null);
   this.registerObject(`actor`, function (action) {
       return action.actor;
   });
   this.registerObject(`location`, function (action) {
       return action.actor.location();
   });
   this.registerObject(`target`, function (action) {
       return action.target;
   });
   this.registerObject(`region`, function (action) {
       return action.actor.location.region;
   });
};

Rules.prototype = {
   // Constants
   ACTION_CANCEL: 0,   // Cancel action
   ACTION_PREPEND: 1,  // Prepend output and continue as normal
   ACTION_APPEND: 1,   // Finish action then append output
   ACTION_NONE: 3,     // Not handled, run action as usual
   RULE_BEFORE: `before`,      // Standard rule, happens before action processing
   RULE_AFTER: `after`,        // After rule, happens after action processing
   RULE_INTERNAL: `internal`,  // Internal rule, handled specially
   // Rule list
   active: true,
   rules: {},
   filterGroups: {},
   // Object list
   objects: {},
   // Helpers
   'add': function (rule) {
       this.rules[rule.type][rule.key] = rule;
   },
   'remove': function (book, key) {
       delete this.rules[book][key];
   },
   'registerObject': function (key, object) {
       this.objects[key] = object;
   },
   // Check command against rulebook
   'check': function (type, action) {
       console.log(`Rulebook: Checking book '`+type+`'`);
       if (!this.active) {
           return action;
       }

       for (var r in this.rules[type]) {
           this.rules[type][r].run(action);

           if (action.mode === this.ACTION_CANCEL) {
               break;
           }
       }

       return action;
   },
   'pause': function () {
       this.active = false;
   },
   'start': function () {
       this.active = true;
   }
};

/**
* Rule Filter Group constructor
* Filter groups are used to organize related filters and specify whether ALL or ANY are required.
*
* @param key
* @param mode
* @constructor
*/
var RuleFilterGroup = function (key, mode) {
   this.key = key;
   this.mode = mode;
};

RuleFilterGroup.prototype = {
   MODE_AND: `and`,
   MODE_OR: `or`,
   key: null,
   mode: null
};

var Rulebook = new Rules();

/**
* Filter constructor. A filter is a generic processor used by a rule. It always includes a callback and can
* optionally include additional parameters specified for the rule. When a callback is executed, it will be given:
* - actor (the entity initiating the action)
* - action (the text of the actor's action)
* - params (the saved parameters for the rule)
* @param callback function
* @param params object
*/
var RuleFilter = function (callback, params) {
   this.callback = callback;
   this.params = params;
   this.type = this.TYPE_DEFAULT;
};

RuleFilter.prototype = {
   TYPE_DEFAULT: 0,
   TYPE_REVERSE: 1,
   'callback': function () {
   },
   'params': {},
   'type': null
};

RuleFilter.prototype.run = function (self, action) {
   var result = this.callback(self, action, this.params);

   return (this.type == this.TYPE_REVERSE) ? !result : result;
};

var understand = function (key) {
   return new Rule(key);
};

var Rule = function (key) {
   this.key = key;
   this.commandType = null;
   this.command = null;
   this.filters = {};
   this.response = null;
   this.mode = Rulebook.ACTION_NONE;
   this.responseText = ``;
   this.prepend = ``;
   this.type = Rulebook.RULE_BEFORE;
   this.currentData = {};

   for (var g in Rulebook.filterGroups) {
       this.filters[g] = [];
   }
};

Rule.prototype = {
   // Constants
   COMMAND_TEXT: 0,
   COMMAND_REGEX: 1,
   COMMAND_VERB: 2,
   COMMAND_CALLBACK: 3,
   // Storage for current instance
   'key': null,
   'commandType': null,
   'command': null,
   'filters': [],
   'response': null,
   'mode': null,
   'responseText': ``,
   'prepend': ``,
   'type': Rulebook.RULE_BEFORE,
   'currentData': null,
   'doUntil': function () {
       return false;
   },
   // Register and unregister self
   'start': function () {
       console.log(`Started Rule: ` + this.key);
       Rulebook.add(this);
       return this;
   },
   'stop': function () {
       Rulebook.remove(this.type, this.key);
   },
   // Execute the rule
   'run': function (action) {
       console.log(`Checking Rule '` + this.key + `'...`);
       this.currentData = {'action': action};

       // Validate command and fail fast
       // Action is expected to be {'verb':verb,'text':text}
       switch (this.commandType) {
           case this.COMMAND_TEXT:
               if ($.inArray(action.text, this.command) < 0) {
                   return this.exit(Rulebook.ACTION_NONE);
               }
               break;
           case this.COMMAND_REGEX:
               if (action.text.match(this.command) === null) {
                   return this.exit(Rulebook.ACTION_NONE);
               }
               break;
           case this.COMMAND_VERB:
               if (action.verb != this.command) {
                   return this.exit(Rulebook.ACTION_NONE);
               }
               break;
           case this.COMMAND_CALLBACK:
               if (!this.command(this, action)) {
                   return this.exit(Rulebook.ACTION_NONE);
               }
               break;
       }

       // Run filters
       for (var g in this.filters) {
           for (var f in this.filters[g]) {
               if (!this.filters[g][f].run(this, action)) {
                   // Filter failed, bail
                   return this.exit();
               }
           }
       }

       // Check 'do until' condition
       if (this.doUntil(action)) {
           console.log(`Rulebook: Stopping action '`+this.key+`'`);
           this.stop();
           this.exit(Rulebook.ACTION_NONE);
       }

       // Return response
       return this.respond();
   },
   'exit': function (mode) {
       if (typeof mode != `undefined`) {
           this.currentData.action.mode = mode;
       }
       return this.currentData.action;
   },
   'respond': function () {
       if (this.response !== null) {
           if (typeof this.response == `string`) {
               queueGMOutput(this.response);
           } else {
               return this.response(this, this.currentData.action);
           }
       }

       return this.exit();
   },
   // Helper methods
   'addFilter': function (callback, params, group) {
       group = group || `default`;
       this.filters[group].push(new RuleFilter(callback, params));
   },
   'addReverseFilter': function (callback, params, group) {
       group = group || `default`;
       var rf = new RuleFilter(callback, params);
       rf.type = RuleFilter.TYPE_REVERSE;
       this.filters[group].push(rf);
   },
   'checkAttribute': function (params, action) {
       var attribute = this.getAttribute(params.chain, action);
       var result = false;
       var value = params.value;

       // Validate attribute
       if (typeof attribute == `undefined`) {
           console.log(`FAILED TO FIND ATTRIBUTE REFERENCE '` + params.chain + `'`);
           return result;
       }

       // Get value result first. It could be a raw value, an attribute reference, or a function.
       if (typeof params.value == `string`) {
           value = this.getAttribute(params.value, action);
       } else if (typeof params.value == `function`) {
           value = params.value(params, action);
       }

       // Handle operator. Could be a function or one of the following:
       // Math: > < >= <= = ==
       // Set: contains
       // Any operator can be preceded by ! to negate it, e.g. !contains
       if (typeof params.operator == `function`) {
           // Callback operator
           result = action(attribute, value, action);
       }
       else {
           // Standard operator
           var operator = params.operator;
           var negate = (operator[0] == `!`);

           // Trim ! from operator
           if (negate) {
               operator = operator.substr(1);
               if (operator.length == 0) {
                   operator = `==`;
               }
           }

           // Special case operators
           if (operator == `in`) {
               // Swap 'in' operator as reverse alias for 'contains' operator
               var tmp = attribute;
               value = attribute;
               attribute = tmp;
               operator = `contains`;
           }

           if (operator == `=` || operator == `==`) {
               result = attribute == value;
           } else if (operator == `>`) {
               result = attribute > value;
           } else if (operator == `<`) {
               result = attribute < value;
           } else if (operator == `>=`) {
               result = attribute >= value;
           } else if (operator == `<=`) {
               result = attribute <= value;
           } else if (operator == `contains`) {
               // Set contains item
               if (attribute instanceof Entity) {
                   // Attribute is an Entity, check its inventory / children
                   result = attribute.hasChild(value);
               } else if (typeof attribute == `object`) {
                   // Attribute is a key/value object, check its properties
                   result = attribute.hasOwnProperty(value);
               } else if ($.isArray(attribute)) {
                   // Attribute is an array, check its contents
                   result = attribute.indexOf(value) >= 0;
               }
           } else if (operator == `containsType`) {
               // Set contains item of given type
               result = attribute.hasChildOfType(value);
           } else if (operator == `is`) {
               // Reference is of given type
               if (attribute instanceof Entity) {
                   // Entity has component(s)
                   if (typeof value == `string`) {
                       result = attribute.hasComponent(value);
                   } else if ($.isArray(value)) {
                       result = attribute.components.indexOf(value) >= 0;
                   }
               } else {
                   // Non-entity matches type
                   result = (typeof attribute == value);
               }
           }

           if (negate) {
               result = !result;
           }
       }

       return result;
   },
   'getAttribute': function (chain, action) {
       // If this isn't a rulebook object, return the string
       if (!$.isArray(chain) || !Rulebook.objects.hasOwnProperty(chain[0])) {
           return chain;
       }

       var value = Rulebook.objects[chain[0]];
       if (typeof value == `function`) {
           value = value(action);
       }

       for (var i = 1; i < chain.length; i++) {
           // If the child property doesn't exist, return
           if (!value.hasOwnProperty(chain[i])) {
               return;
           } else if (typeof value[chain[i]] == `function`) {
               // Otherwise execute/get as new value
               value = ECS.run(value, chain[i]);
           } else {
               value = value[chain[i]];
           }
       }

       return value;
   },
   // Chain: Flag rule as internal
   'internal': function (key) {
       this.type = Rulebook.RULE_INTERNAL;
       this.addFilter(function (self, action, params) {
           return action.internal == params.key;
       }, {'key': key}, `internal`);
       return this;
   },
   // Chain: Flag rule type
   'book': function(type) {
       this.type = type;
       return this;
   },
   // Chain: Add simple text match
   'text': function (text) {
       if(!Array.isArray(text)) {
           text = [text];
       }
       this.command = text;
       this.commandType = this.COMMAND_TEXT;
       return this;
   },
   // Chain: Add verb match
   'verb': function (verb) {
       this.command = ECS.getAction(verb);
       this.commandType = this.COMMAND_VERB;
       return this;
   },
   // Chain: Add regex match
   'regex': function (pattern) {
       this.command = pattern;
       this.commandType = this.COMMAND_REGEX;
       return this;
   },
   // Chain: Add callback match
   'action': function (callback) {
       this.command = callback;
       this.commandType = this.COMMAND_CALLBACK;
       return this;
   },
   // Chain: Location filter
   'in': function (loc) {
       this.addFilter(function (self, action, params) {
           return action.actor.locationIs(params.loc);
       }, {'loc': loc}, `location`);
       return this;
   },
   // Chain: Not In Location filter
   'notIn': function (loc) {
       this.addFilter(function (self, action, params) {
           return !action.actor.locationIs(params.loc);
       }, {'loc': loc}, `location`);
       return this;
   },
   // Chain: Region filter
   'inRegion': function (text) {
       this.addFilter(function (self, action, params) {
           return action.actor.regionIs(params.text);
       }, {'text': text}, `region`);
       return this;
   },
   // Chain: Not in Region filter
   'notInRegion': function (text) {
       this.addFilter(function (self, action, params) {
           return !action.actor.regionIs(params.text);
       }, {'text': text}, `region`);
       return this;
   },
   // If/While (aliases), add a filter callback to restrict requirements
   'if': function (callback, group) {
       return this.while(callback, group);
   },
   'while': function (callback, group) {
       this.addFilter(callback, {}, group);
       return this;
   },
   // Chain: Reverse while condition
   'unless': function (callback, group) {
       this.addReverseFilter(callback, {}, group);
       return this;
   },
   // Chain: Restrict to certain entity targets
   'on': function (target) {
       if(typeof target == `string`) {
           target = ECS.getEntity(target);
       }

       this.addFilter(function (self, action, params) {
           return params.target == action.target;
       }, {'target': target}, `target`);
       return this;
   },
   // Chain: Restrict to certain modifiers
   'modifier': function (modifier) {
       this.addFilter(function (self, action, params) {
           return action.modifiers.indexOf(params.modifier) >= 0;
       }, {'modifier': modifier}, `modifier`);
       return this;
   },
   // Chain: Check for matching attribute
   'attribute': function (a, operator, value) {
       value = (value) ? value : null;

       // Explode attribute
       var chain = a.split(`.`);
       this.addFilter(function (self, action, params) {
           return self.checkAttribute(params, action);
       }, {'chain': chain, 'operator': operator, 'value': value});

       return this;
   },
   // Chain: check for matching target entity
   'entity': function (t) {
       if(t instanceof Entity) { t = t.key; }
       return this.attribute(`target.key`,`=`,t);
   },
   // Chain: Return a raw response if the requirements are met
   'as': function (response, mode) {
       this.response = response;
       this.mode = mode;
       return this;
   },
   // Chain: Execute a callback if the requirements are met
   'do': function (callback) {
       this.response = callback;
       return this;
   },
   // Chain: Execute a callback and then cancel the rule
   'doOnce': function (callback) {
       var f = function(self, action) {
           callback(self,action);
           self.stop();
           action.mode = Rulebook.ACTION_CANCEL;
       };

       this.response = f;
       return this;
   },
   // Chain: Specify a condition at which the Rule will be removed
   'until': function (callback) {
       this.doUntil = callback;
       return this;
   },
   // Chain: Append a response for after the default response
   'append': function (response) {
       this.responseText = response;
       this.mode = Rulebook.ACTION_APPEND;
       return this;
   }
};

//
// Entity-Component System
//

// Utility functions
function isArray(value) {
   return toString.apply(value) === `[object Array]`;
}

//
// Core stuff
//

var ecs = ECS = {
   availableModules: {},
   modules: {},
   components: {},
   entities: {},
   systems: [],
   actions: {},
   nouns: {},
   internalActions: {},
   tick: true,
   // Last entity added, by type
   lastOfType: {},
   // General-purpose storage
   data: {}
};

ECS.setData = function (key, value) {
   this.data[key] = value;
};

ECS.getData = function (key) {
   if (this.data.hasOwnProperty(key)) {
       return this.data[key];
   }

   return null;
};

ECS.isValidMenuOption = function (options, command) {
   for (var o in options) {
       if (options[o].command == command.toLowerCase() || options[o].text.toLowerCase() == command) {
           return true;
       }
   }

   return false;
};

ECS.getMenuOption = function (options, command) {
   if (typeof options == `string`) {
       options = ECS.getData(options);
   }

   for (var o in options) {
       if (options[o].command == command.toLowerCase() || options[o].text.toLowerCase() == command) {
           return options[o];
       }
   }

   return false;
};

ECS.getMenuOptionValue = function (options, command) {
   command = command.toLowerCase();
   for (var o in options) {
       if (options[o].command == command || options[o].text.toLowerCase() == command) {
           return options[o].command;
       }
   }

   return false;
};

ECS.setOptions = function (obj, options) {
   $.extend(true, obj, options);
};

ECS.isComponentLoaded = function (component) {
   return (typeof this.components[component] != `undefined`);
};

ECS.getComponent = function (component) {
   return this.components[component];
};

ECS.hasAction = function (action) {
   return (typeof this.actions[action] != `undefined`);
};

ECS.getAction = function (action) {
   if (!this.hasAction(action)) {
       return null;
   }
   return this.actions[action];
};

ECS.run = function (object, callback, args) {
   if (typeof args != `object`) {
       args = {};
   }

   if (object.hasOwnProperty(callback)) {
       if (typeof object[callback] === `string`) {
           return object[callback];
       }

       return object[callback](args);
   }

   return ``;
};

ECS.runCallbacks = function (object, callback, args) {
   // Add target object to args list
   if (typeof args != `object`) {
       args = {};
   }
   args.obj = object;

   // Get callback list from object
   if (object.hasOwnProperty(callback)) {
       var callbacks = object[callback];

       if (typeof callbacks == `object`) {
           for (var i = 0; i < callbacks.length; i++) {
               if (callbacks[i](args)) {
                   return true;
               }
           }
       }
   }

   return false;
};

ECS.runFilters = function (object, callback, args) {
   // Add target object to args list
   if (typeof args != `object`) {
       args = {};
   }
   args.obj = object;

   // Get callback list from object
   if (object.hasOwnProperty(callback)) {
       var callbacks = object[callback];

       if (typeof callbacks == `object`) {
           for (var i = 0; i < callbacks.length; i++) {
               if (callbacks[i](args) === false) {
                   return false;
               }
           }
       }
   }

   return true;
};

ECS.addInternalAction = function (key, callback) {
   this.internalActions[key] = callback;
};

ECS.runInternalAction = function (key, data) {
   data = $.extend({}, NLP.lastAction, data);
   data.internal = key;

   // Check rules
   var action = Rulebook.check(`internal`, data);

   if (action.mode === Rulebook.ACTION_CANCEL) {
       return action.output;
   } else if (action.mode === Rulebook.ACTION_APPEND) {
       return this.internalActions[key](data) + action.output;
   }

   return this.internalActions[key](data);
};

ECS.findEntityByName = function (noun, scope) {
   if (typeof this.nouns[noun] != `undefined`) {
       for (var n in this.nouns[noun]) {
           var tmp = this.nouns[noun][n];

           var isOk = (scope == null);
           var isGlobal = (tmp.scope == `global`);
           var isLocal = (scope == `local` && tmp.visibleFrom(player.location()));
           var isInPlayerInventory = (tmp.parent == player);

           if (isOk || isInPlayerInventory || isLocal || isGlobal) {
               return tmp;
           }
       }
   }

   return null;
};

ECS.hasEntity = function (key) {
   return this.entities.hasOwnProperty(key);
};

ECS.getEntity = function (key) {
   if (this.hasEntity(key)) {
       return this.entities[key];
   }

   return null;
};

ECS.findEntity = function (component, noun) {
   if (this.entities.hasOwnProperty(noun)) {
       if (this.entities[noun].components.indexOf(component) >= 0) {
           return this.entities[noun];
       }
   }

   return null;
};

ECS.findEntitiesByComponent = function (component) {
   var matches = [];
   for (var e in this.entities) {
       if (this.entities[e].components.indexOf(component) >= 0) {
           matches.push(this.entities[e]);
       }
   }

   return matches;
};

ECS.getEntityPrefix = function (e) {
   if(typeof key != `object`) { e = this.getEntity(e); }

   return ECS.run(e, `prefix`);
};

ECS.init = function (modules) {
   for (var m in modules) {
       console.log(`INITIALIZING MODULE `+modules[m]);
       this.modules[modules[m]] = this.availableModules[modules[m]];
       this.modules[modules[m]].init();
   }
};

//
// System stuff
//

var System = function (name, options) {
   this.name = name;
   this.components = [];
   this.priority = 1;
   this.onTick = function () {
   };
};

// Add system to module
ecs.s = function (system) {
   var index = 0;

   // Get index based on priority
   if (this.systems.length > 0) {
       // Find first item of lower priority (higher #)
       for (var s in this.systems) {
           if (this.systems[s].priority > system.priority) {
               index = s;
           }
       }
   }

   // Insert system into list based on priority index
   this.systems.splice(index, 0, system);
};

//
// Module stuff
//

var Module = function (name, options) {
   this.name = name;
   this.dependencies = []; // Required modules
   this.components = []; // Components supplied by this module
   this.systems = []; // Systems supplied by this module
   this.actions = []; // Actions supplied by this module
   ECS.setOptions(this, options);
};

// Default init function for modules
Module.prototype.init = function () {
   console.log(`Module '` + this.name + `' initialized [default].`);
};

// Add system to module
Module.prototype.s = function (system) {
   this.systems.push(system);
};

// Add component to module
Module.prototype.c = function (name, options) {
   this.components[name] = options;
};

// Add action to module
Module.prototype.a = function (name, options) {
   if (!options.hasOwnProperty(`modifiers`)) {
       options.modifiers = [];
   }
   if (!options.hasOwnProperty(`filters`)) {
       options.filters = [];
   }
   this.actions[name] = options;
};

// Add module to ECS
ecs.m = ecs.module = function (module) {
   if (typeof this.modules[module.name] !== `undefined`) {
       console.log(`Module '` + module.name + `' already loaded.`);
       return;
   }

   // Register module
   this.availableModules[module.name] = module;

   // Register systems
   for (var s in module.systems) {
       ecs.s(module.systems[s]);
   }

   // Register components
   for (var c in module.components) {
       ecs.c(c, module.components[c]);
   }

   // Register actions
   for (var a in module.actions) {
       for (var i = 0; i < module.actions[a].aliases.length; i++) {
           this.actions[module.actions[a].aliases[i]] = module.actions[a];
       }
       console.log(`Added action '` + a + `' from module '` + module.name + `'`);
   }
};

// Get module from ECS
ecs.getModule = function (module) {
   return this.modules[module];
};

//
// Component stuff
//

var Component = function () {
   this.name = ``;
   this.parent = null; // Parent component to inherit from
   this.dependencies = []; // Required components
   this.onAdd = [];
};

// Default init for components. Does nothing.
Component.prototype.onInit = function () {
};

// Register component with ECS
ECS.c = ecs.c = ecs.component = function (name, options) {
   if (this.isComponentLoaded(name)) {
       console.log(`Component '` + name + `' already loaded.`);
       return;
   }

   // Create component instance
   var instance = new Component();
   instance.name = name;

   // Set options
   for (var option in options) {
       instance[option] = options[option];
   }

   // Add component to internal list
   this.components[name] = instance;

   // Run component's init callback
   this.components[name].onInit();
};

//
// Entity stuff
//

var Entity = function () {
   this.key = ``; // Identifier
   this.parent = null; // Parent entity
   this.children = []; // Child entities
   this.empty = true; // Whether the entity is empty (has no children)
   this.components = []; // Component list, for convenience / searching
   this.onComponentAdd = []; // Add component callback
   this.tags = []; // Tag list; generally the same as the component list
   this.persist = [`parent`]; // Raw attributes to persist
   this.persistActive = true; // Save objects by default
   this.isVisible = [
       function(args){
           // Entity is in location
           return args.location == args.obj.location();
       },
       function(args){
           // Entity is scenery in region
           return args.obj.regionIs(args.location.region) && args.obj.hasComponent(`scenery`);
       }
   ]; // Visibility callbacks
};

// Entity prototype; includes global variables
Entity.prototype = {
   contextActions: [],
};

// Default init for entities
Entity.prototype.init = function () {
   console.log(`Entity '` + this.key + `' initialized [default].`);
};

// Check for component on entity
Entity.prototype.hasComponent = function (component) {
   return (this.components.indexOf(component) >= 0);
};

// Alias for hasComponent
Entity.prototype.is = function (component) {
   return this.hasComponent(component);
};

// Add component to entity
Entity.prototype.c = function (component) {
   // Skip if already loaded for this entity
   if (this.hasComponent(component)) {
       return true;
   }

   // Make sure component is available
   if (ecs.isComponentLoaded(component)) {
       var success = true;
       var tmp = $.extend(true, {'e': this}, ecs.getComponent(component));

       // Load dependencies
       for (var c in tmp.dependencies) {
           success &= this.c(tmp.dependencies[c]);
       }

       // Add to entity's component list
       this.components.push(component);

       // Add to tag list
       this.tags.push(component);

       // Handle onComponentAdd callback
       if (tmp.hasOwnProperty(`onAdd`)) {
           var callbacks = tmp.onAdd;

           if (!$.isArray(tmp.onAdd)) {
               callbacks = [tmp.onAdd];
           }

           for (c in callbacks) {
               this.onComponentAdd.push(callbacks[c]);
           }

           delete tmp.onAdd;
       }

       // Handle persist data to avoid overwrite
       if (typeof tmp.persist != `undefined`) {
           $.merge(tmp.persist, this.persist);
       }

       // Load component
       $.extend(true, this, tmp);
       console.log(`Added component '` + component + `' to entity '` + this.key + `'`);

       return success;
   }

   console.log(`Failed to load component '` + component + `' or dependent component for Entity '` + this.name + `'`);
   return false;
};

// Update stats for entity
Entity.prototype.updateStats = function () {
   // Set entity as empty if it has no children
   this.empty = (this.children.length == 0);
};

// Add child to entity
Entity.prototype.addChild = function (e) {
   if (this.hasChild(e.key)) {
       return;
   }
   this.children.push(e);
   e.parent = this;

   ECS.runCallbacks(this, `onAddChild`, {'child': e});

   this.updateStats();
};

// Remove child from entity
Entity.prototype.removeChild = function (e) {
   var i = this.children.indexOf(e);

   if (i >= 0) {
       this.children.splice(i, 1);
   }

   ECS.runCallbacks(this, `onRemoveChild`, {'child': e});

   this.updateStats();
};

// Check if entity has a specific child, by key
Entity.prototype.hasChild = function (e) {
   var key = e;
   if(typeof e != `string`) {
       key = e.key;
   }

   for (e in this.children) {
       if (this.children[e].key == key) {
           return true;
       }
   }

   return false;
};

// Find children with matching component
Entity.prototype.findChildren = function (component) {
   var matches = [];

   for (var c in this.children) {
       if (this.children[c].hasComponent(component)) {
           matches.push(this.children[c]);
       }
   }

   return matches;
};

// Automatically determine article (a, an, etc) from name
Entity.prototype.article = function () {
   var vowels = [`a`, `e`, `i`, `o`, `u`];
   var firstLetter = this.name.substring(0, 1);
   var article = `a`;

   // For proper nouns, use no article
   if(firstLetter == firstLetter.toUpperCase()) {
       return ``;
   }

   if (vowels.indexOf(firstLetter.toLowerCase()) >= 0) {
       article = `an`;
   }

   return article;
};

// Save an entity
Entity.prototype.save = function () {
   var e = this;
   var data = {'key': this.key, 'values': {}};

   $.each(this.persist, function (i, v) {
       data.values[v] = ecs.getSaveValue(e[v]);
   });

   return data;
};

// Load an entity
Entity.prototype.load = function (data) {
   var e = this;

   $.each(this.persist, function (i, v) {
       if (typeof data.values[v] != `undefined`) {
           var value = data.values[v][1];
           if (data.values[v][0] == `reference`) {
               value = ECS.entities[value];
           }

           if (v == `parent` && e.parent != null) {
               e.parent.removeChild(e);
           }

           if ((v == `place` || v == `parent`) && value instanceof Entity) {
               value.addChild(e);
           }

           e[v] = value;
       }
   });

   // If parent and place don't match, remove from place children
   if (this.parent != null && this.parent != this.place && this.place instanceof Entity) {
       this.place.removeChild(this);
   }
};

// Add context action
Entity.prototype.addContext = function(callback) {
   this.contextActions.push(callback);
};

// Get context actions
Entity.prototype.getContextActions = function () {
   var actions = [];
   for(var a in this.contextActions) {
       var tmp = this.contextActions[a](this);
       if(tmp !== false) {
           actions.push(tmp);
       }
   }

   return actions;
};

// Get a value from the entity, or a default value if specified
Entity.prototype.get = function (key,defaultValue) {
   if(this.hasOwnProperty(key)) {
       return this[key];
   }

   return defaultValue;
};

// Set a value on the entity
Entity.prototype.set = function (key,value) {
   this[key] = value;
};

// Check visibility
Entity.prototype.visibleFrom = function(location) {
   return ECS.runCallbacks(this, `isVisible`, {'location': location});
};

// Add entity to ECS
ECS.e = ECS.entity = function (key, components, options) {
   // Create instance
   var e = new Entity();
   e.key = key;

   // Check for existing key and abort if found
   // This is considered an unrecoverable error
   if(ECS.hasEntity(key)) {
       throw `ECS: Entity with key '`+key+`' already exists.`;
   }

   // Load components
   if (!isArray(components) || components.length == 0) {
       components = [`thing`];
   }

   for (var c in components) {
       var componentName = components[c];
       if (!e.c(componentName)) {
           return;
       }
   }

   // Handle persist data to avoid overwrite
   if (typeof options.persist != `undefined`) {
       $.merge(options.persist, e.persist);
   }

   // Set options
   ECS.setOptions(e, options);

   // Add tags
   if (typeof options.extraTags !== `undefined`) {
       e.tags = e.tags.concat(options.extraTags);
   }

   // Add nouns for entity (used by NLP)
   if (!e.hasOwnProperty(`nouns`)) {
       e.nouns = [];
   }

   // Add full name to noun list
   e.nouns.push(e.name);

   for (var i = 0; i < e.nouns.length; i++) {
       var noun = e.nouns[i].toLowerCase();
       if (!isArray(
               this.nouns[noun]
           )) {
           this.nouns[noun] = [];
       }

       this.nouns[noun].push(e);
   }

   // Execute Add Component callbacks
   ECS.runCallbacks(e, `onComponentAdd`);

   // Init entity
   e.init();

   // Add entity to ECS
   this.entities[key] = e;

   // Save entry to 'last of type' list
   for(c in components) {
       ECS.lastOfType[components[c]] = e;
   }

   return e;
};

// Remove entity from ECS
ecs.removeEntity = function (e) {
   // Remove from noun list
   if (e.hasOwnProperty(`nouns`)) {
       for (var i = 0; i < e.nouns.length; i++) {
           delete this.nouns[e.nouns[i]];
       }
   }

   // Remove from entity list
   delete this.entities[e.key];
};

ecs.moveEntity = function (e, d) {
   // Get target entity from key
   if(typeof e == `string`) {
       e = ECS.getEntity(e);
   }

   // Get destination entity from key
   if (typeof d == `string`) {
       d = ECS.getEntity(d);
   }

   // Remove entity from current location, if any
   if (e.place != null) {
       e.place.removeChild(e);
   }

   // Add entity to destination
   d.addChild(e);

   if(d.hasComponent(`place`)) {
       e.place = d;
   }
};

// Get save-safe value from an entity
ecs.getSaveValue = function (v) {
   if (typeof v == `undefined`) {
       return [`null`, null];
   }
   if (v instanceof Entity) {
       return [`reference`, v.key];
   }
   return [`value`, v];
};

// Save ECS
ecs.save = function () {
   var data = {
       'seed': seed,
       'entities': {}
   };

   for (var i in this.entities) {
       if (this.entities[i].persistActive) {
           data.entities[this.entities[i].key] = this.entities[i].save();
       }
   }

   return data;
};

// Load from json string
ecs.load = function (data) {
   data = JSON.parse(data);

   // Replace current seed with value from save data
   seed = data.seed;

   for (var i in data.entities) {
       var e = data.entities[i];
       this.entities[e.key].load(data.entities[i]);
   }
};

/**
* Counter helpers to keep track of how many times an arbitrary event has happened.
*/

ECS.setData(`counters`, {});

/**
* Get the count value for a given key, or set a new value.
*
* @param string key
* @param int value
* @returns
*/
var count = function (key, value) {
   var counters = ECS.getData(`counters`);

   // Set a new value for a given key
   if (typeof value != `undefined`) {
       counters[key] = value;
       return;
   }

   // Check if the given key exists, and return the value if it does
   if (counters.hasOwnProperty(key)) {
       return counters[key];
   }

   // The requested key doesn't exist
   return null;
};

/**
* Increment a counter.
*
* @param key
*/
var incrementCounter = function(key) {
   var c = count(key);
   c = (c) ? c + 1 : 1;
   count(key, c);
};

/**
* Decrement a counter.
*
* @param key
*/
var decrementCounter = function(key) {
   var c = count(key);
   c = (c) ? c - 1 : -1;
   count(key, c);
};

/**
* Check if the given key matches the given value
*
* @param key
* @param value
* @returns {boolean}
*/
var nth = function (key, value) {
   return count(key) === value;
};

/**
* Check if the given key has a value of 1
* Alias for nth(key,1)
*
* @param key
* @returns {boolean}
*/
var first = function (key) {
   return nth(key, 1);
};

/**
* Check if the given key has a value of 2
* Alias for nth(key,2)
*
* @param key
* @returns {boolean}
*/
var second = function (key) {
   return nth(key, 2);
};

/**
* Check if the given key has a value of 3
* Alias for nth(key,3)
*
* @param key
* @returns {boolean}
*/
var third = function (key) {
   return nth(key, 3);
};

/**
* Check if the given key is on a repeating nth count.
*
* @param key
* @param n
* @returns {boolean}
*/
var everyNth = function (key, n) {
   console.log(`CHECKING `+key+` for `+n);
   return (count(key) % n ) == 0;
};

/**
* Check if the given key is on a repeating 2nd count.
* Alias for everyNth(key,2)
*
* @param key
* @returns {boolean}
*/
var everyOther = function(key) {
   return everyNth(key, 2);
};

/**
* Check if the given key is on a repeating 3rd count.
* Alias for everyNth(key,3)
*
* @param key
* @returns {boolean}
*/
var everyThird = function(key) {
   return everyNth(key, 3);
};

Handlebars.registerHelper(`first`, function(counter, options) {
   if(first(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`second`, function(counter, options) {
   if(second(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`third`, function(counter, options) {
   if(third(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`nth`, function(counter, options) {
   if(nth(counter, options.hash.n)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`everyOther`, function(counter, options) {
   if(everyOther(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`everyThird`, function(counter, options) {
   if(everyThird(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`everyNth`, function(counter, options) {
   console.log(options.hash);
   if(everyNth(counter, options.hash.n)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

//
// Natural Language Processor
//

var Action = function (actor, text) {
   this.actor = actor;
   this.string = text;
   this.verb = null;
   this.target = null;
   this.nouns = [];
   this.modifiers = [];
   this.output = ``;
};

Action.prototype = {
   actor: null,
   string: ``,
   verb: null,
   target: null,
   nouns: [],
   modifiers: [],
   output: ``,
   update: function (data) {
       $.extend(this, data);
   }
};

var Response = function (mode, output) {
   this.mode = mode;
   this.out = output;
};

Response.prototype = {
   'output':function(action) {
       if(typeof this.out == `string`) {
           action.output += this.out;
       } else if(typeof this.out == `function`) {
           this.out(action);
       }
   }
};

var nlp = NLP = {
   // Response flags
   RESPONSE_BEFORE: 0,
   RESPONSE_AFTER: 1,
   RESPONSE_INSTEAD: 2,

   // Trigger a tick for the current action
   // Time only passes for ticks, so non-tick actions will not trigger most systems
   'tick': true,

   // Verb list
   'verbs': [],

   // Command Interrupt
   // Used for modules to interrupt the normal game cycle and take direct input (e.g. inputting a name)
   // Null by default. Modules should attach a callback as needed. The callback is responsible for self-deactivation.
   'command_interrupt': [],

   // Pattern list
   // Cannot start with a modifier under any circumstances. Any command that would make sense that way should be handled as an interrupt or a rule.
   // In order of priority (highest to lowest):
   'patterns': [
       `VERB`,                                                 // inventory
       `VERB NOUN`,                                    // eat baby
       `VERB MODIFIER`,                                // saunter west
       `VERB MODIFIER NOUN`,                   // get in closet
       `VERB NOUN MODIFIER`,               // turn wheel clockwise
       `VERB NOUN MODIFIER NOUN`,              // attack goblin with hammer
       `VERB MODIFIER MODIFIER NOUN`,      // look north through telescope
       `VERB MODIFIER NOUN MODIFIER NOUN`, // look through the telescope at bob
       // Overflow patterns
       `VERB MODIFIER NOUN MODIFIER TEXT`, // talk to goblin about greatest fears
       `VERB MODIFIER MODIFIER TEXT`,      // look north through telescope saucily
       `VERB NOUN MODIFIER TEXT`,          // ask bob about back pain
       `VERB MODIFIER TEXT`                // talk about floops
   ],

   // Current action data
   'actor': null,
   'currentAction': [],
   'lastAction': null,
   'afterAction': null, // Prev action reference for After rulebook

   // Command interrupt function
   'interrupt': function (init, callback) {
       if(typeof callback == `undefined`) {
           console.log(`NLP: INTERRUPT MISSING CALLBACK, REJECTING`);
           return;
       }

       this.command_interrupt.push({'init': init, 'callback': callback});
       this.init_next_interrupt();
   },

   // Simple command interrupt variant
   'interrupt_simple': function (command, response) {
       this.interrupt(
           null,
           function (string) {
               if (string == command) {
                   queueOutput(response);
               } else {
                   NLP.parse(string);
               }
               return true;
           }
       );
   },

   // Initialize next interrupt
   'init_next_interrupt': function () {
       if (this.command_interrupt.length > 0 && this.command_interrupt[0].init !== null) {
           this.command_interrupt[0].init();
           this.command_interrupt[0].init = null;
       }
   },

   // Create and register a new action
   'newAction': function(input_string) {
       var action = new Action(this.actor, input_string);
       this.currentAction.push(action);
       this.lastAction = action;

       return action;
   },

   // Get top action in stack
   'topAction': function() {
       var c = this.currentAction.length;
       return (c > 0) ? this.currentAction[c] : undefined;
   },

   // Clean up and exit
   'exit': function(output) {
       this.currentAction.pop();
       return output;
   },

   // Parsing function. Translates input like 'look at box' to something internally useful
   // Actions are generally dispatched directly, so return values are only used when parsing has failed
   'parse': function (input_string) {
       // Clear previous action data
       var currentAction = this.newAction(input_string);

       // Convert multiple spaces/whitespaces characters to a single space
       input_string = input_string.replace(/\s{2,}/g, ` `);
       var string = input_string;

       // Make sure command isn't empty
       if(typeof string == `undefined` || !string.length) {
           console.log(`NLP: Empty command string!`);
           console.trace();
       }

       // Trigger a tick by default
       this.tick = true;

       // If a command interrupt is enabled, skip normal input
       if (this.command_interrupt.length > 0) {
           var interrupt = this.command_interrupt.shift();
           var handled = interrupt.callback(string);
           if (handled) {
               console.log(`Interrupt handled, removing`);
               this.init_next_interrupt();
               return this.exit();
           }

           // Wasn't handled, return the interrupt to the beginning of the queue
           console.log(`Interrupt not handled, re-queuing`);
           this.command_interrupt.unshift(interrupt);
           return this.exit();
       }

       // Convert string to lowercase for easier matching to verbs/nouns
       // If case-sensitive matching is required, a command interrupt must be used
       input_string = input_string.toLowerCase();

       var target = null;
       var modifier = null;
       var halt = false;
       var tmp = null;

       // Loop through patterns and attempt to match against string
       var verb = null; // Only one verb is allowed
       var modifiers = null; // Any number of modifiers are allowed
       var nouns = null; // Any number of nouns are allowed

       for (var p = 0; p < this.patterns.length; p++) {
           // If a hard halt has been triggered, cancel here
           // Used in cases where the input is obviously malformed, such as GO NORTH NORTH
           if (halt) {
               break;
           }

           // Reset the modifier and noun lists. Some items may have been parsed last time,
           // even if the pattern was not successful overall
           modifiers = [];
           nouns = [];

           // Make a copy of the input string, since we're going to modify it
           var parse_string = input_string;

           // Tokenize the command
           // Using the term 'tokenize' generously
           var command_tokens = parse_string.toLowerCase().split(` `);

           // Get current pattern and tokenize
           var pattern = this.patterns[p];
           var pattern_tokens = pattern.toLowerCase().split(` `);

           // If there are less tokens in the command than in the pattern, skip this pattern; it won't be a match
           if (command_tokens.length < pattern_tokens.length) {
               continue;
           }

           // Loop through pattern tokens and start matching by type
           for (var t = 0; t < pattern_tokens.length; t++) {
               var token = pattern_tokens[t];

               // Run matching function
               if (token == `verb`) {
                   tmp = this.matchVerb(parse_string);
                   if (tmp.match != null) {
                       verb = tmp.match;
                       parse_string = tmp.string;
                   } else {
                       // Didn't find matching verb, bail
                       break;
                   }
               } else if (token == `modifier`) {
                   tmp = this.matchModifier(parse_string, verb);
                   if (tmp.match != null) {
                       if (modifiers.length > 0 && modifiers[0] == tmp.match) {
                           // Can't match the same modifier twice
                           halt = true;
                           break;
                       }
                       modifiers.push(tmp.match);
                       parse_string = tmp.string;
                   } else {
                       // Didn't find modifier, bail
                       break;
                   }
               } else if (token == `noun`) {
                   tmp = this.matchNoun(parse_string);
                   if (tmp.match != null) {
                       nouns.push(tmp.match);
                       parse_string = tmp.string;
                   } else {
                       // Didn't find noun, bail
                       console.log(`NLP: No noun match for token '`+parse_string+`'`);
                       break;
                   }
               } else if (token == `text`) {
                   // Some verbs allow overflow words to be parsed specially
                   // Example: writing text on a sign, or indicating a topic of discussion
                   if (verb.hasOwnProperty(`overflow`) && verb.overflow && parse_string.length > 0) {
                       nouns.push(parse_string);
                       parse_string = ``;
                   } else {
                       // Overflow not allowed, bail
                       break;
                   }
               }
           }

           // If we've matched all tokens in the pattern, break out
           if (parse_string.length == 0) {
               console.log(`MATCHED PATTERN: ` + pattern);
               break;
           }
       }

       // Get target (for rule purposes)

       target = (nouns.length > 0) ? nouns[0] : null;

       // Build data for action
       currentAction.update({
           'verb': verb,               // The matched verb object
           'text': input_string,       // The original input string (some verbs and rules will use it)
           'target': target,           // The first target object
           'modifiers': modifiers,     // A list of modifiers fed to the pattern
           'nouns': nouns,             // A list of nouns fed to the pattern
       });

       // Pre-process verb
       if(verb && verb.hasOwnProperty(`pre`)) {
           verb.pre(currentAction);
       }

       // Run 'Before' ruleset
       currentAction = Rulebook.check(`before`, currentAction);
       if (currentAction.mode === Rulebook.ACTION_CANCEL) {
           return this.exit();
       }
       console.log(currentAction);

       if(currentAction.output) {
           queueOutput(currentAction.output);
           currentAction.output = ``;
       }

       // No commands without verbs are allowed
       if (verb == null) {
           return this.exit(`<p>I don't understand.</p>`);
       }

       // If remainder parse string length is not 0, we failed to fully parse the string
       // Fail to avoid unintended consequences
       if (parse_string.length > 0) {
           // Get the understood portion of the command
           var clean_string = string.replace(` ` + parse_string, ``);

           // Allow actions to handle broken inputs on their own, if the verb was matched but the rest of the string didn't quite make sense
           if (typeof verb.onBadInput == `function`) {
               return this.exit(verb.onBadInput(parse_string));
           }

           return this.exit(`<p>I understood everything up until '` + parse_string + `'. You want to ` + clean_string.toUpperCase() + `, plus something.</p>`);
       }

       // Run verb filters
       // Some verbs only act on certain types of targets, for example
       if (verb.filters.length > 0) {
           var args = {'action': currentAction, 'verb': verb, 'nouns': nouns};
           if (!ECS.runFilters(verb, `filters`, args)) {
               return this.exit();
           }
       }

       // Let target handle action if appropriate callback is provided
       // Objects can define their own behaviors for verbs, which will bypass the standard verb behavior
       var performDefault = true;
       var objectCallback = `onAction.` + verb.aliases[0].toUpperCase().replace(/[\s\-]/g, `.`);
       var response = new Response(NLP.RESPONSE_AFTER, null);
       if (nouns.length > 0 && typeof nouns[0][objectCallback] == `function`) {
           response = nouns[0][objectCallback](currentAction);

           if (typeof response == `string`) {
               response = new Response(NLP.RESPONSE_INSTEAD, response);
           } else if (typeof response == `boolean`) {
               performDefault = response;
               response = new Response(performDefault ? NLP.RESPONSE_BEFORE : NLP.RESPONSE_INSTEAD, null);
           }
       }

       if(response.mode == NLP.RESPONSE_BEFORE) {
           response.output(currentAction);
       }

       if (response.mode != NLP.RESPONSE_INSTEAD) {
           var result = verb.callback(currentAction);

           if(typeof result == `string`) {
               currentAction.output += result;
           }
       }

       if(response.mode == NLP.RESPONSE_AFTER) {
           response.output(currentAction);
       }

       // Save action for After Rulebook
       this.afterAction = currentAction;

       return this.exit(currentAction.output);
   },

   // Match verb
   'matchVerb': function (string) {
       var tokens = string.split(` `);

       // Loop through token list, trying to parse longest string first (most tokens)
       for (var i = tokens.length; i > 0; i--) {
           var verb = tokens.slice(0, i).join(` `);
           var action = ECS.getAction(verb);

           if (action != null) {
               return {'match': action, 'string': tokens.slice(i).join(` `)};
           }
       }

       return {'match': null, 'string': string};
   },

   // Match modifier for action
   'matchModifier': function (string, action) {
       var tokens = string.split(` `);

       // Loop through token list, trying to parse longest string first (most tokens)
       for (var i = tokens.length; i > 0; i--) {
           var modifier = tokens.slice(0, i).join(` `);
           if (action.modifiers.indexOf(modifier) >= 0) {
               // Return found match and leftover string
               return {'match': modifier, 'string': tokens.slice(i).join(` `)};
           }
       }

       // No match found
       return {'match': null, 'string': string};
   },

   // Match noun
   'matchNoun': function (string) {
       var tokens = string.split(` `);

       // Loop through token list, trying to parse longest string first (most tokens)
       for (var i = tokens.length; i > 0; i--) {
           var noun = tokens.slice(0, i).join(` `);
           var object = ECS.findEntityByName(noun, `local`);

           if (object != null) {
               return {'match': object, 'string': tokens.slice(i).join(` `)};
           }
       }

       return {'match': null, 'string': string};
   },
};

var changelog = [
   {
       'version':`0.6.2`,
       'notes':[
           `Added bridge music files`,
           `Improved music transition handling`,
           `Added support for linked music files and playback resume`,
           `Added some missing scenery`
       ]
   },
   {
       'version':`0.6.1`,
       'notes':[
           `First playtest`,
           `Added missing scenery in most underground locations`,
           `Most locations linked for Prologue, Acts 1-3`
       ]
   },
   {
       'version':`0.2.1`,
       'notes':[
           `Added Response handling to NLP for more flexible onAction callbacks.`,
           `Added bridge direction text to hot springs.`,
           `Fixed Bridge state handling.`,
       ]
   },
   {
       'version':`0.2.0`,
       'notes':[
           `Stubbed in Act 1, Act 2, Act 3 locations (all added and linked).`,
           `Added sea witch encounter.`,
           `Added Act 1 transition and starting sequence.`,
           `Added Bridge zones and Underground.`,
           `Reworked process for obtaining gate key.`,
           `Conversation and output handling improvements.`,
       ]
   },
   {
       'version':`0.1.0`,
       'notes':[
           `Fleshed out starting forest region with new locations and scenery.`,
           `Added bonus descriptions for races and classes.`,
           `Added music and closed captioning support.`,
           `Stubbed in social module.`,
           `Improved matching of menu elements.`,
           `Interrupts can now be queued instead of nested.`,
           `Revamped callback handling.`,
           `Revamped handling of multiple objects with overlapping nouns.`
       ]
   },
   {
       'version':`0.0.5`,
       'notes':[
           `Added basic SAVE and LOAD support`,
           `Added LOAD option to endgame screen`,
           `Added onRemoveChild callback to handle removal of objects from containers`,
           `Stubbed out Acts 1-3 modules`
       ]
   },
   {
       'version':`0.0.4`,
       'notes':[
           `Added Systems with game tick support`,
           `Added Living system for NPC activities`,
           `Moved player to campaign module`,
           `Added queueGMOutput convenience function`,
           `THE BLACK BOX:`,
           `Troglodyte will now get annoyed and eventually angry when attacked`,
           `Troglodyte will attack and kill player.`
       ]
   },
   {
       'version':`0.0.3`,
       'notes':[
           `Added basic combat action, hp handling, and death state`,
           `Added basic hug action`,
           `Added callback fallback (callback can indicate action not handled, fallback to generic)`,
           `Added basic dice roller`,
           `Fixed room descriptions when in darkness`,
           `THE BLACK BOX:`,
           `Changed torch to orb to eliminate need for on/off right now`,
           `Fixed double echo issue with Musty Cave entry interrupt`,
           `Added death/roll/vomit sequence for troglodyte death`,
           `Added a couple end-game states`,
           `First BB version where end state can be reached`
       ]
   },
   {
       'version':`0.0.2`,
       'notes':[
           `Remove old entries from output list to avoid infinitely-expanding DOM.`,
           `Added RAINBOW SWORD and MUSTY CAVE to Black Box module.`,
           `Refactored ECS to avoid nesting properties per-component.`,
           `Added support for callback lists and filter lists to ECS.`,
           `Added Darkness module with Emitter component and light attributes for places.`,
           `Added action filters to prevent actions in certain circumstances, e.g. darkness.`,
           `Added simple 'last input' feature (hit up arrow to select previous input string).`,
           `Added object list to room descriptions. Components and objects can specify whether they are listed automatically.`,
           `Improved item tagging.`,
           `Improved container/supporter descriptions.`,
           `Improved local object listings.`,
           `Added missing descriptions for items/places in Black Box module.`
       ]
   },
   {
       'version':`0.0.1`,
       'notes':[
           `Started keeping a changelog.`,
           `Basic ECS structure.`,
           `Working starting location, intro sequence, and common actions (movement, look, etc).`,
           `Support for scenery items.`,
           `Ability to TAKE items.`,
           `Queued output with optional effects (fade, etc).`,
           `Command interrupts for special input handling (e.g. 'what is your name?')`,
           `Handlebars templating with common handlers (tagged items, paragraphs, etc).`
       ]
   }
];


//
// Create World
//

var game = null;
var player = null;
var Display = {};
var Engine = {
   // Global variables
   'outputTimer': null,
   'outputProgress': null,
   'waiting': false,
   'blockId': 0,
   'flags': [],
   'seed': Math.random(),
   'inputEnabled': false,
   'lastInput': ``,
   'inputLimit': 100,
   'inputQueue': [],
   'inputReplayQueue': {'default':[]},
   'outputQueue': [],
   'outputQueueDeferred': [],
   // Default engine functions
   'init': function() {
       // Get hash (url fragment) flags
       var tmp = window.location.hash.substr(1);
       this.flags = tmp.split(`,`);
   },
   'stopWaiting':function() {
       $(`.input`).removeClass(`waiting waiting-1 waiting-2 waiting-3`).attr(`data-waiting`, 0);

       this.inputEnabled = true;
       this.waiting = false;
       Display.giveInputFocus();

       // Execute queued input
       if (this.inputQueue.length > 0) {
           this.execute(this.inputQueue.shift());
       }
   },
   'hasFlag': function(flag) {
       return (this.flags.indexOf(flag) >= 0);
   }
};

/**
* Queues text output.
*
* @param tmp       the unprocessed text to output
* @param delay     the delay to add, in milliseconds
* @param data      extra data to use in the text template
* @param deferred  defer the output as part of action processing
*/
var queueOutput = Engine.queueOutput = function(tmp, delay, data, deferred) {
   if(typeof tmp == `undefined`) {
       console.log(`UNDEFINED OUTPUT`);
       console.trace();
   }

   if (typeof(deferred) === `undefined`) {
       deferred = true;
   }

   if (typeof delay == `undefined`) {
       delay = 0;
   }

   var output = {'tmp': tmp, 'data': data, 'delay': delay};

   if(deferred) {
       Engine.outputQueueDeferred.push(output);
   } else {
       Engine.outputQueue.push(output);
   }
};

/**
* Queues output for a specific character
*
* @param character     the character speaking
* @param tmp        the unprocessed text to output
* @param delay        the delay to add, in milliseconds
* @param data        extra data to use in the text template
*/
var queueCharacterOutput = Engine.queueCharacterOutput = function(character, tmp, delay, data) {
   queueOutput(getSpeechTag(character)+tmp, delay, data);
};

/**
* Queues output prefixed with a GM tag. Convenience function.
*
* @param tmp        the unprocessed text to output
* @param delay        the delay to add, in milliseconds
* @param data        extra data to use in the text template
*/
var queueGMOutput = Engine.queueGMOutput = function(tmp, delay, data) {
   queueOutput(`{{gm}}<p>` + tmp + `</p>`, delay, data);
};

/**
* Queues output only if the originator (the source of the event) and
* the player are in the same location.
*
* TODO: This should be deprecated and replaced with something less ridiculous. Entwining display logic and game logic like this is super dumb.
*
* @param source    the originating entity
* @param tmp        the unprocessed text to output
* @param delay        the delay to add, in milliseconds
* @param data        extra data to use in the text template
*/
var queueLocalOutput = Engine.queueLocalOutput = function(source, tmp, delay, data) {
   if (source.location() == player.location()) {
       queueGMOutput(tmp, delay, data);
   }
};

/**
* Pushes all deferred output to the regular output queue.
*/
var processDeferredOutputQueue = Engine.processDeferredOutputQueue = function() {
   while(Engine.outputQueueDeferred.length > 0) {
       Engine.outputQueue.push(Engine.outputQueueDeferred.shift());
   }
};

/**
* Process the output queue.
*
* Handles output delays, special effects, and input toggling.
*/
var processOutputQueue = Engine.processOutputQueue = function() {
   var effect = null;
   var delay = 100;

   window.clearInterval(Engine.outputProgress);

   if (Engine.outputQueue.length > 0) {
       // Shift item from beginning of queue
       var item = Engine.outputQueue.shift();

       // Flag Engine as waiting
       Engine.waiting = true;

       // Set timer for next item
       // disabled for faster testing
       delay = item.delay;

       if (delay > 0 || delay == `auto`) {
           $(`.input`).animate({'color': `transparent`}, 200);
           $(`.input`).addClass(`waiting`);
           Engine.inputEnabled = false;
       }

       // Parse queued item
       var output = parse(item.tmp, item.data);

       // Handle auto delay
       if(delay == `auto`) {
           // Count words in the output
           var words = $(output).text().split(` `).length;

           // Assuming read speed of 300WPM, padded by 20% + 500ms
           // 300WPM is 5 words per second
           delay = 500 + Math.round((words / 5) * 1000 * 1.2);
       }

       // Disable delay in debug mode
       if(Engine.hasFlag(`debug`)) {
           delay = 0;
       }

       // Handle effect if set
       if (typeof item.data != `undefined` && typeof item.data.effect != `undefined`) {
           effect = item.data.effect;
       }

       // Wrap output
       var classes = (item.data && item.data.classes) ? item.data.classes : [];
       var id = `block-` + (Engine.blockId++);
       output = `<div class='output-line `+classes+`' id='`+id+`'>` + output + `</div>`;

       // Actually output
       if (effect == null) {
           $(`.input`).before(output);
       } else if (effect == `fade`) {
           $(output).hide().insertBefore(`.input`).fadeIn(1000);
       }

       // Handle prefix if set. Prefix setter is responsible for unsetting it later.
       if (typeof item.data != `undefined` && typeof item.data.prefix != `undefined`) {
           Display.setInputPrefix(item.data.prefix);
       }

       // Handle suffix if set. Suffix setter is responsible for unsetting it later.
       if (typeof item.data != `undefined` && typeof item.data.suffix != `undefined`) {
           Display.setInputSuffix(item.data.suffix);
       }

       // Save to transcript
       $(`#transcript`).append(output);

       // If queue is now empty, re-enable input
       if (Engine.outputQueue.length == 0) {
           $(`.input`).delay(delay).animate({'color': `auto`}, 200).promise().done(function () {
               Engine.stopWaiting();
           });
       }
   }

   Engine.outputTimer = window.setTimeout(processOutputQueue, delay);

   if(delay > 3000) {
       Engine.outputProgress = window.setInterval(function(){
           var wait = Math.min(3, $(`.input`).attr(`data-waiting`) || 0);

           if(wait == 3) {
               $(`.input`).attr(`data-waiting`, 0);
               $(`.input`).removeClass(`waiting-3 waiting-2 waiting-1`);
               return;
           }

           $(`.input`).attr(`data-waiting`, ++wait);
           $(`.input`).addClass(`waiting-`+wait);
       }, 1000);
   }
};

// Execute input
var execute = Engine.execute = function(input) {
   ECS.tick = true;

   // Save input to replay queue
   Engine.inputReplayQueue.default.push(input);

   // Execute player input
   if (Engine.inputEnabled) {
       Engine.lastInput = input;

       // Fade previous text
       $(`.output .output-line`).addClass(`old`);

       // Execute player action
       var inputOutput = parse(`echo`, {'text': input});
       queueOutput(inputOutput);

       // Save input line to transcript
       $(`#transcript`).append(inputOutput);

       // Parse input and queue generated response
       NLP.actor = player;
       var response = NLP.parse(input);

       // NLP may output/queue response itself if command is handled by an interrupt
       if(typeof response != `undefined` && response.length > 0) {
           var output = parse(response, {});
           queueOutput(output, 0, {}, true);
       }

       // Execute tick
       if (ECS.tick) {
           console.log(`TICK`);
           for (var s in ECS.systems) {
               var system = ECS.systems[s];
               var matches = [];

               // Get relevant entities for system
               for (var c in system.components) {
                   matches = matches.concat(ECS.findEntitiesByComponent(system.components[c]));
               }
               console.log(system);
               system.onTick(matches);
           }
           console.log(`TOCK`);
       }

       // Check 'after' rules (can't modify/cancel command, but can add on to it)
       Rulebook.check(`after`, NLP.afterAction);

       // Queue deferred output
       processDeferredOutputQueue();

       // Do cleanup on items that are offscreen
       $(`.output`).find(`:offscreen`).remove();
   } else {
       Engine.inputQueue.push(input);
   }
};

// Sequence object
var Sequence = function() {
   this.blocks = [];
   this.index = -1;
};

Object.defineProperty(Sequence, `MODE_WAIT`, { value: 0 });
Object.defineProperty(Sequence, `MODE_CONTINUE`, { value: 1 });

Sequence.prototype = {
   'MODE_WAIT': 0,
   'MODE_CONTINUE': 1,
   'add': function(f, mode) {
       console.log(`Sequence: Adding block with mode `+mode+`(`+((typeof mode == `undefined`) ? this.MODE_WAIT : mode)+`)`);
       this.blocks.push({
           'mode': (typeof mode == `undefined`) ? this.MODE_WAIT : mode,
           'function': f
       });
   },
   'start': function() {
       this.next();
   },
   'next': function() {
       this.index++;
       console.log(`Sequence: Checking block `+this.index);
       if(this.blocks.length > this.index) {
           this.blocks[this.index][`function`]();
           if(this.blocks[this.index][`mode`] == this.MODE_CONTINUE) {
               console.log(`Sequence: Auto-Continuing`);
               this.next();
           } else {
               console.log(`Sequence: Waiting`);
               console.log(this.blocks[this.index]);
           }
       }
   }
};

// Common template list. Submodules can add their own templates to this list
var templates = {
   // Player output echo format
   'default': `<p>{{text}}</p>`,
   'echo': `{{p 'player' text}}`,
};

// Conditional helper
// Invoke with {{when value '<=' otherValue}}
// Credit: http://stackoverflow.com/a/16315366/96089
Handlebars.registerHelper(`when`, function (v1, operator, v2, options) {
   switch (operator) {
       case `==`:
           return (v1 == v2) ? options.fn(this) : options.inverse(this);
       case `===`:
           return (v1 === v2) ? options.fn(this) : options.inverse(this);
       case `<`:
           return (v1 < v2) ? options.fn(this) : options.inverse(this);
       case `<=`:
           return (v1 <= v2) ? options.fn(this) : options.inverse(this);
       case `>`:
           return (v1 > v2) ? options.fn(this) : options.inverse(this);
       case `>=`:
           return (v1 >= v2) ? options.fn(this) : options.inverse(this);
       case `&&`:
           return (v1 && v2) ? options.fn(this) : options.inverse(this);
       case `||`:
           return (v1 || v2) ? options.fn(this) : options.inverse(this);
       default:
           return options.inverse(this);
   }
});

// Expression-based conditional helper
// Credit: http://stackoverflow.com/a/21915381/96089
Handlebars.registerHelper(`xif`, function (expression, options) {
   return Handlebars.helpers[`x`].apply(this, [expression, options]) ? options.fn(this) : options.inverse(this);
});

Handlebars.registerHelper(`x`, function (expression) {
   var fn = function(){}, result;

   // in a try block in case the expression have invalid javascript
   try {
       // create a new function using Function.apply, notice the capital F in Function
       fn = Function.apply(
           this,
           [
               `window`, // or add more '_this, window, a, b' you can add more params if you have references for them when you call fn(window, a, b, c);
               `return ` + expression + `;` // edit that if you know what you're doing
           ]
       );
   } catch (e) {
       console.warn(`[warning] {{x ` + expression + `}} is invalid javascript`, e);
   }

   // then let's execute this new function, and pass it window, like we promised
   // so you can actually use window in your expression
   // i.e expression ==> 'window.config.userLimit + 10 - 5 + 2 - user.count' //
   // or whatever
   try {
       // if you have created the function with more params
       // that would like fn(window, a, b, c)
       result = fn.call(this, window);
   } catch (e) {
       console.warn(`[warning] {{x ` + expression + `}} runtime error`, e);
   }
   // return the output of that result, or undefined if some error occured
   return result;
});

// Header helper (wraps text in a header tag with optional classes)
// Invoke with {{header 'classes here' text}}
Handlebars.registerHelper(`header`, function (classes, text) {
   return new Handlebars.SafeString(
       `<header class='` + classes + `'>` + text + `</header>`
   );
});

// Paragraph helper (wraps text in a p tag with optional classes)
// Invoke with {{p 'classes here' text}}
Handlebars.registerHelper(`p`, function (classes, text) {
   return new Handlebars.SafeString(
       `<p class='` + classes + `'>` + text + `</p>`
   );
});

// Box helper (create an announcement box with title and text)
// Invoke with {{box title text}}
Handlebars.registerHelper(`box`, function (title, text, classes) {
   return new Handlebars.SafeString(
       `<div class='box ` + classes + `'><header>` + title + `</header><p>` + text + `</p></div>`
   );
});

// Tag helper (create higlight / command tags)
// Invoke with {{tag text options}}
Handlebars.registerHelper(`tag`, function (text, options) {
   var attrs = [];
   var tag = ``;
   var tmp = ``;
   var c = ``;

   // Force well-formed option hash
   var hash = $.extend({}, options.hash);
   console.log(this);

   // Set default class(es)
   var classes = [`tag`];

   // Check for command
   if (typeof hash.command != `undefined`) {
       classes.push(`command`);
       attrs.push(`data-command='` + hash.command + `'`);
   }

   // Process additional classes
   if (typeof hash.classes != `undefined`) {
       tmp = hash.classes.split(` `);
       for (c in tmp) {
           classes.push(tmp[c]);
       }
   }

   // Check for context menu
   var contextMenu = ``;
   if (typeof this.context != `undefined`) {
       classes.push(`context`);
       tmp = ``;

       for (c in this.context) {
           tmp += `<li><div class='tag command' data-command='` + this.context[c].command + `'>` + this.context[c].text + `</li>`;
       }

       contextMenu = `<ul>` + tmp + `</ul>`;
   }

   // Build tag
   tag = `<div class='` + classes.join(` `) + `' ` + attrs.join(` `) + `>` + text + contextMenu + `</div>`;

   return new Handlebars.SafeString(tag);
});

// Name tag helper
// Invoke with {{nametag name options}}
Handlebars.registerHelper(`nametag`, function (name, options) {
   // Force well-formed option hash
   var hash = $.extend({}, options.hash);

   // Get object
   var t = ECS.findEntity(`thing`, name);
   if (t == null) {
       return;
   }

   var classes = [].concat(t.tags);

   // Check for extra classes
   if (typeof hash.classes != `undefined`) {
       classes = classes.concat(hash.classes.split(` `));
   }

   // Check for command
   var command = `x ` + t.name;
   if (typeof hash.command != `undefined`) {
       command = hash.command;
   }

   // Check for printed name
   var printedName = t.name;
   if (typeof hash.print != `undefined`) {
       printedName = hash.print;
   }

   // Build context options
   var data = {'context': []};
   data.context = t.getContextActions();

   var tag = `{{tag '` + printedName + `' classes='` + classes.join(` `) + `' command='` + command + `'}}`;

   return new Handlebars.SafeString(parse(tag, data));
});

// Menu helper (create list of command items that can be triggered directly)
// Invoke with: {{menu options}}
// Options is a key-value set where the value is a command string
Handlebars.registerHelper(`menu`, function (options) {
   var output = `<ul class='menu'>`;

   for (var item in options) {
       if (typeof options[item].subtext == `undefined`) {
           options[item].subtext = ``;
       }
       output += `<li class='command' data-command='` + options[item].command + `'>` + options[item].text + `<span class='right subtext'>` + options[item].subtext + `</span></li>`;
   }

   return new Handlebars.SafeString(output);
});

// GM helper (output the GM prefix)
// Invoke with {{gm}}
Handlebars.registerHelper(`gm`, function () {
   return new Handlebars.SafeString(`<span class='gm'>GM:</span> `);
});

// Template parser
function parse(tmp, data) {
   if (typeof tmp == `string` && tmp.length > 0) {
       // By default, use the passed value as a template
       var source = tmp;

       // If the passed value is a string and a common template matches the name,
       // load and use the common template
       if (typeof tmp == `string` && typeof templates[tmp] != `undefined`) {
           source = templates[tmp];
       }

       // Combine data with standard values
       data = $.extend({'player': player, 'place': player.place}, data);

       // Compile template
       var template = Handlebars.compile(source);

       return template(data);
   }
};

/**
* Counter helpers to keep track of how many times an arbitrary event has happened.
*/

ECS.setData(`counters`, {});

/**
* Get the count value for a given key, or set a new value.
*
* @param string key
* @param int value
* @returns
*/
var count = function (key, value) {
   var counters = ECS.getData(`counters`);

   // Set a new value for a given key
   if (typeof value != `undefined`) {
       counters[key] = value;
       return;
   }

   // Check if the given key exists, and return the value if it does
   if (counters.hasOwnProperty(key)) {
       return counters[key];
   }

   // The requested key doesn't exist
   return null;
};

/**
* Increment a counter.
*
* @param key
*/
var incrementCounter = function(key) {
   var c = count(key);
   c = (c) ? c + 1 : 1;
   count(key, c);
};

/**
* Decrement a counter.
*
* @param key
*/
var decrementCounter = function(key) {
   var c = count(key);
   c = (c) ? c - 1 : -1;
   count(key, c);
};

/**
* Check if the given key matches the given value
*
* @param key
* @param value
* @returns {boolean}
*/
var nth = function (key, value) {
   return count(key) === value;
};

/**
* Check if the given key has a value of 1
* Alias for nth(key,1)
*
* @param key
* @returns {boolean}
*/
var first = function (key) {
   return nth(key, 1);
};

/**
* Check if the given key has a value of 2
* Alias for nth(key,2)
*
* @param key
* @returns {boolean}
*/
var second = function (key) {
   return nth(key, 2);
};

/**
* Check if the given key has a value of 3
* Alias for nth(key,3)
*
* @param key
* @returns {boolean}
*/
var third = function (key) {
   return nth(key, 3);
};

/**
* Check if the given key is on a repeating nth count.
*
* @param key
* @param n
* @returns {boolean}
*/
var everyNth = function (key, n) {
   console.log(`CHECKING `+key+` for `+n);
   return (count(key) % n ) == 0;
};

/**
* Check if the given key is on a repeating 2nd count.
* Alias for everyNth(key,2)
*
* @param key
* @returns {boolean}
*/
var everyOther = function(key) {
   return everyNth(key, 2);
};

/**
* Check if the given key is on a repeating 3rd count.
* Alias for everyNth(key,3)
*
* @param key
* @returns {boolean}
*/
var everyThird = function(key) {
   return everyNth(key, 3);
};

Handlebars.registerHelper(`first`, function(counter, options) {
   if(first(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`second`, function(counter, options) {
   if(second(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`third`, function(counter, options) {
   if(third(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`nth`, function(counter, options) {
   if(nth(counter, options.hash.n)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`everyOther`, function(counter, options) {
   if(everyOther(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`everyThird`, function(counter, options) {
   if(everyThird(counter)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

Handlebars.registerHelper(`everyNth`, function(counter, options) {
   console.log(options.hash);
   if(everyNth(counter, options.hash.n)) {
       return new Handlebars.SafeString(options.fn(this));
   }
});

// Replay the session from the beginning, repeating all commands in order
// Optionally restart a named replay
Engine.replay = function(name) {
   // Get named replay or default replay queue
   var queue = (typeof name != `undefined`) ? name : `default`;
   var replayQueue = Engine.inputReplayQueue[queue];

   // Store commands in local storage
   localStorage.setItem(`replay`, true);
   localStorage.setItem(`replay-data`, JSON.stringify({
       'seed':0,
       'commands':replayQueue
   }));

   // Refresh page
   window.location.reload();
};

// Set a named replay marker (save replay up until this point)
Engine.replay_marker = function(name) {
   Engine.inputReplayQueue[name] = Engine.inputReplayQueue.default.slice();
   console.log(`Replay Queue '` + name + `' saved.`);
};

Engine.loadReplayIfAvailable = function() {
   if(localStorage.getItem(`replay-data`)) {
       seed = localStorage.getItem(`replay-data`).seed;
       Engine.inputQueue = JSON.parse(localStorage.getItem(`replay-data`)).commands;

       localStorage.removeItem(`replay`);
       localStorage.removeItem(`replay-data`);
   }
};

Display.giveInputFocus = function() {
   $(`.input`).trigger(`focus`);
};

Display.setInputPrefix = function(prefix) {
   $(`.input .prefix`).html(`&gt;` + prefix);
};

Display.resetInputPrefix = function() {
   $(`.input .prefix`).html(`&gt;`);
};

Display.setInputSuffix = function(suffix) {
   $(`.input .suffix`).html(suffix);
   $(`.input .entry`).addClass(`suffixed`);
};

Display.resetInputSuffix = function() {
   $(`.input .suffix`).html(``);
   $(`.input .entry`).removeClass(`suffixed`);
};

Display.resetInputFixes = function() {
   Display.resetInputPrefix();
   Display.resetInputSuffix();
};

// Keypress for most keys (characters, numerals, spaces, enter)
$(`.input`).keypress(function (event) {
   var k = event.which;
   var output = null;
   var curStr = $(`.input .text`).html();

   if(Engine.waiting && k == 32) { // Spacebar
       console.log(`User skipping wait period.`);
       clearTimeout(Engine.outputTimer);
       Engine.outputTimer = null;
       Engine.processOutputQueue();
       return;
   }

   if (
       (k >= 65 && k <= 90) ||     // A-Z
       (k >= 97 && k <= 122) ||    // a-z
       (k >= 48 && k <= 57) ||     // 0-9
       k == 39 ||                  // ' (apostrophe)
       k == 33 ||                  // !
       k == 36 ||                  // $
       k == 32 ||                  // (space)
       k == 45                     // - (dash)
   ) {
       output = curStr + String.fromCharCode(k);
   }
   else if (k == 13) {
       // ENTER
       output = ``;

       // Tick
       Engine.execute(curStr);
   }
   else {
       // No changes
       return;
   }

   $(`.input .text`).html(output.substr(0, Engine.inputLimit));
});

// Keydown for special keys (backspace, escape)
$(`.input`).keydown(function (event) {
   var k = event.which;
   var output = null;
   var curStr = $(`.input .text`).html();

   if (k == 8) {
       // BACKSPACE
       output = curStr.substr(0, curStr.length - 1);
   }
   else if (k == 27) {
       // ESCAPE (clear)
       output = ``;
   }
   else if (k == 38) {
       // UP ARROW (last item)
       output = Engine.lastInput;
   }
   else {
       // No changes
       return;
   }

   event.preventDefault();
   $(`.input .text`).html(output.substr(0, Engine.inputLimit));
});

// Command tag click handler
$(`body`).on(`click`, `.command`, function (e) {
   console.log(e);
   e.stopPropagation();

   // If engine is waiting, ignore
   if(Engine.waiting) { return; }

   // Get command
   var command = $(this).attr(`data-command`);
   var parent = $(this).parent();

   // Check if this is a menu command
   if (parent.is(`ul.menu`)) {
       // Check if already disabled
       if (parent.hasClass(`disabled`)) {
           // Cancel the command
           return;
       }

       Display.disableMenu(parent, this);
   }

   // Flag in transcript
   $(`#transcript`).append(p(`(from context menu)`));

   Engine.execute(command);
});

/**
* Disable commands in the specified menu.
* Typically triggered when a menu item is clicked.
*
* @param menu      the menu to update
* @param selected  the selected item in the menu
*/
Display.disableMenu = function(menu, selected) {
   // Mark menu as disabled
   menu.addClass(`disabled`);

   // Mark this item as selected
   $(selected).addClass(`selected`);
};

// Disable the latest menu and set the selected item to one matching the given text
var disableLastMenu = Display.disableLastMenu = function(selected) {
   var menu = $(`.output`).find(`.output-line .menu`).last();
   selected = menu.find(`li[data-command="` + selected + `"]`);
   Display.disableMenu(menu, selected);
};

// Enable the latest menu and clear the selected item, if any
var enableLastMenu = Display.enableLastMenu = function() {
   var menu = $(`.output`).find(`.output-line .menu`).last();
   menu.find(`li.selected`).removeClass(`selected`);
   menu.removeClass(`disabled`);
};

// Load a different visual theme
Display.loadTheme = function(theme) {
   $(`#theme`).replaceWith(`<link id="theme" rel="stylesheet" href="css/` + theme + `.css">`);
};

//
// FEEDBACK
//

function saveFeedback(blockId, text) {
   $(`#` + blockId).append(`<div class='feedback'>` + text + `</div>`);
}

$(document).bind(`keydown`, `ctrl+shift+f`, function(e) {
   e.stopPropagation();

   var selection = getSelection();

   // Get selected block or last block if none selected
   var block = null;
   var selectedText = $(`<div/>`);
   if(selection.type == `Range`) {
       block = $(selection.anchorNode).closest(`.output-line`);
       selectedText.addClass(`selection`);
       selectedText.html(`"` + selection.toString() + `"`);
   } else {
       block = $(`.output .output-line`).last();
   }

   console.log(selectedText);
   console.log(block);

   var feedback = $(`<div/>`);
   feedback.addClass(`feedback`);
   feedback.attr(`data-block`, block.attr(`id`));

   var response = window.prompt(`Your Feedback:`);
   feedback.html(selectedText[0].outerHTML + response);
   $(block).append(feedback.clone());

   $(`#transcript #`+block.attr(`id`)).append(feedback.clone());

   //$(`tmp`).css({"background":`yellow`});
});

/**
* Save the game transcript.
* Writes an HTML document with the transcript and pushes it as a download to the browser.
*/
function saveTranscript() {
   var html = [];
   var date = (new Date).toDateString();
   var name = player.name;
   html.push(`<html><head><title>SRPG Transcript - `+ date +` - `+ name +`</title>`);
   html.push(`<link href='https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700' rel='stylesheet' type='text/css'>`);
   html.push(`<link id='theme' rel='stylesheet' href='Onyx/css/default.css'>`);
   html.push(`<script src='Onyx/js/vendor/modernizr-2.6.2.min.js'></script>`);
   html.push(`</head><body>`);
   html.push(`<div class='frame'><div class='output'>`);
   html.push($(`#transcript`).html());
   html.push(`</div></div>`);
   html.push(`</body></html>`);
   var blob = new Blob([html.join(``)], {type: `text/html;charset=utf-8`});
   saveAs(blob, `SRPG-Transcript.html`);
}

/**
* Add local scenery for the most recently added location.
*/
function localScenery(nouns, description) {
   var parent = ECS.lastOfType.place;

   ECS.e(nouns[0], [`scenery`], {
       'name':nouns[0],
       'nouns':nouns,
       'spawn':parent.key,
       'descriptions':{
           'default':description
       }
   });
}

/**
* Get a canonical direction.
*/
function getCanonicalDirection(d) {
   d = d.toLowerCase();
   if ([`n`, `north`].indexOf(d) >= 0) {
       return `n`;
   }
   if ([`e`, `east`].indexOf(d) >= 0) {
       return `e`;
   }
   if ([`s`, `south`].indexOf(d) >= 0) {
       return `s`;
   }
   if ([`w`, `west`].indexOf(d) >= 0) {
       return `w`;
   }
   if ([`nw`, `northwest`].indexOf(d) >= 0) {
       return `nw`;
   }
   if ([`sw`, `southwest`].indexOf(d) >= 0) {
       return `sw`;
   }
   if ([`ne`, `northeast`].indexOf(d) >= 0) {
       return `ne`;
   }
   if ([`se`, `southeast`].indexOf(d) >= 0) {
       return `se`;
   }
   if ([`d`, `down`].indexOf(d) >= 0) {
       return `d`;
   }
   if ([`u`, `up`].indexOf(d) >= 0) {
       return `u`;
   }
   return d;
};

// jQuery filter to see if element is offscreen
$.expr.filters.offscreen = function (el) {
   return ($(el).offset().top < -500);
};

/**
* Shuffle an array.
*
* Uses the Fisher-Yates shuffle algorithm.
*
* @param array
* @returns {*}
*/
function shuffle(array) {
   var currentIndex = array.length
       , temporaryValue
       , randomIndex
       ;

   // While there remain elements to shuffle...
   while (0 !== currentIndex) {

       // Pick a remaining element...
       randomIndex = Math.floor(Math.random() * currentIndex);
       currentIndex -= 1;

       // And swap it with the current element.
       temporaryValue = array[currentIndex];
       array[currentIndex] = array[randomIndex];
       array[randomIndex] = temporaryValue;
   }

   return array;
}

// Seeded PRNG
function random() {
   var x = Math.sin(Engine.seed++) * 10000;
   return x - Math.floor(x);
}

/**
* Roll a single die with a specified number of sides.
*
* @param sides
* @returns {number}
*/
function dice(sides) {
   return Math.floor(random() * sides) + 1;
}

/**
* Wrap a piece of text in a paragraph container.
* <div> is used instead of <p> to allow for nested tags.
*
* @param text
* @returns {string}
*/
function p(text) { return `<div class='p'>`+text+`</div>`; }

/**
* Check whether a string matches any of the given values
*/
String.prototype.is = function(...args) {
   return (args.indexOf(this.valueOf()) >= 0);
};


var StartGame = function () {
   // Set game version
   $(`.info .version`).html(`v` + changelog[0].version);

   // Initialize engine
   Engine.init();

   // Initialize ecs
   game = ECS;
   game.init([
       `Core`,
       `Darkness`,
       `Combat`,
       `Containers`,
       `Doors`,
       `Locks`,
       `Music`,
       `Clothing`,
       `Social`,
       `Quests`,
       `Temperature`,
       `Sleep`,
       // Only initiate prologue campaign module for now
       // Just kidding we're in debug mode for the foreseeable future
       `BlackBox`,
       `Act1`,
       `Act2`,
       `Act3`,
   ]);

   // GM entity
   game.e(`gm`, [`living`], {
       'name': `GM`,
       'descriptions': {
           'default': `Just pretend I'm not here.`
       },
       'scope': `global`,
       'hp': 1,
       'nouns': [`gm`]
   });

   // Fire onGameStart
   game.getModule(`BlackBox`).onGameStart();

   // Get replay commands
   Engine.loadReplayIfAvailable();

   // Kick off the output queue, which will already have a bunch of stuff in it from the onGameStart call above
   processDeferredOutputQueue();
};

// Start output processor
Engine.outputTimer = window.setTimeout(Engine.processOutputQueue, 100);