UNPKG

tracery-improved

Version:

An improved version of Tracery that minimizes symbol reuse

898 lines (729 loc) 27.8 kB
/** * @author Kate */ var tracery = function() { var TraceryNode = function(parent, childIndex, settings) { this.errors = []; // No input? Add an error, but continue anyways if (settings.raw === undefined) { this.errors.push("Empty input for node"); settings.raw = ""; } // If the root node of an expansion, it will have the grammar passed as the 'parent' // set the grammar from the 'parent', and set all other values for a root node if ( parent instanceof tracery.Grammar) { this.grammar = parent; this.parent = null; this.depth = 0; this.childIndex = 0; } else { this.grammar = parent.grammar; this.parent = parent; this.depth = parent.depth + 1; this.childIndex = childIndex; } this.raw = settings.raw; this.type = settings.type; this.isExpanded = false; if (!this.grammar) { this.errors.push("No grammar specified for this node " + this); } }; TraceryNode.prototype.toString = function() { return "Node('" + this.raw + "' " + this.type + " d:" + this.depth + ")"; }; // Expand the node (with the given child rule) // Make children if the node has any TraceryNode.prototype.expandChildren = function(childRule, preventRecursion) { this.children = []; this.finishedText = ""; // Set the rule for making children, // and expand it into section this.childRule = childRule; if (this.childRule !== undefined) { var sections = tracery.parse(childRule); // Add errors to this if (sections.errors.length > 0) { this.errors = this.errors.concat(sections.errors); } for (var i = 0; i < sections.length; i++) { this.children[i] = new TraceryNode(this, i, sections[i]); if (!preventRecursion) this.children[i].expand(preventRecursion); // Add in the finished text this.finishedText += this.children[i].finishedText; } } else { // In normal operation, this shouldn't ever happen this.errors.push("No child rule provided, can't expand children"); } }; // Expand this rule (possibly creating children) TraceryNode.prototype.expand = function(preventRecursion) { if (!this.isExpanded) { this.isExpanded = true; this.expansionErrors = []; // Types of nodes // -1: raw, needs parsing // 0: Plaintext // 1: Tag ("#symbol.mod.mod2.mod3#" or "#[pushTarget:pushRule]symbol.mod") // 2: Action ("[pushTarget:pushRule], [pushTarget:POP]", more in the future) switch(this.type) { // Raw rule case -1: this.expandChildren(this.raw, preventRecursion); break; // plaintext, do nothing but copy text into finsihed text case 0: this.finishedText = this.raw; break; // Tag case 1: // Parse to find any actions, and figure out what the symbol is this.preactions = []; this.postactions = []; var parsed = tracery.parseTag(this.raw); // Break into symbol actions and modifiers this.symbol = parsed.symbol; this.modifiers = parsed.modifiers; // Create all the preactions from the raw syntax for (var i = 0; i < parsed.preactions.length; i++) { this.preactions[i] = new NodeAction(this, parsed.preactions[i].raw); } for (var i = 0; i < parsed.postactions.length; i++) { // this.postactions[i] = new NodeAction(this, parsed.postactions[i].raw); } // Make undo actions for all preactions (pops for each push) for (var i = 0; i < this.preactions.length; i++) { if (this.preactions[i].type === 0) this.postactions.push(this.preactions[i].createUndo()); } // Activate all the preactions for (var i = 0; i < this.preactions.length; i++) { this.preactions[i].activate(); } this.finishedText = this.raw; // Expand (passing the node, this allows tracking of recursion depth) var selectedRule = this.grammar.selectRule(this.symbol, this, this.errors); this.expandChildren(selectedRule, preventRecursion); // Apply modifiers // TODO: Update parse function to not trigger on hashtags within parenthesis within tags, // so that modifier parameters can contain tags "#story.replace(#protagonist#, #newCharacter#)#" for (var i = 0; i < this.modifiers.length; i++) { var modName = this.modifiers[i]; var modParams = []; if (modName.indexOf("(") > 0) { var regExp = /\(([^)]+)\)/; // Todo: ignore any escaped commas. For now, commas always split var results = regExp.exec(this.modifiers[i]); if (!results || results.length < 2) { } else { var modParams = results[1].split(","); modName = this.modifiers[i].substring(0, modName.indexOf("(")); } } var mod = this.grammar.modifiers[modName]; // Missing modifier? if (!mod) { this.errors.push("Missing modifier " + modName); this.finishedText += "((." + modName + "))"; } else { this.finishedText = mod(this.finishedText, modParams); } } // Perform post-actions for (var i = 0; i < this.postactions.length; i++) { this.postactions[i].activate(); } break; case 2: // Just a bare action? Expand it! this.action = new NodeAction(this, this.raw); this.action.activate(); // No visible text for an action // TODO: some visible text for if there is a failure to perform the action? this.finishedText = ""; break; } } else { //console.warn("Already expanded " + this); } }; TraceryNode.prototype.clearEscapeChars = function() { this.finishedText = this.finishedText.replace(/\\\\/g, "DOUBLEBACKSLASH").replace(/\\/g, "").replace(/DOUBLEBACKSLASH/g, "\\"); }; // An action that occurs when a node is expanded // Types of actions: // 0 Push: [key:rule] // 1 Pop: [key:POP] // 2 function: [functionName(param0,param1)] (TODO!) function NodeAction(node, raw) { /* if (!node) console.warn("No node for NodeAction"); if (!raw) console.warn("No raw commands for NodeAction"); */ this.node = node; var sections = raw.split(":"); this.target = sections[0]; // No colon? A function! if (sections.length === 1) { this.type = 2; } // Colon? It's either a push or a pop else { this.rule = sections[1]; if (this.rule === "POP") { this.type = 1; } else { this.type = 0; } } } NodeAction.prototype.createUndo = function() { if (this.type === 0) { return new NodeAction(this.node, this.target + ":POP"); } // TODO Not sure how to make Undo actions for functions or POPs return null; }; NodeAction.prototype.activate = function() { var grammar = this.node.grammar; switch(this.type) { case 0: // split into sections (the way to denote an array of rules) this.ruleSections = this.rule.split(","); this.finishedRules = []; this.ruleNodes = []; for (var i = 0; i < this.ruleSections.length; i++) { var n = new TraceryNode(grammar, 0, { type : -1, raw : this.ruleSections[i] }); n.expand(); this.finishedRules.push(n.finishedText); } // TODO: escape commas properly grammar.pushRules(this.target, this.finishedRules, this); break; case 1: grammar.popRules(this.target); break; case 2: grammar.flatten(this.target, true); break; } }; NodeAction.prototype.toText = function() { switch(this.type) { case 0: return this.target + ":" + this.rule; case 1: return this.target + ":POP"; case 2: return "((some function))"; default: return "((Unknown Action))"; } }; // Sets of rules // Can also contain conditional or fallback sets of rulesets) function RuleSet(grammar, raw) { this.raw = raw; this.grammar = grammar; this.falloff = 1; if (Array.isArray(raw)) { this.defaultRules = raw; } else if (typeof raw === 'string' || raw instanceof String) { this.defaultRules = [raw]; } else if (raw === 'object') { // TODO: support for conditional and hierarchical rule sets } // Initialize used rules this.usedRules = []; } function RuleSet(grammar, raw) { this.raw = raw; this.grammar = grammar; this.falloff = 1; if (Array.isArray(raw)) { this.defaultRules = raw; } else if (typeof raw === 'string' || raw instanceof String) { this.defaultRules = [raw]; } else if (raw === 'object') { // TODO: support for conditional and hierarchical rule sets } // Initialize used rules this.usedRules = []; } function RuleSet(grammar, raw) { this.raw = raw; this.grammar = grammar; this.falloff = 1; if (Array.isArray(raw)) { this.defaultRules = raw; } else if (typeof raw === 'string' || raw instanceof String) { this.defaultRules = [raw]; } else if (raw === 'object') { // TODO: support for conditional and hierarchical rule sets } // Initialize used rules this.usedRules = new Set(); } RuleSet.prototype.selectRule = function(errors) { // Handle conditional rules if (this.conditionalRule) { var value = this.grammar.expand(this.conditionalRule, true); if (this.conditionalValues[value]) { var v = this.conditionalValues[value].selectRule(errors); if (v !== null && v !== undefined) return v; } } // Handle ranked rules if (this.ranking) { for (var i = 0; i < this.ranking.length; i++) { var v = this.ranking.selectRule(); if (v !== null && v !== undefined) return v; } } // Handle default rules if (this.defaultRules !== undefined) { // If all rules have been used, reset the usedRules array if (this.usedRules.length >= this.defaultRules.length) { this.usedRules = []; } // Select a rule that hasn't been used yet var availableRules = this.defaultRules.filter(rule => !this.usedRules.includes(rule)); var index = 0; // Get the distribution from the grammar if there is no other var distribution = this.distribution; if (!distribution) distribution = this.grammar.distribution; switch (distribution) { case "shuffle": if (!this.shuffledDeck || this.shuffledDeck.length === 0) { this.shuffledDeck = fyshuffle( Array.apply(null, { length: availableRules.length }).map(Number.call, Number), this.falloff ); } index = this.shuffledDeck.pop(); break; case "weighted": errors.push("Weighted distribution not yet implemented"); break; case "falloff": errors.push("Falloff distribution not yet implemented"); break; default: index = Math.floor(Math.pow(Math.random(), this.falloff) * availableRules.length); break; } var selectedRule = availableRules[index]; this.usedRules.push(selectedRule); return selectedRule; } errors.push("No default rules defined for " + this); return null; }; RuleSet.prototype.clearState = function() { this.usedRules = []; if (this.defaultUses) { this.defaultUses = []; } }; function fyshuffle(array, falloff) { 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; } var Symbol = function(grammar, key, rawRules) { // Symbols can be made with a single value, and array, or array of objects of (conditions/values) this.key = key; this.grammar = grammar; this.rawRules = rawRules; this.baseRules = new RuleSet(this.grammar, rawRules); this.clearState(); }; Symbol.prototype.clearState = function() { // Clear the stack and clear all ruleset usages this.stack = [this.baseRules]; this.uses = []; this.baseRules.clearState(); }; Symbol.prototype.pushRules = function(rawRules) { var rules = new RuleSet(this.grammar, rawRules); this.stack.push(rules); }; Symbol.prototype.popRules = function() { this.stack.pop(); }; Symbol.prototype.selectRule = function(node, errors) { this.uses.push({ node : node }); if (this.stack.length === 0) { errors.push("The rule stack for '" + this.key + "' is empty, too many pops?"); return "((" + this.key + "))"; } return this.stack[this.stack.length - 1].selectRule(); }; Symbol.prototype.getActiveRules = function() { if (this.stack.length === 0) { return null; } return this.stack[this.stack.length - 1].selectRule(); }; Symbol.prototype.rulesToJSON = function() { return JSON.stringify(this.rawRules); }; var Grammar = function(raw, settings) { this.modifiers = {}; this.loadFromRawObj(raw); }; Grammar.prototype.clearState = function() { var keys = Object.keys(this.symbols); for (var i = 0; i < keys.length; i++) { this.symbols[keys[i]].clearState(); } }; Grammar.prototype.addModifiers = function(mods) { // copy over the base modifiers for (var key in mods) { if (mods.hasOwnProperty(key)) { this.modifiers[key] = mods[key]; } }; }; Grammar.prototype.loadFromRawObj = function(raw) { this.raw = raw; this.symbols = {}; this.subgrammars = []; if (this.raw) { // Add all rules to the grammar for (var key in this.raw) { if (this.raw.hasOwnProperty(key)) { this.symbols[key] = new Symbol(this, key, this.raw[key]); } } } }; Grammar.prototype.createRoot = function(rule) { // Create a node and subnodes var root = new TraceryNode(this, 0, { type : -1, raw : rule, }); return root; }; Grammar.prototype.expand = function(rule, allowEscapeChars) { var root = this.createRoot(rule); root.expand(); if (!allowEscapeChars) root.clearEscapeChars(); return root; }; Grammar.prototype.flatten = function(rule, allowEscapeChars) { var root = this.expand(rule, allowEscapeChars); return root.finishedText; }; Grammar.prototype.toJSON = function() { var keys = Object.keys(this.symbols); var symbolJSON = []; for (var i = 0; i < keys.length; i++) { var key = keys[i]; symbolJSON.push(' "' + key + '" : ' + this.symbols[key].rulesToJSON()); } return "{\n" + symbolJSON.join(",\n") + "\n}"; }; // Create or push rules Grammar.prototype.pushRules = function(key, rawRules, sourceAction) { if (this.symbols[key] === undefined) { this.symbols[key] = new Symbol(this, key, rawRules); if (sourceAction) this.symbols[key].isDynamic = true; } else { this.symbols[key].pushRules(rawRules); } }; Grammar.prototype.popRules = function(key) { if (!this.symbols[key]) this.errors.push("Can't pop: no symbol for key " + key); this.symbols[key].popRules(); }; Grammar.prototype.selectRule = function(key, node, errors) { if (this.symbols[key]) { var rule = this.symbols[key].selectRule(node, errors); return rule; } // Failover to alternative subgrammars for (var i = 0; i < this.subgrammars.length; i++) { if (this.subgrammars[i].symbols[key]) return this.subgrammars[i].symbols[key].selectRule(); } // No symbol? errors.push("No symbol for '" + key + "'"); return "((" + key + "))"; }; // Parses a plaintext rule in the tracery syntax tracery = { createGrammar : function(raw) { return new Grammar(raw); }, // Parse the contents of a tag parseTag : function(tagContents) { var parsed = { symbol : undefined, preactions : [], postactions : [], modifiers : [] }; var sections = tracery.parse(tagContents); var symbolSection = undefined; for (var i = 0; i < sections.length; i++) { if (sections[i].type === 0) { if (symbolSection === undefined) { symbolSection = sections[i].raw; } else { throw ("multiple main sections in " + tagContents); } } else { parsed.preactions.push(sections[i]); } } if (symbolSection === undefined) { // throw ("no main section in " + tagContents); } else { var components = symbolSection.split("."); parsed.symbol = components[0]; parsed.modifiers = components.slice(1); } return parsed; }, parse : function(rule) { var depth = 0; var inTag = false; var sections = []; var escaped = false; var errors = []; var start = 0; var escapedSubstring = ""; var lastEscapedChar = undefined; if (rule === null) { var sections = []; sections.errors = errors; return sections; } function createSection(start, end, type) { if (end - start < 1) { if (type === 1) errors.push(start + ": empty tag"); if (type === 2) errors.push(start + ": empty action"); } var rawSubstring; if (lastEscapedChar !== undefined) { rawSubstring = escapedSubstring + "\\" + rule.substring(lastEscapedChar + 1, end); } else { rawSubstring = rule.substring(start, end); } sections.push({ type : type, raw : rawSubstring }); lastEscapedChar = undefined; escapedSubstring = ""; }; for (var i = 0; i < rule.length; i++) { if (!escaped) { var c = rule.charAt(i); switch(c) { // Enter a deeper bracketed section case '[': if (depth === 0 && !inTag) { if (start < i) createSection(start, i, 0); start = i + 1; } depth++; break; case ']': depth--; // End a bracketed section if (depth === 0 && !inTag) { createSection(start, i, 2); start = i + 1; } break; // Hashtag // ignore if not at depth 0, that means we are in a bracket case '#': if (depth === 0) { if (inTag) { createSection(start, i, 1); start = i + 1; } else { if (start < i) createSection(start, i, 0); start = i + 1; } inTag = !inTag; } break; case '\\': escaped = true; escapedSubstring = escapedSubstring + rule.substring(start, i); start = i + 1; lastEscapedChar = i; break; } } else { escaped = false; } } if (start < rule.length) createSection(start, rule.length, 0); if (inTag) { errors.push("Unclosed tag"); } if (depth > 0) { errors.push("Too many ["); } if (depth < 0) { errors.push("Too many ]"); } // Strip out empty plaintext sections sections = sections.filter(function(section) { if (section.type === 0 && section.raw.length === 0) return false; return true; }); sections.errors = errors; return sections; }, }; function isVowel(c) { var c2 = c.toLowerCase(); return (c2 === 'a') || (c2 === 'e') || (c2 === 'i') || (c2 === 'o') || (c2 === 'u'); }; function isAlphaNum(c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); }; function escapeRegExp(str) { return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); } var baseEngModifiers = { replace : function(s, params) { //http://stackoverflow.com/questions/1144783/replacing-all-occurrences-of-a-string-in-javascript return s.replace(new RegExp(escapeRegExp(params[0]), 'g'), params[1]); }, capitalizeAll : function(s) { var s2 = ""; var capNext = true; for (var i = 0; i < s.length; i++) { if (!isAlphaNum(s.charAt(i))) { capNext = true; s2 += s.charAt(i); } else { if (!capNext) { s2 += s.charAt(i); } else { s2 += s.charAt(i).toUpperCase(); capNext = false; } } } return s2; }, capitalize : function(s) { return s.charAt(0).toUpperCase() + s.substring(1); }, a : function(s) { if (s.length > 0) { if (s.charAt(0).toLowerCase() === 'u') { if (s.length > 2) { if (s.charAt(2).toLowerCase() === 'i') return "a " + s; } } if (isVowel(s.charAt(0))) { return "an " + s; } } return "a " + s; }, firstS : function(s) { console.log(s); var s2 = s.split(" "); var finished = baseEngModifiers.s(s2[0]) + " " + s2.slice(1).join(" "); console.log(finished); return finished; }, s : function(s) { switch (s.charAt(s.length -1)) { case 's': return s + "es"; break; case 'h': return s + "es"; break; case 'x': return s + "es"; break; case 'y': if (!isVowel(s.charAt(s.length - 2))) return s.substring(0, s.length - 1) + "ies"; else return s + "s"; break; default: return s + "s"; } }, ed : function(s) { switch (s.charAt(s.length -1)) { case 's': return s + "ed"; break; case 'e': return s + "d"; break; case 'h': return s + "ed"; break; case 'x': return s + "ed"; break; case 'y': if (!isVowel(s.charAt(s.length - 2))) return s.substring(0, s.length - 1) + "ied"; else return s + "d"; break; default: return s + "ed"; } } }; tracery.baseEngModifiers = baseEngModifiers; // Externalize tracery.TraceryNode = TraceryNode; tracery.Grammar = Grammar; tracery.Symbol = Symbol; tracery.RuleSet = RuleSet; return tracery; }(); module.exports = tracery;